From 7b9a218d62e1f804a04d2ec33cbcad7d2cad9d76 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 28 Apr 2026 15:35:14 +0200 Subject: [PATCH 001/131] feat(chat): add multi-agent mode routing scaffold and telemetry. --- docs/multi-agent-architecture-plan.md | 211 ++++++++++++++++++ docs/multi-agent-phase1-runbook.md | 70 ++++++ .../app/agents/multi_agent_v1/__init__.py | 17 ++ .../app/agents/multi_agent_v1/contracts.py | 36 +++ .../app/agents/multi_agent_v1/entrypoint.py | 24 ++ .../app/agents/new_chat/architecture_mode.py | 40 ++++ .../app/agents/new_chat/telemetry.py | 43 ++++ surfsense_backend/app/config/__init__.py | 2 + .../app/routes/anonymous_chat_routes.py | 55 ++++- .../app/routes/new_chat_routes.py | 104 +++++---- surfsense_backend/app/schemas/new_chat.py | 8 + .../app/tasks/chat/stream_dispatch.py | 47 ++++ .../app/tasks/chat/stream_new_chat.py | 143 ++++++++++-- 13 files changed, 742 insertions(+), 58 deletions(-) create mode 100644 docs/multi-agent-architecture-plan.md create mode 100644 docs/multi-agent-phase1-runbook.md create mode 100644 surfsense_backend/app/agents/multi_agent_v1/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_v1/contracts.py create mode 100644 surfsense_backend/app/agents/multi_agent_v1/entrypoint.py create mode 100644 surfsense_backend/app/agents/new_chat/architecture_mode.py create mode 100644 surfsense_backend/app/agents/new_chat/telemetry.py create mode 100644 surfsense_backend/app/tasks/chat/stream_dispatch.py diff --git a/docs/multi-agent-architecture-plan.md b/docs/multi-agent-architecture-plan.md new file mode 100644 index 000000000..45b5caf61 --- /dev/null +++ b/docs/multi-agent-architecture-plan.md @@ -0,0 +1,211 @@ +# Multi-Agent Architecture Plan (Phased) + +This document defines a phased migration from the current `single_agent` flow to a multi-agent architecture while keeping rollback simple and immediate. + +## Naming + +- `single_agent`: current architecture (default at start) +- `shadow_multi_agent_v1`: run multi-agent path in background, return `single_agent` output +- `multi_agent_v1`: multi-agent architecture is the user-facing path + +--- + +## Phase 1 - Parallel Safety Layer + +**Goal:** Add safe routing controls with zero behavior change. + +### Todo + +- [ ] Add mode selector with values: `single_agent`, `shadow_multi_agent_v1`, `multi_agent_v1` +- [ ] Add global kill switch: force all traffic to `single_agent` +- [ ] Add mode resolution priority: + 1. kill switch + 2. request override + 3. system default +- [ ] Keep `single_agent` as default mode +- [ ] Keep frontend stream/output contract unchanged +- [ ] Add telemetry tags: + - `architecture_mode` + - `worker_count` + - `retry_count` + - `latency_ms` + - `token_total` +- [ ] Write short rollback runbook + +### Exit Criteria + +- [ ] Can switch modes in staging +- [ ] Kill switch verified +- [ ] No frontend contract regressions + +--- + +## Phase 2 - Orchestrator Core and Contracts + +**Goal:** Build multi-agent control-plane only (planner/router/merge), with strict schemas. + +### Todo + +- [ ] Implement orchestrator responsibilities: + - intent detection + - routing + - delegation + - fan-in merge +- [ ] Add budget controls: + - max workers per turn + - max parallel workers + - max turn duration +- [ ] Add loop/stall guard: + - repeated task signature detection + - no-progress threshold +- [ ] Define `WorkerTask` schema: + - `domain`, `goal`, `constraints`, `budget` +- [ ] Define `WorkerResult` schema: + - `status`, `summary`, `evidence[]`, `artifacts[]`, `needs_human`, `error_class` +- [ ] Add schema validation on send/receive boundaries +- [ ] Add controlled fallback on invalid worker results + +### Exit Criteria + +- [ ] Orchestrator works end-to-end with mock workers +- [ ] Invalid worker payloads are blocked cleanly + +--- + +## Phase 3 - Pilot Workers (Gmail and Calendar) + +**Goal:** Validate multi-agent architecture with two real domains only. + +### Todo + +- [ ] Create Gmail worker + - [ ] domain-scoped prompt + - [ ] domain-only tool loadout + - [ ] local query rewrite + - [ ] normalized `WorkerResult` +- [ ] Create Calendar worker + - [ ] domain-scoped prompt + - [ ] domain-only tool loadout + - [ ] local query rewrite/time normalization + - [ ] normalized `WorkerResult` +- [ ] Enforce no cross-domain tool access +- [ ] Preserve HITL for write actions +- [ ] Add retry policy by `error_class` +- [ ] Add tests for routing, loadout isolation, HITL behavior + +### Exit Criteria + +- [ ] Gmail and Calendar tasks complete in `multi_agent_v1` +- [ ] No cross-domain tool leakage +- [ ] HITL still enforced for sensitive writes + +--- + +## Phase 4 - Knowledge Base and Evidence Normalization + +**Goal:** Isolate KB retrieval and make evidence citation-ready. + +### Todo + +- [ ] Move KB retrieval behind dedicated worker/stage +- [ ] Reuse current KB retrieval logic, but return compact structured evidence only +- [ ] Define `EvidenceItem` fields: + - `claim`, `source_type`, `source_ref`, `confidence`, `snippet` +- [ ] Add top-k and output-size controls +- [ ] Add quote-first extraction mode for long contexts +- [ ] Add tests for traceability and bounded payloads + +### Exit Criteria + +- [ ] Orchestrator consumes compact evidence (no raw KB dumps) +- [ ] Citation refs remain valid and traceable + +--- + +## Phase 5 - Verifier and Citation Gate + +**Goal:** Prevent unsupported factual claims in final responses. + +### Todo + +- [ ] Add verifier stage before final synthesis +- [ ] Enforce claim-to-evidence checks +- [ ] Add conflict handling policy: + - consistent evidence -> accept + - conflicting evidence -> label uncertainty or retry +- [ ] Add unsupported-claim policy: + - remove claim or mark uncertain +- [ ] Add verifier telemetry: + - supported claims + - unsupported claims + - conflicts +- [ ] Support strict gate and warning modes + +### Exit Criteria + +- [ ] Unsupported factual claims are blocked or clearly annotated +- [ ] Citation precision improves on evaluation set + +--- + +## Phase 6 - Shadow Evaluation and Canary + +**Goal:** Ship based on data, not intuition. + +### Todo + +- [ ] Enable `shadow_multi_agent_v1` for selected traffic +- [ ] Compare metrics vs `single_agent`: + - success rate + - citation precision + - tool-selection accuracy + - p95 latency + - tokens/request + - cost per successful task +- [ ] Define rollout gates and auto-stop thresholds +- [ ] Start canary rollout for `multi_agent_v1` +- [ ] Ramp traffic only if quality and reliability gates pass +- [ ] Keep kill switch live for entire rollout +- [ ] Record go/no-go decision with evidence + +### Exit Criteria + +- [ ] Clear decision based on measured outcomes +- [ ] Rollback tested successfully during canary + +--- + +## Phase 7 - Domain Expansion and Heavy Tool Reassignment + +**Goal:** Scale multi-agent architecture safely across more domains. + +### Todo + +- [ ] Add domains incrementally (`notion`, `slack`, `jira`, ...) +- [ ] For each new domain enforce: + - scoped tool loadout + - local query rewrite + - contract validation + - eval plus canary gate +- [ ] Move heavy tools to specialist workers: + - podcast generation + - artifact/report generation + - video presentation +- [ ] Keep orchestrator toolbelt minimal and control-plane focused +- [ ] Regularly prune prompts and tool descriptions + +### Exit Criteria + +- [ ] New domains onboard without reliability regressions +- [ ] Orchestrator remains lean and stable +- [ ] Cost per successful task stays controlled + +--- + +## Always-On Checklist + +- [ ] Keep `single_agent` path healthy until rollout completion +- [ ] Keep one-click rollback available at all times +- [ ] Update observability dashboards every phase +- [ ] Track failure taxonomy and review weekly +- [ ] Validate prompt/tool changes via eval before broad rollout diff --git a/docs/multi-agent-phase1-runbook.md b/docs/multi-agent-phase1-runbook.md new file mode 100644 index 000000000..c3d871e52 --- /dev/null +++ b/docs/multi-agent-phase1-runbook.md @@ -0,0 +1,70 @@ +# Multi-Agent Architecture Phase 1 Runbook + +## Scope + +This runbook covers mode selection and emergency rollback for: + +- `single_agent` +- `shadow_multi_agent_v1` +- `multi_agent_v1` + +Phase 1 keeps execution behavior on the current single-agent path while mode wiring and telemetry are introduced. + +## Resolution Priority + +Mode resolution follows this fixed order: + +1. Global kill switch (`FORCE_SINGLE_AGENT`) +2. Request override (`architecture_mode` in chat payload) +3. System default (`AGENT_ARCHITECTURE_MODE`) +4. Safe fallback (`single_agent`) + +## Configuration + +Set environment values in backend runtime: + +- `AGENT_ARCHITECTURE_MODE=single_agent` (default) +- `FORCE_SINGLE_AGENT=FALSE` (default) + +Changes require backend restart because config is loaded at process startup. + +## Mode Switching + +### System default switch + +1. Set `AGENT_ARCHITECTURE_MODE` to desired value. +2. Keep `FORCE_SINGLE_AGENT=FALSE`. +3. Restart backend. +4. Verify logs include `[architecture_telemetry]` with expected `architecture_mode`. + +### Per-request override + +Send optional `architecture_mode` in chat request payload: + +- `"single_agent"` +- `"shadow_multi_agent_v1"` +- `"multi_agent_v1"` + +If `FORCE_SINGLE_AGENT=TRUE`, request override is ignored by design. + +## Emergency Rollback + +Use the kill switch: + +1. Set `FORCE_SINGLE_AGENT=TRUE`. +2. Restart backend. +3. Verify new requests log `architecture_mode=single_agent`. +4. Keep this state until incident is resolved. + +## Verification Checklist + +- Mode resolves according to the priority order. +- Kill switch overrides all request/default values. +- Streaming response schema remains unchanged. +- Architecture telemetry is emitted with: + - `architecture_mode` + - `orchestrator_used` + - `worker_count` + - `retry_count` + - `latency_ms` + - `token_total` diff --git a/surfsense_backend/app/agents/multi_agent_v1/__init__.py b/surfsense_backend/app/agents/multi_agent_v1/__init__.py new file mode 100644 index 000000000..4fb75203a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_v1/__init__.py @@ -0,0 +1,17 @@ +"""Multi-agent v1 architecture package.""" + +from app.agents.multi_agent_v1.contracts import ( + GroundingEvidence, + SubagentResult, + SubagentTaskPlan, + WorkerBudget, +) +from app.agents.multi_agent_v1.entrypoint import MultiAgentEntrypoint + +__all__ = [ + "GroundingEvidence", + "MultiAgentEntrypoint", + "SubagentResult", + "SubagentTaskPlan", + "WorkerBudget", +] diff --git a/surfsense_backend/app/agents/multi_agent_v1/contracts.py b/surfsense_backend/app/agents/multi_agent_v1/contracts.py new file mode 100644 index 000000000..c6eef1a06 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_v1/contracts.py @@ -0,0 +1,36 @@ +"""Contracts for multi_agent_v1 orchestrator and subagent communication.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class WorkerBudget(BaseModel): + max_steps: int = Field(default=1, ge=1) + max_duration_ms: int = Field(default=15_000, ge=100) + + +class SubagentTaskPlan(BaseModel): + domain: str = Field(..., min_length=1) + goal: str = Field(..., min_length=1) + constraints: list[str] = Field(default_factory=list) + budget: WorkerBudget = Field(default_factory=WorkerBudget) + + +class GroundingEvidence(BaseModel): + claim: str = Field(..., min_length=1) + source_type: str = Field(..., min_length=1) + source_ref: str = Field(..., min_length=1) + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + snippet: str = "" + + +class SubagentResult(BaseModel): + status: Literal["success", "partial", "error"] + summary: str = "" + evidence: list[GroundingEvidence] = Field(default_factory=list) + artifacts: list[str] = Field(default_factory=list) + needs_human: bool = False + error_class: str | None = None diff --git a/surfsense_backend/app/agents/multi_agent_v1/entrypoint.py b/surfsense_backend/app/agents/multi_agent_v1/entrypoint.py new file mode 100644 index 000000000..417643633 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_v1/entrypoint.py @@ -0,0 +1,24 @@ +"""Multi-agent v1 entrypoint scaffold with safe fallback behavior.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Callable +from typing import Any + + +class MultiAgentEntrypoint: + def stream_new_chat( + self, + *, + fallback_streamer: Callable[..., AsyncGenerator[str, None]], + fallback_kwargs: dict[str, Any], + ) -> AsyncGenerator[str, None]: + return fallback_streamer(**fallback_kwargs) + + def stream_resume_chat( + self, + *, + fallback_streamer: Callable[..., AsyncGenerator[str, None]], + fallback_kwargs: dict[str, Any], + ) -> AsyncGenerator[str, None]: + return fallback_streamer(**fallback_kwargs) diff --git a/surfsense_backend/app/agents/new_chat/architecture_mode.py b/surfsense_backend/app/agents/new_chat/architecture_mode.py new file mode 100644 index 000000000..db74bb308 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/architecture_mode.py @@ -0,0 +1,40 @@ +"""Architecture mode contracts and resolution helpers for chat sessions.""" + +from __future__ import annotations + +from enum import StrEnum + +from app.config import config + + +class ArchitectureMode(StrEnum): + SINGLE_AGENT = "single_agent" + SHADOW_MULTI_AGENT_V1 = "shadow_multi_agent_v1" + MULTI_AGENT_V1 = "multi_agent_v1" + + +def parse_architecture_mode(value: str | None) -> ArchitectureMode | None: + if not value: + return None + normalized = value.strip().lower() + if not normalized: + return None + try: + return ArchitectureMode(normalized) + except ValueError: + return None + + +def resolve_architecture_mode(request_override: str | None = None) -> ArchitectureMode: + if config.FORCE_SINGLE_AGENT: + return ArchitectureMode.SINGLE_AGENT + + override_mode = parse_architecture_mode(request_override) + if override_mode is not None: + return override_mode + + default_mode = parse_architecture_mode(config.AGENT_ARCHITECTURE_MODE) + if default_mode is not None: + return default_mode + + return ArchitectureMode.SINGLE_AGENT diff --git a/surfsense_backend/app/agents/new_chat/telemetry.py b/surfsense_backend/app/agents/new_chat/telemetry.py new file mode 100644 index 000000000..6e5ae408d --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/telemetry.py @@ -0,0 +1,43 @@ +"""Architecture telemetry logging for chat execution modes.""" + +from __future__ import annotations + +import json +from typing import Any + +from app.utils.perf import get_perf_logger + +_perf_log = get_perf_logger() + + +def log_architecture_telemetry( + *, + phase: str, + architecture_mode: str, + orchestrator_used: bool, + worker_count: int, + retry_count: int, + latency_ms: float, + token_total: int, + request_id: str | None = None, + turn_id: str | None = None, + status: str = "ok", + source: str = "new_chat", + extra: dict[str, Any] | None = None, +) -> None: + payload: dict[str, Any] = { + "phase": phase, + "source": source, + "status": status, + "architecture_mode": architecture_mode, + "orchestrator_used": orchestrator_used, + "worker_count": worker_count, + "retry_count": retry_count, + "latency_ms": round(latency_ms, 2), + "token_total": token_total, + "request_id": request_id or "unknown", + "turn_id": turn_id or "unknown", + } + if extra: + payload.update(extra) + _perf_log.info("[architecture_telemetry] %s", json.dumps(payload, ensure_ascii=False)) diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index bd97d2bb1..eb4464b13 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -342,6 +342,8 @@ class Config: ENABLE_DESKTOP_LOCAL_FILESYSTEM = ( os.getenv("ENABLE_DESKTOP_LOCAL_FILESYSTEM", "FALSE").upper() == "TRUE" ) + AGENT_ARCHITECTURE_MODE = os.getenv("AGENT_ARCHITECTURE_MODE", "single_agent") + FORCE_SINGLE_AGENT = os.getenv("FORCE_SINGLE_AGENT", "FALSE").upper() == "TRUE" @classmethod def is_self_hosted(cls) -> bool: diff --git a/surfsense_backend/app/routes/anonymous_chat_routes.py b/surfsense_backend/app/routes/anonymous_chat_routes.py index f9d694e5a..1af7418d9 100644 --- a/surfsense_backend/app/routes/anonymous_chat_routes.py +++ b/surfsense_backend/app/routes/anonymous_chat_routes.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import secrets +import time import uuid from pathlib import PurePosixPath from typing import Any @@ -12,6 +13,11 @@ from fastapi import APIRouter, HTTPException, Request, Response, UploadFile, sta from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field +from app.agents.new_chat.architecture_mode import ( + ArchitectureMode, + resolve_architecture_mode, +) +from app.agents.new_chat.telemetry import log_architecture_telemetry from app.config import config from app.etl_pipeline.file_classifier import ( DIRECT_CONVERT_EXTENSIONS, @@ -84,6 +90,7 @@ class AnonChatRequest(BaseModel): messages: list[dict[str, Any]] = Field(..., min_length=1) disabled_tools: list[str] | None = None turnstile_token: str | None = None + architecture_mode: ArchitectureMode | None = None class AnonQuotaResponse(BaseModel): @@ -361,6 +368,22 @@ async def stream_anonymous_chat( accumulator = start_turn() streaming_service = VercelStreamingService() + architecture_mode = resolve_architecture_mode(body.architecture_mode) + started_at = time.perf_counter() + turn_id = f"anon:{session_id}:{request_id}" + log_architecture_telemetry( + phase="turn_start", + source="anon_chat", + status="started", + architecture_mode=architecture_mode.value, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=0.0, + token_total=0, + request_id=request_id, + turn_id=turn_id, + ) try: async with shielded_async_session() as session: @@ -400,7 +423,10 @@ async def stream_anonymous_chat( } langgraph_config = { - "configurable": {"thread_id": anon_thread_id}, + "configurable": { + "thread_id": anon_thread_id, + "architecture_mode": architecture_mode.value, + }, "recursion_limit": 40, } @@ -468,6 +494,19 @@ async def stream_anonymous_chat( "total_tokens": accumulator.grand_total, }, ) + log_architecture_telemetry( + phase="turn_end", + source="anon_chat", + status="completed", + architecture_mode=architecture_mode.value, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - started_at) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=turn_id, + ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -475,6 +514,20 @@ async def stream_anonymous_chat( except Exception as e: logger.exception("Anonymous chat stream error") + log_architecture_telemetry( + phase="turn_end", + source="anon_chat", + status="error", + architecture_mode=architecture_mode.value, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - started_at) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=turn_id, + extra={"error_type": type(e).__name__}, + ) await TokenQuotaService.anon_release(session_key, ip_key, request_id) yield streaming_service.format_error(f"Error during chat: {e!s}") yield streaming_service.format_done() diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index cbc660222..896a4bd31 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload +from app.agents.new_chat.architecture_mode import resolve_architecture_mode from app.agents.new_chat.filesystem_selection import ( ClientPlatform, FilesystemMode, @@ -61,7 +62,10 @@ from app.schemas.new_chat import ( TokenUsageSummary, ) from app.services.token_tracking_service import record_token_usage -from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat +from app.tasks.chat.stream_dispatch import ( + dispatch_new_chat_stream, + dispatch_resume_chat_stream, +) from app.users import current_active_user from app.utils.rbac import check_permission from app.utils.user_message_multimodal import ( @@ -1244,23 +1248,28 @@ async def handle_new_chat( image_urls = ( [p.as_data_url() for p in request.user_images] if request.user_images else None ) + architecture_mode = resolve_architecture_mode(request.architecture_mode) return StreamingResponse( - stream_new_chat( - user_query=request.user_query, - search_space_id=request.search_space_id, - chat_id=request.chat_id, - user_id=str(user.id), - llm_config_id=llm_config_id, - mentioned_document_ids=request.mentioned_document_ids, - mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, - needs_history_bootstrap=thread.needs_history_bootstrap, - thread_visibility=thread.visibility, - current_user_display_name=user.display_name or "A team member", - disabled_tools=request.disabled_tools, - filesystem_selection=filesystem_selection, - request_id=getattr(http_request.state, "request_id", "unknown"), - user_image_data_urls=image_urls, + dispatch_new_chat_stream( + architecture_mode=architecture_mode.value, + stream_kwargs={ + "user_query": request.user_query, + "search_space_id": request.search_space_id, + "chat_id": request.chat_id, + "user_id": str(user.id), + "llm_config_id": llm_config_id, + "mentioned_document_ids": request.mentioned_document_ids, + "mentioned_surfsense_doc_ids": request.mentioned_surfsense_doc_ids, + "needs_history_bootstrap": thread.needs_history_bootstrap, + "thread_visibility": thread.visibility, + "current_user_display_name": user.display_name or "A team member", + "disabled_tools": request.disabled_tools, + "filesystem_selection": filesystem_selection, + "request_id": getattr(http_request.state, "request_id", "unknown"), + "user_image_data_urls": image_urls, + "architecture_mode": architecture_mode.value, + }, ), media_type="text/event-stream", headers={ @@ -1458,6 +1467,7 @@ async def regenerate_response( if request.user_images is not None: regenerate_image_urls = [p.as_data_url() for p in request.user_images] + architecture_mode = resolve_architecture_mode(request.architecture_mode) if user_query_to_use is None: raise HTTPException( @@ -1506,23 +1516,28 @@ async def regenerate_response( async def stream_with_cleanup(): streaming_completed = False try: - async for chunk in stream_new_chat( - user_query=str(user_query_to_use), - search_space_id=request.search_space_id, - chat_id=thread_id, - user_id=str(user.id), - llm_config_id=llm_config_id, - mentioned_document_ids=request.mentioned_document_ids, - mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, - checkpoint_id=target_checkpoint_id, - needs_history_bootstrap=thread.needs_history_bootstrap, - thread_visibility=thread.visibility, - current_user_display_name=user.display_name or "A team member", - disabled_tools=request.disabled_tools, - filesystem_selection=filesystem_selection, - request_id=getattr(http_request.state, "request_id", "unknown"), - user_image_data_urls=regenerate_image_urls or None, - ): + stream = dispatch_new_chat_stream( + architecture_mode=architecture_mode.value, + stream_kwargs={ + "user_query": str(user_query_to_use), + "search_space_id": request.search_space_id, + "chat_id": thread_id, + "user_id": str(user.id), + "llm_config_id": llm_config_id, + "mentioned_document_ids": request.mentioned_document_ids, + "mentioned_surfsense_doc_ids": request.mentioned_surfsense_doc_ids, + "checkpoint_id": target_checkpoint_id, + "needs_history_bootstrap": thread.needs_history_bootstrap, + "thread_visibility": thread.visibility, + "current_user_display_name": user.display_name or "A team member", + "disabled_tools": request.disabled_tools, + "filesystem_selection": filesystem_selection, + "request_id": getattr(http_request.state, "request_id", "unknown"), + "user_image_data_urls": regenerate_image_urls or None, + "architecture_mode": architecture_mode.value, + }, + ) + async for chunk in stream: yield chunk streaming_completed = True finally: @@ -1628,6 +1643,7 @@ async def resume_chat( ) decisions = [d.model_dump() for d in request.decisions] + architecture_mode = resolve_architecture_mode(request.architecture_mode) # Release the read-transaction so we don't hold ACCESS SHARE locks # on searchspaces/documents for the entire duration of the stream. @@ -1635,15 +1651,19 @@ async def resume_chat( await session.close() return StreamingResponse( - stream_resume_chat( - chat_id=thread_id, - search_space_id=request.search_space_id, - decisions=decisions, - user_id=str(user.id), - llm_config_id=llm_config_id, - thread_visibility=thread.visibility, - filesystem_selection=filesystem_selection, - request_id=getattr(http_request.state, "request_id", "unknown"), + dispatch_resume_chat_stream( + architecture_mode=architecture_mode.value, + stream_kwargs={ + "chat_id": thread_id, + "search_space_id": request.search_space_id, + "decisions": decisions, + "user_id": str(user.id), + "llm_config_id": llm_config_id, + "thread_visibility": thread.visibility, + "filesystem_selection": filesystem_selection, + "request_id": getattr(http_request.state, "request_id", "unknown"), + "architecture_mode": architecture_mode.value, + }, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 477fdf2ca..f446eb0b5 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -176,6 +176,11 @@ class LocalFilesystemMountPayload(BaseModel): MAX_NEW_CHAT_IMAGE_BYTES = 8 * 1024 * 1024 MAX_NEW_CHAT_IMAGES = 4 +ArchitectureModeLiteral = Literal[ + "single_agent", + "shadow_multi_agent_v1", + "multi_agent_v1", +] class NewChatUserImagePart(BaseModel): @@ -210,6 +215,7 @@ class NewChatRequest(BaseModel): disabled_tools: list[str] | None = ( None # Optional list of tool names the user has disabled from the UI ) + architecture_mode: ArchitectureModeLiteral | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None @@ -250,6 +256,7 @@ class RegenerateRequest(BaseModel): mentioned_document_ids: list[int] | None = None mentioned_surfsense_doc_ids: list[int] | None = None disabled_tools: list[str] | None = None + architecture_mode: ArchitectureModeLiteral | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None @@ -286,6 +293,7 @@ class ResumeDecision(BaseModel): class ResumeRequest(BaseModel): search_space_id: int decisions: list[ResumeDecision] + architecture_mode: ArchitectureModeLiteral | 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/tasks/chat/stream_dispatch.py b/surfsense_backend/app/tasks/chat/stream_dispatch.py new file mode 100644 index 000000000..73d7fc076 --- /dev/null +++ b/surfsense_backend/app/tasks/chat/stream_dispatch.py @@ -0,0 +1,47 @@ +"""Thin architecture dispatch seam for chat streaming entrypoints.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Any + +from app.agents.multi_agent_v1.entrypoint import MultiAgentEntrypoint +from app.agents.new_chat.architecture_mode import ( + ArchitectureMode, + parse_architecture_mode, +) +from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat + + +def _resolve_mode(mode_value: str) -> ArchitectureMode: + return parse_architecture_mode(mode_value) or ArchitectureMode.SINGLE_AGENT + + +def dispatch_new_chat_stream( + *, + architecture_mode: str, + stream_kwargs: dict[str, Any], +) -> AsyncGenerator[str, None]: + mode = _resolve_mode(architecture_mode) + if mode == ArchitectureMode.SINGLE_AGENT: + return stream_new_chat(**stream_kwargs) + entrypoint = MultiAgentEntrypoint() + return entrypoint.stream_new_chat( + fallback_streamer=stream_new_chat, + fallback_kwargs=stream_kwargs, + ) + + +def dispatch_resume_chat_stream( + *, + architecture_mode: str, + stream_kwargs: dict[str, Any], +) -> AsyncGenerator[str, None]: + mode = _resolve_mode(architecture_mode) + if mode == ArchitectureMode.SINGLE_AGENT: + return stream_resume_chat(**stream_kwargs) + entrypoint = MultiAgentEntrypoint() + return entrypoint.stream_resume_chat( + fallback_streamer=stream_resume_chat, + fallback_kwargs=stream_kwargs, + ) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 396c7574e..5edfcd658 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -42,6 +42,7 @@ from app.agents.new_chat.memory_extraction import ( extract_and_save_memory, extract_and_save_team_memory, ) +from app.agents.new_chat.telemetry import log_architecture_telemetry from app.db import ( ChatVisibility, NewChatMessage, @@ -149,6 +150,7 @@ class StreamResult: agent_called_update_memory: bool = False request_id: str | None = None turn_id: str = "" + architecture_mode: str = "single_agent" filesystem_mode: str = "cloud" client_platform: str = "web" intent_detected: str = "chat_only" @@ -182,9 +184,7 @@ def _tool_output_has_error(tool_output: Any) -> bool: if tool_output.get("error"): return True result = tool_output.get("result") - if isinstance(result, str) and result.strip().lower().startswith("error:"): - return True - return False + return isinstance(result, str) and result.strip().lower().startswith("error:") if isinstance(tool_output, str): return tool_output.strip().lower().startswith("error:") return False @@ -231,6 +231,7 @@ def _log_file_contract(stage: str, result: StreamResult, **extra: Any) -> None: "request_id": result.request_id or "unknown", "turn_id": result.turn_id or "unknown", "chat_id": result.turn_id.split(":", 1)[0] if ":" in result.turn_id else "unknown", + "architecture_mode": result.architecture_mode, "filesystem_mode": result.filesystem_mode, "client_platform": result.client_platform, "intent_detected": result.intent_detected, @@ -1308,18 +1309,17 @@ async def _stream_agent_events( result.commit_gate_passed, result.commit_gate_reason = ( _evaluate_file_contract_outcome(result) ) - if not result.commit_gate_passed: - if _contract_enforcement_active(result): - gate_notice = ( - "I could not complete the requested file write because no successful " - "write_file/edit_file operation was confirmed." - ) - gate_text_id = streaming_service.generate_text_id() - yield streaming_service.format_text_start(gate_text_id) - yield streaming_service.format_text_delta(gate_text_id, gate_notice) - yield streaming_service.format_text_end(gate_text_id) - yield streaming_service.format_terminal_info(gate_notice, "error") - accumulated_text = gate_notice + if not result.commit_gate_passed and _contract_enforcement_active(result): + gate_notice = ( + "I could not complete the requested file write because no successful " + "write_file/edit_file operation was confirmed." + ) + gate_text_id = streaming_service.generate_text_id() + yield streaming_service.format_text_start(gate_text_id) + yield streaming_service.format_text_delta(gate_text_id, gate_notice) + yield streaming_service.format_text_end(gate_text_id) + yield streaming_service.format_terminal_info(gate_notice, "error") + accumulated_text = gate_notice else: result.commit_gate_passed = True result.commit_gate_reason = "" @@ -1351,6 +1351,7 @@ async def stream_new_chat( filesystem_selection: FilesystemSelection | None = None, request_id: str | None = None, user_image_data_urls: list[str] | None = None, + architecture_mode: str = "single_agent", ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. @@ -1384,8 +1385,22 @@ async def stream_new_chat( ) stream_result.request_id = request_id stream_result.turn_id = f"{chat_id}:{int(time.time() * 1000)}" + stream_result.architecture_mode = architecture_mode stream_result.filesystem_mode = fs_mode stream_result.client_platform = fs_platform + log_architecture_telemetry( + phase="turn_start", + source="new_chat", + status="started", + architecture_mode=architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=0.0, + token_total=0, + request_id=request_id, + turn_id=stream_result.turn_id, + ) _log_file_contract("turn_start", stream_result) _perf_log.info( "[stream_new_chat] filesystem_mode=%s client_platform=%s", @@ -1638,6 +1653,7 @@ async def stream_new_chat( "search_space_id": search_space_id, "request_id": request_id or "unknown", "turn_id": stream_result.turn_id, + "architecture_mode": architecture_mode, } _perf_log.info( @@ -1669,6 +1685,7 @@ async def stream_new_chat( configurable = {"thread_id": str(chat_id)} configurable["request_id"] = request_id or "unknown" configurable["turn_id"] = stream_result.turn_id + configurable["architecture_mode"] = architecture_mode if checkpoint_id: configurable["checkpoint_id"] = checkpoint_id @@ -1884,6 +1901,19 @@ async def stream_new_chat( "call_details": accumulator.serialized_calls(), }, ) + log_architecture_telemetry( + phase="turn_end", + source="new_chat", + status="interrupted", + architecture_mode=stream_result.architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - _t_total) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=stream_result.turn_id, + ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -1956,6 +1986,19 @@ async def stream_new_chat( "call_details": accumulator.serialized_calls(), }, ) + log_architecture_telemetry( + phase="turn_end", + source="new_chat", + status="completed", + architecture_mode=stream_result.architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - _t_total) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=stream_result.turn_id, + ) # Fire background memory extraction if the agent didn't handle it. # Shared threads write to team memory; private threads write to user memory. @@ -2000,6 +2043,20 @@ async def stream_new_chat( print(f"[stream_new_chat] {error_message}") print(f"[stream_new_chat] Exception type: {type(e).__name__}") print(f"[stream_new_chat] Traceback:\n{traceback.format_exc()}") + log_architecture_telemetry( + phase="turn_end", + source="new_chat", + status="error", + architecture_mode=stream_result.architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - _t_total) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=stream_result.turn_id, + extra={"error_type": type(e).__name__}, + ) yield streaming_service.format_error(error_message) yield streaming_service.format_finish_step() @@ -2093,6 +2150,7 @@ async def stream_resume_chat( thread_visibility: ChatVisibility | None = None, filesystem_selection: FilesystemSelection | None = None, request_id: str | None = None, + architecture_mode: str = "single_agent", ) -> AsyncGenerator[str, None]: streaming_service = VercelStreamingService() stream_result = StreamResult() @@ -2103,8 +2161,22 @@ async def stream_resume_chat( ) stream_result.request_id = request_id stream_result.turn_id = f"{chat_id}:{int(time.time() * 1000)}" + stream_result.architecture_mode = architecture_mode stream_result.filesystem_mode = fs_mode stream_result.client_platform = fs_platform + log_architecture_telemetry( + phase="turn_start", + source="resume_chat", + status="started", + architecture_mode=architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=0.0, + token_total=0, + request_id=request_id, + turn_id=stream_result.turn_id, + ) _log_file_contract("turn_start", stream_result) _perf_log.info( "[stream_resume] filesystem_mode=%s client_platform=%s", @@ -2250,6 +2322,7 @@ async def stream_resume_chat( "thread_id": str(chat_id), "request_id": request_id or "unknown", "turn_id": stream_result.turn_id, + "architecture_mode": architecture_mode, }, "recursion_limit": 80, } @@ -2300,6 +2373,19 @@ async def stream_resume_chat( "call_details": accumulator.serialized_calls(), }, ) + log_architecture_telemetry( + phase="turn_end", + source="resume_chat", + status="interrupted", + architecture_mode=stream_result.architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - _t_total) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=stream_result.turn_id, + ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -2353,6 +2439,19 @@ async def stream_resume_chat( "call_details": accumulator.serialized_calls(), }, ) + log_architecture_telemetry( + phase="turn_end", + source="resume_chat", + status="completed", + architecture_mode=stream_result.architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - _t_total) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=stream_result.turn_id, + ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -2364,6 +2463,20 @@ async def stream_resume_chat( error_message = f"Error during resume: {e!s}" print(f"[stream_resume_chat] {error_message}") print(f"[stream_resume_chat] Traceback:\n{traceback.format_exc()}") + log_architecture_telemetry( + phase="turn_end", + source="resume_chat", + status="error", + architecture_mode=stream_result.architecture_mode, + orchestrator_used=False, + worker_count=0, + retry_count=0, + latency_ms=(time.perf_counter() - _t_total) * 1000.0, + token_total=accumulator.grand_total, + request_id=request_id, + turn_id=stream_result.turn_id, + extra={"error_type": type(e).__name__}, + ) yield streaming_service.format_error(error_message) yield streaming_service.format_finish_step() yield streaming_service.format_finish() From 8d0a6798052c968de489a23ec9838161157227e5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 28 Apr 2026 18:37:32 +0200 Subject: [PATCH 002/131] feat(subagents): export registry helpers. --- .../app/agents/multi_agent_v1/subagents/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py new file mode 100644 index 000000000..a25f6a476 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py @@ -0,0 +1,11 @@ +"""Subagent implementations and registry for multi-agent v1.""" + +from app.agents.multi_agent_v1.subagents.registry import ( + SubagentRegistry, + subagent_task_signature, +) + +__all__ = [ + "SubagentRegistry", + "subagent_task_signature", +] From 755d20f6ea698e2151e3cbe485e12f73b4cc955e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 28 Apr 2026 18:37:32 +0200 Subject: [PATCH 003/131] feat(subagents): add shared subagent utility helpers. --- .../agents/multi_agent_v1/subagents/utils.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py new file mode 100644 index 000000000..fade0a60a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py @@ -0,0 +1,143 @@ +"""Shared helpers for multi-agent v1 subagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.llm_config import ( + create_chat_litellm_from_agent_config, + load_agent_config, +) + + +async def load_llm_for_request( + *, + session: AsyncSession, + llm_config_id: int, + search_space_id: int, +) -> Any | None: + """Load the configured chat model for a subagent run.""" + agent_config = await load_agent_config( + session=session, + config_id=llm_config_id, + search_space_id=search_space_id, + ) + if agent_config is None: + return None + return create_chat_litellm_from_agent_config(agent_config) + + +def build_subagent_input_state( + *, + goal: str, + stream_kwargs: dict[str, Any], +) -> dict[str, Any]: + """Build the initial state payload passed to a subagent.""" + return { + "messages": [HumanMessage(content=goal)], + "search_space_id": stream_kwargs["search_space_id"], + "request_id": read_optional_nonempty_string( + stream_kwargs, "request_id", "unknown" + ), + "turn_id": f"subagent:{read_optional_integer(stream_kwargs, 'chat_id') or 'unknown'}", + "architecture_mode": "multi_agent_v1_subagent", + } + + +def build_subagent_run_config( + *, + stream_kwargs: dict[str, Any], + scope: str, +) -> dict[str, Any]: + """Build runnable config with a scope-specific thread id.""" + return { + "configurable": { + "thread_id": build_subagent_thread_id(stream_kwargs=stream_kwargs, scope=scope), + "request_id": read_optional_nonempty_string( + stream_kwargs, "request_id", "unknown" + ), + "turn_id": f"subagent:{read_optional_integer(stream_kwargs, 'chat_id') or 'unknown'}", + "architecture_mode": "multi_agent_v1_subagent", + }, + "recursion_limit": 40, + } + + +def build_subagent_error_result(error_class: str) -> dict[str, Any]: + """Build a standardized error result payload for subagents.""" + return { + "status": "error", + "summary": "", + "evidence": [], + "artifacts": [], + "needs_human": False, + "error_class": error_class, + } + + +def extract_final_ai_message_text_from_state(state: Any) -> str: + """Return the latest AI message text from an agent state payload.""" + if not isinstance(state, dict): + return "" + messages = state.get("messages") + if not isinstance(messages, Sequence): + return "" + for message in reversed(messages): + if isinstance(message, AIMessage): + return extract_plain_text_from_message_content(message).strip() + return "" + + +def extract_plain_text_from_message_content(message: BaseMessage) -> str: + """Flatten a LangChain message content payload into plain text.""" + content = getattr(message, "content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text", ""))) + return "\n".join(part for part in parts if part) + return str(content) + + +def build_disabled_tools_list(disabled_tools: Any) -> list[str]: + """Normalize disabled tools input to a list of tool names.""" + if not isinstance(disabled_tools, list): + return [] + return [tool_name for tool_name in disabled_tools if isinstance(tool_name, str)] + + +def read_optional_nonempty_string( + payload: dict[str, Any], + key: str, + default: str | None = None, +) -> str | None: + """Read a non-empty string from payload, otherwise return default.""" + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return default + + +def read_optional_integer(payload: dict[str, Any], key: str) -> int | None: + """Read an integer from payload when present and valid.""" + value = payload.get(key) + if isinstance(value, int): + return value + return None + + +def build_subagent_thread_id(*, stream_kwargs: dict[str, Any], scope: str) -> str: + """Build a stable thread id for a scope-specific subagent run.""" + chat_id = read_optional_integer(stream_kwargs, "chat_id") + if chat_id is None: + return "ma-subagent:unknown" + return f"ma-subagent:{chat_id}:{scope}" From 6afe65a449d9ef7249018cbf82a5a98ebe0a8a4b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 28 Apr 2026 18:37:32 +0200 Subject: [PATCH 004/131] feat(subagents): add read-only calendar subagent. --- .../subagents/calendar_subagent.py | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py new file mode 100644 index 000000000..ee269f3c5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py @@ -0,0 +1,142 @@ +"""Calendar-focused subagent implementation.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from langchain.agents import create_agent + +from app.agents.multi_agent_v1.contracts import SubagentTaskPlan +from app.agents.multi_agent_v1.subagents.utils import ( + build_disabled_tools_list, + build_subagent_error_result, + build_subagent_input_state, + build_subagent_run_config, + extract_final_ai_message_text_from_state, + load_llm_for_request, + read_optional_integer, + read_optional_nonempty_string, +) +from app.agents.new_chat.system_prompt import build_surfsense_system_prompt +from app.agents.new_chat.tools.registry import ( + build_tools_async, + get_connector_gated_tools, +) +from app.db import ChatVisibility, async_session_maker +from app.services.connector_service import ConnectorService + +CALENDAR_SUBAGENT_READ_ONLY_TOOLS: tuple[str, ...] = ( + "get_connected_accounts", + "search_calendar_events", + "search_surfsense_docs", + "web_search", +) + + +class CalendarSubagent: + async def run( + self, + *, + plan: SubagentTaskPlan, + stream_kwargs: dict[str, Any], + ) -> dict[str, Any]: + search_space_id = stream_kwargs.get("search_space_id") + if not isinstance(search_space_id, int): + return build_subagent_error_result("invalid_search_space_id") + + llm_config_id = stream_kwargs.get("llm_config_id") + if not isinstance(llm_config_id, int): + return build_subagent_error_result("invalid_llm_config_id") + + async with async_session_maker() as session: + llm = await load_llm_for_request( + session=session, + llm_config_id=llm_config_id, + search_space_id=search_space_id, + ) + if llm is None: + return build_subagent_error_result("missing_llm") + + agent = await _create_calendar_subagent_agent( + session=session, + llm=llm, + search_space_id=search_space_id, + disabled_tools=build_disabled_tools_list( + stream_kwargs.get("disabled_tools") + ), + user_id=read_optional_nonempty_string(stream_kwargs, "user_id"), + thread_id=read_optional_integer(stream_kwargs, "chat_id"), + thread_visibility=stream_kwargs.get("thread_visibility") + or ChatVisibility.PRIVATE, + ) + state = await agent.ainvoke( + build_subagent_input_state(goal=plan.goal, stream_kwargs=stream_kwargs), + config=build_subagent_run_config( + stream_kwargs=stream_kwargs, + scope="calendar", + ), + ) + summary = extract_final_ai_message_text_from_state(state) + if not summary: + return build_subagent_error_result("empty_subagent_summary") + return { + "status": "success", + "summary": summary, + "evidence": [], + "artifacts": [], + "needs_human": False, + "error_class": None, + } + + +async def _create_calendar_subagent_agent( + *, + session: Any, + llm: Any, + search_space_id: int, + disabled_tools: list[str], + user_id: str | None, + thread_id: int | None, + thread_visibility: ChatVisibility, +) -> Any: + connector_service = ConnectorService(session, search_space_id=search_space_id) + available_connector_enums = await connector_service.get_available_connectors( + search_space_id + ) + available_connectors = [ + connector.value if hasattr(connector, "value") else str(connector) + for connector in available_connector_enums + ] + available_document_types = await connector_service.get_available_document_types( + search_space_id + ) + effective_disabled_tools = list(disabled_tools) + effective_disabled_tools.extend(get_connector_gated_tools(available_connectors)) + dependencies = { + "search_space_id": search_space_id, + "db_session": session, + "connector_service": connector_service, + "user_id": user_id, + "thread_id": thread_id, + "thread_visibility": thread_visibility, + "available_connectors": available_connectors, + "available_document_types": available_document_types, + "llm": llm, + } + tools = await build_tools_async( + dependencies=dependencies, + enabled_tools=list(CALENDAR_SUBAGENT_READ_ONLY_TOOLS), + disabled_tools=effective_disabled_tools, + ) + system_prompt = build_surfsense_system_prompt( + thread_visibility=thread_visibility, + enabled_tool_names={tool.name for tool in tools}, + disabled_tool_names=set(effective_disabled_tools), + ) + return await asyncio.to_thread( + create_agent, + llm, + system_prompt=system_prompt, + tools=tools, + ) From e57633dab2b6a496470f767419678a47f630b03c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 28 Apr 2026 18:37:32 +0200 Subject: [PATCH 005/131] feat(subagents): add read-only gmail subagent. --- .../subagents/gmail_subagent.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py new file mode 100644 index 000000000..8edc3bd96 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py @@ -0,0 +1,143 @@ +"""Gmail-focused subagent implementation.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from langchain.agents import create_agent + +from app.agents.multi_agent_v1.contracts import SubagentTaskPlan +from app.agents.multi_agent_v1.subagents.utils import ( + build_disabled_tools_list, + build_subagent_error_result, + build_subagent_input_state, + build_subagent_run_config, + extract_final_ai_message_text_from_state, + load_llm_for_request, + read_optional_integer, + read_optional_nonempty_string, +) +from app.agents.new_chat.system_prompt import build_surfsense_system_prompt +from app.agents.new_chat.tools.registry import ( + build_tools_async, + get_connector_gated_tools, +) +from app.db import ChatVisibility, async_session_maker +from app.services.connector_service import ConnectorService + +GMAIL_SUBAGENT_READ_ONLY_TOOLS: tuple[str, ...] = ( + "get_connected_accounts", + "search_gmail", + "read_gmail_email", + "search_surfsense_docs", + "web_search", +) + + +class GmailSubagent: + async def run( + self, + *, + plan: SubagentTaskPlan, + stream_kwargs: dict[str, Any], + ) -> dict[str, Any]: + search_space_id = stream_kwargs.get("search_space_id") + if not isinstance(search_space_id, int): + return build_subagent_error_result("invalid_search_space_id") + + llm_config_id = stream_kwargs.get("llm_config_id") + if not isinstance(llm_config_id, int): + return build_subagent_error_result("invalid_llm_config_id") + + async with async_session_maker() as session: + llm = await load_llm_for_request( + session=session, + llm_config_id=llm_config_id, + search_space_id=search_space_id, + ) + if llm is None: + return build_subagent_error_result("missing_llm") + + agent = await _create_gmail_subagent_agent( + session=session, + llm=llm, + search_space_id=search_space_id, + disabled_tools=build_disabled_tools_list( + stream_kwargs.get("disabled_tools") + ), + user_id=read_optional_nonempty_string(stream_kwargs, "user_id"), + thread_id=read_optional_integer(stream_kwargs, "chat_id"), + thread_visibility=stream_kwargs.get("thread_visibility") + or ChatVisibility.PRIVATE, + ) + state = await agent.ainvoke( + build_subagent_input_state(goal=plan.goal, stream_kwargs=stream_kwargs), + config=build_subagent_run_config( + stream_kwargs=stream_kwargs, + scope="gmail", + ), + ) + summary = extract_final_ai_message_text_from_state(state) + if not summary: + return build_subagent_error_result("empty_subagent_summary") + return { + "status": "success", + "summary": summary, + "evidence": [], + "artifacts": [], + "needs_human": False, + "error_class": None, + } + + +async def _create_gmail_subagent_agent( + *, + session: Any, + llm: Any, + search_space_id: int, + disabled_tools: list[str], + user_id: str | None, + thread_id: int | None, + thread_visibility: ChatVisibility, +) -> Any: + connector_service = ConnectorService(session, search_space_id=search_space_id) + available_connector_enums = await connector_service.get_available_connectors( + search_space_id + ) + available_connectors = [ + connector.value if hasattr(connector, "value") else str(connector) + for connector in available_connector_enums + ] + available_document_types = await connector_service.get_available_document_types( + search_space_id + ) + effective_disabled_tools = list(disabled_tools) + effective_disabled_tools.extend(get_connector_gated_tools(available_connectors)) + dependencies = { + "search_space_id": search_space_id, + "db_session": session, + "connector_service": connector_service, + "user_id": user_id, + "thread_id": thread_id, + "thread_visibility": thread_visibility, + "available_connectors": available_connectors, + "available_document_types": available_document_types, + "llm": llm, + } + tools = await build_tools_async( + dependencies=dependencies, + enabled_tools=list(GMAIL_SUBAGENT_READ_ONLY_TOOLS), + disabled_tools=effective_disabled_tools, + ) + system_prompt = build_surfsense_system_prompt( + thread_visibility=thread_visibility, + enabled_tool_names={tool.name for tool in tools}, + disabled_tool_names=set(effective_disabled_tools), + ) + return await asyncio.to_thread( + create_agent, + llm, + system_prompt=system_prompt, + tools=tools, + ) From fa5a209e5a4a1f9bd72f381231c6f35e1c754f02 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 16:21:57 +0200 Subject: [PATCH 006/131] Revert "feat(subagents): add read-only gmail subagent." This reverts commit e57633dab2b6a496470f767419678a47f630b03c. --- .../subagents/gmail_subagent.py | 143 ------------------ 1 file changed, 143 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py deleted file mode 100644 index 8edc3bd96..000000000 --- a/surfsense_backend/app/agents/multi_agent_v1/subagents/gmail_subagent.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Gmail-focused subagent implementation.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from langchain.agents import create_agent - -from app.agents.multi_agent_v1.contracts import SubagentTaskPlan -from app.agents.multi_agent_v1.subagents.utils import ( - build_disabled_tools_list, - build_subagent_error_result, - build_subagent_input_state, - build_subagent_run_config, - extract_final_ai_message_text_from_state, - load_llm_for_request, - read_optional_integer, - read_optional_nonempty_string, -) -from app.agents.new_chat.system_prompt import build_surfsense_system_prompt -from app.agents.new_chat.tools.registry import ( - build_tools_async, - get_connector_gated_tools, -) -from app.db import ChatVisibility, async_session_maker -from app.services.connector_service import ConnectorService - -GMAIL_SUBAGENT_READ_ONLY_TOOLS: tuple[str, ...] = ( - "get_connected_accounts", - "search_gmail", - "read_gmail_email", - "search_surfsense_docs", - "web_search", -) - - -class GmailSubagent: - async def run( - self, - *, - plan: SubagentTaskPlan, - stream_kwargs: dict[str, Any], - ) -> dict[str, Any]: - search_space_id = stream_kwargs.get("search_space_id") - if not isinstance(search_space_id, int): - return build_subagent_error_result("invalid_search_space_id") - - llm_config_id = stream_kwargs.get("llm_config_id") - if not isinstance(llm_config_id, int): - return build_subagent_error_result("invalid_llm_config_id") - - async with async_session_maker() as session: - llm = await load_llm_for_request( - session=session, - llm_config_id=llm_config_id, - search_space_id=search_space_id, - ) - if llm is None: - return build_subagent_error_result("missing_llm") - - agent = await _create_gmail_subagent_agent( - session=session, - llm=llm, - search_space_id=search_space_id, - disabled_tools=build_disabled_tools_list( - stream_kwargs.get("disabled_tools") - ), - user_id=read_optional_nonempty_string(stream_kwargs, "user_id"), - thread_id=read_optional_integer(stream_kwargs, "chat_id"), - thread_visibility=stream_kwargs.get("thread_visibility") - or ChatVisibility.PRIVATE, - ) - state = await agent.ainvoke( - build_subagent_input_state(goal=plan.goal, stream_kwargs=stream_kwargs), - config=build_subagent_run_config( - stream_kwargs=stream_kwargs, - scope="gmail", - ), - ) - summary = extract_final_ai_message_text_from_state(state) - if not summary: - return build_subagent_error_result("empty_subagent_summary") - return { - "status": "success", - "summary": summary, - "evidence": [], - "artifacts": [], - "needs_human": False, - "error_class": None, - } - - -async def _create_gmail_subagent_agent( - *, - session: Any, - llm: Any, - search_space_id: int, - disabled_tools: list[str], - user_id: str | None, - thread_id: int | None, - thread_visibility: ChatVisibility, -) -> Any: - connector_service = ConnectorService(session, search_space_id=search_space_id) - available_connector_enums = await connector_service.get_available_connectors( - search_space_id - ) - available_connectors = [ - connector.value if hasattr(connector, "value") else str(connector) - for connector in available_connector_enums - ] - available_document_types = await connector_service.get_available_document_types( - search_space_id - ) - effective_disabled_tools = list(disabled_tools) - effective_disabled_tools.extend(get_connector_gated_tools(available_connectors)) - dependencies = { - "search_space_id": search_space_id, - "db_session": session, - "connector_service": connector_service, - "user_id": user_id, - "thread_id": thread_id, - "thread_visibility": thread_visibility, - "available_connectors": available_connectors, - "available_document_types": available_document_types, - "llm": llm, - } - tools = await build_tools_async( - dependencies=dependencies, - enabled_tools=list(GMAIL_SUBAGENT_READ_ONLY_TOOLS), - disabled_tools=effective_disabled_tools, - ) - system_prompt = build_surfsense_system_prompt( - thread_visibility=thread_visibility, - enabled_tool_names={tool.name for tool in tools}, - disabled_tool_names=set(effective_disabled_tools), - ) - return await asyncio.to_thread( - create_agent, - llm, - system_prompt=system_prompt, - tools=tools, - ) From 14dbcf0874cdff9781a967d3c6937906b4ac37cb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 16:21:57 +0200 Subject: [PATCH 007/131] Revert "feat(subagents): add read-only calendar subagent." This reverts commit 6afe65a449d9ef7249018cbf82a5a98ebe0a8a4b. --- .../subagents/calendar_subagent.py | 142 ------------------ 1 file changed, 142 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py deleted file mode 100644 index ee269f3c5..000000000 --- a/surfsense_backend/app/agents/multi_agent_v1/subagents/calendar_subagent.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Calendar-focused subagent implementation.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from langchain.agents import create_agent - -from app.agents.multi_agent_v1.contracts import SubagentTaskPlan -from app.agents.multi_agent_v1.subagents.utils import ( - build_disabled_tools_list, - build_subagent_error_result, - build_subagent_input_state, - build_subagent_run_config, - extract_final_ai_message_text_from_state, - load_llm_for_request, - read_optional_integer, - read_optional_nonempty_string, -) -from app.agents.new_chat.system_prompt import build_surfsense_system_prompt -from app.agents.new_chat.tools.registry import ( - build_tools_async, - get_connector_gated_tools, -) -from app.db import ChatVisibility, async_session_maker -from app.services.connector_service import ConnectorService - -CALENDAR_SUBAGENT_READ_ONLY_TOOLS: tuple[str, ...] = ( - "get_connected_accounts", - "search_calendar_events", - "search_surfsense_docs", - "web_search", -) - - -class CalendarSubagent: - async def run( - self, - *, - plan: SubagentTaskPlan, - stream_kwargs: dict[str, Any], - ) -> dict[str, Any]: - search_space_id = stream_kwargs.get("search_space_id") - if not isinstance(search_space_id, int): - return build_subagent_error_result("invalid_search_space_id") - - llm_config_id = stream_kwargs.get("llm_config_id") - if not isinstance(llm_config_id, int): - return build_subagent_error_result("invalid_llm_config_id") - - async with async_session_maker() as session: - llm = await load_llm_for_request( - session=session, - llm_config_id=llm_config_id, - search_space_id=search_space_id, - ) - if llm is None: - return build_subagent_error_result("missing_llm") - - agent = await _create_calendar_subagent_agent( - session=session, - llm=llm, - search_space_id=search_space_id, - disabled_tools=build_disabled_tools_list( - stream_kwargs.get("disabled_tools") - ), - user_id=read_optional_nonempty_string(stream_kwargs, "user_id"), - thread_id=read_optional_integer(stream_kwargs, "chat_id"), - thread_visibility=stream_kwargs.get("thread_visibility") - or ChatVisibility.PRIVATE, - ) - state = await agent.ainvoke( - build_subagent_input_state(goal=plan.goal, stream_kwargs=stream_kwargs), - config=build_subagent_run_config( - stream_kwargs=stream_kwargs, - scope="calendar", - ), - ) - summary = extract_final_ai_message_text_from_state(state) - if not summary: - return build_subagent_error_result("empty_subagent_summary") - return { - "status": "success", - "summary": summary, - "evidence": [], - "artifacts": [], - "needs_human": False, - "error_class": None, - } - - -async def _create_calendar_subagent_agent( - *, - session: Any, - llm: Any, - search_space_id: int, - disabled_tools: list[str], - user_id: str | None, - thread_id: int | None, - thread_visibility: ChatVisibility, -) -> Any: - connector_service = ConnectorService(session, search_space_id=search_space_id) - available_connector_enums = await connector_service.get_available_connectors( - search_space_id - ) - available_connectors = [ - connector.value if hasattr(connector, "value") else str(connector) - for connector in available_connector_enums - ] - available_document_types = await connector_service.get_available_document_types( - search_space_id - ) - effective_disabled_tools = list(disabled_tools) - effective_disabled_tools.extend(get_connector_gated_tools(available_connectors)) - dependencies = { - "search_space_id": search_space_id, - "db_session": session, - "connector_service": connector_service, - "user_id": user_id, - "thread_id": thread_id, - "thread_visibility": thread_visibility, - "available_connectors": available_connectors, - "available_document_types": available_document_types, - "llm": llm, - } - tools = await build_tools_async( - dependencies=dependencies, - enabled_tools=list(CALENDAR_SUBAGENT_READ_ONLY_TOOLS), - disabled_tools=effective_disabled_tools, - ) - system_prompt = build_surfsense_system_prompt( - thread_visibility=thread_visibility, - enabled_tool_names={tool.name for tool in tools}, - disabled_tool_names=set(effective_disabled_tools), - ) - return await asyncio.to_thread( - create_agent, - llm, - system_prompt=system_prompt, - tools=tools, - ) From 6e5206e47cab73b8d91ac20778f0959e2d46af2c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 16:21:57 +0200 Subject: [PATCH 008/131] Revert "feat(subagents): add shared subagent utility helpers." This reverts commit 755d20f6ea698e2151e3cbe485e12f73b4cc955e. --- .../agents/multi_agent_v1/subagents/utils.py | 143 ------------------ 1 file changed, 143 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py deleted file mode 100644 index fade0a60a..000000000 --- a/surfsense_backend/app/agents/multi_agent_v1/subagents/utils.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Shared helpers for multi-agent v1 subagents.""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import Any - -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage -from sqlalchemy.ext.asyncio import AsyncSession - -from app.agents.new_chat.llm_config import ( - create_chat_litellm_from_agent_config, - load_agent_config, -) - - -async def load_llm_for_request( - *, - session: AsyncSession, - llm_config_id: int, - search_space_id: int, -) -> Any | None: - """Load the configured chat model for a subagent run.""" - agent_config = await load_agent_config( - session=session, - config_id=llm_config_id, - search_space_id=search_space_id, - ) - if agent_config is None: - return None - return create_chat_litellm_from_agent_config(agent_config) - - -def build_subagent_input_state( - *, - goal: str, - stream_kwargs: dict[str, Any], -) -> dict[str, Any]: - """Build the initial state payload passed to a subagent.""" - return { - "messages": [HumanMessage(content=goal)], - "search_space_id": stream_kwargs["search_space_id"], - "request_id": read_optional_nonempty_string( - stream_kwargs, "request_id", "unknown" - ), - "turn_id": f"subagent:{read_optional_integer(stream_kwargs, 'chat_id') or 'unknown'}", - "architecture_mode": "multi_agent_v1_subagent", - } - - -def build_subagent_run_config( - *, - stream_kwargs: dict[str, Any], - scope: str, -) -> dict[str, Any]: - """Build runnable config with a scope-specific thread id.""" - return { - "configurable": { - "thread_id": build_subagent_thread_id(stream_kwargs=stream_kwargs, scope=scope), - "request_id": read_optional_nonempty_string( - stream_kwargs, "request_id", "unknown" - ), - "turn_id": f"subagent:{read_optional_integer(stream_kwargs, 'chat_id') or 'unknown'}", - "architecture_mode": "multi_agent_v1_subagent", - }, - "recursion_limit": 40, - } - - -def build_subagent_error_result(error_class: str) -> dict[str, Any]: - """Build a standardized error result payload for subagents.""" - return { - "status": "error", - "summary": "", - "evidence": [], - "artifacts": [], - "needs_human": False, - "error_class": error_class, - } - - -def extract_final_ai_message_text_from_state(state: Any) -> str: - """Return the latest AI message text from an agent state payload.""" - if not isinstance(state, dict): - return "" - messages = state.get("messages") - if not isinstance(messages, Sequence): - return "" - for message in reversed(messages): - if isinstance(message, AIMessage): - return extract_plain_text_from_message_content(message).strip() - return "" - - -def extract_plain_text_from_message_content(message: BaseMessage) -> str: - """Flatten a LangChain message content payload into plain text.""" - content = getattr(message, "content", "") - if isinstance(content, str): - return content - if isinstance(content, list): - parts: list[str] = [] - for item in content: - if isinstance(item, str): - parts.append(item) - elif isinstance(item, dict) and item.get("type") == "text": - parts.append(str(item.get("text", ""))) - return "\n".join(part for part in parts if part) - return str(content) - - -def build_disabled_tools_list(disabled_tools: Any) -> list[str]: - """Normalize disabled tools input to a list of tool names.""" - if not isinstance(disabled_tools, list): - return [] - return [tool_name for tool_name in disabled_tools if isinstance(tool_name, str)] - - -def read_optional_nonempty_string( - payload: dict[str, Any], - key: str, - default: str | None = None, -) -> str | None: - """Read a non-empty string from payload, otherwise return default.""" - value = payload.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return default - - -def read_optional_integer(payload: dict[str, Any], key: str) -> int | None: - """Read an integer from payload when present and valid.""" - value = payload.get(key) - if isinstance(value, int): - return value - return None - - -def build_subagent_thread_id(*, stream_kwargs: dict[str, Any], scope: str) -> str: - """Build a stable thread id for a scope-specific subagent run.""" - chat_id = read_optional_integer(stream_kwargs, "chat_id") - if chat_id is None: - return "ma-subagent:unknown" - return f"ma-subagent:{chat_id}:{scope}" From 2eed81d05982b609ec539a0ef8866354c3977c11 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 16:21:57 +0200 Subject: [PATCH 009/131] Revert "feat(subagents): export registry helpers." This reverts commit 8d0a6798052c968de489a23ec9838161157227e5. --- .../app/agents/multi_agent_v1/subagents/__init__.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py b/surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py deleted file mode 100644 index a25f6a476..000000000 --- a/surfsense_backend/app/agents/multi_agent_v1/subagents/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Subagent implementations and registry for multi-agent v1.""" - -from app.agents.multi_agent_v1.subagents.registry import ( - SubagentRegistry, - subagent_task_signature, -) - -__all__ = [ - "SubagentRegistry", - "subagent_task_signature", -] From dcae196eea9d0a988917d44e15c0cff6c1a35882 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 16:21:57 +0200 Subject: [PATCH 010/131] Revert "feat(chat): add multi-agent mode routing scaffold and telemetry." This reverts commit 7b9a218d62e1f804a04d2ec33cbcad7d2cad9d76. --- docs/multi-agent-architecture-plan.md | 211 ------------------ docs/multi-agent-phase1-runbook.md | 70 ------ .../app/agents/multi_agent_v1/__init__.py | 17 -- .../app/agents/multi_agent_v1/contracts.py | 36 --- .../app/agents/multi_agent_v1/entrypoint.py | 24 -- .../app/agents/new_chat/architecture_mode.py | 40 ---- .../app/agents/new_chat/telemetry.py | 43 ---- surfsense_backend/app/config/__init__.py | 2 - .../app/routes/anonymous_chat_routes.py | 55 +---- .../app/routes/new_chat_routes.py | 104 ++++----- surfsense_backend/app/schemas/new_chat.py | 8 - .../app/tasks/chat/stream_dispatch.py | 47 ---- .../app/tasks/chat/stream_new_chat.py | 143 ++---------- 13 files changed, 58 insertions(+), 742 deletions(-) delete mode 100644 docs/multi-agent-architecture-plan.md delete mode 100644 docs/multi-agent-phase1-runbook.md delete mode 100644 surfsense_backend/app/agents/multi_agent_v1/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_v1/contracts.py delete mode 100644 surfsense_backend/app/agents/multi_agent_v1/entrypoint.py delete mode 100644 surfsense_backend/app/agents/new_chat/architecture_mode.py delete mode 100644 surfsense_backend/app/agents/new_chat/telemetry.py delete mode 100644 surfsense_backend/app/tasks/chat/stream_dispatch.py diff --git a/docs/multi-agent-architecture-plan.md b/docs/multi-agent-architecture-plan.md deleted file mode 100644 index 45b5caf61..000000000 --- a/docs/multi-agent-architecture-plan.md +++ /dev/null @@ -1,211 +0,0 @@ -# Multi-Agent Architecture Plan (Phased) - -This document defines a phased migration from the current `single_agent` flow to a multi-agent architecture while keeping rollback simple and immediate. - -## Naming - -- `single_agent`: current architecture (default at start) -- `shadow_multi_agent_v1`: run multi-agent path in background, return `single_agent` output -- `multi_agent_v1`: multi-agent architecture is the user-facing path - ---- - -## Phase 1 - Parallel Safety Layer - -**Goal:** Add safe routing controls with zero behavior change. - -### Todo - -- [ ] Add mode selector with values: `single_agent`, `shadow_multi_agent_v1`, `multi_agent_v1` -- [ ] Add global kill switch: force all traffic to `single_agent` -- [ ] Add mode resolution priority: - 1. kill switch - 2. request override - 3. system default -- [ ] Keep `single_agent` as default mode -- [ ] Keep frontend stream/output contract unchanged -- [ ] Add telemetry tags: - - `architecture_mode` - - `worker_count` - - `retry_count` - - `latency_ms` - - `token_total` -- [ ] Write short rollback runbook - -### Exit Criteria - -- [ ] Can switch modes in staging -- [ ] Kill switch verified -- [ ] No frontend contract regressions - ---- - -## Phase 2 - Orchestrator Core and Contracts - -**Goal:** Build multi-agent control-plane only (planner/router/merge), with strict schemas. - -### Todo - -- [ ] Implement orchestrator responsibilities: - - intent detection - - routing - - delegation - - fan-in merge -- [ ] Add budget controls: - - max workers per turn - - max parallel workers - - max turn duration -- [ ] Add loop/stall guard: - - repeated task signature detection - - no-progress threshold -- [ ] Define `WorkerTask` schema: - - `domain`, `goal`, `constraints`, `budget` -- [ ] Define `WorkerResult` schema: - - `status`, `summary`, `evidence[]`, `artifacts[]`, `needs_human`, `error_class` -- [ ] Add schema validation on send/receive boundaries -- [ ] Add controlled fallback on invalid worker results - -### Exit Criteria - -- [ ] Orchestrator works end-to-end with mock workers -- [ ] Invalid worker payloads are blocked cleanly - ---- - -## Phase 3 - Pilot Workers (Gmail and Calendar) - -**Goal:** Validate multi-agent architecture with two real domains only. - -### Todo - -- [ ] Create Gmail worker - - [ ] domain-scoped prompt - - [ ] domain-only tool loadout - - [ ] local query rewrite - - [ ] normalized `WorkerResult` -- [ ] Create Calendar worker - - [ ] domain-scoped prompt - - [ ] domain-only tool loadout - - [ ] local query rewrite/time normalization - - [ ] normalized `WorkerResult` -- [ ] Enforce no cross-domain tool access -- [ ] Preserve HITL for write actions -- [ ] Add retry policy by `error_class` -- [ ] Add tests for routing, loadout isolation, HITL behavior - -### Exit Criteria - -- [ ] Gmail and Calendar tasks complete in `multi_agent_v1` -- [ ] No cross-domain tool leakage -- [ ] HITL still enforced for sensitive writes - ---- - -## Phase 4 - Knowledge Base and Evidence Normalization - -**Goal:** Isolate KB retrieval and make evidence citation-ready. - -### Todo - -- [ ] Move KB retrieval behind dedicated worker/stage -- [ ] Reuse current KB retrieval logic, but return compact structured evidence only -- [ ] Define `EvidenceItem` fields: - - `claim`, `source_type`, `source_ref`, `confidence`, `snippet` -- [ ] Add top-k and output-size controls -- [ ] Add quote-first extraction mode for long contexts -- [ ] Add tests for traceability and bounded payloads - -### Exit Criteria - -- [ ] Orchestrator consumes compact evidence (no raw KB dumps) -- [ ] Citation refs remain valid and traceable - ---- - -## Phase 5 - Verifier and Citation Gate - -**Goal:** Prevent unsupported factual claims in final responses. - -### Todo - -- [ ] Add verifier stage before final synthesis -- [ ] Enforce claim-to-evidence checks -- [ ] Add conflict handling policy: - - consistent evidence -> accept - - conflicting evidence -> label uncertainty or retry -- [ ] Add unsupported-claim policy: - - remove claim or mark uncertain -- [ ] Add verifier telemetry: - - supported claims - - unsupported claims - - conflicts -- [ ] Support strict gate and warning modes - -### Exit Criteria - -- [ ] Unsupported factual claims are blocked or clearly annotated -- [ ] Citation precision improves on evaluation set - ---- - -## Phase 6 - Shadow Evaluation and Canary - -**Goal:** Ship based on data, not intuition. - -### Todo - -- [ ] Enable `shadow_multi_agent_v1` for selected traffic -- [ ] Compare metrics vs `single_agent`: - - success rate - - citation precision - - tool-selection accuracy - - p95 latency - - tokens/request - - cost per successful task -- [ ] Define rollout gates and auto-stop thresholds -- [ ] Start canary rollout for `multi_agent_v1` -- [ ] Ramp traffic only if quality and reliability gates pass -- [ ] Keep kill switch live for entire rollout -- [ ] Record go/no-go decision with evidence - -### Exit Criteria - -- [ ] Clear decision based on measured outcomes -- [ ] Rollback tested successfully during canary - ---- - -## Phase 7 - Domain Expansion and Heavy Tool Reassignment - -**Goal:** Scale multi-agent architecture safely across more domains. - -### Todo - -- [ ] Add domains incrementally (`notion`, `slack`, `jira`, ...) -- [ ] For each new domain enforce: - - scoped tool loadout - - local query rewrite - - contract validation - - eval plus canary gate -- [ ] Move heavy tools to specialist workers: - - podcast generation - - artifact/report generation - - video presentation -- [ ] Keep orchestrator toolbelt minimal and control-plane focused -- [ ] Regularly prune prompts and tool descriptions - -### Exit Criteria - -- [ ] New domains onboard without reliability regressions -- [ ] Orchestrator remains lean and stable -- [ ] Cost per successful task stays controlled - ---- - -## Always-On Checklist - -- [ ] Keep `single_agent` path healthy until rollout completion -- [ ] Keep one-click rollback available at all times -- [ ] Update observability dashboards every phase -- [ ] Track failure taxonomy and review weekly -- [ ] Validate prompt/tool changes via eval before broad rollout diff --git a/docs/multi-agent-phase1-runbook.md b/docs/multi-agent-phase1-runbook.md deleted file mode 100644 index c3d871e52..000000000 --- a/docs/multi-agent-phase1-runbook.md +++ /dev/null @@ -1,70 +0,0 @@ -# Multi-Agent Architecture Phase 1 Runbook - -## Scope - -This runbook covers mode selection and emergency rollback for: - -- `single_agent` -- `shadow_multi_agent_v1` -- `multi_agent_v1` - -Phase 1 keeps execution behavior on the current single-agent path while mode wiring and telemetry are introduced. - -## Resolution Priority - -Mode resolution follows this fixed order: - -1. Global kill switch (`FORCE_SINGLE_AGENT`) -2. Request override (`architecture_mode` in chat payload) -3. System default (`AGENT_ARCHITECTURE_MODE`) -4. Safe fallback (`single_agent`) - -## Configuration - -Set environment values in backend runtime: - -- `AGENT_ARCHITECTURE_MODE=single_agent` (default) -- `FORCE_SINGLE_AGENT=FALSE` (default) - -Changes require backend restart because config is loaded at process startup. - -## Mode Switching - -### System default switch - -1. Set `AGENT_ARCHITECTURE_MODE` to desired value. -2. Keep `FORCE_SINGLE_AGENT=FALSE`. -3. Restart backend. -4. Verify logs include `[architecture_telemetry]` with expected `architecture_mode`. - -### Per-request override - -Send optional `architecture_mode` in chat request payload: - -- `"single_agent"` -- `"shadow_multi_agent_v1"` -- `"multi_agent_v1"` - -If `FORCE_SINGLE_AGENT=TRUE`, request override is ignored by design. - -## Emergency Rollback - -Use the kill switch: - -1. Set `FORCE_SINGLE_AGENT=TRUE`. -2. Restart backend. -3. Verify new requests log `architecture_mode=single_agent`. -4. Keep this state until incident is resolved. - -## Verification Checklist - -- Mode resolves according to the priority order. -- Kill switch overrides all request/default values. -- Streaming response schema remains unchanged. -- Architecture telemetry is emitted with: - - `architecture_mode` - - `orchestrator_used` - - `worker_count` - - `retry_count` - - `latency_ms` - - `token_total` diff --git a/surfsense_backend/app/agents/multi_agent_v1/__init__.py b/surfsense_backend/app/agents/multi_agent_v1/__init__.py deleted file mode 100644 index 4fb75203a..000000000 --- a/surfsense_backend/app/agents/multi_agent_v1/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Multi-agent v1 architecture package.""" - -from app.agents.multi_agent_v1.contracts import ( - GroundingEvidence, - SubagentResult, - SubagentTaskPlan, - WorkerBudget, -) -from app.agents.multi_agent_v1.entrypoint import MultiAgentEntrypoint - -__all__ = [ - "GroundingEvidence", - "MultiAgentEntrypoint", - "SubagentResult", - "SubagentTaskPlan", - "WorkerBudget", -] diff --git a/surfsense_backend/app/agents/multi_agent_v1/contracts.py b/surfsense_backend/app/agents/multi_agent_v1/contracts.py deleted file mode 100644 index c6eef1a06..000000000 --- a/surfsense_backend/app/agents/multi_agent_v1/contracts.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Contracts for multi_agent_v1 orchestrator and subagent communication.""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, Field - - -class WorkerBudget(BaseModel): - max_steps: int = Field(default=1, ge=1) - max_duration_ms: int = Field(default=15_000, ge=100) - - -class SubagentTaskPlan(BaseModel): - domain: str = Field(..., min_length=1) - goal: str = Field(..., min_length=1) - constraints: list[str] = Field(default_factory=list) - budget: WorkerBudget = Field(default_factory=WorkerBudget) - - -class GroundingEvidence(BaseModel): - claim: str = Field(..., min_length=1) - source_type: str = Field(..., min_length=1) - source_ref: str = Field(..., min_length=1) - confidence: float = Field(default=0.0, ge=0.0, le=1.0) - snippet: str = "" - - -class SubagentResult(BaseModel): - status: Literal["success", "partial", "error"] - summary: str = "" - evidence: list[GroundingEvidence] = Field(default_factory=list) - artifacts: list[str] = Field(default_factory=list) - needs_human: bool = False - error_class: str | None = None diff --git a/surfsense_backend/app/agents/multi_agent_v1/entrypoint.py b/surfsense_backend/app/agents/multi_agent_v1/entrypoint.py deleted file mode 100644 index 417643633..000000000 --- a/surfsense_backend/app/agents/multi_agent_v1/entrypoint.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Multi-agent v1 entrypoint scaffold with safe fallback behavior.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator, Callable -from typing import Any - - -class MultiAgentEntrypoint: - def stream_new_chat( - self, - *, - fallback_streamer: Callable[..., AsyncGenerator[str, None]], - fallback_kwargs: dict[str, Any], - ) -> AsyncGenerator[str, None]: - return fallback_streamer(**fallback_kwargs) - - def stream_resume_chat( - self, - *, - fallback_streamer: Callable[..., AsyncGenerator[str, None]], - fallback_kwargs: dict[str, Any], - ) -> AsyncGenerator[str, None]: - return fallback_streamer(**fallback_kwargs) diff --git a/surfsense_backend/app/agents/new_chat/architecture_mode.py b/surfsense_backend/app/agents/new_chat/architecture_mode.py deleted file mode 100644 index db74bb308..000000000 --- a/surfsense_backend/app/agents/new_chat/architecture_mode.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Architecture mode contracts and resolution helpers for chat sessions.""" - -from __future__ import annotations - -from enum import StrEnum - -from app.config import config - - -class ArchitectureMode(StrEnum): - SINGLE_AGENT = "single_agent" - SHADOW_MULTI_AGENT_V1 = "shadow_multi_agent_v1" - MULTI_AGENT_V1 = "multi_agent_v1" - - -def parse_architecture_mode(value: str | None) -> ArchitectureMode | None: - if not value: - return None - normalized = value.strip().lower() - if not normalized: - return None - try: - return ArchitectureMode(normalized) - except ValueError: - return None - - -def resolve_architecture_mode(request_override: str | None = None) -> ArchitectureMode: - if config.FORCE_SINGLE_AGENT: - return ArchitectureMode.SINGLE_AGENT - - override_mode = parse_architecture_mode(request_override) - if override_mode is not None: - return override_mode - - default_mode = parse_architecture_mode(config.AGENT_ARCHITECTURE_MODE) - if default_mode is not None: - return default_mode - - return ArchitectureMode.SINGLE_AGENT diff --git a/surfsense_backend/app/agents/new_chat/telemetry.py b/surfsense_backend/app/agents/new_chat/telemetry.py deleted file mode 100644 index 6e5ae408d..000000000 --- a/surfsense_backend/app/agents/new_chat/telemetry.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Architecture telemetry logging for chat execution modes.""" - -from __future__ import annotations - -import json -from typing import Any - -from app.utils.perf import get_perf_logger - -_perf_log = get_perf_logger() - - -def log_architecture_telemetry( - *, - phase: str, - architecture_mode: str, - orchestrator_used: bool, - worker_count: int, - retry_count: int, - latency_ms: float, - token_total: int, - request_id: str | None = None, - turn_id: str | None = None, - status: str = "ok", - source: str = "new_chat", - extra: dict[str, Any] | None = None, -) -> None: - payload: dict[str, Any] = { - "phase": phase, - "source": source, - "status": status, - "architecture_mode": architecture_mode, - "orchestrator_used": orchestrator_used, - "worker_count": worker_count, - "retry_count": retry_count, - "latency_ms": round(latency_ms, 2), - "token_total": token_total, - "request_id": request_id or "unknown", - "turn_id": turn_id or "unknown", - } - if extra: - payload.update(extra) - _perf_log.info("[architecture_telemetry] %s", json.dumps(payload, ensure_ascii=False)) diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index eb4464b13..bd97d2bb1 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -342,8 +342,6 @@ class Config: ENABLE_DESKTOP_LOCAL_FILESYSTEM = ( os.getenv("ENABLE_DESKTOP_LOCAL_FILESYSTEM", "FALSE").upper() == "TRUE" ) - AGENT_ARCHITECTURE_MODE = os.getenv("AGENT_ARCHITECTURE_MODE", "single_agent") - FORCE_SINGLE_AGENT = os.getenv("FORCE_SINGLE_AGENT", "FALSE").upper() == "TRUE" @classmethod def is_self_hosted(cls) -> bool: diff --git a/surfsense_backend/app/routes/anonymous_chat_routes.py b/surfsense_backend/app/routes/anonymous_chat_routes.py index 1af7418d9..f9d694e5a 100644 --- a/surfsense_backend/app/routes/anonymous_chat_routes.py +++ b/surfsense_backend/app/routes/anonymous_chat_routes.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import secrets -import time import uuid from pathlib import PurePosixPath from typing import Any @@ -13,11 +12,6 @@ from fastapi import APIRouter, HTTPException, Request, Response, UploadFile, sta from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from app.agents.new_chat.architecture_mode import ( - ArchitectureMode, - resolve_architecture_mode, -) -from app.agents.new_chat.telemetry import log_architecture_telemetry from app.config import config from app.etl_pipeline.file_classifier import ( DIRECT_CONVERT_EXTENSIONS, @@ -90,7 +84,6 @@ class AnonChatRequest(BaseModel): messages: list[dict[str, Any]] = Field(..., min_length=1) disabled_tools: list[str] | None = None turnstile_token: str | None = None - architecture_mode: ArchitectureMode | None = None class AnonQuotaResponse(BaseModel): @@ -368,22 +361,6 @@ async def stream_anonymous_chat( accumulator = start_turn() streaming_service = VercelStreamingService() - architecture_mode = resolve_architecture_mode(body.architecture_mode) - started_at = time.perf_counter() - turn_id = f"anon:{session_id}:{request_id}" - log_architecture_telemetry( - phase="turn_start", - source="anon_chat", - status="started", - architecture_mode=architecture_mode.value, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=0.0, - token_total=0, - request_id=request_id, - turn_id=turn_id, - ) try: async with shielded_async_session() as session: @@ -423,10 +400,7 @@ async def stream_anonymous_chat( } langgraph_config = { - "configurable": { - "thread_id": anon_thread_id, - "architecture_mode": architecture_mode.value, - }, + "configurable": {"thread_id": anon_thread_id}, "recursion_limit": 40, } @@ -494,19 +468,6 @@ async def stream_anonymous_chat( "total_tokens": accumulator.grand_total, }, ) - log_architecture_telemetry( - phase="turn_end", - source="anon_chat", - status="completed", - architecture_mode=architecture_mode.value, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - started_at) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=turn_id, - ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -514,20 +475,6 @@ async def stream_anonymous_chat( except Exception as e: logger.exception("Anonymous chat stream error") - log_architecture_telemetry( - phase="turn_end", - source="anon_chat", - status="error", - architecture_mode=architecture_mode.value, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - started_at) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=turn_id, - extra={"error_type": type(e).__name__}, - ) await TokenQuotaService.anon_release(session_key, ip_key, request_id) yield streaming_service.format_error(f"Error during chat: {e!s}") yield streaming_service.format_done() diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 896a4bd31..cbc660222 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -22,7 +22,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from app.agents.new_chat.architecture_mode import resolve_architecture_mode from app.agents.new_chat.filesystem_selection import ( ClientPlatform, FilesystemMode, @@ -62,10 +61,7 @@ from app.schemas.new_chat import ( TokenUsageSummary, ) from app.services.token_tracking_service import record_token_usage -from app.tasks.chat.stream_dispatch import ( - dispatch_new_chat_stream, - dispatch_resume_chat_stream, -) +from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat from app.users import current_active_user from app.utils.rbac import check_permission from app.utils.user_message_multimodal import ( @@ -1248,28 +1244,23 @@ async def handle_new_chat( image_urls = ( [p.as_data_url() for p in request.user_images] if request.user_images else None ) - architecture_mode = resolve_architecture_mode(request.architecture_mode) return StreamingResponse( - dispatch_new_chat_stream( - architecture_mode=architecture_mode.value, - stream_kwargs={ - "user_query": request.user_query, - "search_space_id": request.search_space_id, - "chat_id": request.chat_id, - "user_id": str(user.id), - "llm_config_id": llm_config_id, - "mentioned_document_ids": request.mentioned_document_ids, - "mentioned_surfsense_doc_ids": request.mentioned_surfsense_doc_ids, - "needs_history_bootstrap": thread.needs_history_bootstrap, - "thread_visibility": thread.visibility, - "current_user_display_name": user.display_name or "A team member", - "disabled_tools": request.disabled_tools, - "filesystem_selection": filesystem_selection, - "request_id": getattr(http_request.state, "request_id", "unknown"), - "user_image_data_urls": image_urls, - "architecture_mode": architecture_mode.value, - }, + stream_new_chat( + user_query=request.user_query, + search_space_id=request.search_space_id, + chat_id=request.chat_id, + user_id=str(user.id), + llm_config_id=llm_config_id, + mentioned_document_ids=request.mentioned_document_ids, + mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, + needs_history_bootstrap=thread.needs_history_bootstrap, + thread_visibility=thread.visibility, + current_user_display_name=user.display_name or "A team member", + disabled_tools=request.disabled_tools, + filesystem_selection=filesystem_selection, + request_id=getattr(http_request.state, "request_id", "unknown"), + user_image_data_urls=image_urls, ), media_type="text/event-stream", headers={ @@ -1467,7 +1458,6 @@ async def regenerate_response( if request.user_images is not None: regenerate_image_urls = [p.as_data_url() for p in request.user_images] - architecture_mode = resolve_architecture_mode(request.architecture_mode) if user_query_to_use is None: raise HTTPException( @@ -1516,28 +1506,23 @@ async def regenerate_response( async def stream_with_cleanup(): streaming_completed = False try: - stream = dispatch_new_chat_stream( - architecture_mode=architecture_mode.value, - stream_kwargs={ - "user_query": str(user_query_to_use), - "search_space_id": request.search_space_id, - "chat_id": thread_id, - "user_id": str(user.id), - "llm_config_id": llm_config_id, - "mentioned_document_ids": request.mentioned_document_ids, - "mentioned_surfsense_doc_ids": request.mentioned_surfsense_doc_ids, - "checkpoint_id": target_checkpoint_id, - "needs_history_bootstrap": thread.needs_history_bootstrap, - "thread_visibility": thread.visibility, - "current_user_display_name": user.display_name or "A team member", - "disabled_tools": request.disabled_tools, - "filesystem_selection": filesystem_selection, - "request_id": getattr(http_request.state, "request_id", "unknown"), - "user_image_data_urls": regenerate_image_urls or None, - "architecture_mode": architecture_mode.value, - }, - ) - async for chunk in stream: + async for chunk in stream_new_chat( + user_query=str(user_query_to_use), + search_space_id=request.search_space_id, + chat_id=thread_id, + user_id=str(user.id), + llm_config_id=llm_config_id, + mentioned_document_ids=request.mentioned_document_ids, + mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids, + checkpoint_id=target_checkpoint_id, + needs_history_bootstrap=thread.needs_history_bootstrap, + thread_visibility=thread.visibility, + current_user_display_name=user.display_name or "A team member", + disabled_tools=request.disabled_tools, + filesystem_selection=filesystem_selection, + request_id=getattr(http_request.state, "request_id", "unknown"), + user_image_data_urls=regenerate_image_urls or None, + ): yield chunk streaming_completed = True finally: @@ -1643,7 +1628,6 @@ async def resume_chat( ) decisions = [d.model_dump() for d in request.decisions] - architecture_mode = resolve_architecture_mode(request.architecture_mode) # Release the read-transaction so we don't hold ACCESS SHARE locks # on searchspaces/documents for the entire duration of the stream. @@ -1651,19 +1635,15 @@ async def resume_chat( await session.close() return StreamingResponse( - dispatch_resume_chat_stream( - architecture_mode=architecture_mode.value, - stream_kwargs={ - "chat_id": thread_id, - "search_space_id": request.search_space_id, - "decisions": decisions, - "user_id": str(user.id), - "llm_config_id": llm_config_id, - "thread_visibility": thread.visibility, - "filesystem_selection": filesystem_selection, - "request_id": getattr(http_request.state, "request_id", "unknown"), - "architecture_mode": architecture_mode.value, - }, + stream_resume_chat( + chat_id=thread_id, + search_space_id=request.search_space_id, + decisions=decisions, + user_id=str(user.id), + llm_config_id=llm_config_id, + thread_visibility=thread.visibility, + filesystem_selection=filesystem_selection, + request_id=getattr(http_request.state, "request_id", "unknown"), ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index f446eb0b5..477fdf2ca 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -176,11 +176,6 @@ class LocalFilesystemMountPayload(BaseModel): MAX_NEW_CHAT_IMAGE_BYTES = 8 * 1024 * 1024 MAX_NEW_CHAT_IMAGES = 4 -ArchitectureModeLiteral = Literal[ - "single_agent", - "shadow_multi_agent_v1", - "multi_agent_v1", -] class NewChatUserImagePart(BaseModel): @@ -215,7 +210,6 @@ class NewChatRequest(BaseModel): disabled_tools: list[str] | None = ( None # Optional list of tool names the user has disabled from the UI ) - architecture_mode: ArchitectureModeLiteral | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None @@ -256,7 +250,6 @@ class RegenerateRequest(BaseModel): mentioned_document_ids: list[int] | None = None mentioned_surfsense_doc_ids: list[int] | None = None disabled_tools: list[str] | None = None - architecture_mode: ArchitectureModeLiteral | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None @@ -293,7 +286,6 @@ class ResumeDecision(BaseModel): class ResumeRequest(BaseModel): search_space_id: int decisions: list[ResumeDecision] - architecture_mode: ArchitectureModeLiteral | 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/tasks/chat/stream_dispatch.py b/surfsense_backend/app/tasks/chat/stream_dispatch.py deleted file mode 100644 index 73d7fc076..000000000 --- a/surfsense_backend/app/tasks/chat/stream_dispatch.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Thin architecture dispatch seam for chat streaming entrypoints.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator -from typing import Any - -from app.agents.multi_agent_v1.entrypoint import MultiAgentEntrypoint -from app.agents.new_chat.architecture_mode import ( - ArchitectureMode, - parse_architecture_mode, -) -from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat - - -def _resolve_mode(mode_value: str) -> ArchitectureMode: - return parse_architecture_mode(mode_value) or ArchitectureMode.SINGLE_AGENT - - -def dispatch_new_chat_stream( - *, - architecture_mode: str, - stream_kwargs: dict[str, Any], -) -> AsyncGenerator[str, None]: - mode = _resolve_mode(architecture_mode) - if mode == ArchitectureMode.SINGLE_AGENT: - return stream_new_chat(**stream_kwargs) - entrypoint = MultiAgentEntrypoint() - return entrypoint.stream_new_chat( - fallback_streamer=stream_new_chat, - fallback_kwargs=stream_kwargs, - ) - - -def dispatch_resume_chat_stream( - *, - architecture_mode: str, - stream_kwargs: dict[str, Any], -) -> AsyncGenerator[str, None]: - mode = _resolve_mode(architecture_mode) - if mode == ArchitectureMode.SINGLE_AGENT: - return stream_resume_chat(**stream_kwargs) - entrypoint = MultiAgentEntrypoint() - return entrypoint.stream_resume_chat( - fallback_streamer=stream_resume_chat, - fallback_kwargs=stream_kwargs, - ) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 5edfcd658..396c7574e 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -42,7 +42,6 @@ from app.agents.new_chat.memory_extraction import ( extract_and_save_memory, extract_and_save_team_memory, ) -from app.agents.new_chat.telemetry import log_architecture_telemetry from app.db import ( ChatVisibility, NewChatMessage, @@ -150,7 +149,6 @@ class StreamResult: agent_called_update_memory: bool = False request_id: str | None = None turn_id: str = "" - architecture_mode: str = "single_agent" filesystem_mode: str = "cloud" client_platform: str = "web" intent_detected: str = "chat_only" @@ -184,7 +182,9 @@ def _tool_output_has_error(tool_output: Any) -> bool: if tool_output.get("error"): return True result = tool_output.get("result") - return isinstance(result, str) and result.strip().lower().startswith("error:") + if isinstance(result, str) and result.strip().lower().startswith("error:"): + return True + return False if isinstance(tool_output, str): return tool_output.strip().lower().startswith("error:") return False @@ -231,7 +231,6 @@ def _log_file_contract(stage: str, result: StreamResult, **extra: Any) -> None: "request_id": result.request_id or "unknown", "turn_id": result.turn_id or "unknown", "chat_id": result.turn_id.split(":", 1)[0] if ":" in result.turn_id else "unknown", - "architecture_mode": result.architecture_mode, "filesystem_mode": result.filesystem_mode, "client_platform": result.client_platform, "intent_detected": result.intent_detected, @@ -1309,17 +1308,18 @@ async def _stream_agent_events( result.commit_gate_passed, result.commit_gate_reason = ( _evaluate_file_contract_outcome(result) ) - if not result.commit_gate_passed and _contract_enforcement_active(result): - gate_notice = ( - "I could not complete the requested file write because no successful " - "write_file/edit_file operation was confirmed." - ) - gate_text_id = streaming_service.generate_text_id() - yield streaming_service.format_text_start(gate_text_id) - yield streaming_service.format_text_delta(gate_text_id, gate_notice) - yield streaming_service.format_text_end(gate_text_id) - yield streaming_service.format_terminal_info(gate_notice, "error") - accumulated_text = gate_notice + if not result.commit_gate_passed: + if _contract_enforcement_active(result): + gate_notice = ( + "I could not complete the requested file write because no successful " + "write_file/edit_file operation was confirmed." + ) + gate_text_id = streaming_service.generate_text_id() + yield streaming_service.format_text_start(gate_text_id) + yield streaming_service.format_text_delta(gate_text_id, gate_notice) + yield streaming_service.format_text_end(gate_text_id) + yield streaming_service.format_terminal_info(gate_notice, "error") + accumulated_text = gate_notice else: result.commit_gate_passed = True result.commit_gate_reason = "" @@ -1351,7 +1351,6 @@ async def stream_new_chat( filesystem_selection: FilesystemSelection | None = None, request_id: str | None = None, user_image_data_urls: list[str] | None = None, - architecture_mode: str = "single_agent", ) -> AsyncGenerator[str, None]: """ Stream chat responses from the new SurfSense deep agent. @@ -1385,22 +1384,8 @@ async def stream_new_chat( ) stream_result.request_id = request_id stream_result.turn_id = f"{chat_id}:{int(time.time() * 1000)}" - stream_result.architecture_mode = architecture_mode stream_result.filesystem_mode = fs_mode stream_result.client_platform = fs_platform - log_architecture_telemetry( - phase="turn_start", - source="new_chat", - status="started", - architecture_mode=architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=0.0, - token_total=0, - request_id=request_id, - turn_id=stream_result.turn_id, - ) _log_file_contract("turn_start", stream_result) _perf_log.info( "[stream_new_chat] filesystem_mode=%s client_platform=%s", @@ -1653,7 +1638,6 @@ async def stream_new_chat( "search_space_id": search_space_id, "request_id": request_id or "unknown", "turn_id": stream_result.turn_id, - "architecture_mode": architecture_mode, } _perf_log.info( @@ -1685,7 +1669,6 @@ async def stream_new_chat( configurable = {"thread_id": str(chat_id)} configurable["request_id"] = request_id or "unknown" configurable["turn_id"] = stream_result.turn_id - configurable["architecture_mode"] = architecture_mode if checkpoint_id: configurable["checkpoint_id"] = checkpoint_id @@ -1901,19 +1884,6 @@ async def stream_new_chat( "call_details": accumulator.serialized_calls(), }, ) - log_architecture_telemetry( - phase="turn_end", - source="new_chat", - status="interrupted", - architecture_mode=stream_result.architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - _t_total) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=stream_result.turn_id, - ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -1986,19 +1956,6 @@ async def stream_new_chat( "call_details": accumulator.serialized_calls(), }, ) - log_architecture_telemetry( - phase="turn_end", - source="new_chat", - status="completed", - architecture_mode=stream_result.architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - _t_total) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=stream_result.turn_id, - ) # Fire background memory extraction if the agent didn't handle it. # Shared threads write to team memory; private threads write to user memory. @@ -2043,20 +2000,6 @@ async def stream_new_chat( print(f"[stream_new_chat] {error_message}") print(f"[stream_new_chat] Exception type: {type(e).__name__}") print(f"[stream_new_chat] Traceback:\n{traceback.format_exc()}") - log_architecture_telemetry( - phase="turn_end", - source="new_chat", - status="error", - architecture_mode=stream_result.architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - _t_total) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=stream_result.turn_id, - extra={"error_type": type(e).__name__}, - ) yield streaming_service.format_error(error_message) yield streaming_service.format_finish_step() @@ -2150,7 +2093,6 @@ async def stream_resume_chat( thread_visibility: ChatVisibility | None = None, filesystem_selection: FilesystemSelection | None = None, request_id: str | None = None, - architecture_mode: str = "single_agent", ) -> AsyncGenerator[str, None]: streaming_service = VercelStreamingService() stream_result = StreamResult() @@ -2161,22 +2103,8 @@ async def stream_resume_chat( ) stream_result.request_id = request_id stream_result.turn_id = f"{chat_id}:{int(time.time() * 1000)}" - stream_result.architecture_mode = architecture_mode stream_result.filesystem_mode = fs_mode stream_result.client_platform = fs_platform - log_architecture_telemetry( - phase="turn_start", - source="resume_chat", - status="started", - architecture_mode=architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=0.0, - token_total=0, - request_id=request_id, - turn_id=stream_result.turn_id, - ) _log_file_contract("turn_start", stream_result) _perf_log.info( "[stream_resume] filesystem_mode=%s client_platform=%s", @@ -2322,7 +2250,6 @@ async def stream_resume_chat( "thread_id": str(chat_id), "request_id": request_id or "unknown", "turn_id": stream_result.turn_id, - "architecture_mode": architecture_mode, }, "recursion_limit": 80, } @@ -2373,19 +2300,6 @@ async def stream_resume_chat( "call_details": accumulator.serialized_calls(), }, ) - log_architecture_telemetry( - phase="turn_end", - source="resume_chat", - status="interrupted", - architecture_mode=stream_result.architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - _t_total) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=stream_result.turn_id, - ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -2439,19 +2353,6 @@ async def stream_resume_chat( "call_details": accumulator.serialized_calls(), }, ) - log_architecture_telemetry( - phase="turn_end", - source="resume_chat", - status="completed", - architecture_mode=stream_result.architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - _t_total) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=stream_result.turn_id, - ) yield streaming_service.format_finish_step() yield streaming_service.format_finish() @@ -2463,20 +2364,6 @@ async def stream_resume_chat( error_message = f"Error during resume: {e!s}" print(f"[stream_resume_chat] {error_message}") print(f"[stream_resume_chat] Traceback:\n{traceback.format_exc()}") - log_architecture_telemetry( - phase="turn_end", - source="resume_chat", - status="error", - architecture_mode=stream_result.architecture_mode, - orchestrator_used=False, - worker_count=0, - retry_count=0, - latency_ms=(time.perf_counter() - _t_total) * 1000.0, - token_total=accumulator.grand_total, - request_id=request_id, - turn_id=stream_result.turn_id, - extra={"error_type": type(e).__name__}, - ) yield streaming_service.format_error(error_message) yield streaming_service.format_finish_step() yield streaming_service.format_finish() From 8ddfa6ac6f5509d398e3331dc98e2e4913eb7d37 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 16:25:28 +0200 Subject: [PATCH 011/131] Add shared safety constants for provider subagents. --- .../agents/new_chat/subagents/constants.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat/subagents/constants.py diff --git a/surfsense_backend/app/agents/new_chat/subagents/constants.py b/surfsense_backend/app/agents/new_chat/subagents/constants.py new file mode 100644 index 000000000..ef5a33e22 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/subagents/constants.py @@ -0,0 +1,40 @@ +"""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", + } +) + From 3dec2a73279c984576b8a92c2dbf342aa9950fc8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 16:25:36 +0200 Subject: [PATCH 012/131] Add a Slack operations specialist subagent. --- .../new_chat/subagents/providers/slack.py | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat/subagents/providers/slack.py diff --git a/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py b/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py new file mode 100644 index 000000000..2f400c3a0 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py @@ -0,0 +1,163 @@ +"""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 + + +# Slack MCP references used for this provider policy: +# - https://docs.slack.dev/ai/slack-mcp-server +# - https://www.npmjs.com/package/@modelcontextprotocol/server-slack +# +# We explicitly gate known write/mutation operations behind approval (`ask`) +# instead of relying on broad generic write heuristics. +SLACK_MUTATION_TOOL_NAMES: frozenset[str] = frozenset( + { + # modelcontextprotocol server + "slack_post_message", + "slack_reply_to_thread", + "slack_add_reaction", + # Slack-hosted MCP naming variants + "slack_send_message", + "slack_draft_message", + "slack_create_canvas", + "slack_update_canvas", + } +) + +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() -> Any: + """Permission policy for Slack specialist. + + Intent: + - Allow Slack-domain operations by default. + - Gate known 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 + + 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 SLACK_MUTATION_TOOL_NAMES + ) + 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(), + 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] + From 41cb4a567bebb976c775551647777d3ac0a92146 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 20:24:21 +0200 Subject: [PATCH 013/131] Harden Linear and Slack MCP subagent permissions. --- .../new_chat/subagents/providers/linear.py | 166 ++++++++++++++++++ .../new_chat/subagents/providers/slack.py | 45 +++-- .../app/services/mcp_oauth/registry.py | 55 +++++- 3 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 surfsense_backend/app/agents/new_chat/subagents/providers/linear.py diff --git a/surfsense_backend/app/agents/new_chat/subagents/providers/linear.py b/surfsense_backend/app/agents/new_chat/subagents/providers/linear.py new file mode 100644 index 000000000..238b13e8e --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/subagents/providers/linear.py @@ -0,0 +1,166 @@ +"""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 index 2f400c3a0..b72edeee8 100644 --- a/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py +++ b/surfsense_backend/app/agents/new_chat/subagents/providers/slack.py @@ -18,23 +18,26 @@ if TYPE_CHECKING: from langchain_core.tools import BaseTool -# Slack MCP references used for this provider policy: +# Official references: # - https://docs.slack.dev/ai/slack-mcp-server # - https://www.npmjs.com/package/@modelcontextprotocol/server-slack # -# We explicitly gate known write/mutation operations behind approval (`ask`) -# instead of relying on broad generic write heuristics. -SLACK_MUTATION_TOOL_NAMES: frozenset[str] = frozenset( +# 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( { - # modelcontextprotocol server - "slack_post_message", - "slack_reply_to_thread", - "slack_add_reaction", - # Slack-hosted MCP naming variants - "slack_send_message", - "slack_draft_message", - "slack_create_canvas", - "slack_update_canvas", + # 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", } ) @@ -92,17 +95,25 @@ def _select_slack_tools(tools: Sequence[BaseTool]) -> list[BaseTool]: return selected -def _permission_middleware() -> Any: +def _permission_middleware(*, selected_tools: Sequence[BaseTool]) -> Any: """Permission policy for Slack specialist. Intent: - Allow Slack-domain operations by default. - - Gate known Slack mutating operations behind approval (`ask`). + - 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") @@ -110,7 +121,7 @@ def _permission_middleware() -> Any: ) rules.extend( Rule(permission=name, pattern="*", action="ask") - for name in SLACK_MUTATION_TOOL_NAMES + for name in ask_tools ) return PermissionMiddleware( rulesets=[Ruleset(rules=rules, origin="subagent_slack_specialist")] @@ -129,7 +140,7 @@ def _wrap_subagent_middleware( return [ *(extra_middleware or []), - _permission_middleware(), + _permission_middleware(selected_tools=selected_tools), PatchToolCallsMiddleware(), DedupHITLToolCallsMiddleware(agent_tools=list(selected_tools)), ] diff --git a/surfsense_backend/app/services/mcp_oauth/registry.py b/surfsense_backend/app/services/mcp_oauth/registry.py index 835d70184..310c3f6e8 100644 --- a/surfsense_backend/app/services/mcp_oauth/registry.py +++ b/surfsense_backend/app/services/mcp_oauth/registry.py @@ -14,10 +14,57 @@ accuracy high. from __future__ import annotations +import re from dataclasses import dataclass, field from app.db import SearchSourceConnectorType +# Linear hosted MCP (https://linear.app/docs/mcp). Tool names are matched at +# discovery time: names the server does not advertise are ignored. +# See also https://github.com/linear/linear/issues/1049 for server-reported names. +LINEAR_MCP_WRITE_TOOL_NAMES: frozenset[str] = frozenset({"save_issue"}) +LINEAR_MCP_READONLY_TOOL_NAMES: frozenset[str] = frozenset( + { + # Issues + "list_issues", + "get_issue", + "list_my_issues", + "list_issue_statuses", + "list_issue_labels", + "list_comments", + # People & teams + "list_users", + "get_user", + "list_teams", + "get_team", + # Projects & planning + "list_projects", + "get_project", + "list_project_labels", + "list_cycles", + # Documents + "list_documents", + "get_document", + # Misc read + "search_documentation", + } +) +LINEAR_MCP_TOOL_NAMES: frozenset[str] = ( + LINEAR_MCP_READONLY_TOOL_NAMES | LINEAR_MCP_WRITE_TOOL_NAMES +) +_LINEAR_MCP_PREFIXED_NAME_RE = re.compile(r"^linear_\d+_(.+)$") + + +def linear_mcp_original_tool_name(name: str) -> str | None: + """Map ``linear__`` or bare MCP tool name to base name.""" + m = _LINEAR_MCP_PREFIXED_NAME_RE.match(name) + if m: + base = m.group(1) + return base if base in LINEAR_MCP_TOOL_NAMES else None + if name in LINEAR_MCP_TOOL_NAMES: + return name + return None + @dataclass(frozen=True) class MCPServiceConfig: @@ -50,12 +97,8 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = { name="Linear", mcp_url="https://mcp.linear.app/mcp", connector_type="LINEAR_CONNECTOR", - allowed_tools=[ - "list_issues", - "get_issue", - "save_issue", - ], - readonly_tools=frozenset({"list_issues", "get_issue"}), + allowed_tools=sorted(LINEAR_MCP_TOOL_NAMES), + readonly_tools=LINEAR_MCP_READONLY_TOOL_NAMES, account_metadata_keys=["organization_name", "organization_url_key"], ), "jira": MCPServiceConfig( From bf9b606a619dc779570b5e931cdf1c2dc679ae06 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 20:51:06 +0200 Subject: [PATCH 014/131] Wire Linear and Slack specialists and prompt routing. --- .../new_chat/prompts/base/tool_routing_private.md | 11 +++++++++-- .../new_chat/prompts/base/tool_routing_team.md | 11 +++++++++-- .../app/agents/new_chat/prompts/routing/linear.md | 4 +++- .../app/agents/new_chat/prompts/routing/slack.md | 4 +++- .../app/agents/new_chat/subagents/__init__.py | 4 ++++ .../app/agents/new_chat/subagents/config.py | 12 ++++++++++++ .../app/agents/new_chat/system_prompt.py | 5 +++++ .../agents/new_chat/test_specialized_subagents.py | 10 ++++++++-- 8 files changed, 53 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md index ec667bf88..b8bb069e2 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md +++ b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_private.md @@ -5,12 +5,19 @@ say "I don't see it in the knowledge base" or ask the user if they want you to c Ignore any knowledge base results for these services. When to use which tool: -- Linear (issues) → list_issues, get_issue, save_issue (create/update) +- Linear (issues, teams, users, projects when MCP exposes them) → hosted Linear MCP read tools (e.g. `list_issues`, `get_issue`, `list_teams`, `list_users`, …) and `save_issue` for create/update; native SurfSense Linear issue tools when present. For **multi-step Linear-only** work (several reads, structured evidence), delegate with the `task` tool to subagent **`linear_specialist`** instead of mixing unrelated tools. - ClickUp (tasks) → clickup_search, clickup_get_task - Jira (issues) → getAccessibleAtlassianResources (cloudId discovery), getVisibleJiraProjects (project discovery), getJiraProjectIssueTypesMetadata (issue type discovery), searchJiraIssuesUsingJql, createJiraIssue, editJiraIssue -- Slack (messages, channels) → slack_search_channels, slack_read_channel, slack_read_thread +- Slack (messages, channels) → `slack_search_channels`, `slack_read_channel`, `slack_read_thread`, and other `slack_*` tools when connected. For **multi-step Slack-only** work, delegate with `task` to **`slack_specialist`**. - Airtable (bases, tables, records) → list_bases, list_tables_for_base, list_records_for_table - Knowledge base content (Notion, GitHub, files, notes) → automatically searched - Real-time public web data → call web_search - Reading a specific webpage → call scrape_webpage + +**`task` subagents (when to delegate):** +- **`linear_specialist`** — Linear-only investigations and tool use. +- **`slack_specialist`** — Slack-only investigations and tool use. +- **`connector_negotiator`** — **Cross-connector** chains (e.g. data from Slack then action in Linear). +- **`explore`** — Read-only KB + web research with citations. +- **`report_writer`** — Single `generate_report` deliverable. diff --git a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md index 48b7a990b..b081a2123 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md +++ b/surfsense_backend/app/agents/new_chat/prompts/base/tool_routing_team.md @@ -5,12 +5,19 @@ say "I don't see it in the knowledge base" or ask if they want you to check. Ignore any knowledge base results for these services. When to use which tool: -- Linear (issues) → list_issues, get_issue, save_issue (create/update) +- Linear (issues, teams, users, projects when MCP exposes them) → hosted Linear MCP read tools (e.g. `list_issues`, `get_issue`, `list_teams`, `list_users`, …) and `save_issue` for create/update; native SurfSense Linear issue tools when present. For **multi-step Linear-only** work (several reads, structured evidence), delegate with the `task` tool to subagent **`linear_specialist`** instead of mixing unrelated tools. - ClickUp (tasks) → clickup_search, clickup_get_task - Jira (issues) → getAccessibleAtlassianResources (cloudId discovery), getVisibleJiraProjects (project discovery), getJiraProjectIssueTypesMetadata (issue type discovery), searchJiraIssuesUsingJql, createJiraIssue, editJiraIssue -- Slack (messages, channels) → slack_search_channels, slack_read_channel, slack_read_thread +- Slack (messages, channels) → `slack_search_channels`, `slack_read_channel`, `slack_read_thread`, and other `slack_*` tools when connected. For **multi-step Slack-only** work, delegate with `task` to **`slack_specialist`**. - Airtable (bases, tables, records) → list_bases, list_tables_for_base, list_records_for_table - Knowledge base content (Notion, GitHub, files, notes) → automatically searched - Real-time public web data → call web_search - Reading a specific webpage → call scrape_webpage + +**`task` subagents (when to delegate):** +- **`linear_specialist`** — Linear-only investigations and tool use. +- **`slack_specialist`** — Slack-only investigations and tool use. +- **`connector_negotiator`** — **Cross-connector** chains (e.g. data from Slack then action in Linear). +- **`explore`** — Read-only KB + web research with citations. +- **`report_writer`** — Single `generate_report` deliverable. diff --git a/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md b/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md index 8b1378917..2f1bfacd9 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md +++ b/surfsense_backend/app/agents/new_chat/prompts/routing/linear.md @@ -1 +1,3 @@ - + +**Linear:** Prefer the `task` tool with subagent **`linear_specialist`** when the user’s request is **only about Linear** and may need several tool calls (list issues, inspect one issue, teams, users, statuses, comments, documents). Use **`connector_negotiator`** when Linear is one hop in a **multi-connector** workflow. Call Linear MCP tools directly from the parent when a **single** quick call is enough. + diff --git a/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md b/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md index 8b1378917..4b5d07a9a 100644 --- a/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md +++ b/surfsense_backend/app/agents/new_chat/prompts/routing/slack.md @@ -1 +1,3 @@ - + +**Slack:** Prefer `task` with **`slack_specialist`** for **Slack-only** multi-step work (channels, threads, reads, writes that need approval in the specialist). Use **`connector_negotiator`** when Slack feeds another connector in one chain. Use direct `slack_*` tools from the parent for a **single** quick read or write when appropriate. + diff --git a/surfsense_backend/app/agents/new_chat/subagents/__init__.py b/surfsense_backend/app/agents/new_chat/subagents/__init__.py index 7d678ec79..bd1823b57 100644 --- a/surfsense_backend/app/agents/new_chat/subagents/__init__.py +++ b/surfsense_backend/app/agents/new_chat/subagents/__init__.py @@ -20,10 +20,14 @@ from .config import ( build_report_writer_subagent, build_specialized_subagents, ) +from .providers.linear import build_linear_specialist_subagent +from .providers.slack import build_slack_specialist_subagent __all__ = [ "build_connector_negotiator_subagent", "build_explore_subagent", + "build_linear_specialist_subagent", "build_report_writer_subagent", + "build_slack_specialist_subagent", "build_specialized_subagents", ] diff --git a/surfsense_backend/app/agents/new_chat/subagents/config.py b/surfsense_backend/app/agents/new_chat/subagents/config.py index b36d35fa0..78436f674 100644 --- a/surfsense_backend/app/agents/new_chat/subagents/config.py +++ b/surfsense_backend/app/agents/new_chat/subagents/config.py @@ -22,6 +22,12 @@ from typing import TYPE_CHECKING, Any from app.agents.new_chat.middleware.skills_backends import default_skills_sources from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.new_chat.subagents.providers.linear import ( + build_linear_specialist_subagent, +) +from app.agents.new_chat.subagents.providers.slack import ( + build_slack_specialist_subagent, +) if TYPE_CHECKING: from deepagents import SubAgent @@ -419,6 +425,12 @@ def build_specialized_subagents( build_report_writer_subagent( tools=tools, model=model, extra_middleware=extra_middleware ), + build_linear_specialist_subagent( + tools=tools, model=model, extra_middleware=extra_middleware + ), + build_slack_specialist_subagent( + tools=tools, model=model, extra_middleware=extra_middleware + ), build_connector_negotiator_subagent( tools=tools, model=model, extra_middleware=extra_middleware ), diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 56f838d7e..70634c65d 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -26,6 +26,9 @@ from .prompts.composer import ( detect_provider_variant, ) +# Optional routing fragments under ``prompts/routing/`` (see composer). +_DEFAULT_CONNECTOR_ROUTING: tuple[str, ...] = ("linear", "slack") + # Public re-exports for backwards compatibility (some legacy code reads the # raw default-instructions text directly). SURFSENSE_SYSTEM_INSTRUCTIONS_TEMPLATE = ( @@ -63,6 +66,7 @@ def build_surfsense_system_prompt( mcp_connector_tools=mcp_connector_tools, citations_enabled=True, model_name=model_name, + connector_routing=_DEFAULT_CONNECTOR_ROUTING, ) @@ -93,6 +97,7 @@ def build_configurable_system_prompt( use_default_system_instructions=use_default_system_instructions, citations_enabled=citations_enabled, model_name=model_name, + connector_routing=_DEFAULT_CONNECTOR_ROUTING, ) diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py index 0adb578ce..3035cc8e0 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py @@ -210,10 +210,16 @@ class TestConnectorNegotiatorSubagent: class TestBuildSpecializedSubagents: - def test_returns_three_specs(self) -> None: + def test_returns_five_specs(self) -> None: specs = build_specialized_subagents(tools=ALL_TOOLS) names = [s["name"] for s in specs] # type: ignore[index] - assert names == ["explore", "report_writer", "connector_negotiator"] + assert names == [ + "explore", + "report_writer", + "linear_specialist", + "slack_specialist", + "connector_negotiator", + ] def test_all_specs_have_unique_names(self) -> None: specs = build_specialized_subagents(tools=ALL_TOOLS) From 3908131105ee34f93478dad4871b718c8ec47037 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 21:36:34 +0200 Subject: [PATCH 015/131] Fix stale feature flags cache so specialized subagents respect env. --- .../app/agents/new_chat/chat_deepagent.py | 4 ++++ .../app/agents/new_chat/feature_flags.py | 23 ++++++++----------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index bfb94ba2d..1bdeee100 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -607,6 +607,10 @@ def _build_compiled_agent_blocking( model=llm, extra_middleware=subagent_extra_middleware, ) + logging.info( + "Specialized subagents registered for task tool: %s", + [s["name"] for s in specialized_subagents], + ) except Exception as exc: # pragma: no cover - defensive logging.warning( "Specialized subagent build failed; running without them: %s", diff --git a/surfsense_backend/app/agents/new_chat/feature_flags.py b/surfsense_backend/app/agents/new_chat/feature_flags.py index 55525abc5..5d770de00 100644 --- a/surfsense_backend/app/agents/new_chat/feature_flags.py +++ b/surfsense_backend/app/agents/new_chat/feature_flags.py @@ -172,24 +172,19 @@ class AgentFeatureFlags: ) -# Module-level cache. Read once at import time so the values are consistent -# across the process lifetime. Use ``reload_for_tests`` to reset in tests. -_FLAGS: AgentFeatureFlags | None = None - - def get_flags() -> AgentFeatureFlags: - """Return the resolved feature-flag state, caching on first call.""" - global _FLAGS - if _FLAGS is None: - _FLAGS = AgentFeatureFlags.from_env() - return _FLAGS + """Return the resolved feature-flag state from the **current** process environment. + + Intentionally **not** cached: ``load_dotenv`` and operator edits to env vars + must affect the next agent build without requiring a full process restart. + Cost is negligible (reads ``os.environ`` once per call). + """ + return AgentFeatureFlags.from_env() def reload_for_tests() -> AgentFeatureFlags: - """Force a fresh read from env. Tests should call this after monkeypatching env.""" - global _FLAGS - _FLAGS = AgentFeatureFlags.from_env() - return _FLAGS + """Compatibility helper for tests; equivalent to :func:`get_flags`.""" + return AgentFeatureFlags.from_env() __all__ = [ From 525527d8027f0c79cea8040d924d39ad5d4aaf58 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 016/131] Add deep_agent package for supervisor baseline. --- .../agents/new_chat_supervisor_baseline/deep_agent/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/__init__.py diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/__init__.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/__init__.py new file mode 100644 index 000000000..df82f9377 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/__init__.py @@ -0,0 +1 @@ +"""Helpers used only by :mod:`app.agents.new_chat_supervisor_baseline.chat_deepagent`.""" From 15e105454d1d75334875e6d6d3e6f07274ec76f4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 017/131] Map connector enums to searchable KB types. --- .../deep_agent/connector_searchable.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/connector_searchable.py diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/connector_searchable.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/connector_searchable.py new file mode 100644 index 000000000..974416dfb --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/connector_searchable.py @@ -0,0 +1,62 @@ +"""Map connector enum values to searchable document/connector type strings.""" + +from __future__ import annotations + +from typing import Any + +_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = { + "TAVILY_API": "TAVILY_API", + "LINKUP_API": "LINKUP_API", + "BAIDU_SEARCH_API": "BAIDU_SEARCH_API", + "SLACK_CONNECTOR": "SLACK_CONNECTOR", + "TEAMS_CONNECTOR": "TEAMS_CONNECTOR", + "NOTION_CONNECTOR": "NOTION_CONNECTOR", + "GITHUB_CONNECTOR": "GITHUB_CONNECTOR", + "LINEAR_CONNECTOR": "LINEAR_CONNECTOR", + "DISCORD_CONNECTOR": "DISCORD_CONNECTOR", + "JIRA_CONNECTOR": "JIRA_CONNECTOR", + "CONFLUENCE_CONNECTOR": "CONFLUENCE_CONNECTOR", + "CLICKUP_CONNECTOR": "CLICKUP_CONNECTOR", + "GOOGLE_CALENDAR_CONNECTOR": "GOOGLE_CALENDAR_CONNECTOR", + "GOOGLE_GMAIL_CONNECTOR": "GOOGLE_GMAIL_CONNECTOR", + "GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE", + "AIRTABLE_CONNECTOR": "AIRTABLE_CONNECTOR", + "LUMA_CONNECTOR": "LUMA_CONNECTOR", + "ELASTICSEARCH_CONNECTOR": "ELASTICSEARCH_CONNECTOR", + "WEBCRAWLER_CONNECTOR": "CRAWLED_URL", + "BOOKSTACK_CONNECTOR": "BOOKSTACK_CONNECTOR", + "CIRCLEBACK_CONNECTOR": "CIRCLEBACK", + "OBSIDIAN_CONNECTOR": "OBSIDIAN_CONNECTOR", + "DROPBOX_CONNECTOR": "DROPBOX_FILE", + "ONEDRIVE_CONNECTOR": "ONEDRIVE_FILE", + "COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE", + "COMPOSIO_GMAIL_CONNECTOR": "GOOGLE_GMAIL_CONNECTOR", + "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "GOOGLE_CALENDAR_CONNECTOR", +} + +_ALWAYS_AVAILABLE_DOC_TYPES: tuple[str, ...] = ( + "EXTENSION", + "FILE", + "NOTE", + "YOUTUBE_VIDEO", +) + + +def map_connectors_to_searchable_types(connector_types: list[Any]) -> list[str]: + """Map connector types to searchable strings; dedupe preserving order.""" + result_set: set[str] = set() + result_list: list[str] = [] + + for doc_type in _ALWAYS_AVAILABLE_DOC_TYPES: + if doc_type not in result_set: + result_set.add(doc_type) + result_list.append(doc_type) + + for ct in connector_types: + ct_str = ct.value if hasattr(ct, "value") else str(ct) + searchable = _CONNECTOR_TYPE_TO_SEARCHABLE.get(ct_str) + if searchable and searchable not in result_set: + result_set.add(searchable) + result_list.append(searchable) + + return result_list From 615a23b3ec54a4168a0bc0ace3a32a5e92720d5a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 018/131] Build compiled LangGraph agent without tool-injecting middleware. --- .../deep_agent/compiled_agent.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py new file mode 100644 index 000000000..b43e0364d --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py @@ -0,0 +1,112 @@ +"""Compile a minimal supervisor graph: no bound tools, no tool-injecting middleware.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from deepagents import __version__ as deepagents_version +from deepagents.backends import StateBackend +from langchain.agents import create_agent +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.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.agents.new_chat.middleware import ( + AnonymousDocumentMiddleware, + FileIntentMiddleware, + KnowledgeBasePersistenceMiddleware, + KnowledgePriorityMiddleware, + KnowledgeTreeMiddleware, + MemoryInjectionMiddleware, + create_surfsense_compaction_middleware, +) +from app.db import ChatVisibility + + +def build_compiled_agent_blocking( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + final_system_prompt: str, + 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, + flags: AgentFeatureFlags, + checkpointer: Checkpointer, +): + """Build middleware + compile graph synchronously (typically ``asyncio.to_thread``). + + Intentionally excludes registry tools (``tools`` should be ``[]``), SubAgent/task, + filesystem/todo/skills middleware, and tool-centric hygiene (repair, dedup, permission). + """ + _ = flags # retained for API parity with callers; stack is fixed minimal for now + + _memory_middleware = MemoryInjectionMiddleware( + user_id=user_id, + search_space_id=search_space_id, + thread_visibility=visibility, + ) + + summarization_mw = create_surfsense_compaction_middleware(llm, StateBackend) + + deepagent_middleware = [ + _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), + 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, + summarization_mw, + AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), + ] + deepagent_middleware = [m for m in deepagent_middleware if m is not None] + + agent = create_agent( + llm, + system_prompt=final_system_prompt, + tools=list(tools), + middleware=deepagent_middleware, + context_schema=SurfSenseContextSchema, + checkpointer=checkpointer, + ) + return agent.with_config( + { + "recursion_limit": 10_000, + "metadata": { + "ls_integration": "deepagents", + "versions": {"deepagents": deepagents_version}, + }, + } + ) From c1273c41b91e2d5ff3f80e2d0ff21cae4898e5b7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 019/131] Add prompts package for supervisor baseline fragments. --- .../app/agents/new_chat_supervisor_baseline/prompts/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py new file mode 100644 index 000000000..68441a70e --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py @@ -0,0 +1 @@ +"""Supervisor-scoped prompt fragments (adaptations of ``new_chat/prompts/base``).""" From b506c79611b3956d47e5a1cf0b6620fb522126a9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 020/131] Describe LangGraph supervisor scope for baseline. --- .../prompts/supervisor_graph_role.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md new file mode 100644 index 000000000..875e09510 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md @@ -0,0 +1,9 @@ + +This node follows the LangGraph multi-agent **supervisor** pattern: the supervisor +language model responds from the current conversation and optional supervisor-scoped +system prompt (see LangChain Reference: ``langgraph_supervisor.create_supervisor``, +parameter ``prompt`` — typically a ``SystemMessage`` that scopes routing and handoff +behavior). In this SurfSense deployment the supervisor graph does **not** attach +registry tools or worker subgraphs—answer from messages and system-injected context, +and state plainly when the user expects tools or delegations that are not wired here. + From 26095805a8cecd1e0d6a0146391293de15aa103c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 021/131] Add private-space KB-first policy for supervisor prompts. --- .../prompts/kb_policy_supervisor_private.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md new file mode 100644 index 000000000..45dc30869 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md @@ -0,0 +1,18 @@ + +Adapted from ``prompts/base/kb_only_policy_private.md`` for supervisor-only runs (no web +search / scrape / connector tools on this node). + +CRITICAL RULE — KNOWLEDGE CONTEXT FIRST FOR FACTUAL QUESTIONS: +- For factual or informational questions, rely on information in this thread and on + knowledge SurfSense surfaces in your prompt (for example priority document excerpts + or injected memory text). Do not substitute unchecked general knowledge unless the + user explicitly opts in. +- If nothing in the conversation or injected context answers the question, you MUST: + 1. Say you could not find it in the available SurfSense context for this turn. + 2. Ask: "Would you like me to answer from my general knowledge instead?" + 3. ONLY provide a general-knowledge answer AFTER the user explicitly says yes. +- This policy does NOT apply to: + * Casual conversation, greetings, or meta-questions about SurfSense itself + * Formatting, summarization, or analysis of content already present in the conversation + * Following user instructions that are clearly task-oriented (e.g., "rewrite this in bullet points") + From fc68cbf22f9c80870ea21d500553907df1df983b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 022/131] Add team-space KB-first policy for supervisor prompts. --- .../prompts/kb_policy_supervisor_team.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md new file mode 100644 index 000000000..c201d11c1 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md @@ -0,0 +1,18 @@ + +Adapted from ``prompts/base/kb_only_policy_team.md`` for supervisor-only runs (no web +search / scrape / connector tools on this node). + +CRITICAL RULE — TEAM KNOWLEDGE CONTEXT FIRST FOR FACTUAL QUESTIONS: +- For factual or informational questions, rely on information in this thread and on + knowledge SurfSense surfaces in your prompt from the shared space (for example + priority document excerpts or injected memory text). Do not substitute unchecked + general knowledge unless a team member explicitly opts in. +- If nothing in the conversation or injected context answers the question, you MUST: + 1. Say you could not find it in the available SurfSense context for this turn. + 2. Ask: "Would you like me to answer from my general knowledge instead?" + 3. ONLY provide a general-knowledge answer AFTER a team member explicitly says yes. +- This policy does NOT apply to: + * Casual conversation, greetings, or meta-questions about SurfSense itself + * Formatting, summarization, or analysis of content already present in the conversation + * Following user instructions that are clearly task-oriented (e.g., "rewrite this in bullet points") + From 06f4b9b4d5254ffa661c08a4e9ed23075f7189fc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 023/131] Clarify memory limits without update_memory tool. --- .../prompts/memory_context_supervisor.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md new file mode 100644 index 000000000..7d5a7c648 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md @@ -0,0 +1,9 @@ + +Derived from ``prompts/base/memory_protocol_*.md``, without requiring ``update_memory`` +calls (this supervisor node does not expose that tool). + +Personalized memory text may be injected into your prompt when configured. You cannot +persist new long-term memory from this supervisor node; if the user asks you to +remember something permanently, explain that doing so requires the full SurfSense +agent with memory tools enabled or another persistence path they configure. + From 53adac0cc9c63282e8e179de6f62478c4662a3dc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 024/131] Compose supervisor prompt from SurfSense fragments and composer blocks. --- .../supervisor_system_prompt.py | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py new file mode 100644 index 000000000..82c0077e3 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py @@ -0,0 +1,121 @@ +"""Supervisor-scoped system prompt for ``new_chat_supervisor_baseline``. + +Composition follows the same fragment discipline as +:func:`app.agents.new_chat.prompts.composer.compose_system_prompt`, but **omits** +sections that assume registry tools: ``base/tool_routing_*.md``, ``tools/_preamble.md``, +the tools/examples blocks, ``base/parameter_resolution.md`` (discovery lists concrete +tools), and ``base/memory_protocol_*.md`` (requires ``update_memory`` calls). + +**Authoritative supervisor semantics:** LangChain Reference documents +``langgraph_supervisor.create_supervisor`` — the supervisor graph accepts an optional +``prompt`` (typically a ``SystemMessage``) that scopes the supervisor LLM alongside +managed worker graphs. + +**SurfSense sources reused verbatim where applicable:** ``prompts/base/agent_private.md`` / +``agent_team.md`` from :mod:`app.agents.new_chat.prompts`. KB policy is adapted from +``base/kb_only_policy_*.md`` into supervisor-local fragments that reference injected +context instead of tool outputs. Provider and citation blocks reuse +``composer._build_provider_block`` / ``_build_citation_block`` and +``composer.detect_provider_variant`` unchanged. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from importlib import resources + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.llm_config import AgentConfig +from app.agents.new_chat.prompts import composer as pc +from app.db import ChatVisibility + +_SUP_PROMPTS_PKG = "app.agents.new_chat_supervisor_baseline.prompts" + + +def _read_supervisor_fragment(filename: str) -> str: + try: + ref = resources.files(_SUP_PROMPTS_PKG).joinpath(filename) + if not ref.is_file(): + return "" + text = ref.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError, OSError): + return "" + if text.endswith("\n"): + text = text[:-1] + return text + + +def _build_supervisor_system_instruction_block( + *, + visibility: ChatVisibility, + resolved_today: str, +) -> str: + """```` body: LangGraph supervisor scope + SurfSense identity + adapted KB + memory limits.""" + variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private" + sections = [ + _read_supervisor_fragment("supervisor_graph_role.md"), + pc._read_fragment(f"base/agent_{variant}.md"), + _read_supervisor_fragment(f"kb_policy_supervisor_{variant}.md"), + _read_supervisor_fragment("memory_context_supervisor.md"), + ] + body = "\n\n".join(s for s in sections if s) + block = f"\n\n{body}\n\n\n" + return block.format(resolved_today=resolved_today) + + +def resolve_llm_model_name(llm: BaseChatModel) -> str | None: + """Best-effort model id string for :func:`composer.detect_provider_variant`.""" + name = getattr(llm, "model_name", None) + if isinstance(name, str) and name.strip(): + return name.strip() + model = getattr(llm, "model", None) + if isinstance(model, str) and model.strip(): + return model.strip() + profile = getattr(llm, "profile", None) + if isinstance(profile, dict): + for key in ("model", "model_name"): + m = profile.get(key) + if isinstance(m, str) and m.strip(): + return m.strip() + return None + + +def build_supervisor_system_prompt( + *, + agent_config: AgentConfig | None, + thread_visibility: ChatVisibility | None, + llm: BaseChatModel, +) -> str: + """Assemble the supervisor system prompt (no tool-list or tool-routing fragments).""" + resolved_today = datetime.now(UTC).astimezone(UTC).date().isoformat() + visibility = thread_visibility or ChatVisibility.PRIVATE + model_name = resolve_llm_model_name(llm) + + if agent_config is not None: + custom = (agent_config.system_instructions or "").strip() + if custom: + sys_block = agent_config.system_instructions.format(resolved_today=resolved_today) + elif agent_config.use_default_system_instructions: + sys_block = _build_supervisor_system_instruction_block( + visibility=visibility, + resolved_today=resolved_today, + ) + else: + sys_block = "" + else: + sys_block = _build_supervisor_system_instruction_block( + visibility=visibility, + resolved_today=resolved_today, + ) + + provider_variant = pc.detect_provider_variant(model_name) + sys_block += pc._build_provider_block(provider_variant) + + if agent_config is None: + citations_enabled = True + else: + citations_enabled = agent_config.citations_enabled + + sys_block += pc._build_citation_block(citations_enabled) + return sys_block From 93fb4011e90e383d1e5a35602e9a813855026564 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 025/131] Add async factory for tool-free supervisor baseline agent. --- .../chat_deepagent.py | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py new file mode 100644 index 000000000..3626a4789 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py @@ -0,0 +1,145 @@ +""" +Supervisor baseline: **no registry tools** and **no tool-injecting middleware** +(no ``task`` / subagents, filesystem, todos, skills, permission, pruning, repair, …). + +Connector/document discovery still feeds :class:`KnowledgePriorityMiddleware` so turns +can include KB priority hints. + +System prompt: :func:`build_supervisor_system_prompt` — SurfSense ``agent_*`` identity +fragments plus supervisor-scoped KB/memory text and composer citation/provider blocks, +without tool lists or ``tool_routing`` (see module docstring there). + +See :mod:`app.agents.new_chat.chat_deepagent` for the full production agent. + +Implementation: :mod:`app.agents.new_chat_supervisor_baseline.deep_agent`. +""" + +import asyncio +import logging +import time +from collections.abc import Sequence + +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.new_chat.feature_flags import AgentFeatureFlags, get_flags +from app.agents.new_chat.filesystem_selection import FilesystemSelection +from app.agents.new_chat.llm_config import AgentConfig +from app.db import ChatVisibility +from app.services.connector_service import ConnectorService +from app.utils.perf import get_perf_logger + +from app.agents.new_chat_supervisor_baseline.deep_agent.compiled_agent import ( + build_compiled_agent_blocking, +) +from app.agents.new_chat_supervisor_baseline.deep_agent.connector_searchable import ( + map_connectors_to_searchable_types, +) +from app.agents.new_chat_supervisor_baseline.supervisor_system_prompt import ( + build_supervisor_system_prompt, +) + +_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, +): + """ + Build the supervisor baseline agent: registry tools are not loaded. + + Parameters such as ``enabled_tools``, ``additional_tools``, and ``firecrawl_api_key`` + are ignored for now; kept so call sites stay compatible. + """ + _ = (enabled_tools, disabled_tools, additional_tools, firecrawl_api_key, db_session) + + _t_agent_total = time.perf_counter() + + filesystem_selection = filesystem_selection or FilesystemSelection() + _fs_mode = filesystem_selection.mode + + 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 + ) + if connector_types: + 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(f"Failed to discover available connectors/document types: {e}") + _perf_log.info( + "[create_agent] Connector/doc-type discovery in %.3fs", + time.perf_counter() - _t0, + ) + + visibility = thread_visibility or ChatVisibility.PRIVATE + + tools: list[BaseTool] = [] + + _flags: AgentFeatureFlags = get_flags() + _perf_log.info("[create_agent] supervisor baseline: 0 registry tools") + + _t0 = time.perf_counter() + + final_system_prompt = build_supervisor_system_prompt( + agent_config=agent_config, + thread_visibility=thread_visibility, + llm=llm, + ) + _perf_log.info( + "[create_agent] System prompt built in %.3fs", time.perf_counter() - _t0 + ) + + _t0 = time.perf_counter() + agent = await asyncio.to_thread( + build_compiled_agent_blocking, + llm=llm, + tools=tools, + final_system_prompt=final_system_prompt, + filesystem_mode=_fs_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, + flags=_flags, + checkpointer=checkpointer, + ) + _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 From b9132f8544d172c294229d80f7179c5a38d882e9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 29 Apr 2026 23:41:29 +0200 Subject: [PATCH 026/131] Export supervisor baseline create_surfsense_deep_agent. --- .../new_chat_supervisor_baseline/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py new file mode 100644 index 000000000..e8939e4ca --- /dev/null +++ b/surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py @@ -0,0 +1,16 @@ +"""Baseline deep-agent factory without SurfSense specialist subagents. + +Swap imports manually while building supervisor-style delegation:: + + # from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent + from app.agents.new_chat_supervisor_baseline.chat_deepagent import ( + create_surfsense_deep_agent, + ) + +""" + +from app.agents.new_chat_supervisor_baseline.chat_deepagent import ( + create_surfsense_deep_agent, +) + +__all__ = ["create_surfsense_deep_agent"] From 5ff2678253d0ab6107a4bb9278d4a9cbc19a4d59 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 00:59:47 +0200 Subject: [PATCH 027/131] Add multi_agent_chat shared helpers for prompts, domain agents, deps, and invoke parsing. --- .../multi_agent_chat/shared/__init__.py | 13 +++++++++ .../agents/multi_agent_chat/shared/deps.py | 18 +++++++++++++ .../shared/domain_agent_factory.py | 27 +++++++++++++++++++ .../multi_agent_chat/shared/invoke_output.py | 17 ++++++++++++ .../multi_agent_chat/shared/prompt_loader.py | 19 +++++++++++++ 5 files changed, 94 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/deps.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py new file mode 100644 index 000000000..1ef1ad771 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py @@ -0,0 +1,13 @@ +"""Cross-cutting helpers: prompt loading, domain agent factory, connector deps.""" + +from app.agents.multi_agent_chat.shared.deps import connector_binding +from app.agents.multi_agent_chat.shared.domain_agent_factory import build_domain_agent +from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text +from app.agents.multi_agent_chat.shared.prompt_loader import read_prompt_md + +__all__ = [ + "build_domain_agent", + "connector_binding", + "extract_last_assistant_text", + "read_prompt_md", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/deps.py b/surfsense_backend/app/agents/multi_agent_chat/shared/deps.py new file mode 100644 index 000000000..c1e18e849 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/shared/deps.py @@ -0,0 +1,18 @@ +"""Shared kwargs for ``new_chat`` connector tool factories.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + + +def connector_binding( + *, + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> dict[str, AsyncSession | int | str]: + return { + "db_session": db_session, + "search_space_id": search_space_id, + "user_id": user_id, + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py b/surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py new file mode 100644 index 000000000..c6c5b061a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py @@ -0,0 +1,27 @@ +"""Compile a domain agent graph from a co-located prompt + tool list.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain.agents import create_agent +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.shared.prompt_loader import read_prompt_md + + +def build_domain_agent( + llm: BaseChatModel, + tools: Sequence[BaseTool], + *, + prompt_package: str, + prompt_stem: str = "domain_prompt", +): + """``create_agent`` + ``{prompt_stem}.md`` loaded from ``prompt_package``.""" + system_prompt = read_prompt_md(prompt_package, prompt_stem) + return create_agent( + llm, + system_prompt=system_prompt, + tools=list(tools), + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py b/surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py new file mode 100644 index 000000000..2bbab6e57 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py @@ -0,0 +1,17 @@ +"""Extract displayable text from a LangGraph agent ``invoke`` / ``ainvoke`` result.""" + +from __future__ import annotations + +from typing import Any + + +def extract_last_assistant_text(result: dict[str, Any]) -> str: + """Return the last message's string content, or ``\"\"`` if missing.""" + messages = result.get("messages") or [] + if not messages: + return "" + last = messages[-1] + content = getattr(last, "content", None) + if isinstance(content, str): + return content + return str(last) diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py b/surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py new file mode 100644 index 000000000..940647364 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py @@ -0,0 +1,19 @@ +"""Load ``*.md`` from any package (vertical slices use co-located prompts).""" + +from __future__ import annotations + +from importlib import resources + + +def read_prompt_md(package: str, stem: str) -> str: + """Read ``{stem}.md`` from the given import package (e.g. ``app.agents.multi_agent_chat.gmail``).""" + try: + ref = resources.files(package).joinpath(f"{stem}.md") + if not ref.is_file(): + return "" + text = ref.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError, OSError, TypeError): + return "" + if text.endswith("\n"): + text = text[:-1] + return text From 09a46a282a7e9c7bcad732a0235aaf36d2384382 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 00:59:55 +0200 Subject: [PATCH 028/131] Add multi_agent_chat gmail slice with connector tools, agent, and domain prompt. --- .../agents/multi_agent_chat/gmail/__init__.py | 9 +++++ .../agents/multi_agent_chat/gmail/agent.py | 21 +++++++++++ .../multi_agent_chat/gmail/connector_tools.py | 37 +++++++++++++++++++ .../multi_agent_chat/gmail/domain_prompt.md | 1 + 4 files changed, 68 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py new file mode 100644 index 000000000..dbd4911e0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py @@ -0,0 +1,9 @@ +"""Gmail vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.gmail.agent import build_gmail_domain_agent +from app.agents.multi_agent_chat.gmail.connector_tools import build_gmail_connector_tools + +__all__ = [ + "build_gmail_connector_tools", + "build_gmail_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py new file mode 100644 index 000000000..1e591986f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py @@ -0,0 +1,21 @@ +"""Gmail domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.gmail as gmail_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.shared.domain_agent_factory import build_domain_agent + + +def build_gmail_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Gmail domain-agent graph (prompt + tools co-located under ``gmail``).""" + return build_domain_agent( + llm, + tools, + prompt_package=gmail_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py b/surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py new file mode 100644 index 000000000..4042293ad --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py @@ -0,0 +1,37 @@ +"""Gmail connector LangChain tools (``new_chat`` factories; order matches registry).""" + +from __future__ import annotations + +from langchain_core.tools import BaseTool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.multi_agent_chat.shared.deps import connector_binding +from app.agents.new_chat.tools.gmail import ( + create_create_gmail_draft_tool, + create_read_gmail_email_tool, + create_search_gmail_tool, + create_send_gmail_email_tool, + create_trash_gmail_email_tool, + create_update_gmail_draft_tool, +) + + +def build_gmail_connector_tools( + *, + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> list[BaseTool]: + d = connector_binding( + db_session=db_session, + search_space_id=search_space_id, + user_id=user_id, + ) + return [ + create_search_gmail_tool(**d), + create_read_gmail_email_tool(**d), + create_create_gmail_draft_tool(**d), + create_send_gmail_email_tool(**d), + create_trash_gmail_email_tool(**d), + create_update_gmail_draft_tool(**d), + ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md new file mode 100644 index 000000000..4f51f10f6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md @@ -0,0 +1 @@ +You are the Gmail domain agent. Use only the tools provided to complete Gmail-related tasks. Stay focused on email operations and respond concisely. From 5a0a265b2b19a9d8b39a3643e6dfa60309d05c4f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 00:59:56 +0200 Subject: [PATCH 029/131] Add multi_agent_chat calendar slice with connector tools, agent, and domain prompt. --- .../multi_agent_chat/calendar/__init__.py | 11 +++++++ .../agents/multi_agent_chat/calendar/agent.py | 21 ++++++++++++ .../calendar/connector_tools.py | 33 +++++++++++++++++++ .../calendar/domain_prompt.md | 1 + 4 files changed, 66 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py new file mode 100644 index 000000000..7d207b01f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py @@ -0,0 +1,11 @@ +"""Google Calendar vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.calendar.agent import build_calendar_domain_agent +from app.agents.multi_agent_chat.calendar.connector_tools import ( + build_google_calendar_connector_tools, +) + +__all__ = [ + "build_calendar_domain_agent", + "build_google_calendar_connector_tools", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py new file mode 100644 index 000000000..23110ea61 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py @@ -0,0 +1,21 @@ +"""Google Calendar domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.calendar as calendar_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.shared.domain_agent_factory import build_domain_agent + + +def build_calendar_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Calendar domain-agent graph (prompt + tools co-located under ``calendar``).""" + return build_domain_agent( + llm, + tools, + prompt_package=calendar_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py b/surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py new file mode 100644 index 000000000..8fb7356ff --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py @@ -0,0 +1,33 @@ +"""Google Calendar connector LangChain tools (``new_chat`` factories).""" + +from __future__ import annotations + +from langchain_core.tools import BaseTool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.multi_agent_chat.shared.deps import connector_binding +from app.agents.new_chat.tools.google_calendar import ( + create_create_calendar_event_tool, + create_delete_calendar_event_tool, + create_search_calendar_events_tool, + create_update_calendar_event_tool, +) + + +def build_google_calendar_connector_tools( + *, + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> list[BaseTool]: + d = connector_binding( + db_session=db_session, + search_space_id=search_space_id, + user_id=user_id, + ) + return [ + create_search_calendar_events_tool(**d), + create_create_calendar_event_tool(**d), + create_update_calendar_event_tool(**d), + create_delete_calendar_event_tool(**d), + ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md new file mode 100644 index 000000000..6815e77db --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md @@ -0,0 +1 @@ +You are the Google Calendar domain agent. Use only the tools provided to complete calendar-related tasks. Stay focused on scheduling and calendar operations and respond concisely. From 0fcb2acfdc42eefdc6fc3d92597806dd88821962 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 00:59:57 +0200 Subject: [PATCH 030/131] Add multi_agent_chat supervisor agent graph and supervisor prompt. --- .../multi_agent_chat/supervisor/__init__.py | 5 ++++ .../multi_agent_chat/supervisor/graph.py | 30 +++++++++++++++++++ .../supervisor/supervisor_prompt.md | 1 + 3 files changed, 36 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py new file mode 100644 index 000000000..d96ee3e39 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py @@ -0,0 +1,5 @@ +"""Supervisor agent graph only; supply routing ``tools`` from ``build_supervisor_routing_tools``.""" + +from app.agents.multi_agent_chat.supervisor.graph import build_supervisor_agent + +__all__ = ["build_supervisor_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py new file mode 100644 index 000000000..5cee73c37 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py @@ -0,0 +1,30 @@ +"""Compile the supervisor agent graph (supervisor prompt + caller-supplied routing tools).""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.supervisor as supervisor_pkg + +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.shared.prompt_loader import read_prompt_md + + +def build_supervisor_agent( + llm: BaseChatModel, + *, + tools: Sequence[BaseTool], + checkpointer: Checkpointer | None = None, +): + """Compile the supervisor **agent** (graph). ``tools`` = output of ``build_supervisor_routing_tools``.""" + system_prompt = read_prompt_md(supervisor_pkg.__name__, "supervisor_prompt") + return create_agent( + llm, + system_prompt=system_prompt, + tools=list(tools), + checkpointer=checkpointer, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md new file mode 100644 index 000000000..071a9eafe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md @@ -0,0 +1 @@ +You are the supervisor agent. Route Gmail-related requests through the **gmail** tool and Google Calendar requests through the **calendar** tool, each with a clear task description. Answer directly when no sub-agent is needed. When sub-agents return results, combine them into one coherent reply for the user. From f1a5f1c683b19110c1548a052e4cc8ce23340bb5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 00:59:58 +0200 Subject: [PATCH 031/131] Add multi_agent_chat routing tools and supervisor routing composition. --- .../multi_agent_chat/routing/__init__.py | 11 +++++ .../routing/from_domain_agents.py | 45 +++++++++++++++++++ .../routing/supervisor_routing.py | 27 +++++++++++ 3 files changed, 83 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py new file mode 100644 index 000000000..783d1fad2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py @@ -0,0 +1,11 @@ +"""Supervisor routing: domain-agent wrappers and composed routing tool lists.""" + +from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_domain_agents +from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools +from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text + +__all__ = [ + "build_supervisor_routing_tools", + "extract_last_assistant_text", + "routing_tools_from_domain_agents", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py new file mode 100644 index 000000000..92ca14150 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py @@ -0,0 +1,45 @@ +"""LangChain ``@tool`` wrappers that invoke compiled domain-agent graphs (supervisor-facing only).""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool, tool + +from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text + + +def routing_tools_from_domain_agents( + *, + gmail_domain_agent: Any, + calendar_domain_agent: Any, +) -> list[BaseTool]: + """Build ``gmail`` / ``calendar`` tools that invoke the given graphs (factory, not import-time exports).""" + + @tool( + "gmail", + description=( + "Route Gmail-related work to the Gmail sub-agent. " + "Pass a clear natural-language task." + ), + ) + def call_gmail_agent(task: str) -> str: + result = gmail_domain_agent.invoke( + {"messages": [{"role": "user", "content": task}]} + ) + return extract_last_assistant_text(result) + + @tool( + "calendar", + description=( + "Route Google Calendar work to the Calendar sub-agent. " + "Pass a clear natural-language task." + ), + ) + def call_calendar_agent(task: str) -> str: + result = calendar_domain_agent.invoke( + {"messages": [{"role": "user", "content": task}]} + ) + return extract_last_assistant_text(result) + + return [call_gmail_agent, call_calendar_agent] diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py new file mode 100644 index 000000000..a69528b8e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -0,0 +1,27 @@ +"""Compose domain agents + connector tool lists into supervisor ``gmail`` / ``calendar`` routing tools.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.calendar import build_calendar_domain_agent +from app.agents.multi_agent_chat.gmail import build_gmail_domain_agent +from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_domain_agents + + +def build_supervisor_routing_tools( + llm: BaseChatModel, + *, + gmail_tools: Sequence[BaseTool] | None = None, + calendar_tools: Sequence[BaseTool] | None = None, +) -> list[BaseTool]: + """Domain agents (with their connector tools) → ``gmail`` / ``calendar`` routing tools.""" + gmail_domain_agent = build_gmail_domain_agent(llm, list(gmail_tools or [])) + calendar_domain_agent = build_calendar_domain_agent(llm, list(calendar_tools or [])) + return routing_tools_from_domain_agents( + gmail_domain_agent=gmail_domain_agent, + calendar_domain_agent=calendar_domain_agent, + ) From e18675a40a8b6d888d34087894d01e72677132b0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 00:59:59 +0200 Subject: [PATCH 032/131] Add multi_agent_chat integration entrypoint create_multi_agent_chat. --- .../multi_agent_chat/integration/__init__.py | 5 +++ .../integration/create_multi_agent_chat.py | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py new file mode 100644 index 000000000..6dee8c1be --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py @@ -0,0 +1,5 @@ +"""Full-stack wiring (DB-scoped) on top of :mod:`routing` and :mod:`supervisor`.""" + +from app.agents.multi_agent_chat.integration.create_multi_agent_chat import create_multi_agent_chat + +__all__ = ["create_multi_agent_chat"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py new file mode 100644 index 000000000..2d4046134 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py @@ -0,0 +1,37 @@ +"""Single entry: SurfSense connectors + multi-agent stack → compiled supervisor graph.""" + +from __future__ import annotations + +from langchain_core.language_models import BaseChatModel +from langgraph.types import Checkpointer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.multi_agent_chat.calendar import build_google_calendar_connector_tools +from app.agents.multi_agent_chat.gmail import build_gmail_connector_tools +from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools +from app.agents.multi_agent_chat.supervisor import build_supervisor_agent + + +def create_multi_agent_chat( + llm: BaseChatModel, + *, + db_session: AsyncSession, + search_space_id: int, + user_id: str, + checkpointer: Checkpointer | None = None, +): + """Build the full multi-agent chat graph (supervisor + Gmail + Calendar sub-agents via ``new_chat`` tools).""" + routing_tools = build_supervisor_routing_tools( + llm, + gmail_tools=build_gmail_connector_tools( + db_session=db_session, + search_space_id=search_space_id, + user_id=user_id, + ), + calendar_tools=build_google_calendar_connector_tools( + db_session=db_session, + search_space_id=search_space_id, + user_id=user_id, + ), + ) + return build_supervisor_agent(llm, tools=routing_tools, checkpointer=checkpointer) From 0c8ea2085eff101608a182a2fe7ea7b44f7eaaef Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 01:00:01 +0200 Subject: [PATCH 033/131] Add multi_agent_chat package exports and implementation layout doc. --- .../multi_agent_chat/IMPLEMENTATION_PLAN.md | 35 +++++++++++ .../app/agents/multi_agent_chat/__init__.py | 60 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md b/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..eafe6d692 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md @@ -0,0 +1,35 @@ +# `multi_agent_chat` — vertical slices + shared + +``` +multi_agent_chat/ + __init__.py + + shared/ # Cross-domain helpers (one level) + deps.py # connector_binding for new_chat factories + prompt_loader.py # read_prompt_md(package, stem) + domain_agent_factory.py # build_domain_agent(..., prompt_package=...) + invoke_output.py # extract_last_assistant_text (invoke result parsing) + + gmail/ # Gmail slice (agent + tooling + prompt) + domain_prompt.md + connector_tools.py + agent.py + + calendar/ # Google Calendar slice + domain_prompt.md + connector_tools.py + agent.py + + routing/ + from_domain_agents.py + supervisor_routing.py + + supervisor/ + supervisor_prompt.md + graph.py + + integration/ + create_multi_agent_chat.py +``` + +**References:** [Multi-agent](https://docs.langchain.com/oss/python/langchain/multi-agent), [Subagents](https://docs.langchain.com/oss/python/langchain/multi-agent/subagents). diff --git a/surfsense_backend/app/agents/multi_agent_chat/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/__init__.py new file mode 100644 index 000000000..ba4878d15 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/__init__.py @@ -0,0 +1,60 @@ +""" +Multi-agent chat (LangChain Subagents pattern). + +**Vertical slices** + +- :mod:`gmail` — connector tools, domain agent, ``domain_prompt.md`` +- :mod:`calendar` — connector tools, domain agent, ``domain_prompt.md`` + +**Shared** + +- :mod:`shared` — prompt loader, ``build_domain_agent``, connector deps, invoke result parsing + +**Cross-cutting** + +- :mod:`routing` — supervisor routing tools + invoke helpers +- :mod:`supervisor` — top graph + ``supervisor_prompt.md`` +- :mod:`integration` — ``create_multi_agent_chat`` + +Documentation: +https://docs.langchain.com/oss/python/langchain/multi-agent +https://docs.langchain.com/oss/python/langchain/multi-agent/subagents + +Display name: ``multi-agent-chat`` — Python package: ``multi_agent_chat``. +""" + +from app.agents.multi_agent_chat.calendar import ( + build_calendar_domain_agent, + build_google_calendar_connector_tools, +) +from app.agents.multi_agent_chat.gmail import ( + build_gmail_connector_tools, + build_gmail_domain_agent, +) +from app.agents.multi_agent_chat.integration import create_multi_agent_chat +from app.agents.multi_agent_chat.shared import ( + build_domain_agent, + connector_binding, + extract_last_assistant_text, + read_prompt_md, +) +from app.agents.multi_agent_chat.routing import ( + build_supervisor_routing_tools, + routing_tools_from_domain_agents, +) +from app.agents.multi_agent_chat.supervisor import build_supervisor_agent + +__all__ = [ + "build_calendar_domain_agent", + "build_domain_agent", + "build_gmail_connector_tools", + "build_gmail_domain_agent", + "build_google_calendar_connector_tools", + "build_supervisor_agent", + "build_supervisor_routing_tools", + "connector_binding", + "create_multi_agent_chat", + "extract_last_assistant_text", + "read_prompt_md", + "routing_tools_from_domain_agents", +] From c974fcefe60e1a96ab3dcfc8860862fb76f51f5a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 02:36:06 +0200 Subject: [PATCH 034/131] Add multi-agent core registry, delegation, and MCP partitioning. --- .../agents/multi_agent_chat/core/__init__.py | 25 ++++ .../multi_agent_chat/core/agents/__init__.py | 5 + .../core/agents/domain_graph.py | 27 ++++ .../core/bindings/__init__.py | 5 + .../multi_agent_chat/core/bindings/binding.py | 18 +++ .../core/delegation/__init__.py | 5 + .../core/delegation/child_task.py | 15 ++ .../core/invocation/__init__.py | 5 + .../core/invocation/output.py | 17 +++ .../multi_agent_chat/core/mcp_partition.py | 128 ++++++++++++++++++ .../multi_agent_chat/core/prompts/__init__.py | 5 + .../multi_agent_chat/core/prompts/load.py | 19 +++ .../core/registry/__init__.py | 15 ++ .../core/registry/categories.py | 84 ++++++++++++ .../core/registry/dependencies.py | 42 ++++++ .../multi_agent_chat/core/registry/subset.py | 22 +++ 16 files changed, 437 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/__init__.py new file mode 100644 index 000000000..0299138fe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/__init__.py @@ -0,0 +1,25 @@ +"""Cross-cutting building blocks (prompts, agents, delegation, registry) — not domain logic.""" + +from app.agents.multi_agent_chat.core.agents import build_domain_agent +from app.agents.multi_agent_chat.core.bindings import connector_binding +from app.agents.multi_agent_chat.core.delegation import compose_child_task +from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text +from app.agents.multi_agent_chat.core.prompts import read_prompt_md +from app.agents.multi_agent_chat.core.registry import ( + REGISTRY_ROUTING_CATEGORY_KEYS, + TOOL_NAMES_BY_CATEGORY, + build_registry_dependencies, + build_registry_tools_for_category, +) + +__all__ = [ + "REGISTRY_ROUTING_CATEGORY_KEYS", + "TOOL_NAMES_BY_CATEGORY", + "build_domain_agent", + "build_registry_dependencies", + "build_registry_tools_for_category", + "compose_child_task", + "connector_binding", + "extract_last_assistant_text", + "read_prompt_md", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py new file mode 100644 index 000000000..7586c72b0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py @@ -0,0 +1,5 @@ +"""Compiled subgraph factories shared by domain slices.""" + +from app.agents.multi_agent_chat.core.agents.domain_graph import build_domain_agent + +__all__ = ["build_domain_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py b/surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py new file mode 100644 index 000000000..51b745553 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py @@ -0,0 +1,27 @@ +"""Compile a domain LangGraph agent from a co-located prompt + tool list.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain.agents import create_agent +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.prompts import read_prompt_md + + +def build_domain_agent( + llm: BaseChatModel, + tools: Sequence[BaseTool], + *, + prompt_package: str, + prompt_stem: str = "domain_prompt", +): + """``create_agent`` + ``{prompt_stem}.md`` loaded from ``prompt_package``.""" + system_prompt = read_prompt_md(prompt_package, prompt_stem) + return create_agent( + llm, + system_prompt=system_prompt, + tools=list(tools), + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py new file mode 100644 index 000000000..d6a826113 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py @@ -0,0 +1,5 @@ +"""Search-space / DB kwargs shared by ``new_chat`` tool factories (distinct from ``expert_agent.connectors`` integrations).""" + +from app.agents.multi_agent_chat.core.bindings.binding import connector_binding + +__all__ = ["connector_binding"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py new file mode 100644 index 000000000..25e6a03fd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py @@ -0,0 +1,18 @@ +"""Shared kwargs dict for ``new_chat`` tool factories (DB session + search space + user).""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + + +def connector_binding( + *, + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> dict[str, AsyncSession | int | str]: + return { + "db_session": db_session, + "search_space_id": search_space_id, + "user_id": user_id, + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py new file mode 100644 index 000000000..cc27ec6f5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py @@ -0,0 +1,5 @@ +"""Supervisor → domain message shaping.""" + +from app.agents.multi_agent_chat.core.delegation.child_task import compose_child_task + +__all__ = ["compose_child_task"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py new file mode 100644 index 000000000..1d2e86ed2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py @@ -0,0 +1,15 @@ +"""Fold orchestrator-selected context into the single user message sent to a domain agent.""" + +from __future__ import annotations + + +def compose_child_task(task: str, *, curated_context: str | None = None) -> str: + """Build the domain-agent user message: optional curated KB/context + task. + + When ``curated_context`` is set (from supervisor/KB wiring), it is prepended so the + child sees only what orchestration chose — not the full parent transcript. + """ + task = task.strip() + if not curated_context or not curated_context.strip(): + return task + return f"{curated_context.strip()}\n\n---\n\nTask:\n{task}" diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py new file mode 100644 index 000000000..60d0ff9fa --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py @@ -0,0 +1,5 @@ +"""Parsing LangGraph invoke results.""" + +from app.agents.multi_agent_chat.core.invocation.output import extract_last_assistant_text + +__all__ = ["extract_last_assistant_text"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py b/surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py new file mode 100644 index 000000000..2bbab6e57 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py @@ -0,0 +1,17 @@ +"""Extract displayable text from a LangGraph agent ``invoke`` / ``ainvoke`` result.""" + +from __future__ import annotations + +from typing import Any + + +def extract_last_assistant_text(result: dict[str, Any]) -> str: + """Return the last message's string content, or ``\"\"`` if missing.""" + messages = result.get("messages") or [] + if not messages: + return "" + last = messages[-1] + content = getattr(last, "content", None) + if isinstance(content, str): + return content + return str(last) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py new file mode 100644 index 000000000..55980d6ac --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py @@ -0,0 +1,128 @@ +"""Partition MCP tools onto multi-agent expert routes without modifying ``new_chat``. + +Uses the same connector discovery shape as ``load_mcp_tools`` (copied query below). Tools come from +``app.agents.new_chat.tools.mcp_tool.load_mcp_tools``; routing uses metadata already set there: + +- HTTP tools: ``metadata["mcp_connector_id"]`` → DB connector row → expert route. +- stdio tools: no connector id on the tool; ``metadata["mcp_connector_name"]`` → connector name map + (duplicate names: last row wins — rare). +""" + +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.db import SearchSourceConnector + +logger = logging.getLogger(__name__) + +# SurfSense ``SearchSourceConnectorType`` string → supervisor routing key (must match +# ``DomainRoutingSpec.tool_name`` values used in ``supervisor_routing``). +_CONNECTOR_TYPE_TO_EXPERT_ROUTE: 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", + "MCP_CONNECTOR": "generic_mcp", +} + +# Ordering when appending MCP-only routes (no native registry slice for these types). +MCP_ONLY_ROUTE_KEYS_IN_ORDER: tuple[str, ...] = ( + "linear", + "slack", + "jira", + "clickup", + "airtable", + "generic_mcp", +) + + +async def fetch_mcp_connector_metadata_maps( + session: AsyncSession, + search_space_id: int, +) -> tuple[dict[int, str], dict[str, str]]: + """Read-only copy of connector discovery used alongside ``load_mcp_tools``. + + Same filter as ``new_chat.tools.mcp_tool.load_mcp_tools`` (connectors with ``server_config``). + """ + 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 + + +def partition_mcp_tools_by_expert_route( + tools: Sequence[BaseTool], + connector_id_to_type: dict[int, str], + connector_name_to_type: dict[str, str], +) -> dict[str, list[BaseTool]]: + """Bucket MCP tools by expert route key. Supervisor never receives raw MCP tools.""" + 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 + + route = _CONNECTOR_TYPE_TO_EXPERT_ROUTE.get(connector_type) + if route is None: + logger.warning( + "MCP tool %r has unmapped connector type %s — skipped", + getattr(tool, "name", None), + connector_type, + ) + continue + + buckets[route].append(tool) + + return dict(buckets) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py new file mode 100644 index 000000000..92dd9b854 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py @@ -0,0 +1,5 @@ +"""Markdown prompt loading for domain and supervisor packages.""" + +from app.agents.multi_agent_chat.core.prompts.load import read_prompt_md + +__all__ = ["read_prompt_md"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py new file mode 100644 index 000000000..fee9a69f6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py @@ -0,0 +1,19 @@ +"""Load ``*.md`` prompt files from co-located packages (domain slices ship ``domain_prompt.md``).""" + +from __future__ import annotations + +from importlib import resources + + +def read_prompt_md(package: str, stem: str) -> str: + """Read ``{stem}.md`` from the given import package (e.g. ``…expert_agent.connectors.gmail``).""" + try: + ref = resources.files(package).joinpath(f"{stem}.md") + if not ref.is_file(): + return "" + text = ref.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError, OSError, TypeError): + return "" + if text.endswith("\n"): + text = text[:-1] + return text diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py new file mode 100644 index 000000000..0655115c0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py @@ -0,0 +1,15 @@ +"""``new_chat`` tool registry grouping + dependency bundles for domain slices.""" + +from app.agents.multi_agent_chat.core.registry.categories import ( + REGISTRY_ROUTING_CATEGORY_KEYS, + TOOL_NAMES_BY_CATEGORY, +) +from app.agents.multi_agent_chat.core.registry.dependencies import build_registry_dependencies +from app.agents.multi_agent_chat.core.registry.subset import build_registry_tools_for_category + +__all__ = [ + "REGISTRY_ROUTING_CATEGORY_KEYS", + "TOOL_NAMES_BY_CATEGORY", + "build_registry_dependencies", + "build_registry_tools_for_category", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py new file mode 100644 index 000000000..13d8cd12f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py @@ -0,0 +1,84 @@ +"""Registry tool names grouped by multi-agent routing category. + +Each string must match ``ToolDefinition.name`` in +``app.agents.new_chat.tools.registry.BUILTIN_TOOLS`` — these are **not** guessed or MCP-only: +:class:`~app.agents.multi_agent_chat.core.registry.subset.build_registry_tools_for_category` +uses synchronous :func:`~app.agents.new_chat.tools.registry.build_tools`, which only instantiates +``BUILTIN_TOOLS``. MCP tools are loaded separately and merged in ``supervisor_routing``. + +Connectors that exist for search/indexing but have **no** entry in ``BUILTIN_TOOLS`` correctly have +no row here (no chat tools to delegate).""" + +from __future__ import annotations + +# Keys match supervisor routing tool names; values match ``BUILTIN_TOOLS`` names exactly. +TOOL_NAMES_BY_CATEGORY: dict[str, list[str]] = { + "gmail": [ + "search_gmail", + "read_gmail_email", + "create_gmail_draft", + "send_gmail_email", + "trash_gmail_email", + "update_gmail_draft", + ], + "calendar": [ + "search_calendar_events", + "create_calendar_event", + "update_calendar_event", + "delete_calendar_event", + ], + "research": [ + "web_search", + "scrape_webpage", + "search_surfsense_docs", + ], + "deliverables": [ + "generate_podcast", + "generate_video_presentation", + "generate_report", + "generate_resume", + "generate_image", + ], + "memory": [ + "update_memory", + ], + "discord": [ + "list_discord_channels", + "read_discord_messages", + "send_discord_message", + ], + "teams": [ + "list_teams_channels", + "read_teams_messages", + "send_teams_message", + ], + "notion": [ + "create_notion_page", + "update_notion_page", + "delete_notion_page", + ], + "confluence": [ + "create_confluence_page", + "update_confluence_page", + "delete_confluence_page", + ], + "google_drive": [ + "create_google_drive_file", + "delete_google_drive_file", + ], + "dropbox": [ + "create_dropbox_file", + "delete_dropbox_file", + ], + "onedrive": [ + "create_onedrive_file", + "delete_onedrive_file", + ], + "luma": [ + "list_luma_events", + "read_luma_event", + "create_luma_event", + ], +} + +REGISTRY_ROUTING_CATEGORY_KEYS: tuple[str, ...] = tuple(TOOL_NAMES_BY_CATEGORY.keys()) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py new file mode 100644 index 000000000..e7e36d766 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py @@ -0,0 +1,42 @@ +"""Dependency dict for :func:`app.agents.new_chat.tools.registry.build_tools` in multi-agent graphs.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.language_models import BaseChatModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import ChatVisibility + + +def build_registry_dependencies( + *, + db_session: AsyncSession, + search_space_id: int, + user_id: str, + thread_id: str, + llm: BaseChatModel | None = None, + firecrawl_api_key: str | None = None, + connector_service: Any | None = None, + available_connectors: list[str] | None = None, + available_document_types: list[str] | None = None, + thread_visibility: ChatVisibility = ChatVisibility.PRIVATE, +) -> dict[str, Any]: + """Union of kwargs commonly required by registry factories across category slices. + + Individual categories enable a subset of tools; each tool still validates its own + ``ToolDefinition.requires`` against this dict. + """ + return { + "db_session": db_session, + "search_space_id": search_space_id, + "user_id": user_id, + "thread_id": thread_id, + "llm": llm, + "firecrawl_api_key": firecrawl_api_key, + "connector_service": connector_service, + "available_connectors": available_connectors, + "available_document_types": available_document_types, + "thread_visibility": thread_visibility, + } diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py new file mode 100644 index 000000000..027a8af8f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py @@ -0,0 +1,22 @@ +"""Build :mod:`new_chat` registry tool subsets for multi-agent domain slices.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.new_chat.tools.registry import build_tools +from app.agents.multi_agent_chat.core.registry.categories import TOOL_NAMES_BY_CATEGORY + + +def build_registry_tools_for_category( + dependencies: dict[str, Any], + category: str, +) -> list[BaseTool]: + """Instantiate only the tools registered for ``category`` (see ``TOOL_NAMES_BY_CATEGORY``).""" + names = TOOL_NAMES_BY_CATEGORY.get(category) + if not names: + msg = f"Unknown registry category: {category!r}" + raise ValueError(msg) + return build_tools(dependencies, enabled_tools=names) From 1f7343298c8b763036ee86490501f07bf9ccb35f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 02:36:11 +0200 Subject: [PATCH 035/131] Add expert_agent vertical slices and MCP bridge prompts. --- .../multi_agent_chat/expert_agent/__init__.py | 5 ++++ .../expert_agent/builtins/__init__.py | 1 + .../builtins/deliverables/__init__.py | 11 ++++++++ .../builtins/deliverables/agent.py | 21 ++++++++++++++++ .../builtins/deliverables/domain_prompt.md | 1 + .../builtins/deliverables/slice_tools.py | 14 +++++++++++ .../expert_agent/builtins/memory/__init__.py | 9 +++++++ .../expert_agent/builtins/memory/agent.py | 21 ++++++++++++++++ .../builtins/memory/domain_prompt.md | 1 + .../builtins/memory/slice_tools.py | 14 +++++++++++ .../builtins/research/__init__.py | 9 +++++++ .../expert_agent/builtins/research/agent.py | 21 ++++++++++++++++ .../builtins/research/domain_prompt.md | 1 + .../builtins/research/slice_tools.py | 14 +++++++++++ .../expert_agent/connectors/__init__.py | 1 + .../connectors/calendar/__init__.py | 11 ++++++++ .../expert_agent/connectors/calendar/agent.py | 21 ++++++++++++++++ .../connectors/calendar/domain_prompt.md | 1 + .../connectors/calendar/slice_tools.py | 14 +++++++++++ .../connectors/confluence/__init__.py | 11 ++++++++ .../connectors/confluence/agent.py | 21 ++++++++++++++++ .../connectors/confluence/domain_prompt.md | 1 + .../connectors/confluence/slice_tools.py | 14 +++++++++++ .../connectors/discord/__init__.py | 9 +++++++ .../expert_agent/connectors/discord/agent.py | 21 ++++++++++++++++ .../connectors/discord/domain_prompt.md | 1 + .../connectors/discord/slice_tools.py | 14 +++++++++++ .../connectors/dropbox/__init__.py | 11 ++++++++ .../expert_agent/connectors/dropbox/agent.py | 21 ++++++++++++++++ .../connectors/dropbox/domain_prompt.md | 1 + .../connectors/dropbox/slice_tools.py | 14 +++++++++++ .../expert_agent/connectors/gmail/__init__.py | 9 +++++++ .../expert_agent/connectors/gmail/agent.py | 21 ++++++++++++++++ .../connectors/gmail/domain_prompt.md | 1 + .../connectors/gmail/slice_tools.py | 14 +++++++++++ .../connectors/google_drive/__init__.py | 13 ++++++++++ .../connectors/google_drive/agent.py | 21 ++++++++++++++++ .../connectors/google_drive/domain_prompt.md | 1 + .../connectors/google_drive/slice_tools.py | 14 +++++++++++ .../expert_agent/connectors/luma/__init__.py | 9 +++++++ .../expert_agent/connectors/luma/agent.py | 21 ++++++++++++++++ .../connectors/luma/domain_prompt.md | 1 + .../connectors/luma/slice_tools.py | 14 +++++++++++ .../connectors/notion/__init__.py | 11 ++++++++ .../expert_agent/connectors/notion/agent.py | 21 ++++++++++++++++ .../connectors/notion/domain_prompt.md | 1 + .../connectors/notion/slice_tools.py | 14 +++++++++++ .../connectors/onedrive/__init__.py | 11 ++++++++ .../expert_agent/connectors/onedrive/agent.py | 21 ++++++++++++++++ .../connectors/onedrive/domain_prompt.md | 1 + .../connectors/onedrive/slice_tools.py | 14 +++++++++++ .../expert_agent/connectors/teams/__init__.py | 9 +++++++ .../expert_agent/connectors/teams/agent.py | 21 ++++++++++++++++ .../connectors/teams/domain_prompt.md | 1 + .../connectors/teams/slice_tools.py | 14 +++++++++++ .../expert_agent/mcp_bridge/__init__.py | 5 ++++ .../expert_agent/mcp_bridge/agent.py | 25 +++++++++++++++++++ .../mcp_bridge/airtable_domain.md | 1 + .../expert_agent/mcp_bridge/clickup_domain.md | 1 + .../mcp_bridge/generic_mcp_domain.md | 1 + .../expert_agent/mcp_bridge/jira_domain.md | 1 + .../expert_agent/mcp_bridge/linear_domain.md | 1 + .../expert_agent/mcp_bridge/slack_domain.md | 1 + 63 files changed, 644 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py new file mode 100644 index 000000000..c8194306b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py @@ -0,0 +1,5 @@ +"""Expert subgraphs (specialists the supervisor delegates to). + +- :mod:`expert_agent.builtins` — cross-cutting registry categories (e.g. research, memory, deliverables). +- :mod:`expert_agent.connectors` — vendor/product integrations (mail, calendar, chat, doc stores, …). +""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py new file mode 100644 index 000000000..84bd2948d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py @@ -0,0 +1 @@ +"""Built-ins: broad capability categories from the registry (not single-vendor integrations).""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py new file mode 100644 index 000000000..8a225b50b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py @@ -0,0 +1,11 @@ +"""Deliverables vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.builtins.deliverables.agent import build_deliverables_domain_agent +from app.agents.multi_agent_chat.expert_agent.builtins.deliverables.slice_tools import ( + build_deliverables_tools, +) + +__all__ = [ + "build_deliverables_tools", + "build_deliverables_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py new file mode 100644 index 000000000..729dc9410 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py @@ -0,0 +1,21 @@ +"""Deliverables domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.builtins.deliverables as deliverables_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_deliverables_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled deliverables domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=deliverables_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md new file mode 100644 index 000000000..e67100626 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md @@ -0,0 +1 @@ +You are the deliverables domain agent. Use reports, podcasts, video presentations, resumes, and image generation tools as provided. Clarify constraints in your reasoning and respond concisely when reporting results. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py new file mode 100644 index 000000000..2c8e80a55 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed deliverables tools (reports, media exports, resume, images).""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_deliverables_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``deliverables`` category.""" + return build_registry_tools_for_category(dependencies, "deliverables") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py new file mode 100644 index 000000000..0499bfdf4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py @@ -0,0 +1,9 @@ +"""Memory vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.builtins.memory.agent import build_memory_domain_agent +from app.agents.multi_agent_chat.expert_agent.builtins.memory.slice_tools import build_memory_tools + +__all__ = [ + "build_memory_tools", + "build_memory_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py new file mode 100644 index 000000000..6f7999557 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py @@ -0,0 +1,21 @@ +"""Memory domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.builtins.memory as memory_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_memory_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled memory domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=memory_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md new file mode 100644 index 000000000..7d79b874b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md @@ -0,0 +1 @@ +You are the memory domain agent. Use the update_memory tool only when the user explicitly asks to remember something, or when saving durable preferences and facts that should persist across sessions. Do not store secrets unless the user requests it. Respond concisely after updating memory. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py new file mode 100644 index 000000000..0a1485770 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed memory tools (long-term personal or team memory).""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_memory_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``memory`` category.""" + return build_registry_tools_for_category(dependencies, "memory") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py new file mode 100644 index 000000000..ada6c9853 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py @@ -0,0 +1,9 @@ +"""Research vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.builtins.research.agent import build_research_domain_agent +from app.agents.multi_agent_chat.expert_agent.builtins.research.slice_tools import build_research_tools + +__all__ = [ + "build_research_tools", + "build_research_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py new file mode 100644 index 000000000..a7dc635c9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py @@ -0,0 +1,21 @@ +"""Research domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.builtins.research as research_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_research_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled research domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=research_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md new file mode 100644 index 000000000..ce3d05800 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md @@ -0,0 +1 @@ +You are the research domain agent. Use web search, page scraping, and SurfSense documentation search to gather facts. Stay focused on research tasks and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py new file mode 100644 index 000000000..4018c5a18 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed research tools (web, scrape, SurfSense docs help).""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_research_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``research`` category.""" + return build_registry_tools_for_category(dependencies, "research") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py new file mode 100644 index 000000000..f752e4dd9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py @@ -0,0 +1 @@ +"""External integrations: third-party products (explicit factories or registry-backed connector tools).""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py new file mode 100644 index 000000000..d244c6217 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py @@ -0,0 +1,11 @@ +"""Google Calendar vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.calendar.agent import build_calendar_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.calendar.slice_tools import ( + build_calendar_tools, +) + +__all__ = [ + "build_calendar_domain_agent", + "build_calendar_tools", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py new file mode 100644 index 000000000..dd0f99f77 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py @@ -0,0 +1,21 @@ +"""Google Calendar domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.calendar as calendar_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_calendar_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Calendar domain-agent graph (prompt + tools co-located under ``calendar``).""" + return build_domain_agent( + llm, + tools, + prompt_package=calendar_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md new file mode 100644 index 000000000..6815e77db --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md @@ -0,0 +1 @@ +You are the Google Calendar domain agent. Use only the tools provided to complete calendar-related tasks. Stay focused on scheduling and calendar operations and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py new file mode 100644 index 000000000..49e316c01 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Google Calendar tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_calendar_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``calendar`` category.""" + return build_registry_tools_for_category(dependencies, "calendar") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py new file mode 100644 index 000000000..a3aa01959 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py @@ -0,0 +1,11 @@ +"""Confluence connector slice.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.confluence.agent import build_confluence_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.confluence.slice_tools import ( + build_confluence_tools, +) + +__all__ = [ + "build_confluence_tools", + "build_confluence_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py new file mode 100644 index 000000000..2746d31f0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py @@ -0,0 +1,21 @@ +"""Confluence domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.confluence as confluence_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_confluence_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Confluence domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=confluence_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md new file mode 100644 index 000000000..6608aec31 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md @@ -0,0 +1 @@ +You are the Confluence domain agent. Use only the Confluence tools provided for pages in the connected space. Respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py new file mode 100644 index 000000000..2889e8a3a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Confluence tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_confluence_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``confluence`` category.""" + return build_registry_tools_for_category(dependencies, "confluence") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py new file mode 100644 index 000000000..a7b864f16 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py @@ -0,0 +1,9 @@ +"""Discord vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.discord.agent import build_discord_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.discord.slice_tools import build_discord_tools + +__all__ = [ + "build_discord_tools", + "build_discord_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py new file mode 100644 index 000000000..dfcd4ec45 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py @@ -0,0 +1,21 @@ +"""Discord domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.discord as discord_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_discord_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Discord domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=discord_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md new file mode 100644 index 000000000..b279fed5f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md @@ -0,0 +1 @@ +You are the Discord domain agent. Use only the Discord tools provided (list channels, read messages, send messages). Stay focused on the connected server and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py new file mode 100644 index 000000000..3511054ab --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Discord tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_discord_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``discord`` category.""" + return build_registry_tools_for_category(dependencies, "discord") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py new file mode 100644 index 000000000..61c58aaa6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py @@ -0,0 +1,11 @@ +"""Dropbox connector slice.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.dropbox.agent import build_dropbox_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.dropbox.slice_tools import ( + build_dropbox_tools, +) + +__all__ = [ + "build_dropbox_tools", + "build_dropbox_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py new file mode 100644 index 000000000..6913f4e6f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py @@ -0,0 +1,21 @@ +"""Dropbox domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.dropbox as dropbox_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_dropbox_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Dropbox domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=dropbox_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md new file mode 100644 index 000000000..c6c636fb0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md @@ -0,0 +1 @@ +You are the Dropbox domain agent. Use only the Dropbox tools provided for files in the connected account. Respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py new file mode 100644 index 000000000..3adc4a480 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Dropbox tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_dropbox_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``dropbox`` category.""" + return build_registry_tools_for_category(dependencies, "dropbox") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py new file mode 100644 index 000000000..ed4955d71 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py @@ -0,0 +1,9 @@ +"""Gmail vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.gmail.agent import build_gmail_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.gmail.slice_tools import build_gmail_tools + +__all__ = [ + "build_gmail_tools", + "build_gmail_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py new file mode 100644 index 000000000..fed6ab289 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py @@ -0,0 +1,21 @@ +"""Gmail domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.gmail as gmail_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_gmail_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Gmail domain-agent graph (prompt + tools co-located under ``gmail``).""" + return build_domain_agent( + llm, + tools, + prompt_package=gmail_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md new file mode 100644 index 000000000..4f51f10f6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md @@ -0,0 +1 @@ +You are the Gmail domain agent. Use only the tools provided to complete Gmail-related tasks. Stay focused on email operations and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py new file mode 100644 index 000000000..50c070075 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Gmail tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_gmail_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``gmail`` category.""" + return build_registry_tools_for_category(dependencies, "gmail") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py new file mode 100644 index 000000000..b1cf3680d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py @@ -0,0 +1,13 @@ +"""Google Drive connector slice.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.google_drive.agent import ( + build_google_drive_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.google_drive.slice_tools import ( + build_google_drive_tools, +) + +__all__ = [ + "build_google_drive_tools", + "build_google_drive_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py new file mode 100644 index 000000000..674c17188 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py @@ -0,0 +1,21 @@ +"""Google Drive domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.google_drive as google_drive_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_google_drive_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Google Drive domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=google_drive_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md new file mode 100644 index 000000000..f2c8b623b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md @@ -0,0 +1 @@ +You are the Google Drive domain agent. Use only the Google Drive tools provided for Docs/Sheets files in the connected account. Respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py new file mode 100644 index 000000000..7f63f6eb3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Google Drive tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_google_drive_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``google_drive`` category.""" + return build_registry_tools_for_category(dependencies, "google_drive") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py new file mode 100644 index 000000000..6c070ebdd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py @@ -0,0 +1,9 @@ +"""Luma vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.luma.agent import build_luma_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.luma.slice_tools import build_luma_tools + +__all__ = [ + "build_luma_tools", + "build_luma_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py new file mode 100644 index 000000000..d0d3c11d9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py @@ -0,0 +1,21 @@ +"""Luma domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.luma as luma_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_luma_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Luma domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=luma_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md new file mode 100644 index 000000000..5ecadf2c5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md @@ -0,0 +1 @@ +You are the Luma domain agent. Use only the Luma tools provided (list events, read event details, create events). Stay focused on the user's calendar and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py new file mode 100644 index 000000000..4e8350f2e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Luma tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_luma_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``luma`` category.""" + return build_registry_tools_for_category(dependencies, "luma") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py new file mode 100644 index 000000000..2e17a4749 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py @@ -0,0 +1,11 @@ +"""Notion connector slice.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.notion.agent import build_notion_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.notion.slice_tools import ( + build_notion_tools, +) + +__all__ = [ + "build_notion_tools", + "build_notion_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py new file mode 100644 index 000000000..3dc971f41 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py @@ -0,0 +1,21 @@ +"""Notion domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.notion as notion_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_notion_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Notion domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=notion_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md new file mode 100644 index 000000000..3f17a4120 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md @@ -0,0 +1 @@ +You are the Notion domain agent. Use only the Notion tools provided for pages in the connected workspace. Respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py new file mode 100644 index 000000000..0229b5b82 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Notion tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_notion_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``notion`` category.""" + return build_registry_tools_for_category(dependencies, "notion") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py new file mode 100644 index 000000000..d350176de --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py @@ -0,0 +1,11 @@ +"""Microsoft OneDrive connector slice.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.onedrive.agent import build_onedrive_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.onedrive.slice_tools import ( + build_onedrive_tools, +) + +__all__ = [ + "build_onedrive_tools", + "build_onedrive_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py new file mode 100644 index 000000000..d97083232 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py @@ -0,0 +1,21 @@ +"""Microsoft OneDrive domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.onedrive as onedrive_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_onedrive_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled OneDrive domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=onedrive_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md new file mode 100644 index 000000000..ab2eaf406 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md @@ -0,0 +1 @@ +You are the Microsoft OneDrive domain agent. Use only the OneDrive tools provided for files in the connected account. Respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py new file mode 100644 index 000000000..2f7c82dad --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Microsoft OneDrive tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_onedrive_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``onedrive`` category.""" + return build_registry_tools_for_category(dependencies, "onedrive") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py new file mode 100644 index 000000000..b9ab5a862 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py @@ -0,0 +1,9 @@ +"""Microsoft Teams vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" + +from app.agents.multi_agent_chat.expert_agent.connectors.teams.agent import build_teams_domain_agent +from app.agents.multi_agent_chat.expert_agent.connectors.teams.slice_tools import build_teams_tools + +__all__ = [ + "build_teams_tools", + "build_teams_domain_agent", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py new file mode 100644 index 000000000..d8c55e462 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py @@ -0,0 +1,21 @@ +"""Microsoft Teams domain agent graph.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.connectors.teams as teams_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_teams_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): + """Compiled Microsoft Teams domain-agent graph.""" + return build_domain_agent( + llm, + tools, + prompt_package=teams_pkg.__name__, + prompt_stem="domain_prompt", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md new file mode 100644 index 000000000..3e8c7029e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md @@ -0,0 +1 @@ +You are the Microsoft Teams domain agent. Use only the Teams tools provided (list teams and channels, read messages, send messages). Stay focused on the connected workspace and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py new file mode 100644 index 000000000..b88f29843 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py @@ -0,0 +1,14 @@ +"""Registry-backed Microsoft Teams tools.""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category + + +def build_teams_tools(dependencies: dict[str, Any]) -> list[BaseTool]: + """Tools from ``new_chat`` registry: ``teams`` category.""" + return build_registry_tools_for_category(dependencies, "teams") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py new file mode 100644 index 000000000..2b03b4235 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py @@ -0,0 +1,5 @@ +"""Prompt-backed subgraphs for MCP OAuth integrations without a native tool registry slice.""" + +from app.agents.multi_agent_chat.expert_agent.mcp_bridge.agent import build_mcp_route_domain_agent + +__all__ = ["build_mcp_route_domain_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py new file mode 100644 index 000000000..be495488e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py @@ -0,0 +1,25 @@ +"""Domain agents for MCP-only OAuth integrations (no native registry slice).""" + +from __future__ import annotations + +from collections.abc import Sequence + +import app.agents.multi_agent_chat.expert_agent.mcp_bridge as mcp_bridge_pkg +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.core.agents import build_domain_agent + + +def build_mcp_route_domain_agent( + llm: BaseChatModel, + route_key: str, + tools: Sequence[BaseTool], +): + """One subgraph per MCP-only route (``linear``, ``slack``, …); prompt stem ``{route_key}_domain``.""" + return build_domain_agent( + llm, + tools, + prompt_package=mcp_bridge_pkg.__name__, + prompt_stem=f"{route_key}_domain", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md new file mode 100644 index 000000000..d55e16ef6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md @@ -0,0 +1 @@ +You are the Airtable expert (MCP). Use only the Airtable MCP tools provided. Stay focused on bases, tables, and records; respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md new file mode 100644 index 000000000..5e8c7f495 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md @@ -0,0 +1 @@ +You are the ClickUp expert (MCP). Use only the ClickUp MCP tools provided. Stay focused on tasks and workspace search; respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md new file mode 100644 index 000000000..2f16d9f76 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md @@ -0,0 +1 @@ +You are the expert for user-defined MCP servers (stdio). Use only the MCP tools provided for this connection. Follow tool descriptions exactly; respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md new file mode 100644 index 000000000..1b0b003be --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md @@ -0,0 +1 @@ +You are the Jira expert (MCP). Use only the Atlassian Jira MCP tools provided. Stay focused on issues and projects; respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md new file mode 100644 index 000000000..e68b9228f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md @@ -0,0 +1 @@ +You are the Linear expert (MCP). Use only the Linear MCP tools provided. Stay focused on issues, projects, and workspace tasks; respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md new file mode 100644 index 000000000..5c25ec57c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md @@ -0,0 +1 @@ +You are the Slack expert (MCP). Use only the Slack MCP tools provided. Stay focused on search and channel/thread reads; respond concisely. From 5497f472b24d64285715105d75dd2f8a8933a432 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 02:36:16 +0200 Subject: [PATCH 036/131] Wire supervisor routing specs and registry-backed expert routes. --- .../multi_agent_chat/routing/__init__.py | 8 +- .../routing/domain_routing_spec.py | 17 + .../routing/from_domain_agents.py | 48 +-- .../routing/supervisor_routing.py | 315 +++++++++++++++++- 4 files changed, 340 insertions(+), 48 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py index 783d1fad2..c369aeea5 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py @@ -1,11 +1,13 @@ """Supervisor routing: domain-agent wrappers and composed routing tool lists.""" -from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_domain_agents +from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec +from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_specs from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools -from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text +from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text __all__ = [ + "DomainRoutingSpec", "build_supervisor_routing_tools", "extract_last_assistant_text", - "routing_tools_from_domain_agents", + "routing_tools_from_specs", ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py new file mode 100644 index 000000000..fedd19cfd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py @@ -0,0 +1,17 @@ +"""Declarative description of one supervisor routing tool → domain agent.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class DomainRoutingSpec: + """One ``@tool`` the supervisor calls to delegate to a compiled domain graph.""" + + tool_name: str + description: str + domain_agent: Any + curated_context: Callable[[str], str | None] | None = None diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py index 92ca14150..48d643d4d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py @@ -2,44 +2,28 @@ from __future__ import annotations -from typing import Any +from collections.abc import Sequence from langchain_core.tools import BaseTool, tool -from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text +from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec +from app.agents.multi_agent_chat.core.delegation import compose_child_task +from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text -def routing_tools_from_domain_agents( - *, - gmail_domain_agent: Any, - calendar_domain_agent: Any, -) -> list[BaseTool]: - """Build ``gmail`` / ``calendar`` tools that invoke the given graphs (factory, not import-time exports).""" - - @tool( - "gmail", - description=( - "Route Gmail-related work to the Gmail sub-agent. " - "Pass a clear natural-language task." - ), - ) - def call_gmail_agent(task: str) -> str: - result = gmail_domain_agent.invoke( - {"messages": [{"role": "user", "content": task}]} +def _routing_tool_for_spec(spec: DomainRoutingSpec) -> BaseTool: + @tool(spec.tool_name, description=spec.description) + def _route(task: str) -> str: + curated = spec.curated_context(task) if spec.curated_context else None + content = compose_child_task(task, curated_context=curated) + result = spec.domain_agent.invoke( + {"messages": [{"role": "user", "content": content}]}, ) return extract_last_assistant_text(result) - @tool( - "calendar", - description=( - "Route Google Calendar work to the Calendar sub-agent. " - "Pass a clear natural-language task." - ), - ) - def call_calendar_agent(task: str) -> str: - result = calendar_domain_agent.invoke( - {"messages": [{"role": "user", "content": task}]} - ) - return extract_last_assistant_text(result) + return _route - return [call_gmail_agent, call_calendar_agent] + +def routing_tools_from_specs(specs: Sequence[DomainRoutingSpec]) -> list[BaseTool]: + """Build one supervisor-facing routing ``@tool`` per :class:`DomainRoutingSpec`.""" + return [_routing_tool_for_spec(spec) for spec in specs] diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py index a69528b8e..8ebeed469 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -1,27 +1,316 @@ -"""Compose domain agents + connector tool lists into supervisor ``gmail`` / ``calendar`` routing tools.""" +"""Compose domain agents + tool lists into supervisor routing tools (one ``@tool`` per category).""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable +from typing import Any from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool -from app.agents.multi_agent_chat.calendar import build_calendar_domain_agent -from app.agents.multi_agent_chat.gmail import build_gmail_domain_agent -from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_domain_agents +from app.agents.multi_agent_chat.expert_agent.builtins.deliverables import ( + build_deliverables_tools, + build_deliverables_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.builtins.memory import ( + build_memory_tools, + build_memory_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.builtins.research import ( + build_research_tools, + build_research_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.calendar import ( + build_calendar_tools, + build_calendar_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.confluence import ( + build_confluence_tools, + build_confluence_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.discord import ( + build_discord_tools, + build_discord_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.dropbox import ( + build_dropbox_tools, + build_dropbox_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.gmail import ( + build_gmail_tools, + build_gmail_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.google_drive import ( + build_google_drive_tools, + build_google_drive_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.luma import ( + build_luma_tools, + build_luma_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.notion import ( + build_notion_tools, + build_notion_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.onedrive import ( + build_onedrive_tools, + build_onedrive_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.teams import ( + build_teams_tools, + build_teams_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.mcp_bridge import build_mcp_route_domain_agent +from app.agents.multi_agent_chat.core.mcp_partition import MCP_ONLY_ROUTE_KEYS_IN_ORDER +from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec +from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_specs + +_MCP_ONLY_ROUTE_DESCRIPTIONS: dict[str, str] = { + "linear": ( + "Route Linear work (issues, projects, cycles, documents) via MCP to the Linear sub-agent. " + "Pass a clear natural-language task." + ), + "slack": ( + "Route Slack search and channel/thread reads via MCP to the Slack sub-agent. " + "Pass a clear natural-language task." + ), + "jira": ( + "Route Jira issues and projects via MCP to the Jira sub-agent. " + "Pass a clear natural-language task." + ), + "clickup": ( + "Route ClickUp tasks via MCP to the ClickUp sub-agent. Pass a clear natural-language task." + ), + "airtable": ( + "Route Airtable bases and records via MCP to the Airtable sub-agent. " + "Pass a clear natural-language task." + ), + "generic_mcp": ( + "Route user-defined MCP (stdio) server tools to the custom MCP sub-agent. " + "Pass a clear natural-language task." + ), +} def build_supervisor_routing_tools( llm: BaseChatModel, *, - gmail_tools: Sequence[BaseTool] | None = None, - calendar_tools: Sequence[BaseTool] | None = None, + registry_dependencies: dict[str, Any] | None = None, + gmail_curated_context: Callable[[str], str | None] | None = None, + calendar_curated_context: Callable[[str], str | None] | None = None, + include_deliverables: bool = True, + mcp_tools_by_route: dict[str, list[BaseTool]] | None = None, ) -> list[BaseTool]: - """Domain agents (with their connector tools) → ``gmail`` / ``calendar`` routing tools.""" - gmail_domain_agent = build_gmail_domain_agent(llm, list(gmail_tools or [])) - calendar_domain_agent = build_calendar_domain_agent(llm, list(calendar_tools or [])) - return routing_tools_from_domain_agents( - gmail_domain_agent=gmail_domain_agent, - calendar_domain_agent=calendar_domain_agent, + """``expert_agent.builtins`` (research, memory, deliverables) plus ``expert_agent.connectors`` → routing tools. + + Pass ``registry_dependencies`` from + :func:`app.agents.multi_agent_chat.core.registry.build_registry_dependencies` + to enable **all** registry-backed routes (Gmail, Calendar, chat, doc stores, Luma, …) and builtins + (**research**, **memory**, **deliverables** when ``include_deliverables``). Use a real chat ``thread_id`` + in deps when deliverables need thread-scoped registry factories. + + ``mcp_tools_by_route`` maps supervisor route keys (e.g. ``gmail``, ``linear``) to MCP tools loaded + elsewhere; those tools are merged into the matching expert subgraph only — the supervisor sees + routing tools, not raw MCP tools. + """ + mcp = mcp_tools_by_route or {} + if registry_dependencies is not None: + gmail_native = build_gmail_tools(registry_dependencies) + calendar_native = build_calendar_tools(registry_dependencies) + else: + gmail_native = [] + calendar_native = [] + + gmail_domain_agent = build_gmail_domain_agent(llm, gmail_native + mcp.get("gmail", [])) + calendar_domain_agent = build_calendar_domain_agent( + llm, + calendar_native + mcp.get("calendar", []), ) + + specs: list[DomainRoutingSpec] = [ + DomainRoutingSpec( + tool_name="gmail", + description=( + "Route Gmail-related work to the Gmail sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=gmail_domain_agent, + curated_context=gmail_curated_context, + ), + DomainRoutingSpec( + tool_name="calendar", + description=( + "Route Google Calendar work to the Calendar sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=calendar_domain_agent, + curated_context=calendar_curated_context, + ), + ] + + if registry_dependencies is not None: + research_tools = build_research_tools(registry_dependencies) + research_agent = build_research_domain_agent(llm, research_tools) + specs.append( + DomainRoutingSpec( + tool_name="research", + description=( + "Route web search, page scraping, and SurfSense documentation help to the " + "research sub-agent. Pass a clear natural-language task." + ), + domain_agent=research_agent, + ), + ) + + memory_tools = build_memory_tools(registry_dependencies) + memory_agent = build_memory_domain_agent(llm, memory_tools) + specs.append( + DomainRoutingSpec( + tool_name="memory", + description=( + "Route saving long-term facts and preferences (personal or team memory) to the " + "memory sub-agent. Pass a clear natural-language task." + ), + domain_agent=memory_agent, + ), + ) + + if include_deliverables: + deliverables_tools = build_deliverables_tools(registry_dependencies) + deliverables_agent = build_deliverables_domain_agent(llm, deliverables_tools) + specs.append( + DomainRoutingSpec( + tool_name="deliverables", + description=( + "Route structured outputs (reports, podcasts, video presentations, resumes, " + "images) to the deliverables sub-agent. Pass a clear natural-language task." + ), + domain_agent=deliverables_agent, + ), + ) + + discord_tools = build_discord_tools(registry_dependencies) + discord_agent = build_discord_domain_agent( + llm, + discord_tools + mcp.get("discord", []), + ) + specs.append( + DomainRoutingSpec( + tool_name="discord", + description=( + "Route Discord work (channels, messages) to the Discord sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=discord_agent, + ), + ) + + teams_tools = build_teams_tools(registry_dependencies) + teams_agent = build_teams_domain_agent( + llm, + teams_tools + mcp.get("teams", []), + ) + specs.append( + DomainRoutingSpec( + tool_name="teams", + description=( + "Route Microsoft Teams work (channels, messages) to the Teams sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=teams_agent, + ), + ) + + notion_tools = build_notion_tools(registry_dependencies) + notion_agent = build_notion_domain_agent(llm, notion_tools) + specs.append( + DomainRoutingSpec( + tool_name="notion", + description=( + "Route Notion page work to the Notion sub-agent. Pass a clear natural-language task." + ), + domain_agent=notion_agent, + ), + ) + + confluence_tools = build_confluence_tools(registry_dependencies) + confluence_agent = build_confluence_domain_agent(llm, confluence_tools) + specs.append( + DomainRoutingSpec( + tool_name="confluence", + description=( + "Route Confluence page work to the Confluence sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=confluence_agent, + ), + ) + + google_drive_tools = build_google_drive_tools(registry_dependencies) + google_drive_agent = build_google_drive_domain_agent(llm, google_drive_tools) + specs.append( + DomainRoutingSpec( + tool_name="google_drive", + description=( + "Route Google Drive file work to the Google Drive sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=google_drive_agent, + ), + ) + + dropbox_tools = build_dropbox_tools(registry_dependencies) + dropbox_agent = build_dropbox_domain_agent(llm, dropbox_tools) + specs.append( + DomainRoutingSpec( + tool_name="dropbox", + description=( + "Route Dropbox file work to the Dropbox sub-agent. Pass a clear natural-language task." + ), + domain_agent=dropbox_agent, + ), + ) + + onedrive_tools = build_onedrive_tools(registry_dependencies) + onedrive_agent = build_onedrive_domain_agent(llm, onedrive_tools) + specs.append( + DomainRoutingSpec( + tool_name="onedrive", + description=( + "Route Microsoft OneDrive file work to the OneDrive sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=onedrive_agent, + ), + ) + + luma_tools = build_luma_tools(registry_dependencies) + luma_agent = build_luma_domain_agent(llm, luma_tools + mcp.get("luma", [])) + specs.append( + DomainRoutingSpec( + tool_name="luma", + description=( + "Route Luma calendar events (list, read, create) to the Luma sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=luma_agent, + ), + ) + + for route_key in MCP_ONLY_ROUTE_KEYS_IN_ORDER: + only_mcp = mcp.get(route_key) or [] + if not only_mcp: + continue + desc = _MCP_ONLY_ROUTE_DESCRIPTIONS.get( + route_key, + f"Route {route_key} MCP work to the {route_key} sub-agent. Pass a clear natural-language task.", + ) + specs.append( + DomainRoutingSpec( + tool_name=route_key, + description=desc, + domain_agent=build_mcp_route_domain_agent(llm, route_key, only_mcp), + ), + ) + + return routing_tools_from_specs(specs) From 388e86ebc99d45621e734981913a0b6cc7dde5e9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 02:36:22 +0200 Subject: [PATCH 037/131] Compose async multi-agent chat entrypoint and drop legacy supervisor scaffolding. --- .../app/agents/multi_agent_chat/__init__.py | 120 ++++++++++++--- .../multi_agent_chat/calendar/__init__.py | 11 -- .../agents/multi_agent_chat/calendar/agent.py | 21 --- .../calendar/connector_tools.py | 33 ---- .../calendar/domain_prompt.md | 1 - .../agents/multi_agent_chat/gmail/__init__.py | 9 -- .../agents/multi_agent_chat/gmail/agent.py | 21 --- .../multi_agent_chat/gmail/connector_tools.py | 37 ----- .../multi_agent_chat/gmail/domain_prompt.md | 1 - .../integration/create_multi_agent_chat.py | 66 ++++++-- .../multi_agent_chat/shared/__init__.py | 13 -- .../agents/multi_agent_chat/shared/deps.py | 18 --- .../shared/domain_agent_factory.py | 27 ---- .../multi_agent_chat/shared/invoke_output.py | 17 -- .../multi_agent_chat/shared/prompt_loader.py | 19 --- .../multi_agent_chat/supervisor/graph.py | 2 +- .../new_chat_supervisor_baseline/__init__.py | 16 -- .../chat_deepagent.py | 145 ------------------ .../deep_agent/__init__.py | 1 - .../deep_agent/compiled_agent.py | 112 -------------- .../deep_agent/connector_searchable.py | 62 -------- .../prompts/__init__.py | 1 - .../prompts/kb_policy_supervisor_private.md | 18 --- .../prompts/kb_policy_supervisor_team.md | 18 --- .../prompts/memory_context_supervisor.md | 9 -- .../prompts/supervisor_graph_role.md | 9 -- .../supervisor_system_prompt.py | 121 --------------- 27 files changed, 149 insertions(+), 779 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/deps.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/__init__.py delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/connector_searchable.py delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md delete mode 100644 surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/__init__.py index ba4878d15..bdd54b4e0 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/__init__.py @@ -1,20 +1,14 @@ """ Multi-agent chat (LangChain Subagents pattern). -**Vertical slices** +**Layout (SRP)** -- :mod:`gmail` — connector tools, domain agent, ``domain_prompt.md`` -- :mod:`calendar` — connector tools, domain agent, ``domain_prompt.md`` - -**Shared** - -- :mod:`shared` — prompt loader, ``build_domain_agent``, connector deps, invoke result parsing - -**Cross-cutting** - -- :mod:`routing` — supervisor routing tools + invoke helpers -- :mod:`supervisor` — top graph + ``supervisor_prompt.md`` -- :mod:`integration` — ``create_multi_agent_chat`` +- :mod:`expert_agent.builtins` — general categories from the tool registry (research, memory, deliverables — not tied to one vendor). +- :mod:`expert_agent.connectors` — external integrations (one subgraph per product where split). +- :mod:`core` — prompts, compiled subgraph helper, delegation, registry subsets, tool-factory kwargs (:mod:`core.bindings`). +- :mod:`routing` — supervisor-facing ``@tool`` routers → domain invoke. +- :mod:`supervisor` — orchestrator graph + ``supervisor_prompt.md``. +- :mod:`integration` — async ``create_multi_agent_chat`` composer (partitions MCP tools into experts). Documentation: https://docs.langchain.com/oss/python/langchain/multi-agent @@ -23,38 +17,116 @@ https://docs.langchain.com/oss/python/langchain/multi-agent/subagents Display name: ``multi-agent-chat`` — Python package: ``multi_agent_chat``. """ -from app.agents.multi_agent_chat.calendar import ( - build_calendar_domain_agent, - build_google_calendar_connector_tools, +from app.agents.multi_agent_chat.expert_agent.builtins.deliverables import ( + build_deliverables_tools, + build_deliverables_domain_agent, ) -from app.agents.multi_agent_chat.gmail import ( - build_gmail_connector_tools, +from app.agents.multi_agent_chat.expert_agent.builtins.memory import ( + build_memory_tools, + build_memory_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.builtins.research import ( + build_research_tools, + build_research_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.calendar import ( + build_calendar_domain_agent, + build_calendar_tools, +) +from app.agents.multi_agent_chat.expert_agent.connectors.confluence import ( + build_confluence_tools, + build_confluence_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.discord import ( + build_discord_tools, + build_discord_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.dropbox import ( + build_dropbox_tools, + build_dropbox_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.gmail import ( + build_gmail_tools, build_gmail_domain_agent, ) -from app.agents.multi_agent_chat.integration import create_multi_agent_chat -from app.agents.multi_agent_chat.shared import ( +from app.agents.multi_agent_chat.expert_agent.connectors.google_drive import ( + build_google_drive_tools, + build_google_drive_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.luma import ( + build_luma_tools, + build_luma_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.notion import ( + build_notion_tools, + build_notion_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.onedrive import ( + build_onedrive_tools, + build_onedrive_domain_agent, +) +from app.agents.multi_agent_chat.expert_agent.connectors.teams import ( + build_teams_tools, + build_teams_domain_agent, +) +from app.agents.multi_agent_chat.core import ( + REGISTRY_ROUTING_CATEGORY_KEYS, + TOOL_NAMES_BY_CATEGORY, build_domain_agent, + build_registry_dependencies, + build_registry_tools_for_category, + compose_child_task, connector_binding, extract_last_assistant_text, read_prompt_md, ) +from app.agents.multi_agent_chat.integration import create_multi_agent_chat from app.agents.multi_agent_chat.routing import ( + DomainRoutingSpec, build_supervisor_routing_tools, - routing_tools_from_domain_agents, + routing_tools_from_specs, ) from app.agents.multi_agent_chat.supervisor import build_supervisor_agent __all__ = [ + "REGISTRY_ROUTING_CATEGORY_KEYS", + "TOOL_NAMES_BY_CATEGORY", + "DomainRoutingSpec", "build_calendar_domain_agent", + "build_confluence_tools", + "build_confluence_domain_agent", + "build_deliverables_tools", + "build_deliverables_domain_agent", + "build_discord_tools", + "build_discord_domain_agent", "build_domain_agent", - "build_gmail_connector_tools", + "build_dropbox_tools", + "build_dropbox_domain_agent", + "build_gmail_tools", "build_gmail_domain_agent", - "build_google_calendar_connector_tools", + "build_calendar_tools", + "build_google_drive_tools", + "build_google_drive_domain_agent", + "build_luma_tools", + "build_luma_domain_agent", + "build_memory_tools", + "build_memory_domain_agent", + "build_notion_tools", + "build_notion_domain_agent", + "build_onedrive_tools", + "build_onedrive_domain_agent", + "build_registry_dependencies", + "build_registry_tools_for_category", + "build_research_tools", + "build_research_domain_agent", "build_supervisor_agent", "build_supervisor_routing_tools", + "build_teams_tools", + "build_teams_domain_agent", "connector_binding", + "compose_child_task", "create_multi_agent_chat", "extract_last_assistant_text", "read_prompt_md", - "routing_tools_from_domain_agents", + "routing_tools_from_specs", ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py deleted file mode 100644 index 7d207b01f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/calendar/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Google Calendar vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.calendar.agent import build_calendar_domain_agent -from app.agents.multi_agent_chat.calendar.connector_tools import ( - build_google_calendar_connector_tools, -) - -__all__ = [ - "build_calendar_domain_agent", - "build_google_calendar_connector_tools", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py deleted file mode 100644 index 23110ea61..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/calendar/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Google Calendar domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.calendar as calendar_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.shared.domain_agent_factory import build_domain_agent - - -def build_calendar_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Calendar domain-agent graph (prompt + tools co-located under ``calendar``).""" - return build_domain_agent( - llm, - tools, - prompt_package=calendar_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py b/surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py deleted file mode 100644 index 8fb7356ff..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/calendar/connector_tools.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Google Calendar connector LangChain tools (``new_chat`` factories).""" - -from __future__ import annotations - -from langchain_core.tools import BaseTool -from sqlalchemy.ext.asyncio import AsyncSession - -from app.agents.multi_agent_chat.shared.deps import connector_binding -from app.agents.new_chat.tools.google_calendar import ( - create_create_calendar_event_tool, - create_delete_calendar_event_tool, - create_search_calendar_events_tool, - create_update_calendar_event_tool, -) - - -def build_google_calendar_connector_tools( - *, - db_session: AsyncSession, - search_space_id: int, - user_id: str, -) -> list[BaseTool]: - d = connector_binding( - db_session=db_session, - search_space_id=search_space_id, - user_id=user_id, - ) - return [ - create_search_calendar_events_tool(**d), - create_create_calendar_event_tool(**d), - create_update_calendar_event_tool(**d), - create_delete_calendar_event_tool(**d), - ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md deleted file mode 100644 index 6815e77db..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/calendar/domain_prompt.md +++ /dev/null @@ -1 +0,0 @@ -You are the Google Calendar domain agent. Use only the tools provided to complete calendar-related tasks. Stay focused on scheduling and calendar operations and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py deleted file mode 100644 index dbd4911e0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/gmail/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Gmail vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.gmail.agent import build_gmail_domain_agent -from app.agents.multi_agent_chat.gmail.connector_tools import build_gmail_connector_tools - -__all__ = [ - "build_gmail_connector_tools", - "build_gmail_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py deleted file mode 100644 index 1e591986f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/gmail/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Gmail domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.gmail as gmail_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.shared.domain_agent_factory import build_domain_agent - - -def build_gmail_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Gmail domain-agent graph (prompt + tools co-located under ``gmail``).""" - return build_domain_agent( - llm, - tools, - prompt_package=gmail_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py b/surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py deleted file mode 100644 index 4042293ad..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/gmail/connector_tools.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Gmail connector LangChain tools (``new_chat`` factories; order matches registry).""" - -from __future__ import annotations - -from langchain_core.tools import BaseTool -from sqlalchemy.ext.asyncio import AsyncSession - -from app.agents.multi_agent_chat.shared.deps import connector_binding -from app.agents.new_chat.tools.gmail import ( - create_create_gmail_draft_tool, - create_read_gmail_email_tool, - create_search_gmail_tool, - create_send_gmail_email_tool, - create_trash_gmail_email_tool, - create_update_gmail_draft_tool, -) - - -def build_gmail_connector_tools( - *, - db_session: AsyncSession, - search_space_id: int, - user_id: str, -) -> list[BaseTool]: - d = connector_binding( - db_session=db_session, - search_space_id=search_space_id, - user_id=user_id, - ) - return [ - create_search_gmail_tool(**d), - create_read_gmail_email_tool(**d), - create_create_gmail_draft_tool(**d), - create_send_gmail_email_tool(**d), - create_trash_gmail_email_tool(**d), - create_update_gmail_draft_tool(**d), - ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md deleted file mode 100644 index 4f51f10f6..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/gmail/domain_prompt.md +++ /dev/null @@ -1 +0,0 @@ -You are the Gmail domain agent. Use only the tools provided to complete Gmail-related tasks. Stay focused on email operations and respond concisely. diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py index 2d4046134..4bfb7f64d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py @@ -2,36 +2,74 @@ from __future__ import annotations +from typing import Any + 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.calendar import build_google_calendar_connector_tools -from app.agents.multi_agent_chat.gmail import build_gmail_connector_tools +from app.db import ChatVisibility + +from app.agents.new_chat.tools.mcp_tool import load_mcp_tools + +from app.agents.multi_agent_chat.core.mcp_partition import ( + fetch_mcp_connector_metadata_maps, + partition_mcp_tools_by_expert_route, +) +from app.agents.multi_agent_chat.core.registry import build_registry_dependencies from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools from app.agents.multi_agent_chat.supervisor import build_supervisor_agent -def create_multi_agent_chat( +async def create_multi_agent_chat( llm: BaseChatModel, *, db_session: AsyncSession, search_space_id: int, user_id: str, checkpointer: Checkpointer | None = None, + thread_id: str | None = None, + firecrawl_api_key: str | None = None, + connector_service: Any | None = None, + available_connectors: list[str] | None = None, + available_document_types: list[str] | None = None, + thread_visibility: ChatVisibility = ChatVisibility.PRIVATE, + include_mcp_tools: bool = True, ): - """Build the full multi-agent chat graph (supervisor + Gmail + Calendar sub-agents via ``new_chat`` tools).""" + """Build the full multi-agent chat graph (supervisor + domain subgraphs via routing tools). + + **Builtins** (:mod:`expert_agent.builtins`): registry-grouped **categories** (research, memory, deliverables). + **Connectors** (:mod:`expert_agent.connectors`): **vendor integrations** — one subgraph each where split + (e.g. Gmail, Calendar, Discord, Teams, Notion, Confluence, Google Drive, Dropbox, OneDrive, Luma). + + MCP tools from ``new_chat`` (``load_mcp_tools``) are partitioned inside this package and attached only + to the matching expert subgraphs — not to the supervisor tool list as raw MCP calls. + + Deliverables (thread-scoped reports, podcasts, etc.) are registered only when ``thread_id`` is set. + """ + mcp_tools_by_route: dict[str, list[BaseTool]] | None = None + if include_mcp_tools: + mcp_flat = await load_mcp_tools(db_session, search_space_id) + id_map, name_map = await fetch_mcp_connector_metadata_maps(db_session, search_space_id) + mcp_tools_by_route = partition_mcp_tools_by_expert_route(mcp_flat, id_map, name_map) + + registry_dependencies = build_registry_dependencies( + db_session=db_session, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id or "", + llm=llm, + firecrawl_api_key=firecrawl_api_key, + connector_service=connector_service, + available_connectors=available_connectors, + available_document_types=available_document_types, + thread_visibility=thread_visibility, + ) routing_tools = build_supervisor_routing_tools( llm, - gmail_tools=build_gmail_connector_tools( - db_session=db_session, - search_space_id=search_space_id, - user_id=user_id, - ), - calendar_tools=build_google_calendar_connector_tools( - db_session=db_session, - search_space_id=search_space_id, - user_id=user_id, - ), + registry_dependencies=registry_dependencies, + include_deliverables=thread_id is not None, + mcp_tools_by_route=mcp_tools_by_route, ) return build_supervisor_agent(llm, tools=routing_tools, checkpointer=checkpointer) diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py deleted file mode 100644 index 1ef1ad771..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/shared/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Cross-cutting helpers: prompt loading, domain agent factory, connector deps.""" - -from app.agents.multi_agent_chat.shared.deps import connector_binding -from app.agents.multi_agent_chat.shared.domain_agent_factory import build_domain_agent -from app.agents.multi_agent_chat.shared.invoke_output import extract_last_assistant_text -from app.agents.multi_agent_chat.shared.prompt_loader import read_prompt_md - -__all__ = [ - "build_domain_agent", - "connector_binding", - "extract_last_assistant_text", - "read_prompt_md", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/deps.py b/surfsense_backend/app/agents/multi_agent_chat/shared/deps.py deleted file mode 100644 index c1e18e849..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/shared/deps.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Shared kwargs for ``new_chat`` connector tool factories.""" - -from __future__ import annotations - -from sqlalchemy.ext.asyncio import AsyncSession - - -def connector_binding( - *, - db_session: AsyncSession, - search_space_id: int, - user_id: str, -) -> dict[str, AsyncSession | int | str]: - return { - "db_session": db_session, - "search_space_id": search_space_id, - "user_id": user_id, - } diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py b/surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py deleted file mode 100644 index c6c5b061a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/shared/domain_agent_factory.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Compile a domain agent graph from a co-located prompt + tool list.""" - -from __future__ import annotations - -from collections.abc import Sequence - -from langchain.agents import create_agent -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.shared.prompt_loader import read_prompt_md - - -def build_domain_agent( - llm: BaseChatModel, - tools: Sequence[BaseTool], - *, - prompt_package: str, - prompt_stem: str = "domain_prompt", -): - """``create_agent`` + ``{prompt_stem}.md`` loaded from ``prompt_package``.""" - system_prompt = read_prompt_md(prompt_package, prompt_stem) - return create_agent( - llm, - system_prompt=system_prompt, - tools=list(tools), - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py b/surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py deleted file mode 100644 index 2bbab6e57..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/shared/invoke_output.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Extract displayable text from a LangGraph agent ``invoke`` / ``ainvoke`` result.""" - -from __future__ import annotations - -from typing import Any - - -def extract_last_assistant_text(result: dict[str, Any]) -> str: - """Return the last message's string content, or ``\"\"`` if missing.""" - messages = result.get("messages") or [] - if not messages: - return "" - last = messages[-1] - content = getattr(last, "content", None) - if isinstance(content, str): - return content - return str(last) diff --git a/surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py b/surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py deleted file mode 100644 index 940647364..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/shared/prompt_loader.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Load ``*.md`` from any package (vertical slices use co-located prompts).""" - -from __future__ import annotations - -from importlib import resources - - -def read_prompt_md(package: str, stem: str) -> str: - """Read ``{stem}.md`` from the given import package (e.g. ``app.agents.multi_agent_chat.gmail``).""" - try: - ref = resources.files(package).joinpath(f"{stem}.md") - if not ref.is_file(): - return "" - text = ref.read_text(encoding="utf-8") - except (FileNotFoundError, ModuleNotFoundError, OSError, TypeError): - return "" - if text.endswith("\n"): - text = text[:-1] - return text diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py index 5cee73c37..c157a719b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py @@ -11,7 +11,7 @@ from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool from langgraph.types import Checkpointer -from app.agents.multi_agent_chat.shared.prompt_loader import read_prompt_md +from app.agents.multi_agent_chat.core.prompts import read_prompt_md def build_supervisor_agent( diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py deleted file mode 100644 index e8939e4ca..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Baseline deep-agent factory without SurfSense specialist subagents. - -Swap imports manually while building supervisor-style delegation:: - - # from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent - from app.agents.new_chat_supervisor_baseline.chat_deepagent import ( - create_surfsense_deep_agent, - ) - -""" - -from app.agents.new_chat_supervisor_baseline.chat_deepagent import ( - create_surfsense_deep_agent, -) - -__all__ = ["create_surfsense_deep_agent"] diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py deleted file mode 100644 index 3626a4789..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/chat_deepagent.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Supervisor baseline: **no registry tools** and **no tool-injecting middleware** -(no ``task`` / subagents, filesystem, todos, skills, permission, pruning, repair, …). - -Connector/document discovery still feeds :class:`KnowledgePriorityMiddleware` so turns -can include KB priority hints. - -System prompt: :func:`build_supervisor_system_prompt` — SurfSense ``agent_*`` identity -fragments plus supervisor-scoped KB/memory text and composer citation/provider blocks, -without tool lists or ``tool_routing`` (see module docstring there). - -See :mod:`app.agents.new_chat.chat_deepagent` for the full production agent. - -Implementation: :mod:`app.agents.new_chat_supervisor_baseline.deep_agent`. -""" - -import asyncio -import logging -import time -from collections.abc import Sequence - -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.new_chat.feature_flags import AgentFeatureFlags, get_flags -from app.agents.new_chat.filesystem_selection import FilesystemSelection -from app.agents.new_chat.llm_config import AgentConfig -from app.db import ChatVisibility -from app.services.connector_service import ConnectorService -from app.utils.perf import get_perf_logger - -from app.agents.new_chat_supervisor_baseline.deep_agent.compiled_agent import ( - build_compiled_agent_blocking, -) -from app.agents.new_chat_supervisor_baseline.deep_agent.connector_searchable import ( - map_connectors_to_searchable_types, -) -from app.agents.new_chat_supervisor_baseline.supervisor_system_prompt import ( - build_supervisor_system_prompt, -) - -_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, -): - """ - Build the supervisor baseline agent: registry tools are not loaded. - - Parameters such as ``enabled_tools``, ``additional_tools``, and ``firecrawl_api_key`` - are ignored for now; kept so call sites stay compatible. - """ - _ = (enabled_tools, disabled_tools, additional_tools, firecrawl_api_key, db_session) - - _t_agent_total = time.perf_counter() - - filesystem_selection = filesystem_selection or FilesystemSelection() - _fs_mode = filesystem_selection.mode - - 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 - ) - if connector_types: - 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(f"Failed to discover available connectors/document types: {e}") - _perf_log.info( - "[create_agent] Connector/doc-type discovery in %.3fs", - time.perf_counter() - _t0, - ) - - visibility = thread_visibility or ChatVisibility.PRIVATE - - tools: list[BaseTool] = [] - - _flags: AgentFeatureFlags = get_flags() - _perf_log.info("[create_agent] supervisor baseline: 0 registry tools") - - _t0 = time.perf_counter() - - final_system_prompt = build_supervisor_system_prompt( - agent_config=agent_config, - thread_visibility=thread_visibility, - llm=llm, - ) - _perf_log.info( - "[create_agent] System prompt built in %.3fs", time.perf_counter() - _t0 - ) - - _t0 = time.perf_counter() - agent = await asyncio.to_thread( - build_compiled_agent_blocking, - llm=llm, - tools=tools, - final_system_prompt=final_system_prompt, - filesystem_mode=_fs_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, - flags=_flags, - checkpointer=checkpointer, - ) - _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/new_chat_supervisor_baseline/deep_agent/__init__.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/__init__.py deleted file mode 100644 index df82f9377..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Helpers used only by :mod:`app.agents.new_chat_supervisor_baseline.chat_deepagent`.""" diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py deleted file mode 100644 index b43e0364d..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/compiled_agent.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Compile a minimal supervisor graph: no bound tools, no tool-injecting middleware.""" - -from __future__ import annotations - -from collections.abc import Sequence - -from deepagents import __version__ as deepagents_version -from deepagents.backends import StateBackend -from langchain.agents import create_agent -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.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.agents.new_chat.middleware import ( - AnonymousDocumentMiddleware, - FileIntentMiddleware, - KnowledgeBasePersistenceMiddleware, - KnowledgePriorityMiddleware, - KnowledgeTreeMiddleware, - MemoryInjectionMiddleware, - create_surfsense_compaction_middleware, -) -from app.db import ChatVisibility - - -def build_compiled_agent_blocking( - *, - llm: BaseChatModel, - tools: Sequence[BaseTool], - final_system_prompt: str, - 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, - flags: AgentFeatureFlags, - checkpointer: Checkpointer, -): - """Build middleware + compile graph synchronously (typically ``asyncio.to_thread``). - - Intentionally excludes registry tools (``tools`` should be ``[]``), SubAgent/task, - filesystem/todo/skills middleware, and tool-centric hygiene (repair, dedup, permission). - """ - _ = flags # retained for API parity with callers; stack is fixed minimal for now - - _memory_middleware = MemoryInjectionMiddleware( - user_id=user_id, - search_space_id=search_space_id, - thread_visibility=visibility, - ) - - summarization_mw = create_surfsense_compaction_middleware(llm, StateBackend) - - deepagent_middleware = [ - _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), - 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, - summarization_mw, - AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), - ] - deepagent_middleware = [m for m in deepagent_middleware if m is not None] - - agent = create_agent( - llm, - system_prompt=final_system_prompt, - tools=list(tools), - middleware=deepagent_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/new_chat_supervisor_baseline/deep_agent/connector_searchable.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/connector_searchable.py deleted file mode 100644 index 974416dfb..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/deep_agent/connector_searchable.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Map connector enum values to searchable document/connector type strings.""" - -from __future__ import annotations - -from typing import Any - -_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = { - "TAVILY_API": "TAVILY_API", - "LINKUP_API": "LINKUP_API", - "BAIDU_SEARCH_API": "BAIDU_SEARCH_API", - "SLACK_CONNECTOR": "SLACK_CONNECTOR", - "TEAMS_CONNECTOR": "TEAMS_CONNECTOR", - "NOTION_CONNECTOR": "NOTION_CONNECTOR", - "GITHUB_CONNECTOR": "GITHUB_CONNECTOR", - "LINEAR_CONNECTOR": "LINEAR_CONNECTOR", - "DISCORD_CONNECTOR": "DISCORD_CONNECTOR", - "JIRA_CONNECTOR": "JIRA_CONNECTOR", - "CONFLUENCE_CONNECTOR": "CONFLUENCE_CONNECTOR", - "CLICKUP_CONNECTOR": "CLICKUP_CONNECTOR", - "GOOGLE_CALENDAR_CONNECTOR": "GOOGLE_CALENDAR_CONNECTOR", - "GOOGLE_GMAIL_CONNECTOR": "GOOGLE_GMAIL_CONNECTOR", - "GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE", - "AIRTABLE_CONNECTOR": "AIRTABLE_CONNECTOR", - "LUMA_CONNECTOR": "LUMA_CONNECTOR", - "ELASTICSEARCH_CONNECTOR": "ELASTICSEARCH_CONNECTOR", - "WEBCRAWLER_CONNECTOR": "CRAWLED_URL", - "BOOKSTACK_CONNECTOR": "BOOKSTACK_CONNECTOR", - "CIRCLEBACK_CONNECTOR": "CIRCLEBACK", - "OBSIDIAN_CONNECTOR": "OBSIDIAN_CONNECTOR", - "DROPBOX_CONNECTOR": "DROPBOX_FILE", - "ONEDRIVE_CONNECTOR": "ONEDRIVE_FILE", - "COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE", - "COMPOSIO_GMAIL_CONNECTOR": "GOOGLE_GMAIL_CONNECTOR", - "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "GOOGLE_CALENDAR_CONNECTOR", -} - -_ALWAYS_AVAILABLE_DOC_TYPES: tuple[str, ...] = ( - "EXTENSION", - "FILE", - "NOTE", - "YOUTUBE_VIDEO", -) - - -def map_connectors_to_searchable_types(connector_types: list[Any]) -> list[str]: - """Map connector types to searchable strings; dedupe preserving order.""" - result_set: set[str] = set() - result_list: list[str] = [] - - for doc_type in _ALWAYS_AVAILABLE_DOC_TYPES: - if doc_type not in result_set: - result_set.add(doc_type) - result_list.append(doc_type) - - for ct in connector_types: - ct_str = ct.value if hasattr(ct, "value") else str(ct) - searchable = _CONNECTOR_TYPE_TO_SEARCHABLE.get(ct_str) - if searchable and searchable not in result_set: - result_set.add(searchable) - result_list.append(searchable) - - return result_list diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py deleted file mode 100644 index 68441a70e..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Supervisor-scoped prompt fragments (adaptations of ``new_chat/prompts/base``).""" diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md deleted file mode 100644 index 45dc30869..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_private.md +++ /dev/null @@ -1,18 +0,0 @@ - -Adapted from ``prompts/base/kb_only_policy_private.md`` for supervisor-only runs (no web -search / scrape / connector tools on this node). - -CRITICAL RULE — KNOWLEDGE CONTEXT FIRST FOR FACTUAL QUESTIONS: -- For factual or informational questions, rely on information in this thread and on - knowledge SurfSense surfaces in your prompt (for example priority document excerpts - or injected memory text). Do not substitute unchecked general knowledge unless the - user explicitly opts in. -- If nothing in the conversation or injected context answers the question, you MUST: - 1. Say you could not find it in the available SurfSense context for this turn. - 2. Ask: "Would you like me to answer from my general knowledge instead?" - 3. ONLY provide a general-knowledge answer AFTER the user explicitly says yes. -- This policy does NOT apply to: - * Casual conversation, greetings, or meta-questions about SurfSense itself - * Formatting, summarization, or analysis of content already present in the conversation - * Following user instructions that are clearly task-oriented (e.g., "rewrite this in bullet points") - diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md deleted file mode 100644 index c201d11c1..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/kb_policy_supervisor_team.md +++ /dev/null @@ -1,18 +0,0 @@ - -Adapted from ``prompts/base/kb_only_policy_team.md`` for supervisor-only runs (no web -search / scrape / connector tools on this node). - -CRITICAL RULE — TEAM KNOWLEDGE CONTEXT FIRST FOR FACTUAL QUESTIONS: -- For factual or informational questions, rely on information in this thread and on - knowledge SurfSense surfaces in your prompt from the shared space (for example - priority document excerpts or injected memory text). Do not substitute unchecked - general knowledge unless a team member explicitly opts in. -- If nothing in the conversation or injected context answers the question, you MUST: - 1. Say you could not find it in the available SurfSense context for this turn. - 2. Ask: "Would you like me to answer from my general knowledge instead?" - 3. ONLY provide a general-knowledge answer AFTER a team member explicitly says yes. -- This policy does NOT apply to: - * Casual conversation, greetings, or meta-questions about SurfSense itself - * Formatting, summarization, or analysis of content already present in the conversation - * Following user instructions that are clearly task-oriented (e.g., "rewrite this in bullet points") - diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md deleted file mode 100644 index 7d5a7c648..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/memory_context_supervisor.md +++ /dev/null @@ -1,9 +0,0 @@ - -Derived from ``prompts/base/memory_protocol_*.md``, without requiring ``update_memory`` -calls (this supervisor node does not expose that tool). - -Personalized memory text may be injected into your prompt when configured. You cannot -persist new long-term memory from this supervisor node; if the user asks you to -remember something permanently, explain that doing so requires the full SurfSense -agent with memory tools enabled or another persistence path they configure. - diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md b/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md deleted file mode 100644 index 875e09510..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/prompts/supervisor_graph_role.md +++ /dev/null @@ -1,9 +0,0 @@ - -This node follows the LangGraph multi-agent **supervisor** pattern: the supervisor -language model responds from the current conversation and optional supervisor-scoped -system prompt (see LangChain Reference: ``langgraph_supervisor.create_supervisor``, -parameter ``prompt`` — typically a ``SystemMessage`` that scopes routing and handoff -behavior). In this SurfSense deployment the supervisor graph does **not** attach -registry tools or worker subgraphs—answer from messages and system-injected context, -and state plainly when the user expects tools or delegations that are not wired here. - diff --git a/surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py b/surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py deleted file mode 100644 index 82c0077e3..000000000 --- a/surfsense_backend/app/agents/new_chat_supervisor_baseline/supervisor_system_prompt.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Supervisor-scoped system prompt for ``new_chat_supervisor_baseline``. - -Composition follows the same fragment discipline as -:func:`app.agents.new_chat.prompts.composer.compose_system_prompt`, but **omits** -sections that assume registry tools: ``base/tool_routing_*.md``, ``tools/_preamble.md``, -the tools/examples blocks, ``base/parameter_resolution.md`` (discovery lists concrete -tools), and ``base/memory_protocol_*.md`` (requires ``update_memory`` calls). - -**Authoritative supervisor semantics:** LangChain Reference documents -``langgraph_supervisor.create_supervisor`` — the supervisor graph accepts an optional -``prompt`` (typically a ``SystemMessage``) that scopes the supervisor LLM alongside -managed worker graphs. - -**SurfSense sources reused verbatim where applicable:** ``prompts/base/agent_private.md`` / -``agent_team.md`` from :mod:`app.agents.new_chat.prompts`. KB policy is adapted from -``base/kb_only_policy_*.md`` into supervisor-local fragments that reference injected -context instead of tool outputs. Provider and citation blocks reuse -``composer._build_provider_block`` / ``_build_citation_block`` and -``composer.detect_provider_variant`` unchanged. -""" - -from __future__ import annotations - -from datetime import UTC, datetime -from importlib import resources - -from langchain_core.language_models import BaseChatModel - -from app.agents.new_chat.llm_config import AgentConfig -from app.agents.new_chat.prompts import composer as pc -from app.db import ChatVisibility - -_SUP_PROMPTS_PKG = "app.agents.new_chat_supervisor_baseline.prompts" - - -def _read_supervisor_fragment(filename: str) -> str: - try: - ref = resources.files(_SUP_PROMPTS_PKG).joinpath(filename) - if not ref.is_file(): - return "" - text = ref.read_text(encoding="utf-8") - except (FileNotFoundError, ModuleNotFoundError, OSError): - return "" - if text.endswith("\n"): - text = text[:-1] - return text - - -def _build_supervisor_system_instruction_block( - *, - visibility: ChatVisibility, - resolved_today: str, -) -> str: - """```` body: LangGraph supervisor scope + SurfSense identity + adapted KB + memory limits.""" - variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private" - sections = [ - _read_supervisor_fragment("supervisor_graph_role.md"), - pc._read_fragment(f"base/agent_{variant}.md"), - _read_supervisor_fragment(f"kb_policy_supervisor_{variant}.md"), - _read_supervisor_fragment("memory_context_supervisor.md"), - ] - body = "\n\n".join(s for s in sections if s) - block = f"\n\n{body}\n\n\n" - return block.format(resolved_today=resolved_today) - - -def resolve_llm_model_name(llm: BaseChatModel) -> str | None: - """Best-effort model id string for :func:`composer.detect_provider_variant`.""" - name = getattr(llm, "model_name", None) - if isinstance(name, str) and name.strip(): - return name.strip() - model = getattr(llm, "model", None) - if isinstance(model, str) and model.strip(): - return model.strip() - profile = getattr(llm, "profile", None) - if isinstance(profile, dict): - for key in ("model", "model_name"): - m = profile.get(key) - if isinstance(m, str) and m.strip(): - return m.strip() - return None - - -def build_supervisor_system_prompt( - *, - agent_config: AgentConfig | None, - thread_visibility: ChatVisibility | None, - llm: BaseChatModel, -) -> str: - """Assemble the supervisor system prompt (no tool-list or tool-routing fragments).""" - resolved_today = datetime.now(UTC).astimezone(UTC).date().isoformat() - visibility = thread_visibility or ChatVisibility.PRIVATE - model_name = resolve_llm_model_name(llm) - - if agent_config is not None: - custom = (agent_config.system_instructions or "").strip() - if custom: - sys_block = agent_config.system_instructions.format(resolved_today=resolved_today) - elif agent_config.use_default_system_instructions: - sys_block = _build_supervisor_system_instruction_block( - visibility=visibility, - resolved_today=resolved_today, - ) - else: - sys_block = "" - else: - sys_block = _build_supervisor_system_instruction_block( - visibility=visibility, - resolved_today=resolved_today, - ) - - provider_variant = pc.detect_provider_variant(model_name) - sys_block += pc._build_provider_block(provider_variant) - - if agent_config is None: - citations_enabled = True - else: - citations_enabled = agent_config.citations_enabled - - sys_block += pc._build_citation_block(citations_enabled) - return sys_block From 91e12bd31e228d1716b0d5c018865fef57d520e1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 02:36:30 +0200 Subject: [PATCH 038/131] Document multi-agent layout and supervisor delegation prompts. --- .../multi_agent_chat/IMPLEMENTATION_PLAN.md | 119 +++++++++++++++--- .../supervisor/supervisor_prompt.md | 27 +++- 2 files changed, 129 insertions(+), 17 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md b/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md index eafe6d692..2640d2f11 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md +++ b/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md @@ -1,26 +1,108 @@ -# `multi_agent_chat` — vertical slices + shared +# `multi_agent_chat` — layout & alignment with `new_chat` + +## Mission + +**Preserve** everything that makes SurfSense chat agents production-grade in `new_chat` (KB, middleware, tools, prompts, safety, observability). **Rework** how those pieces are composed: a clearer **multi-agent** layout (supervisor + domain slices + routing), less accidental coupling, and one explicit assembly path—so the agent stays **excellent** (correct tools, grounded KB, safe permissions, debuggable traces), not just “different folders.” + +Implementation strategy: **reuse `new_chat` modules** (middleware classes, tool factories, KB helpers, prompts composer pieces) wherever possible; **`multi_agent_chat` owns structure and wiring**, not reimplemented business logic. + +--- + +## What we must not lose from `new_chat` (capability inventory) + +Use this as a checklist when porting middleware/KB into `multi_agent_chat`. Items map to `surfsense_backend/app/agents/new_chat/`. + +| Area | Capabilities to preserve | Typical locations | +|------|-------------------------|-------------------| +| **KB & documents** | Hybrid search → priority docs → lazy XML load; workspace tree; anon-document path; KB persistence / commit staging | `middleware/knowledge_search.py`, `knowledge_tree.py`, `kb_persistence.py`, `anonymous_document.py`, `tools/knowledge_base.py`, `search_surfsense_docs.py` | +| **Filesystem** | Virtual FS, backends, path resolver, file intent | `middleware/filesystem.py`, `filesystem_backends.py`, `path_resolver.py`, `file_intent.py` | +| **Memory & context** | Memory injection, team/private protocols, context schema | `middleware/memory_injection.py`, `prompts/base/memory_protocol_*.md`, `context.py` | +| **Safety & quality** | Permissions, doom-loop detection, dedup HITL tool calls, tool-call repair, action logging | `middleware/permission.py`, `doom_loop.py`, `dedup_tool_calls.py`, `tool_call_repair.py`, `action_log.py` | +| **Model / context limits** | Compaction, context editing / spill, summarization, model & tool call limits, retries / fallback | `middleware/compaction.py`, `context_editing.py`, `chat_deepagent.py` stack | +| **Concurrency & ops** | Busy mutex (single-flight turns), OTel spans | `middleware/busy_mutex.py`, `otel_span.py` | +| **Skills & subagents** | Skills backends, subagent specs and wrapping patterns | `middleware/skills_backends.py`, `subagents/` | +| **Tools** | Async registry, connector gating, MCP loading, feature-flagged tools | `tools/registry.py`, `feature_flags.py`, `tools/mcp_tool.py` | +| **Prompts** | Composer, provider fragments, tool routing (KB vs live connectors), citations | `prompts/composer.py`, `prompts/base/tool_routing_*.md`, `system_prompt.py` | +| **Runtime** | Checkpointer, LLM config, `create_agent` + middleware ordering discipline | `checkpointer.py`, `llm_config.py`, `chat_deepagent.py` | + +Not every row applies to the **first** multi-agent graph (e.g. you may start with a subset of middleware). The rule is: **if `new_chat` does it for correctness or safety, we either reuse it or consciously document why this graph differs.** + +--- + +## Rework principles (better arrangement, same substance) + +1. **Expert agents**: **`expert_agent/builtins/`** — broad registry **categories** (e.g. research, deliverables), not a single vendor. **`expert_agent/connectors/`** — **external integrations** (Gmail, Calendar, Discord, Teams, doc stores, …), whether wired with hand-written factories or registry connector tools. Prompt + tools live together per slice; cross-cutting helpers live in `core/` or are imported from `new_chat`. +2. **Explicit graphs**: supervisor vs domain agents vs routing tools are **named** and testable; avoid one opaque megagraph where behavior is hard to reason about. +3. **Single composer**: integration eventually mirrors `create_surfsense_deep_agent` in spirit—**one factory** that attaches middleware, KB, and tools in documented order (see `chat_deepagent.py` comments on ordering). +4. **No duplicate KB pipelines**: align with `KnowledgePriorityMiddleware` / tree semantics; don’t invent a second hybrid-search path for the same turn. +5. **Parity tests**: when wiring completes, compare behavior against `new_chat` for the same user message + search space where scopes overlap (KB snippet quality, tool allow/deny). + +--- + +## Supervisor vs domain agents — tools and context + +**Supervisor (orchestrator)** + +- Keeps a **small tool surface**: routing tools (`gmail`, `calendar`, future category tools like `research` / `deliverables`) — **not** the full `registry.py` “general” tool list. +- **KB** should primarily benefit the model via **`new_chat`-style middleware** (e.g. hybrid priority docs → state / system adjunct), not by stacking redundant search tools, unless product explicitly requires them. +- **Single hybrid search per user turn** at this layer when possible: full retrieval is expensive; avoid running it again inside every sub-agent for the same message. +- Does **not** own **on-demand connector discovery** (e.g. `get_connected_accounts`): orchestration is route-by-intent, not ID resolution. + +**Domain agents (Gmail, Calendar, future slices)** + +- Carry tools built from **`new_chat` factories** (already pattern in `expert_agent/connectors/gmail/slice_tools.py`, etc.). +- **Curated context belongs in the task message**: when the supervisor calls a routing tool (`gmail`, `calendar`, …), the **tool handler composes the child’s task string** so it includes **only** what that domain needs (KB snippets, constraints, distilled facts) — folded into how the task is written — not the full parent transcript. The sub-agent `invoke` stays a tight payload (`messages` + task content); domain middleware can still add connector-local hints. Still **no second full hybrid search** for the same turn unless the subdomain explicitly needs a new query. +- **Middleware here** still fits **domain-only** grounding (connector availability, search-space hints, metadata) shared across tools in that subgraph. Reuse or thin-wrap `new_chat.middleware` where it applies to a subgraph. +- **Reactive discovery** (resolve a service id mid-task) stays a **tool** on that domain (or shared factory), e.g. `get_connected_accounts` when the model needs it — not something the supervisor must call. + +**Tool grouping by category** + +- Group “horizontal” registry tools by **job** (research, deliverables, creative, …) into **separate compiled subgraphs**; supervisor gets **one routing tool per category** (subagents-as-tools), matching LangChain multi-agent guidance. See prior discussion: not all 10 non–connector-gated tools on the supervisor. + +### KB + virtual filesystem — where it belongs + +In `new_chat`, KB + **virtual FS** (`KnowledgePriorityMiddleware`, tree, **`SurfSenseFilesystemMiddleware`** / **`KBPostgresBackend`**) serves the **orchestrator** that may **read and traverse** the workspace. + +**Connector domain agents** (Gmail, Calendar, …) are **not** mini-parents: the **supervisor** should already decide *what* to do and pass a **clear task** (plus any curated KB snippet folded into **`compose_child_task`**). The specialist runs **connector APIs**, not a second document crawl — duplicating full KB+VFS on every domain subgraph **shifts the parent’s exploration work onto the wrong agent** and adds noise. + +So **no child-side filesystem stack by default** for mail/calendar-style slices unless product demands it. Reserve **KB + VFS on a subgraph** for roles that **actually** need heavy document work (research, coding/explore-style agents, deliverables that grep the KB), matching how `new_chat` uses specialists. + +--- + +## Inspiration map (`new_chat` → `multi_agent_chat`) + +| Concern in `new_chat` | Primary references | Role in `multi_agent_chat` | +|----------------------|-------------------|---------------------------| +| **Main factory** | `chat_deepagent.py` (`create_surfsense_deep_agent`) | `integration/create_multi_agent_chat.py` — eventual single composer after KB + middleware land | +| **Tool lists** | `tools/registry.py`, `build_tools_async` | **`expert_agent/builtins/`** — category bundles (research, deliverables). **`expert_agent/connectors/`** — per-integration graphs (may use hand-written factories or registry subsets). | +| **Middleware stack** | `chat_deepagent.py` → `_build_compiled_agent_blocking`, `middleware/*.py` | **Planned:** `middleware/` — compose `create_agent(..., middleware=[...])` on supervisor and/or domain graphs; reuse or thin-wrap `new_chat.middleware` (ordering matters: see `new_chat` comments, e.g. BusyMutex → OTel → KB priority → filesystem → …) | +| **KB / hybrid search** | `middleware/knowledge_search.py` (`KnowledgePriorityMiddleware`), `middleware/knowledge_tree.py`, `tools/knowledge_base.py` | **Planned:** hybrid priority **once per user turn** at orchestrator; **curated KB/context folded into the routing task message** to children (no second full search for the same message unless explicitly scoped otherwise). | +| **Prompts** | `prompts/composer.py`, `prompts/base/*`, provider fragments | Vertical **`domain_prompt.md`** per slice + **`supervisor/supervisor_prompt.md`**; optional later: thin composer that injects KB/tool-routing fragments like `tool_routing_*.md` | +| **Context / checkpointer** | `context.py`, `checkpointer.py` | Pass **`Checkpointer`** into `create_multi_agent_chat` / `build_supervisor_agent`; align thread IDs with route layer when wired | +| **Subagent middleware** | `subagents/config.py` (`_wrap_with_subagent_essentials`) | Domain agents may eventually take **`middleware=`** on `create_agent` mirroring “inherit parent essentials + local rules” | + +--- + +## Current package tree ``` multi_agent_chat/ __init__.py - shared/ # Cross-domain helpers (one level) - deps.py # connector_binding for new_chat factories - prompt_loader.py # read_prompt_md(package, stem) - domain_agent_factory.py # build_domain_agent(..., prompt_package=...) - invoke_output.py # extract_last_assistant_text (invoke result parsing) + core/ # one concern per subfolder (SRP) + prompts/ # read_prompt_md — markdown next to packages + agents/ # build_domain_agent — compile subgraph + prompt + delegation/ # compose_child_task — supervisor → child message + invocation/ # extract_last_assistant_text — invoke result parsing + bindings/ # ``connector_binding`` — DB/search-space kwargs (not ``expert_agent.connectors`` vendors) + registry/ # TOOL_NAMES_BY_CATEGORY, build_registry_tools_for_category, build_registry_dependencies - gmail/ # Gmail slice (agent + tooling + prompt) - domain_prompt.md - connector_tools.py - agent.py - - calendar/ # Google Calendar slice - domain_prompt.md - connector_tools.py - agent.py + expert_agent/ + builtins/ # broad categories: research, deliverables + connectors/ # one subgraph per vendor: gmail, calendar, discord, teams, notion, … routing/ + domain_routing_spec.py from_domain_agents.py supervisor_routing.py @@ -32,4 +114,9 @@ multi_agent_chat/ create_multi_agent_chat.py ``` -**References:** [Multi-agent](https://docs.langchain.com/oss/python/langchain/multi-agent), [Subagents](https://docs.langchain.com/oss/python/langchain/multi-agent/subagents). +--- + +## References + +- LangChain: [Multi-agent](https://docs.langchain.com/oss/python/langchain/multi-agent), [Subagents](https://docs.langchain.com/oss/python/langchain/multi-agent/subagents). +- Internal: `surfsense_backend/app/agents/new_chat/chat_deepagent.py`, `middleware/`, `tools/registry.py`. diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md index 071a9eafe..ea74d3d50 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md @@ -1 +1,26 @@ -You are the supervisor agent. Route Gmail-related requests through the **gmail** tool and Google Calendar requests through the **calendar** tool, each with a clear task description. Answer directly when no sub-agent is needed. When sub-agents return results, combine them into one coherent reply for the user. +You are the supervisor agent. Route work to the right sub-agent using **one** routing tool per request when delegation is needed: + +- **gmail** — email (search, read, drafts, send, trash). +- **calendar** — Google Calendar events. +- **research** — web search, page scraping, SurfSense documentation help. +- **memory** — save long-term facts and preferences (personal or team memory). +- **deliverables** — reports, podcasts, video presentations, resumes, images (thread-scoped outputs). +- **discord** — Discord server channels and messages. +- **teams** — Microsoft Teams channels and messages. +- **notion** — Notion pages. +- **confluence** — Confluence pages. +- **google_drive** — Google Drive files (Docs/Sheets). +- **dropbox** — Dropbox files. +- **onedrive** — Microsoft OneDrive files. +- **luma** — Luma calendar events (list, read, create). + +When the user has connected OAuth MCP integrations, additional routing tools may appear — use them only for that product’s work: + +- **linear** — Linear (issues, projects) via MCP. +- **slack** — Slack search / reads via MCP. +- **jira** — Jira via MCP. +- **clickup** — ClickUp via MCP. +- **airtable** — Airtable via MCP. +- **generic_mcp** — user-defined MCP servers (stdio). + +Pass each tool a **clear natural-language task** describing what the sub-agent should do. Answer directly when no sub-agent is needed. When sub-agents return results, combine them into one coherent reply for the user. From 33fc457dccdbc226bff71a835a6492b2a5bacf5a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 03:53:15 +0200 Subject: [PATCH 039/131] Gate supervisor connector routes by searchable inventory and simplify routing specs. --- .../routing/domain_routing_spec.py | 7 +- .../routing/route_connector_gate.py | 56 ++++ .../routing/supervisor_routing.py | 297 +++++++++--------- 3 files changed, 215 insertions(+), 145 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py index fedd19cfd..f61d5b151 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py @@ -9,7 +9,12 @@ from typing import Any @dataclass(frozen=True) class DomainRoutingSpec: - """One ``@tool`` the supervisor calls to delegate to a compiled domain graph.""" + """One supervisor-facing routing ``@tool`` bound to a compiled domain graph. + + ``curated_context`` is optional for **any** route: when set, the routing tool prepends its return + value into the child task via :func:`~app.agents.multi_agent_chat.core.delegation.compose_child_task`. + :func:`build_supervisor_routing_tools` does not pass it (all routes treated the same); use when building specs manually. + """ tool_name: str description: str diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py new file mode 100644 index 000000000..adf63d931 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py @@ -0,0 +1,56 @@ +"""Gate supervisor routing tools by connected searchable connector types (aligned with ``new_chat`` KB). + +When ``available_connectors`` is ``None``, all routes are emitted (caller did not pass an inventory). + +When provided, a connector route is emitted only if at least one required searchable type is present. +MCP tools are filtered upstream in :func:`~app.agents.multi_agent_chat.core.mcp_partition.partition_mcp_tools_by_expert_route` +so merges only include tools for connected accounts. +""" + +from __future__ import annotations + +# Route tool_name → searchable connector / doc-type strings (same family as +# ``chat_deepagent._CONNECTOR_TYPE_TO_SEARCHABLE`` values in ``available_connectors``). +_ROUTE_REQUIRES_ANY: dict[str, frozenset[str]] = { + "calendar": frozenset( + {"GOOGLE_CALENDAR_CONNECTOR", "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"} + ), + "confluence": frozenset({"CONFLUENCE_CONNECTOR"}), + "discord": frozenset({"DISCORD_CONNECTOR"}), + "dropbox": frozenset({"DROPBOX_FILE"}), + "gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR", "COMPOSIO_GMAIL_CONNECTOR"}), + "google_drive": frozenset( + {"GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"} + ), + "luma": frozenset({"LUMA_CONNECTOR"}), + "notion": frozenset({"NOTION_CONNECTOR"}), + "onedrive": frozenset({"ONEDRIVE_FILE"}), + "teams": frozenset({"TEAMS_CONNECTOR"}), + # MCP-only supervisor routes (see ``core.mcp_partition.MCP_ONLY_ROUTE_KEYS_IN_ORDER``). + "linear": frozenset({"LINEAR_CONNECTOR"}), + "slack": frozenset({"SLACK_CONNECTOR"}), + "jira": frozenset({"JIRA_CONNECTOR"}), + "clickup": frozenset({"CLICKUP_CONNECTOR"}), + "airtable": frozenset({"AIRTABLE_CONNECTOR"}), + "generic_mcp": frozenset({"MCP_CONNECTOR"}), +} + + +def include_connector_route( + route_key: str, + available_connectors: list[str] | None, +) -> bool: + """Return whether to register this connector route on the supervisor. + + If ``available_connectors`` is omitted, preserve legacy behaviour (emit the route). + + Otherwise require at least one matching entry in ``available_connectors`` for connector-backed routes. + Builtin routes (research, memory, …) have no entry in ``_ROUTE_REQUIRES_ANY`` and are always included. + """ + if available_connectors is None: + return True + required = _ROUTE_REQUIRES_ANY.get(route_key) + if required is None: + return True + avail = set(available_connectors) + return bool(required & avail) diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py index 8ebeed469..e97dec0b7 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from typing import Any from langchain_core.language_models import BaseChatModel @@ -64,6 +63,7 @@ from app.agents.multi_agent_chat.expert_agent.mcp_bridge import build_mcp_route_ from app.agents.multi_agent_chat.core.mcp_partition import MCP_ONLY_ROUTE_KEYS_IN_ORDER from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_specs +from app.agents.multi_agent_chat.routing.route_connector_gate import include_connector_route _MCP_ONLY_ROUTE_DESCRIPTIONS: dict[str, str] = { "linear": ( @@ -96,143 +96,89 @@ def build_supervisor_routing_tools( llm: BaseChatModel, *, registry_dependencies: dict[str, Any] | None = None, - gmail_curated_context: Callable[[str], str | None] | None = None, - calendar_curated_context: Callable[[str], str | None] | None = None, include_deliverables: bool = True, mcp_tools_by_route: dict[str, list[BaseTool]] | None = None, + available_connectors: list[str] | None = None, ) -> list[BaseTool]: - """``expert_agent.builtins`` (research, memory, deliverables) plus ``expert_agent.connectors`` → routing tools. + """Build supervisor routing tools: builtins first, then connector experts (same pattern for all). + + Requires ``registry_dependencies`` to produce any routing tools; otherwise returns an empty list. Pass ``registry_dependencies`` from :func:`app.agents.multi_agent_chat.core.registry.build_registry_dependencies` - to enable **all** registry-backed routes (Gmail, Calendar, chat, doc stores, Luma, …) and builtins - (**research**, **memory**, **deliverables** when ``include_deliverables``). Use a real chat ``thread_id`` - in deps when deliverables need thread-scoped registry factories. + for builtins (**research**, **memory**, **deliverables** when ``include_deliverables``) and every + registry-backed connector route. - ``mcp_tools_by_route`` maps supervisor route keys (e.g. ``gmail``, ``linear``) to MCP tools loaded - elsewhere; those tools are merged into the matching expert subgraph only — the supervisor sees - routing tools, not raw MCP tools. + ``mcp_tools_by_route`` maps route keys to MCP tools merged into the matching expert subgraph. + + When ``available_connectors`` is set (searchable connector strings, same shape as ``new_chat``), + a vendor route is registered only if the connector is available **or** MCP tools are present for + that route. """ - mcp = mcp_tools_by_route or {} - if registry_dependencies is not None: - gmail_native = build_gmail_tools(registry_dependencies) - calendar_native = build_calendar_tools(registry_dependencies) - else: - gmail_native = [] - calendar_native = [] + if registry_dependencies is None: + return routing_tools_from_specs([]) - gmail_domain_agent = build_gmail_domain_agent(llm, gmail_native + mcp.get("gmail", [])) - calendar_domain_agent = build_calendar_domain_agent( - llm, - calendar_native + mcp.get("calendar", []), + mcp = mcp_tools_by_route or {} + specs: list[DomainRoutingSpec] = [] + + research_tools = build_research_tools(registry_dependencies) + research_agent = build_research_domain_agent(llm, research_tools) + specs.append( + DomainRoutingSpec( + tool_name="research", + description=( + "Route web search, page scraping, and SurfSense documentation help to the " + "research sub-agent. Pass a clear natural-language task." + ), + domain_agent=research_agent, + ), ) - specs: list[DomainRoutingSpec] = [ + memory_tools = build_memory_tools(registry_dependencies) + memory_agent = build_memory_domain_agent(llm, memory_tools) + specs.append( DomainRoutingSpec( - tool_name="gmail", + tool_name="memory", description=( - "Route Gmail-related work to the Gmail sub-agent. " - "Pass a clear natural-language task." + "Route saving long-term facts and preferences (personal or team memory) to the " + "memory sub-agent. Pass a clear natural-language task." ), - domain_agent=gmail_domain_agent, - curated_context=gmail_curated_context, + domain_agent=memory_agent, ), - DomainRoutingSpec( - tool_name="calendar", - description=( - "Route Google Calendar work to the Calendar sub-agent. " - "Pass a clear natural-language task." - ), - domain_agent=calendar_domain_agent, - curated_context=calendar_curated_context, - ), - ] + ) - if registry_dependencies is not None: - research_tools = build_research_tools(registry_dependencies) - research_agent = build_research_domain_agent(llm, research_tools) + if include_deliverables: + deliverables_tools = build_deliverables_tools(registry_dependencies) + deliverables_agent = build_deliverables_domain_agent(llm, deliverables_tools) specs.append( DomainRoutingSpec( - tool_name="research", + tool_name="deliverables", description=( - "Route web search, page scraping, and SurfSense documentation help to the " - "research sub-agent. Pass a clear natural-language task." + "Route structured outputs (reports, podcasts, video presentations, resumes, " + "images) to the deliverables sub-agent. Pass a clear natural-language task." ), - domain_agent=research_agent, + domain_agent=deliverables_agent, ), ) - memory_tools = build_memory_tools(registry_dependencies) - memory_agent = build_memory_domain_agent(llm, memory_tools) - specs.append( - DomainRoutingSpec( - tool_name="memory", - description=( - "Route saving long-term facts and preferences (personal or team memory) to the " - "memory sub-agent. Pass a clear natural-language task." - ), - domain_agent=memory_agent, - ), - ) - - if include_deliverables: - deliverables_tools = build_deliverables_tools(registry_dependencies) - deliverables_agent = build_deliverables_domain_agent(llm, deliverables_tools) - specs.append( - DomainRoutingSpec( - tool_name="deliverables", - description=( - "Route structured outputs (reports, podcasts, video presentations, resumes, " - "images) to the deliverables sub-agent. Pass a clear natural-language task." - ), - domain_agent=deliverables_agent, - ), - ) - - discord_tools = build_discord_tools(registry_dependencies) - discord_agent = build_discord_domain_agent( + # Connector experts (registry-backed + optional MCP merge): alphabetical by route key. + if include_connector_route("calendar", available_connectors): + calendar_agent = build_calendar_domain_agent( llm, - discord_tools + mcp.get("discord", []), + build_calendar_tools(registry_dependencies) + mcp.get("calendar", []), ) specs.append( DomainRoutingSpec( - tool_name="discord", + tool_name="calendar", description=( - "Route Discord work (channels, messages) to the Discord sub-agent. " + "Route Google Calendar work to the Calendar sub-agent. " "Pass a clear natural-language task." ), - domain_agent=discord_agent, - ), - ) - - teams_tools = build_teams_tools(registry_dependencies) - teams_agent = build_teams_domain_agent( - llm, - teams_tools + mcp.get("teams", []), - ) - specs.append( - DomainRoutingSpec( - tool_name="teams", - description=( - "Route Microsoft Teams work (channels, messages) to the Teams sub-agent. " - "Pass a clear natural-language task." - ), - domain_agent=teams_agent, - ), - ) - - notion_tools = build_notion_tools(registry_dependencies) - notion_agent = build_notion_domain_agent(llm, notion_tools) - specs.append( - DomainRoutingSpec( - tool_name="notion", - description=( - "Route Notion page work to the Notion sub-agent. Pass a clear natural-language task." - ), - domain_agent=notion_agent, + domain_agent=calendar_agent, ), ) + if include_connector_route("confluence", available_connectors): confluence_tools = build_confluence_tools(registry_dependencies) confluence_agent = build_confluence_domain_agent(llm, confluence_tools) specs.append( @@ -246,6 +192,50 @@ def build_supervisor_routing_tools( ), ) + if include_connector_route("discord", available_connectors): + discord_tools = build_discord_tools(registry_dependencies) + discord_agent = build_discord_domain_agent(llm, discord_tools + mcp.get("discord", [])) + specs.append( + DomainRoutingSpec( + tool_name="discord", + description=( + "Route Discord work (channels, messages) to the Discord sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=discord_agent, + ), + ) + + if include_connector_route("dropbox", available_connectors): + dropbox_tools = build_dropbox_tools(registry_dependencies) + dropbox_agent = build_dropbox_domain_agent(llm, dropbox_tools) + specs.append( + DomainRoutingSpec( + tool_name="dropbox", + description=( + "Route Dropbox file work to the Dropbox sub-agent. Pass a clear natural-language task." + ), + domain_agent=dropbox_agent, + ), + ) + + if include_connector_route("gmail", available_connectors): + gmail_agent = build_gmail_domain_agent( + llm, + build_gmail_tools(registry_dependencies) + mcp.get("gmail", []), + ) + specs.append( + DomainRoutingSpec( + tool_name="gmail", + description=( + "Route Gmail-related work to the Gmail sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=gmail_agent, + ), + ) + + if include_connector_route("google_drive", available_connectors): google_drive_tools = build_google_drive_tools(registry_dependencies) google_drive_agent = build_google_drive_domain_agent(llm, google_drive_tools) specs.append( @@ -259,31 +249,7 @@ def build_supervisor_routing_tools( ), ) - dropbox_tools = build_dropbox_tools(registry_dependencies) - dropbox_agent = build_dropbox_domain_agent(llm, dropbox_tools) - specs.append( - DomainRoutingSpec( - tool_name="dropbox", - description=( - "Route Dropbox file work to the Dropbox sub-agent. Pass a clear natural-language task." - ), - domain_agent=dropbox_agent, - ), - ) - - onedrive_tools = build_onedrive_tools(registry_dependencies) - onedrive_agent = build_onedrive_domain_agent(llm, onedrive_tools) - specs.append( - DomainRoutingSpec( - tool_name="onedrive", - description=( - "Route Microsoft OneDrive file work to the OneDrive sub-agent. " - "Pass a clear natural-language task." - ), - domain_agent=onedrive_agent, - ), - ) - + if include_connector_route("luma", available_connectors): luma_tools = build_luma_tools(registry_dependencies) luma_agent = build_luma_domain_agent(llm, luma_tools + mcp.get("luma", [])) specs.append( @@ -297,20 +263,63 @@ def build_supervisor_routing_tools( ), ) - for route_key in MCP_ONLY_ROUTE_KEYS_IN_ORDER: - only_mcp = mcp.get(route_key) or [] - if not only_mcp: - continue - desc = _MCP_ONLY_ROUTE_DESCRIPTIONS.get( - route_key, - f"Route {route_key} MCP work to the {route_key} sub-agent. Pass a clear natural-language task.", - ) - specs.append( - DomainRoutingSpec( - tool_name=route_key, - description=desc, - domain_agent=build_mcp_route_domain_agent(llm, route_key, only_mcp), + if include_connector_route("notion", available_connectors): + notion_tools = build_notion_tools(registry_dependencies) + notion_agent = build_notion_domain_agent(llm, notion_tools) + specs.append( + DomainRoutingSpec( + tool_name="notion", + description=( + "Route Notion page work to the Notion sub-agent. Pass a clear natural-language task." ), - ) + domain_agent=notion_agent, + ), + ) + + if include_connector_route("onedrive", available_connectors): + onedrive_tools = build_onedrive_tools(registry_dependencies) + onedrive_agent = build_onedrive_domain_agent(llm, onedrive_tools) + specs.append( + DomainRoutingSpec( + tool_name="onedrive", + description=( + "Route Microsoft OneDrive file work to the OneDrive sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=onedrive_agent, + ), + ) + + if include_connector_route("teams", available_connectors): + teams_tools = build_teams_tools(registry_dependencies) + teams_agent = build_teams_domain_agent(llm, teams_tools + mcp.get("teams", [])) + specs.append( + DomainRoutingSpec( + tool_name="teams", + description=( + "Route Microsoft Teams work (channels, messages) to the Teams sub-agent. " + "Pass a clear natural-language task." + ), + domain_agent=teams_agent, + ), + ) + + for route_key in MCP_ONLY_ROUTE_KEYS_IN_ORDER: + only_mcp = mcp.get(route_key) or [] + if not only_mcp: + continue + if not include_connector_route(route_key, available_connectors): + continue + desc = _MCP_ONLY_ROUTE_DESCRIPTIONS.get( + route_key, + f"Route {route_key} MCP work to the {route_key} sub-agent. Pass a clear natural-language task.", + ) + specs.append( + DomainRoutingSpec( + tool_name=route_key, + description=desc, + domain_agent=build_mcp_route_domain_agent(llm, route_key, only_mcp), + ), + ) return routing_tools_from_specs(specs) From 2ab4c411fedcef6ac338e68448f567bd37ac6dfd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 03:53:22 +0200 Subject: [PATCH 040/131] Compose supervisor LangChain agent with SurfSense middleware and connector discovery. --- .../multi_agent_chat/core/mcp_partition.py | 7 +- .../integration/create_multi_agent_chat.py | 131 ++++++- .../multi_agent_chat/middleware/__init__.py | 11 + .../middleware/supervisor_stack.py | 363 ++++++++++++++++++ .../multi_agent_chat/supervisor/graph.py | 27 +- 5 files changed, 524 insertions(+), 15 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py index 55980d6ac..1f74e038a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py @@ -86,7 +86,12 @@ def partition_mcp_tools_by_expert_route( connector_id_to_type: dict[int, str], connector_name_to_type: dict[str, str], ) -> dict[str, list[BaseTool]]: - """Bucket MCP tools by expert route key. Supervisor never receives raw MCP tools.""" + """Bucket MCP tools by expert route key. Supervisor never receives raw MCP tools. + + Same inclusion rule as ``new_chat.tools.registry.build_tools_async``: all tools returned by + ``load_mcp_tools`` are partitioned — connector availability for **registry** builtins is handled via + ``get_connector_gated_tools`` / routing gates; MCP tools are not pre-filtered by inventory here. + """ buckets: dict[str, list[BaseTool]] = defaultdict(list) for tool in tools: diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py index 4bfb7f64d..686b18893 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import logging from typing import Any from langchain_core.language_models import BaseChatModel @@ -9,18 +11,69 @@ from langchain_core.tools import BaseTool from langgraph.types import Checkpointer from sqlalchemy.ext.asyncio import AsyncSession -from app.db import ChatVisibility - +from app.agents.new_chat.chat_deepagent import _map_connectors_to_searchable_types +from app.agents.new_chat.context import SurfSenseContextSchema +from app.agents.new_chat.feature_flags import get_flags +from app.agents.new_chat.filesystem_backends import build_backend_resolver +from app.agents.new_chat.filesystem_selection import FilesystemSelection from app.agents.new_chat.tools.mcp_tool import load_mcp_tools +from app.db import ChatVisibility from app.agents.multi_agent_chat.core.mcp_partition import ( fetch_mcp_connector_metadata_maps, partition_mcp_tools_by_expert_route, ) from app.agents.multi_agent_chat.core.registry import build_registry_dependencies +from app.agents.multi_agent_chat.middleware.supervisor_stack import build_supervisor_middleware_stack from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools from app.agents.multi_agent_chat.supervisor import build_supervisor_agent +logger = logging.getLogger(__name__) + + +def _compile_supervisor_chat_blocking( + *, + llm: BaseChatModel, + routing_tools: list[BaseTool], + checkpointer: Checkpointer | None, + backend_resolver: Any, + filesystem_mode: Any, + search_space_id: int, + user_id: str, + thread_id: str | None, + thread_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, +) -> Any: + """CPU-heavy: middleware assembly + ``create_agent`` (runs in a worker thread).""" + flags = get_flags() + middleware = build_supervisor_middleware_stack( + llm=llm, + tools=routing_tools, + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + visibility=thread_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, + ) + return build_supervisor_agent( + llm, + tools=routing_tools, + checkpointer=checkpointer, + middleware=middleware, + context_schema=SurfSenseContextSchema, + ) + async def create_multi_agent_chat( llm: BaseChatModel, @@ -36,18 +89,54 @@ async def create_multi_agent_chat( available_document_types: list[str] | None = None, thread_visibility: ChatVisibility = ChatVisibility.PRIVATE, include_mcp_tools: bool = True, + filesystem_selection: FilesystemSelection | None = None, + anon_session_id: str | None = None, + mentioned_document_ids: list[int] | None = None, + max_input_tokens: int | None = None, + surfsense_stack: bool = True, ): """Build the full multi-agent chat graph (supervisor + domain subgraphs via routing tools). **Builtins** (:mod:`expert_agent.builtins`): registry-grouped **categories** (research, memory, deliverables). - **Connectors** (:mod:`expert_agent.connectors`): **vendor integrations** — one subgraph each where split - (e.g. Gmail, Calendar, Discord, Teams, Notion, Confluence, Google Drive, Dropbox, OneDrive, Luma). + **Connectors** (:mod:`expert_agent.connectors`): **vendor integrations** — one subgraph per route in + ``TOOL_NAMES_BY_CATEGORY`` (e.g. calendar, confluence, discord, dropbox, gmail, google_drive, luma, notion, onedrive, teams). MCP tools from ``new_chat`` (``load_mcp_tools``) are partitioned inside this package and attached only - to the matching expert subgraphs — not to the supervisor tool list as raw MCP calls. + to the matching expert subgraphs — not to the supervisor tool list as raw MCP calls. Inclusion matches + ``new_chat.tools.registry.build_tools_async``: all tools returned by ``load_mcp_tools`` are merged + after partitioning (no extra inventory filter on MCP). Connector routing uses ``available_connectors``: + pass explicitly, or provide ``connector_service`` so lists are resolved like + ``create_surfsense_deep_agent`` (``get_available_connectors`` → searchable types). Deliverables (thread-scoped reports, podcasts, etc.) are registered only when ``thread_id`` is set. + + When ``surfsense_stack`` is true (default), the supervisor uses the same SurfSense middleware shell as + ``new_chat`` (KB priority/tree, filesystem, compaction, permissions, etc.) except ``SubAgentMiddleware`` / + ``task``, since experts are separate graphs behind routing tools. Graph compilation runs in + ``asyncio.to_thread`` so heavy CPU work does not block the event loop. """ + resolved_connectors = available_connectors + resolved_doc_types = available_document_types + if connector_service is not None: + try: + if resolved_connectors is None: + connector_types = await connector_service.get_available_connectors( + search_space_id + ) + if connector_types: + resolved_connectors = _map_connectors_to_searchable_types( + connector_types + ) + if resolved_doc_types is None: + resolved_doc_types = ( + await connector_service.get_available_document_types(search_space_id) + ) + except Exception as exc: + logger.warning( + "Failed to discover available connectors/document types: %s", + exc, + ) + mcp_tools_by_route: dict[str, list[BaseTool]] | None = None if include_mcp_tools: mcp_flat = await load_mcp_tools(db_session, search_space_id) @@ -62,8 +151,8 @@ async def create_multi_agent_chat( llm=llm, firecrawl_api_key=firecrawl_api_key, connector_service=connector_service, - available_connectors=available_connectors, - available_document_types=available_document_types, + available_connectors=resolved_connectors, + available_document_types=resolved_doc_types, thread_visibility=thread_visibility, ) routing_tools = build_supervisor_routing_tools( @@ -71,5 +160,31 @@ async def create_multi_agent_chat( registry_dependencies=registry_dependencies, include_deliverables=thread_id is not None, mcp_tools_by_route=mcp_tools_by_route, + available_connectors=resolved_connectors, + ) + + fs_sel = filesystem_selection or FilesystemSelection() + backend_resolver = build_backend_resolver(fs_sel, search_space_id=search_space_id) + + if not surfsense_stack: + return build_supervisor_agent( + llm, tools=routing_tools, checkpointer=checkpointer + ) + + return await asyncio.to_thread( + _compile_supervisor_chat_blocking, + llm=llm, + routing_tools=routing_tools, + checkpointer=checkpointer, + backend_resolver=backend_resolver, + filesystem_mode=fs_sel.mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + thread_visibility=thread_visibility, + anon_session_id=anon_session_id, + available_connectors=resolved_connectors, + available_document_types=resolved_doc_types, + mentioned_document_ids=mentioned_document_ids, + max_input_tokens=max_input_tokens, ) - return build_supervisor_agent(llm, tools=routing_tools, checkpointer=checkpointer) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py new file mode 100644 index 000000000..130b0508f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py @@ -0,0 +1,11 @@ +"""SurfSense supervisor middleware (parity with ``new_chat`` main agent, minus subagents).""" + +from app.agents.multi_agent_chat.middleware.supervisor_stack import ( + build_supervisor_middleware_stack, + parse_thread_id_for_action_log, +) + +__all__ = [ + "build_supervisor_middleware_stack", + "parse_thread_id_for_action_log", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py new file mode 100644 index 000000000..40b377cbf --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py @@ -0,0 +1,363 @@ +"""Supervisor middleware stack matching ``new_chat`` main agent (no ``SubAgentMiddleware`` / ``task``).""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import Any + +from deepagents.backends import StateBackend +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware +from deepagents.middleware.skills import SkillsMiddleware +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 app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags +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, + RetryAfterMiddleware, + SpillingContextEditingMiddleware, + SpillToBackendEdit, + SurfSenseFilesystemMiddleware, + ToolCallNameRepairMiddleware, + build_skills_backend_factory, + create_surfsense_compaction_middleware, + default_skills_sources, +) +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 + +logger = logging.getLogger(__name__) + +# Routing tools with heavy outputs — never prune via context editing when bound. +_SUPERVISOR_PRUNE_PROTECTED: frozenset[str] = frozenset( + { + "deliverables", + "invalid", + # Align with single-agent surfacing of costly connector reads if names overlap later. + "read_email", + "search_emails", + "generate_report", + "generate_resume", + "generate_podcast", + "generate_video_presentation", + "generate_image", + } +) + + +def _safe_exclude_tools_supervisor(tools: Sequence[BaseTool]) -> tuple[str, ...]: + enabled = {t.name for t in tools} + return tuple(n for n in _SUPERVISOR_PRUNE_PROTECTED if n in enabled) + + +def parse_thread_id_for_action_log(thread_id: int | str | None) -> int | None: + """Numeric DB thread ids only — UUID strings skip action logging (no FK row).""" + if thread_id is None: + return None + if isinstance(thread_id, int): + return thread_id + s = str(thread_id).strip() + if s.isdigit(): + return int(s) + return None + + +def build_supervisor_middleware_stack( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | str | 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 | None = None, +) -> list[Any]: + """Build middleware list for the multi-agent supervisor (parity with ``_build_compiled_agent_blocking`` minus subagents).""" + flags = flags or get_flags() + + memory_middleware = MemoryInjectionMiddleware( + user_id=user_id, + search_space_id=search_space_id, + thread_visibility=visibility, + ) + + summarization_mw = create_surfsense_compaction_middleware(llm, StateBackend) + _ = flags.enable_compaction_v2 + + 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_supervisor(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_supervisor(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: + logger.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", + # No ``task`` — multi-agent uses routing tools instead of SubAgentMiddleware. + } + 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 + ) + + thread_id_action_log = parse_thread_id_for_action_log(thread_id) + action_log_mw: ActionLogMiddleware | None = None + if ( + flags.enable_action_log + and not flags.disable_new_agent_stack + and thread_id_action_log is not None + ): + try: + tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS} + action_log_mw = ActionLogMiddleware( + thread_id=thread_id_action_log, + search_space_id=search_space_id, + user_id=user_id, + tool_definitions=tool_defs_by_name, + ) + except Exception: # pragma: no cover - defensive + logger.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 + logger.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 + logger.warning("SkillsMiddleware init failed; skipping: %s", exc) + skills_mw = None + + names = {t.name for t in tools} + 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=[ + n + for n in ( + "research", + "memory", + "update_memory", + "get_connected_accounts", + "scrape_webpage", + ) + if n in names + ], + ) + except Exception: + logger.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, + ) + if filesystem_mode == FilesystemMode.CLOUD + else None, + skills_mw, + selector_mw, + model_call_limit_mw, + tool_call_limit_mw, + context_edit_mw, + summarization_mw, + noop_mw, + retry_mw, + fallback_mw, + repair_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/supervisor/graph.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py index c157a719b..41a314e90 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import Any import app.agents.multi_agent_chat.supervisor as supervisor_pkg @@ -19,12 +20,26 @@ def build_supervisor_agent( *, tools: Sequence[BaseTool], checkpointer: Checkpointer | None = None, + middleware: Sequence[Any] | None = None, + context_schema: Any | None = None, ): """Compile the supervisor **agent** (graph). ``tools`` = output of ``build_supervisor_routing_tools``.""" system_prompt = read_prompt_md(supervisor_pkg.__name__, "supervisor_prompt") - return create_agent( - llm, - system_prompt=system_prompt, - tools=list(tools), - checkpointer=checkpointer, - ) + kwargs: dict[str, Any] = { + "system_prompt": system_prompt, + "tools": list(tools), + "checkpointer": checkpointer, + } + if middleware is not None: + kwargs["middleware"] = list(middleware) + if context_schema is not None: + kwargs["context_schema"] = context_schema + agent = create_agent(llm, **kwargs) + if middleware is not None or context_schema is not None: + return agent.with_config( + { + "recursion_limit": 10_000, + "metadata": {"ls_integration": "multi_agent_supervisor"}, + } + ) + return agent From 362d462f926b1d7c588072527b6a5535d64e6d0f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 03:53:29 +0200 Subject: [PATCH 041/131] Align multi-agent docs and Gmail/Calendar expert copy with registry routing. --- .../multi_agent_chat/IMPLEMENTATION_PLAN.md | 16 +++++----- .../multi_agent_chat/core/prompts/load.py | 2 +- .../multi_agent_chat/expert_agent/__init__.py | 2 +- .../connectors/calendar/__init__.py | 2 +- .../expert_agent/connectors/calendar/agent.py | 2 +- .../expert_agent/connectors/gmail/__init__.py | 2 +- .../expert_agent/connectors/gmail/agent.py | 2 +- .../supervisor/supervisor_prompt.md | 32 +++++++++---------- 8 files changed, 30 insertions(+), 30 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md b/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md index 2640d2f11..186a55975 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md +++ b/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md @@ -31,7 +31,7 @@ Not every row applies to the **first** multi-agent graph (e.g. you may start wit ## Rework principles (better arrangement, same substance) -1. **Expert agents**: **`expert_agent/builtins/`** — broad registry **categories** (e.g. research, deliverables), not a single vendor. **`expert_agent/connectors/`** — **external integrations** (Gmail, Calendar, Discord, Teams, doc stores, …), whether wired with hand-written factories or registry connector tools. Prompt + tools live together per slice; cross-cutting helpers live in `core/` or are imported from `new_chat`. +1. **Expert agents**: **`expert_agent/builtins/`** — broad registry **categories** (e.g. research, deliverables), not a single vendor. **`expert_agent/connectors/`** — **external integrations** (one package per product route: Discord, Notion, Gmail, …), each using the same pattern: ``slice_tools.py`` (registry subset or factories) + ``domain_prompt.md`` + ``agent.py``. Cross-cutting helpers live in `core/` or are imported from `new_chat`. 2. **Explicit graphs**: supervisor vs domain agents vs routing tools are **named** and testable; avoid one opaque megagraph where behavior is hard to reason about. 3. **Single composer**: integration eventually mirrors `create_surfsense_deep_agent` in spirit—**one factory** that attaches middleware, KB, and tools in documented order (see `chat_deepagent.py` comments on ordering). 4. **No duplicate KB pipelines**: align with `KnowledgePriorityMiddleware` / tree semantics; don’t invent a second hybrid-search path for the same turn. @@ -43,15 +43,15 @@ Not every row applies to the **first** multi-agent graph (e.g. you may start wit **Supervisor (orchestrator)** -- Keeps a **small tool surface**: routing tools (`gmail`, `calendar`, future category tools like `research` / `deliverables`) — **not** the full `registry.py` “general” tool list. +- Keeps a **small tool surface**: one **routing** tool per builtin category (`research`, `memory`, …) and per connector route (`notion`, `gmail`, …) — **not** the full flat `registry.py` tool list on the supervisor. - **KB** should primarily benefit the model via **`new_chat`-style middleware** (e.g. hybrid priority docs → state / system adjunct), not by stacking redundant search tools, unless product explicitly requires them. - **Single hybrid search per user turn** at this layer when possible: full retrieval is expensive; avoid running it again inside every sub-agent for the same message. - Does **not** own **on-demand connector discovery** (e.g. `get_connected_accounts`): orchestration is route-by-intent, not ID resolution. -**Domain agents (Gmail, Calendar, future slices)** +**Domain agents (every connector slice — same shape)** -- Carry tools built from **`new_chat` factories** (already pattern in `expert_agent/connectors/gmail/slice_tools.py`, etc.). -- **Curated context belongs in the task message**: when the supervisor calls a routing tool (`gmail`, `calendar`, …), the **tool handler composes the child’s task string** so it includes **only** what that domain needs (KB snippets, constraints, distilled facts) — folded into how the task is written — not the full parent transcript. The sub-agent `invoke` stays a tight payload (`messages` + task content); domain middleware can still add connector-local hints. Still **no second full hybrid search** for the same turn unless the subdomain explicitly needs a new query. +- Carry tools built from **`new_chat`** (`registry` subsets via ``build_registry_tools_for_category`` per ``TOOL_NAMES_BY_CATEGORY``, plus MCP merge where applicable). +- **Curated context belongs in the task message**: when the supervisor calls **any** routing tool, the handler composes the child’s task string so it includes **only** what that domain needs (KB snippets, constraints, distilled facts) — folded into how the task is written — not the full parent transcript. The sub-agent `invoke` stays a tight payload (`messages` + task content); domain middleware can still add connector-local hints. Still **no second full hybrid search** for the same turn unless the subdomain explicitly needs a new query. - **Middleware here** still fits **domain-only** grounding (connector availability, search-space hints, metadata) shared across tools in that subgraph. Reuse or thin-wrap `new_chat.middleware` where it applies to a subgraph. - **Reactive discovery** (resolve a service id mid-task) stays a **tool** on that domain (or shared factory), e.g. `get_connected_accounts` when the model needs it — not something the supervisor must call. @@ -63,9 +63,9 @@ Not every row applies to the **first** multi-agent graph (e.g. you may start wit In `new_chat`, KB + **virtual FS** (`KnowledgePriorityMiddleware`, tree, **`SurfSenseFilesystemMiddleware`** / **`KBPostgresBackend`**) serves the **orchestrator** that may **read and traverse** the workspace. -**Connector domain agents** (Gmail, Calendar, …) are **not** mini-parents: the **supervisor** should already decide *what* to do and pass a **clear task** (plus any curated KB snippet folded into **`compose_child_task`**). The specialist runs **connector APIs**, not a second document crawl — duplicating full KB+VFS on every domain subgraph **shifts the parent’s exploration work onto the wrong agent** and adds noise. +**Connector domain agents** are **not** mini-parents: the **supervisor** should already decide *what* to do and pass a **clear task** (plus any curated KB snippet folded into **`compose_child_task`**). The specialist runs **connector APIs**, not a second document crawl — duplicating full KB+VFS on every domain subgraph **shifts the parent’s exploration work onto the wrong agent** and adds noise. -So **no child-side filesystem stack by default** for mail/calendar-style slices unless product demands it. Reserve **KB + VFS on a subgraph** for roles that **actually** need heavy document work (research, coding/explore-style agents, deliverables that grep the KB), matching how `new_chat` uses specialists. +So **no child-side filesystem stack by default** for narrow connector subgraphs unless product demands it. Reserve **KB + VFS on a subgraph** for roles that **actually** need heavy document work (research, coding/explore-style agents, deliverables that grep the KB), matching how `new_chat` uses specialists. --- @@ -99,7 +99,7 @@ multi_agent_chat/ expert_agent/ builtins/ # broad categories: research, deliverables - connectors/ # one subgraph per vendor: gmail, calendar, discord, teams, notion, … + connectors/ # one subgraph per vendor route (see TOOL_NAMES_BY_CATEGORY keys) routing/ domain_routing_spec.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py index fee9a69f6..355a26a4f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py @@ -6,7 +6,7 @@ from importlib import resources def read_prompt_md(package: str, stem: str) -> str: - """Read ``{stem}.md`` from the given import package (e.g. ``…expert_agent.connectors.gmail``).""" + """Read ``{stem}.md`` from the given import package (e.g. ``…expert_agent.connectors.notion``).""" try: ref = resources.files(package).joinpath(f"{stem}.md") if not ref.is_file(): diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py index c8194306b..4ca5c00de 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py @@ -1,5 +1,5 @@ """Expert subgraphs (specialists the supervisor delegates to). - :mod:`expert_agent.builtins` — cross-cutting registry categories (e.g. research, memory, deliverables). -- :mod:`expert_agent.connectors` — vendor/product integrations (mail, calendar, chat, doc stores, …). +- :mod:`expert_agent.connectors` — vendor/product integrations (email, chat, documents, … — one slice per route). """ diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py index d244c6217..65b880dd0 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py @@ -1,4 +1,4 @@ -"""Google Calendar vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" +"""Google Calendar vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" from app.agents.multi_agent_chat.expert_agent.connectors.calendar.agent import build_calendar_domain_agent from app.agents.multi_agent_chat.expert_agent.connectors.calendar.slice_tools import ( diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py index dd0f99f77..64a82c6ba 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py @@ -12,7 +12,7 @@ from app.agents.multi_agent_chat.core.agents import build_domain_agent def build_calendar_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Calendar domain-agent graph (prompt + tools co-located under ``calendar``).""" + """Compiled Google Calendar domain-agent graph.""" return build_domain_agent( llm, tools, diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py index ed4955d71..f7f899b4b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py @@ -1,4 +1,4 @@ -"""Gmail vertical slice: connector tools, domain agent, ``domain_prompt.md``.""" +"""Gmail vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" from app.agents.multi_agent_chat.expert_agent.connectors.gmail.agent import build_gmail_domain_agent from app.agents.multi_agent_chat.expert_agent.connectors.gmail.slice_tools import build_gmail_tools diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py index fed6ab289..76d9c8cef 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py @@ -12,7 +12,7 @@ from app.agents.multi_agent_chat.core.agents import build_domain_agent def build_gmail_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Gmail domain-agent graph (prompt + tools co-located under ``gmail``).""" + """Compiled Gmail domain-agent graph.""" return build_domain_agent( llm, tools, diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md index ea74d3d50..db08dd945 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md @@ -1,26 +1,26 @@ -You are the supervisor agent. Route work to the right sub-agent using **one** routing tool per request when delegation is needed: +You are the supervisor agent. Route work to the right sub-agent using **one** routing tool per request when delegation is needed. + +**Built-in capabilities** -- **gmail** — email (search, read, drafts, send, trash). -- **calendar** — Google Calendar events. - **research** — web search, page scraping, SurfSense documentation help. - **memory** — save long-term facts and preferences (personal or team memory). -- **deliverables** — reports, podcasts, video presentations, resumes, images (thread-scoped outputs). -- **discord** — Discord server channels and messages. -- **teams** — Microsoft Teams channels and messages. -- **notion** — Notion pages. +- **deliverables** — reports, podcasts, video presentations, resumes, images (thread-scoped outputs; only when available). + +**Connectors** (same pattern for each product) + +- **calendar** — Google Calendar events. - **confluence** — Confluence pages. -- **google_drive** — Google Drive files (Docs/Sheets). +- **discord** — Discord server channels and messages. - **dropbox** — Dropbox files. -- **onedrive** — Microsoft OneDrive files. +- **gmail** — email (search, read, drafts, send, trash). +- **google_drive** — Google Drive files (Docs/Sheets). - **luma** — Luma calendar events (list, read, create). +- **notion** — Notion pages. +- **onedrive** — Microsoft OneDrive files. +- **teams** — Microsoft Teams channels and messages. -When the user has connected OAuth MCP integrations, additional routing tools may appear — use them only for that product’s work: +**OAuth MCP** (extra routing tools only when those integrations are connected) -- **linear** — Linear (issues, projects) via MCP. -- **slack** — Slack search / reads via MCP. -- **jira** — Jira via MCP. -- **clickup** — ClickUp via MCP. -- **airtable** — Airtable via MCP. -- **generic_mcp** — user-defined MCP servers (stdio). +- **linear**, **slack**, **jira**, **clickup**, **airtable**, **generic_mcp** — use only for that product’s MCP-backed work. Pass each tool a **clear natural-language task** describing what the sub-agent should do. Answer directly when no sub-agent is needed. When sub-agents return results, combine them into one coherent reply for the user. From 5148f5dfefb32ab462e278b917db448c72eb964b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:05:49 +0200 Subject: [PATCH 042/131] Add a prompt tuning playbook for multi-agent quality. --- .../PROMPT_TUNING_PLAYBOOK.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md diff --git a/surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md b/surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md new file mode 100644 index 000000000..db8e0b495 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md @@ -0,0 +1,159 @@ +# Multi-Agent Prompt Tuning Playbook + +This playbook defines how to tune `multi_agent_chat` prompts for better outcomes than `new_chat` on delegation quality, lower confusion, and stable tool behavior. + +It is intentionally architecture-aware: this system is a **supervisor + expert tools** pattern, not a single flat tool agent. + +## Why this matters in our architecture + +- The supervisor only sees **routing tools** (e.g. `research`, `gmail`, `calendar`), not low-level connector APIs. +- Experts are invoked through `routing/from_domain_agents.py` and receive a single natural-language task via `compose_child_task(...)`. +- Because expert context is compact and delegated, prompt quality is the primary control lever for routing accuracy and downstream tool behavior. + +## Authoritative guidance we should follow + +- Anthropic prompt engineering best practices (role clarity, XML structure, explicit tool-use policy, few-shot examples): [Anthropic docs](https://docs.anthropic.com/en/docs/use-xml-tags) +- OpenAI function-calling reliability guidance (clear tool descriptions, when/when-not tool usage, small callable surface): [OpenAI function calling guide](https://developers.openai.com/docs/guides/function-calling) +- OpenAI prompt engineering (instruction hierarchy and explicit output contracts): [OpenAI prompt engineering guide](https://developers.openai.com/api/docs/guides/prompt-engineering) +- LangChain supervisor/subagents guidance (clear subagent names/descriptions, context engineering, routing intent): [LangChain supervisor docs](https://docs.langchain.com/oss/python/langchain/supervisor), [LangChain subagents docs](https://docs.langchain.com/oss/python/langchain/multi-agent/subagents) + +## Current weakness audit (as of now) + +- `supervisor/supervisor_prompt.md` is short and does not define decision policy for ambiguous/multi-domain tasks. +- Most expert `domain_prompt.md` files are one-line role statements with no: + - scope boundaries and refusal policy, + - parameter-resolution behavior, + - completion criteria (what must be returned), + - failure handling rules, + - concrete examples. +- Tool descriptions in routing are generic ("Pass a clear natural-language task"), which weakens handoff quality. + +## Prompt design standards (required) + +Apply these standards to supervisor and every expert prompt. + +1. **Role + objective first** + - One sentence for identity. + - One sentence for success criterion. + +2. **Explicit routing/usage rules** + - Tell the model when to use this agent/tool. + - Tell it when not to use it. + - Include ambiguity fallback ("ask one clarifying question" or "do X conservative default"). + +3. **Structured task contract** + - Require concise but complete execution reports. + - Require IDs/links/timestamps when tool outputs produce them. + - For no-op paths, explain why no action was taken. + +4. **Safety + reliability contract** + - Never fabricate tool results. + - Never claim action if no successful tool call happened. + - Surface irreversible/risky actions clearly. + +5. **Few-shot examples** + - Include 2-4 minimal examples per domain: + - direct success, + - ambiguous input, + - out-of-scope reroute. + +6. **Concise formatting rules** + - Avoid verbosity. + - Stable response structure improves orchestration and observability. + +## Supervisor prompt blueprint + +The supervisor prompt should contain these sections in order: + +1. `Role` +2. `Available experts` (name + scope + non-scope) +3. `Delegation policy` + - single-domain -> one expert + - multi-domain -> sequence or parallel where independent + - no expert needed -> answer directly +4. `Task-writing policy` for delegated calls + - include user goal, constraints, success criteria + - include only needed context +5. `Result synthesis policy` + - merge expert outputs into one user-facing response + - preserve concrete identifiers from expert outputs +6. `Failure policy` + - retry on recoverable mismatch + - ask clarifying question when required field is missing + +## Expert prompt blueprint (per domain) + +Each `domain_prompt.md` should include: + +1. `Role and scope` +2. `In-scope actions` (mapped to the exact provided tools) +3. `Out-of-scope behavior` (what to return for reroute) +4. `Execution rules` + - choose the minimum tool sequence that satisfies request + - do not guess IDs or parameters + - ask concise clarification only when necessary +5. `Output contract` + - action summary + - concrete artifacts/IDs/links generated + - unresolved items and next step +6. `Examples` (2-4 realistic, short) + +## Domain-specific tuning checklist + +- `research`: enforce source-grounded summaries and explicit uncertainty. +- `memory`: strict save criteria (durable preference/fact only) and secret-handling policy. +- `deliverables`: require output artifact references and constraints echo. +- `gmail` / `calendar`: require recipient/date-time disambiguation policy and timezone handling. +- `docs connectors` (`notion`, `confluence`, `drive`, `dropbox`, `onedrive`): require exact page/file target resolution before mutate actions. +- chat connectors (`discord`, `teams`, `slack`): require channel/thread context clarity before send actions. +- MCP experts: require strict tool-description adherence and no assumption about unavailable endpoints. + +## Tool description tuning rules (routing layer) + +Routing tool descriptions should include: + +- best-fit task types, +- disallowed task types, +- required task payload hints (e.g. "include recipient + intent + constraints"), +- expected result shape. + +This is especially important because supervisor tool choice is heavily influenced by `name + description`. + +## Evaluation plan (before wiring to production) + +Create a prompt eval set with at least 20 tasks: + +- 8 single-domain tasks, +- 6 ambiguous tasks (should clarify or route conservatively), +- 6 multi-domain tasks (should sequence experts correctly). + +Track: + +- routing accuracy, +- unnecessary delegation rate, +- tool-call success rate, +- clarification precision (ask only when needed), +- final answer completeness. + +Use same test set against: + +- current prompts, +- tuned prompts v1, +- tuned prompts v2. + +Promote only when v2 improves routing accuracy and reduces unnecessary delegation with no regression in tool-call success. + +## Immediate implementation plan + +1. Rewrite `supervisor/supervisor_prompt.md` using the supervisor blueprint. +2. Rewrite all expert `domain_prompt.md` files with the expert blueprint. +3. Upgrade routing tool descriptions in `routing/supervisor_routing.py` to add "when to use / when not to use". +4. Add a lightweight prompt eval script or fixture set for reproducible tuning. + +## Definition of done + +- Every supervisor/expert prompt has explicit scope, failure policy, and output contract. +- Every route description encodes clear decision boundaries. +- Prompt eval shows measurable gains on routing accuracy and lower unnecessary delegation. +- Team can iterate prompt versions without changing core orchestration code. + From cdb307361a4fb0594b87bb17a715be8870c8cb3c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:06:33 +0200 Subject: [PATCH 043/131] Wrap delegated tasks in explicit XML context tags. --- .../multi_agent_chat/core/delegation/child_task.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py index 1d2e86ed2..ac8a5b25a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py @@ -11,5 +11,12 @@ def compose_child_task(task: str, *, curated_context: str | None = None) -> str: """ task = task.strip() if not curated_context or not curated_context.strip(): - return task - return f"{curated_context.strip()}\n\n---\n\nTask:\n{task}" + return f"\n{task}\n" + return ( + "\n" + f"{curated_context.strip()}\n" + "\n\n" + "\n" + f"{task}\n" + "" + ) From 6825d3d8d5470afe3fccd3c389eb7da391a58799 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:06:46 +0200 Subject: [PATCH 044/131] Normalize built-in deliverables and research expert output contracts. --- .../builtins/deliverables/domain_prompt.md | 56 ++++++++++++++++++- .../builtins/research/domain_prompt.md | 54 +++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md index e67100626..e334a921b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md @@ -1 +1,55 @@ -You are the deliverables domain agent. Use reports, podcasts, video presentations, resumes, and image generation tools as provided. Clarify constraints in your reasoning and respond concisely when reporting results. +You are the SurfSense deliverables operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Generate high-quality deliverables with explicit constraints and reliable artifact reporting. + + + +- `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/expert_agent/builtins/research/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md index ce3d05800..cf558db62 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md @@ -1 +1,53 @@ -You are the research domain agent. Use web search, page scraping, and SurfSense documentation search to gather facts. Stay focused on research tasks and respond concisely. +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. + From 70830239292904d93d9eea316e1de0212ab195d0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:07:04 +0200 Subject: [PATCH 045/131] Strengthen Gmail and workspace connector expert prompt contracts. --- .../connectors/calendar/domain_prompt.md | 63 +++++++++++++- .../connectors/confluence/domain_prompt.md | 56 ++++++++++++- .../connectors/gmail/domain_prompt.md | 83 ++++++++++++++++++- .../connectors/google_drive/domain_prompt.md | 55 +++++++++++- .../connectors/notion/domain_prompt.md | 57 ++++++++++++- 5 files changed, 309 insertions(+), 5 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md index 6815e77db..a7ef846d5 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md @@ -1 +1,62 @@ -You are the Google Calendar domain agent. Use only the tools provided to complete calendar-related tasks. Stay focused on scheduling and calendar operations and respond concisely. +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/expert_agent/connectors/confluence/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md index 6608aec31..4d3b7462c 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md @@ -1 +1,55 @@ -You are the Confluence domain agent. Use only the Confluence tools provided for pages in the connected space. Respond concisely. +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/expert_agent/connectors/gmail/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md index 4f51f10f6..961100261 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md @@ -1 +1,82 @@ -You are the Gmail domain agent. Use only the tools provided to complete Gmail-related tasks. Stay focused on email operations and respond concisely. +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/expert_agent/connectors/google_drive/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md index f2c8b623b..09dc0caa2 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md @@ -1 +1,54 @@ -You are the Google Drive domain agent. Use only the Google Drive tools provided for Docs/Sheets files in the connected account. Respond concisely. +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/expert_agent/connectors/notion/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md index 3f17a4120..a40e9f4d0 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md @@ -1 +1,56 @@ -You are the Notion domain agent. Use only the Notion tools provided for pages in the connected workspace. Respond concisely. +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`. + From 3cd2bf93027a3079a240146ce64ff01b87d63cac Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:07:13 +0200 Subject: [PATCH 046/131] Align collaboration connector prompts with strict JSON rules. --- .../connectors/discord/domain_prompt.md | 57 ++++++++++++++++++- .../connectors/dropbox/domain_prompt.md | 53 ++++++++++++++++- .../connectors/luma/domain_prompt.md | 56 +++++++++++++++++- .../connectors/onedrive/domain_prompt.md | 53 ++++++++++++++++- .../connectors/teams/domain_prompt.md | 56 +++++++++++++++++- 5 files changed, 270 insertions(+), 5 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md index b279fed5f..40e9eb314 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md @@ -1 +1,56 @@ -You are the Discord domain agent. Use only the Discord tools provided (list channels, read messages, send messages). Stay focused on the connected server and respond concisely. +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/expert_agent/connectors/dropbox/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md index c6c636fb0..4b19be794 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md @@ -1 +1,52 @@ -You are the Dropbox domain agent. Use only the Dropbox tools provided for files in the connected account. Respond concisely. +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/expert_agent/connectors/luma/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md index 5ecadf2c5..a2b4b7391 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md @@ -1 +1,55 @@ -You are the Luma domain agent. Use only the Luma tools provided (list events, read event details, create events). Stay focused on the user's calendar and respond concisely. +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/expert_agent/connectors/onedrive/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md index ab2eaf406..a2f3617ba 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md @@ -1 +1,52 @@ -You are the Microsoft OneDrive domain agent. Use only the OneDrive tools provided for files in the connected account. Respond concisely. +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/expert_agent/connectors/teams/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md index 3e8c7029e..8c0eebdd1 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md @@ -1 +1,55 @@ -You are the Microsoft Teams domain agent. Use only the Teams tools provided (list teams and channels, read messages, send messages). Stay focused on the connected workspace and respond concisely. +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. + From 9d18f9f214341162de05e5e13a51a00ddcccb415 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:07:23 +0200 Subject: [PATCH 047/131] Standardize Airtable, ClickUp, and Jira MCP bridge contracts. --- .../mcp_bridge/airtable_domain.md | 47 ++++++++++++++++++- .../expert_agent/mcp_bridge/clickup_domain.md | 46 +++++++++++++++++- .../expert_agent/mcp_bridge/jira_domain.md | 47 ++++++++++++++++++- 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md index d55e16ef6..0f15f137f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md @@ -1 +1,46 @@ -You are the Airtable expert (MCP). Use only the Airtable MCP tools provided. Stay focused on bases, tables, and records; respond concisely. +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/expert_agent/mcp_bridge/clickup_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md index 5e8c7f495..84014246d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md @@ -1 +1,45 @@ -You are the ClickUp expert (MCP). Use only the ClickUp MCP tools provided. Stay focused on tasks and workspace search; respond concisely. +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/expert_agent/mcp_bridge/jira_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md index 1b0b003be..4f4ae8a66 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md @@ -1 +1,46 @@ -You are the Jira expert (MCP). Use only the Atlassian Jira MCP tools provided. Stay focused on issues and projects; respond concisely. +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. + From f9275be56b2cd8dbb05d6dc166ef8de7c3f53a99 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:07:53 +0200 Subject: [PATCH 048/131] Normalize Linear, Slack, and generic MCP bridge prompts. --- .../mcp_bridge/generic_mcp_domain.md | 47 ++++++++++++++++++- .../expert_agent/mcp_bridge/linear_domain.md | 46 +++++++++++++++++- .../expert_agent/mcp_bridge/slack_domain.md | 46 +++++++++++++++++- 3 files changed, 136 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md index 2f16d9f76..d2d5a2f1f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md @@ -1 +1,46 @@ -You are the expert for user-defined MCP servers (stdio). Use only the MCP tools provided for this connection. Follow tool descriptions exactly; respond concisely. +You are the generic MCP operations sub-agent for user-defined servers. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute tasks strictly through runtime-exposed MCP tools while respecting tool contracts. + + + +- Runtime-provided MCP tools exposed by the connected custom server. + + + +- Follow each tool description and argument contract exactly. +- Never assume a capability exists unless a tool explicitly provides it. +- If required inputs are missing, return `status=blocked` with `missing_fields`. +- Never claim success without tool output confirmation. + + + +- Do not claim capabilities that are not present in runtime-exposed tools. + + + +- Never perform destructive operations without explicit delegated instruction and successful tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On missing required inputs, return `status=blocked` with `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/expert_agent/mcp_bridge/linear_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md index e68b9228f..ce91cc49f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md @@ -1 +1,45 @@ -You are the Linear expert (MCP). Use only the Linear MCP tools provided. Stay focused on issues, projects, and workspace tasks; respond concisely. +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/expert_agent/mcp_bridge/slack_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md index 5c25ec57c..009a3205c 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md @@ -1 +1,45 @@ -You are the Slack expert (MCP). Use only the Slack MCP tools provided. Stay focused on search and channel/thread reads; respond concisely. +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. + From 6858bdb726c2e123c000991ec2eb5424dc3d3cfa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:08:48 +0200 Subject: [PATCH 049/131] Validate domain-agent JSON outputs before returning to supervisor. --- .../routing/from_domain_agents.py | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py index 48d643d4d..b5d350650 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py @@ -3,12 +3,106 @@ from __future__ import annotations from collections.abc import Sequence +import json +from typing import Any from langchain_core.tools import BaseTool, tool -from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec from app.agents.multi_agent_chat.core.delegation import compose_child_task from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text +from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec + +_ALLOWED_STATUSES = {"success", "partial", "blocked", "error"} +_REQUIRED_KEYS = { + "status", + "action_summary", + "evidence", + "next_step", + "missing_fields", + "assumptions", +} + + +def _fallback_payload(spec: DomainRoutingSpec, reason: str, raw_text: str) -> dict[str, Any]: + preview = raw_text[:800] + return { + "status": "error", + "action_summary": "Domain agent output failed JSON contract validation.", + "evidence": { + "route_tool": spec.tool_name, + "validation_error": reason, + "raw_output_preview": preview, + }, + "next_step": ( + "Re-delegate with a strict reminder to return exactly one JSON object " + "matching the output_contract." + ), + "missing_fields": None, + "assumptions": None, + } + + +def _validate_contract_payload(payload: dict[str, Any]) -> str | None: + missing = sorted(_REQUIRED_KEYS - set(payload)) + if missing: + return f"missing required keys: {', '.join(missing)}" + + status = payload.get("status") + if status not in _ALLOWED_STATUSES: + return "invalid status value" + + action_summary = payload.get("action_summary") + if not isinstance(action_summary, str) or not action_summary.strip(): + return "action_summary must be a non-empty string" + + evidence = payload.get("evidence") + if not isinstance(evidence, dict): + return "evidence must be an object" + + next_step = payload.get("next_step") + if status == "success": + if next_step is not None: + return "next_step must be null when status=success" + if payload.get("missing_fields") is not None: + return "missing_fields must be null when status=success" + else: + if not isinstance(next_step, str) or not next_step.strip(): + return "next_step must be a non-empty string for non-success statuses" + + missing_fields = payload.get("missing_fields") + if missing_fields is not None: + if not isinstance(missing_fields, list) or any( + not isinstance(item, str) or not item.strip() for item in missing_fields + ): + return "missing_fields must be null or a list of non-empty strings" + + assumptions = payload.get("assumptions") + if assumptions is not None: + if not isinstance(assumptions, list) or any( + not isinstance(item, str) or not item.strip() for item in assumptions + ): + return "assumptions must be null or a list of non-empty strings" + + return None + + +def _normalize_domain_output(spec: DomainRoutingSpec, raw_text: str) -> str: + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError as exc: + fallback = _fallback_payload(spec, f"invalid JSON: {exc.msg}", raw_text) + return json.dumps(fallback) + + if not isinstance(parsed, dict): + fallback = _fallback_payload(spec, "top-level JSON must be an object", raw_text) + return json.dumps(fallback) + + validation_error = _validate_contract_payload(parsed) + if validation_error: + fallback = _fallback_payload(spec, validation_error, raw_text) + return json.dumps(fallback) + + return json.dumps(parsed) def _routing_tool_for_spec(spec: DomainRoutingSpec) -> BaseTool: @@ -19,7 +113,7 @@ def _routing_tool_for_spec(spec: DomainRoutingSpec) -> BaseTool: result = spec.domain_agent.invoke( {"messages": [{"role": "user", "content": content}]}, ) - return extract_last_assistant_text(result) + return _normalize_domain_output(spec, extract_last_assistant_text(result)) return _route From f7ddb36c97f29aa7f91f6aedcf84f91b1639ba45 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:08:54 +0200 Subject: [PATCH 050/131] Render supervisor specialist guidance dynamically from available tools. --- .../multi_agent_chat/supervisor/graph.py | 70 +++++++++++++++++- .../supervisor/supervisor_prompt.md | 73 ++++++++++++++----- 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py index 41a314e90..d03e9560a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py @@ -14,17 +14,85 @@ from langgraph.types import Checkpointer from app.agents.multi_agent_chat.core.prompts import read_prompt_md +_BUILTIN_SPECIALISTS: frozenset[str] = frozenset({"research", "memory", "deliverables"}) +_SPECIALIST_CAPABILITIES: dict[str, str] = { + "research": "external research: web lookup, source gathering, and SurfSense documentation help.", + "memory": "save durable long-lived memory items.", + "deliverables": "final artifact generation: report, podcast, video presentation, resume, or image.", + "gmail": "email inbox actions: search/read emails, draft updates, send messages, and trash emails.", + "calendar": "scheduling actions: check availability, inspect events, create events, and update events.", + "google_drive": "Drive file/document actions: locate files, inspect content, and manage files/folders.", + "notion": "Notion page actions: create pages, update content, and delete pages.", + "confluence": "Confluence page actions: find/read pages and create/update pages.", + "dropbox": "Dropbox file storage actions: browse folders, read files, and manage file content.", + "onedrive": "OneDrive file storage actions: browse folders, read files, and manage file content.", + "discord": "Discord communication actions: read channels/threads and post replies.", + "teams": "Microsoft Teams communication actions: read channels/threads and post replies.", + "luma": "Luma event actions: list events, inspect event details, and create events.", + "linear": "Linear workflow actions: search/update issues and inspect projects/cycles.", + "jira": "Jira workflow actions: search/update issues and manage workflow transitions.", + "clickup": "ClickUp workflow actions: find/update tasks and lists.", + "airtable": "Airtable data actions: locate bases/tables and create/read/update records.", + "slack": "Slack communication actions: read channel/thread history and post replies.", + # generic_mcp specialist intentionally disabled for now. + # "generic_mcp": "handle tasks through user-defined custom app integration tools not covered above.", +} +_SPECIALIST_ORDER: tuple[str, ...] = tuple(_SPECIALIST_CAPABILITIES.keys()) + + +def _memory_capability_for_visibility(thread_visibility: Any | None) -> str: + vis = str(getattr(thread_visibility, "value", thread_visibility)).upper() + if vis == "SEARCH_SPACE": + return "team memory actions: save shared team preferences, conventions, and long-lived team facts." + return "user memory actions: save personal preferences, instructions, and long-lived user facts." + + +def _render_available_specialists_list( + tools: Sequence[BaseTool], + *, + thread_visibility: Any | None, +) -> str: + available_names = { + tool.name for tool in tools if isinstance(getattr(tool, "name", None), str) + } + capabilities = dict(_SPECIALIST_CAPABILITIES) + capabilities["memory"] = _memory_capability_for_visibility(thread_visibility) + lines: list[str] = [] + for name in _SPECIALIST_ORDER: + if name in _BUILTIN_SPECIALISTS or name in available_names: + capability = capabilities[name] + lines.append(f"- {name}: {capability}") + return "\n".join(lines) + + +def _render_supervisor_prompt( + template: str, + tools: Sequence[BaseTool], + *, + thread_visibility: Any | None, +) -> str: + specialist_list = _render_available_specialists_list( + tools, thread_visibility=thread_visibility + ) + return template.replace("{{AVAILABLE_SPECIALISTS_LIST}}", specialist_list) + def build_supervisor_agent( llm: BaseChatModel, *, tools: Sequence[BaseTool], checkpointer: Checkpointer | None = None, + thread_visibility: Any | None = None, middleware: Sequence[Any] | None = None, context_schema: Any | None = None, ): """Compile the supervisor **agent** (graph). ``tools`` = output of ``build_supervisor_routing_tools``.""" - system_prompt = read_prompt_md(supervisor_pkg.__name__, "supervisor_prompt") + template = read_prompt_md(supervisor_pkg.__name__, "supervisor_prompt") + system_prompt = _render_supervisor_prompt( + template, + tools, + thread_visibility=thread_visibility, + ) kwargs: dict[str, Any] = { "system_prompt": system_prompt, "tools": list(tools), diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md index db08dd945..684c03333 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md @@ -1,26 +1,61 @@ -You are the supervisor agent. Route work to the right sub-agent using **one** routing tool per request when delegation is needed. +You are SurfSense's multi-agent supervisor. -**Built-in capabilities** + +Your job is to decide whether to answer directly or delegate to one or more specialists. +You optimize for correctness, low confusion, and minimal unnecessary delegation. + -- **research** — web search, page scraping, SurfSense documentation help. -- **memory** — save long-term facts and preferences (personal or team memory). -- **deliverables** — reports, podcasts, video presentations, resumes, images (thread-scoped outputs; only when available). + +Use only the specialists listed below. +{{AVAILABLE_SPECIALISTS_LIST}} + -**Connectors** (same pattern for each product) + +1) Delegate when the request clearly belongs to a specialist's capabilities. +2) Answer directly when no expert tool is needed. +3) For multi-domain work, decompose into sequential expert calls (or parallel only when independent). +4) Do not call a specialist "just in case". Every delegation must have a clear purpose. + -- **calendar** — Google Calendar events. -- **confluence** — Confluence pages. -- **discord** — Discord server channels and messages. -- **dropbox** — Dropbox files. -- **gmail** — email (search, read, drafts, send, trash). -- **google_drive** — Google Drive files (Docs/Sheets). -- **luma** — Luma calendar events (list, read, create). -- **notion** — Notion pages. -- **onedrive** — Microsoft OneDrive files. -- **teams** — Microsoft Teams channels and messages. + +When delegating to a specialist, pass a compact but complete task that includes: +- user goal, +- concrete constraints (time range, recipients, format, etc.), +- success criteria, +- required output details (IDs/links/timestamps when applicable). -**OAuth MCP** (extra routing tools only when those integrations are connected) +Never pass implementation chatter. Pass only actionable instructions. + -- **linear**, **slack**, **jira**, **clickup**, **airtable**, **generic_mcp** — use only for that product’s MCP-backed work. + +Every specialist call returns one JSON object. Parse and reason over these fields: +- `status`: `success` | `partial` | `blocked` | `error` +- `action_summary`: concise execution summary +- `evidence`: task-specific proof/results +- `next_step`: required follow-up when not fully successful +- `missing_fields`: required user inputs (when blocked by missing info) +- `assumptions`: inferred values used by the expert -Pass each tool a **clear natural-language task** describing what the sub-agent should do. Answer directly when no sub-agent is needed. When sub-agents return results, combine them into one coherent reply for the user. +Field-handling rules: +1) `status=success`: trust the result only when supported by `evidence`. +2) `status=partial`: use completed `evidence`, then continue with `next_step`. +3) `status=blocked`: do not retry blindly; ask the user only for items in `missing_fields` (or clear disambiguation choices from `evidence`). +4) `status=error`: do not claim completion; either retry with a better task if obvious, or explain failure and propose the expert's `next_step`. +5) If an expert output appears invalid or contradictory, treat it as `error`, avoid fabricating details, and recover with a safer re-delegation or user clarification. + + + +Ask a concise clarifying question only when a missing detail blocks execution. +If one reasonable default is safe and obvious, use it and state the assumption. + + + +After expert calls, produce one coherent final answer: +- what was done, +- key results/artifacts, +- unresolved items and the next best step. +- include assumptions only when they affected outcomes. +- when multiple experts are used, merge outputs into one user-facing narrative (no raw JSON dump). + +Never claim an action succeeded unless an expert returned success evidence. + From 5bc33626b966810977f21e7938c4f5a6a04afb98 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 12:11:43 +0200 Subject: [PATCH 051/131] Tune memory visibility and disable generic MCP supervisor routing. --- .../multi_agent_chat/core/mcp_partition.py | 6 +- .../expert_agent/builtins/memory/agent.py | 40 +++++++-- .../builtins/memory/domain_prompt.md | 57 ++++++++++++- .../builtins/memory/slice_tools.py | 2 +- .../integration/create_multi_agent_chat.py | 7 +- .../routing/route_connector_gate.py | 3 +- .../routing/supervisor_routing.py | 81 +++++++++---------- 7 files changed, 139 insertions(+), 57 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py index 1f74e038a..608d16988 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py @@ -39,7 +39,8 @@ _CONNECTOR_TYPE_TO_EXPERT_ROUTE: dict[str, str] = { "CLICKUP_CONNECTOR": "clickup", "SLACK_CONNECTOR": "slack", "AIRTABLE_CONNECTOR": "airtable", - "MCP_CONNECTOR": "generic_mcp", + # generic_mcp route intentionally disabled for now. + # "MCP_CONNECTOR": "generic_mcp", } # Ordering when appending MCP-only routes (no native registry slice for these types). @@ -49,7 +50,8 @@ MCP_ONLY_ROUTE_KEYS_IN_ORDER: tuple[str, ...] = ( "jira", "clickup", "airtable", - "generic_mcp", + # generic_mcp intentionally disabled for now. + # "generic_mcp", ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py index 6f7999557..6a0c115c2 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py @@ -5,17 +5,41 @@ from __future__ import annotations from collections.abc import Sequence import app.agents.multi_agent_chat.expert_agent.builtins.memory as memory_pkg +from langchain.agents import create_agent from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool -from app.agents.multi_agent_chat.core.agents import build_domain_agent +from app.agents.multi_agent_chat.core.prompts import read_prompt_md +from app.db import ChatVisibility + +_PRIVATE_VISIBILITY_POLICY = ( + "This thread is private. Store user-specific long-lived preferences, facts, and instructions." +) +_TEAM_VISIBILITY_POLICY = ( + "This thread is shared with the search space. Store only team-appropriate shared preferences," + " facts, and instructions that are safe for all members to inherit." +) -def build_memory_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled memory domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=memory_pkg.__name__, - prompt_stem="domain_prompt", +def _render_memory_prompt(thread_visibility: ChatVisibility | None) -> str: + template = read_prompt_md(memory_pkg.__name__, "domain_prompt") + policy = ( + _TEAM_VISIBILITY_POLICY + if thread_visibility == ChatVisibility.SEARCH_SPACE + else _PRIVATE_VISIBILITY_POLICY + ) + return template.replace("{{MEMORY_VISIBILITY_POLICY}}", policy) + + +def build_memory_domain_agent( + llm: BaseChatModel, + tools: Sequence[BaseTool], + *, + thread_visibility: ChatVisibility | None = None, +): + """Compiled memory domain-agent graph.""" + return create_agent( + llm, + system_prompt=_render_memory_prompt(thread_visibility), + tools=list(tools), ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md index 7d79b874b..32becf233 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md @@ -1 +1,56 @@ -You are the memory domain agent. Use the update_memory tool only when the user explicitly asks to remember something, or when saving durable preferences and facts that should persist across sessions. Do not store secrets unless the user requests it. Respond concisely after updating memory. +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/expert_agent/builtins/memory/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py index 0a1485770..e2a482ff0 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py @@ -1,4 +1,4 @@ -"""Registry-backed memory tools (long-term personal or team memory).""" +"""Registry-backed memory tools (long-term user or team memory).""" from __future__ import annotations diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py index 686b18893..36fedc960 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py @@ -70,6 +70,7 @@ def _compile_supervisor_chat_blocking( llm, tools=routing_tools, checkpointer=checkpointer, + thread_visibility=thread_visibility, middleware=middleware, context_schema=SurfSenseContextSchema, ) @@ -161,6 +162,7 @@ async def create_multi_agent_chat( include_deliverables=thread_id is not None, mcp_tools_by_route=mcp_tools_by_route, available_connectors=resolved_connectors, + thread_visibility=thread_visibility, ) fs_sel = filesystem_selection or FilesystemSelection() @@ -168,7 +170,10 @@ async def create_multi_agent_chat( if not surfsense_stack: return build_supervisor_agent( - llm, tools=routing_tools, checkpointer=checkpointer + llm, + tools=routing_tools, + checkpointer=checkpointer, + thread_visibility=thread_visibility, ) return await asyncio.to_thread( diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py index adf63d931..b66b5eb4a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py @@ -32,7 +32,8 @@ _ROUTE_REQUIRES_ANY: dict[str, frozenset[str]] = { "jira": frozenset({"JIRA_CONNECTOR"}), "clickup": frozenset({"CLICKUP_CONNECTOR"}), "airtable": frozenset({"AIRTABLE_CONNECTOR"}), - "generic_mcp": frozenset({"MCP_CONNECTOR"}), + # generic_mcp route intentionally disabled for now. + # "generic_mcp": frozenset({"MCP_CONNECTOR"}), } diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py index e97dec0b7..91fac9cd5 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -7,6 +7,7 @@ from typing import Any from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool +from app.db import ChatVisibility from app.agents.multi_agent_chat.expert_agent.builtins.deliverables import ( build_deliverables_tools, build_deliverables_domain_agent, @@ -67,31 +68,34 @@ from app.agents.multi_agent_chat.routing.route_connector_gate import include_con _MCP_ONLY_ROUTE_DESCRIPTIONS: dict[str, str] = { "linear": ( - "Route Linear work (issues, projects, cycles, documents) via MCP to the Linear sub-agent. " - "Pass a clear natural-language task." + "Use for Linear issue/project work: find/create issues, update status/assignees, review project progress, and inspect cycles." ), "slack": ( - "Route Slack search and channel/thread reads via MCP to the Slack sub-agent. " - "Pass a clear natural-language task." + "Use for Slack channel communication: read channel/thread history, summarize conversations, and post replies." ), "jira": ( - "Route Jira issues and projects via MCP to the Jira sub-agent. " - "Pass a clear natural-language task." + "Use for Jira issue/project workflows: search issues, inspect fields, update tickets, and move work through workflow states." ), "clickup": ( - "Route ClickUp tasks via MCP to the ClickUp sub-agent. Pass a clear natural-language task." + "Use for ClickUp task management: find tasks/lists, update task fields, and track execution progress." ), "airtable": ( - "Route Airtable bases and records via MCP to the Airtable sub-agent. " - "Pass a clear natural-language task." - ), - "generic_mcp": ( - "Route user-defined MCP (stdio) server tools to the custom MCP sub-agent. " - "Pass a clear natural-language task." + "Use for Airtable structured data operations: locate bases/tables and create/read/update records." ), + # generic_mcp intentionally disabled for now. + # "generic_mcp": ( + # "Use as a fallback for custom connected app tasks not covered by a named specialist. " + # "Do not use if another specialist clearly matches." + # ), } +def _memory_route_description(thread_visibility: ChatVisibility | None) -> str: + if thread_visibility == ChatVisibility.SEARCH_SPACE: + return "Use for storing durable team memory: shared team preferences, conventions, and long-lived team facts." + return "Use for storing durable user memory: personal preferences, instructions, and long-lived user facts." + + def build_supervisor_routing_tools( llm: BaseChatModel, *, @@ -99,6 +103,7 @@ def build_supervisor_routing_tools( include_deliverables: bool = True, mcp_tools_by_route: dict[str, list[BaseTool]] | None = None, available_connectors: list[str] | None = None, + thread_visibility: ChatVisibility | None = None, ) -> list[BaseTool]: """Build supervisor routing tools: builtins first, then connector experts (same pattern for all). @@ -112,8 +117,7 @@ def build_supervisor_routing_tools( ``mcp_tools_by_route`` maps route keys to MCP tools merged into the matching expert subgraph. When ``available_connectors`` is set (searchable connector strings, same shape as ``new_chat``), - a vendor route is registered only if the connector is available **or** MCP tools are present for - that route. + a connector-backed route is registered only if its required searchable connector type is available. """ if registry_dependencies is None: return routing_tools_from_specs([]) @@ -127,22 +131,22 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="research", description=( - "Route web search, page scraping, and SurfSense documentation help to the " - "research sub-agent. Pass a clear natural-language task." + "Use for external research: find sources on the web, extract evidence, and answer documentation questions." ), domain_agent=research_agent, ), ) memory_tools = build_memory_tools(registry_dependencies) - memory_agent = build_memory_domain_agent(llm, memory_tools) + memory_agent = build_memory_domain_agent( + llm, + memory_tools, + thread_visibility=thread_visibility, + ) specs.append( DomainRoutingSpec( tool_name="memory", - description=( - "Route saving long-term facts and preferences (personal or team memory) to the " - "memory sub-agent. Pass a clear natural-language task." - ), + description=_memory_route_description(thread_visibility), domain_agent=memory_agent, ), ) @@ -154,8 +158,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="deliverables", description=( - "Route structured outputs (reports, podcasts, video presentations, resumes, " - "images) to the deliverables sub-agent. Pass a clear natural-language task." + "Use for creating final artifacts: reports, podcasts, video presentations, resumes, and images." ), domain_agent=deliverables_agent, ), @@ -171,8 +174,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="calendar", description=( - "Route Google Calendar work to the Calendar sub-agent. " - "Pass a clear natural-language task." + "Use for calendar planning and scheduling: check availability, read event details, create events, and update events." ), domain_agent=calendar_agent, ), @@ -185,8 +187,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="confluence", description=( - "Route Confluence page work to the Confluence sub-agent. " - "Pass a clear natural-language task." + "Use for Confluence knowledge pages: search/read existing pages, create new pages, and update page content." ), domain_agent=confluence_agent, ), @@ -199,8 +200,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="discord", description=( - "Route Discord work (channels, messages) to the Discord sub-agent. " - "Pass a clear natural-language task." + "Use for Discord communication: read channel/thread messages, gather context, and send replies." ), domain_agent=discord_agent, ), @@ -213,7 +213,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="dropbox", description=( - "Route Dropbox file work to the Dropbox sub-agent. Pass a clear natural-language task." + "Use for Dropbox file storage tasks: browse folders, read files, and manage Dropbox file content." ), domain_agent=dropbox_agent, ), @@ -228,8 +228,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="gmail", description=( - "Route Gmail-related work to the Gmail sub-agent. " - "Pass a clear natural-language task." + "Use for Gmail inbox actions: search/read emails, draft or update replies, send messages, and trash emails." ), domain_agent=gmail_agent, ), @@ -242,8 +241,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="google_drive", description=( - "Route Google Drive file work to the Google Drive sub-agent. " - "Pass a clear natural-language task." + "Use for Google Drive document/file tasks: locate files, inspect content, and manage Drive files or folders." ), domain_agent=google_drive_agent, ), @@ -256,8 +254,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="luma", description=( - "Route Luma calendar events (list, read, create) to the Luma sub-agent. " - "Pass a clear natural-language task." + "Use for Luma event operations: list events, inspect event details, and create new events." ), domain_agent=luma_agent, ), @@ -270,7 +267,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="notion", description=( - "Route Notion page work to the Notion sub-agent. Pass a clear natural-language task." + "Use for Notion workspace pages: create pages, update page content, and delete pages." ), domain_agent=notion_agent, ), @@ -283,8 +280,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="onedrive", description=( - "Route Microsoft OneDrive file work to the OneDrive sub-agent. " - "Pass a clear natural-language task." + "Use for OneDrive file storage tasks: browse folders, read files, and manage OneDrive file content." ), domain_agent=onedrive_agent, ), @@ -297,8 +293,7 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="teams", description=( - "Route Microsoft Teams work (channels, messages) to the Teams sub-agent. " - "Pass a clear natural-language task." + "Use for Microsoft Teams communication: read channel/thread messages, gather context, and post replies." ), domain_agent=teams_agent, ), @@ -312,7 +307,7 @@ def build_supervisor_routing_tools( continue desc = _MCP_ONLY_ROUTE_DESCRIPTIONS.get( route_key, - f"Route {route_key} MCP work to the {route_key} sub-agent. Pass a clear natural-language task.", + f"Use for {route_key} tasks related to that system's core work objects and workflows.", ) specs.append( DomainRoutingSpec( From 8f8d7540f0141355f92f540bf498111621a4a437 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 16:41:19 +0200 Subject: [PATCH 052/131] Coerce deliverables thread_id and invoke domain agents asynchronously. --- .../core/registry/dependencies.py | 23 +++++++++++++++++-- .../integration/create_multi_agent_chat.py | 11 +++++---- .../routing/from_domain_agents.py | 4 ++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py index e7e36d766..68125c208 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py @@ -10,12 +10,31 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import ChatVisibility +def coerce_thread_id_for_registry(thread_id: str | int | None) -> int | None: + """Normalize chat thread id for registry tools that FK to ``new_chat_threads.id``. + + ``create_surfsense_deep_agent`` passes an ``int``; multi-agent wiring may pass + ``str(chat_id)`` for LangGraph/checkpointer consistency. AsyncPG requires ``int`` + for integer columns. + """ + if thread_id is None: + return None + if isinstance(thread_id, int): + return thread_id + s = str(thread_id).strip() + if not s: + return None + if s.isdigit(): + return int(s) + return None + + def build_registry_dependencies( *, db_session: AsyncSession, search_space_id: int, user_id: str, - thread_id: str, + thread_id: str | int | None, llm: BaseChatModel | None = None, firecrawl_api_key: str | None = None, connector_service: Any | None = None, @@ -32,7 +51,7 @@ def build_registry_dependencies( "db_session": db_session, "search_space_id": search_space_id, "user_id": user_id, - "thread_id": thread_id, + "thread_id": coerce_thread_id_for_registry(thread_id), "llm": llm, "firecrawl_api_key": firecrawl_api_key, "connector_service": connector_service, diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py index 36fedc960..06c022ec3 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py @@ -23,7 +23,10 @@ from app.agents.multi_agent_chat.core.mcp_partition import ( fetch_mcp_connector_metadata_maps, partition_mcp_tools_by_expert_route, ) -from app.agents.multi_agent_chat.core.registry import build_registry_dependencies +from app.agents.multi_agent_chat.core.registry.dependencies import ( + build_registry_dependencies, + coerce_thread_id_for_registry, +) from app.agents.multi_agent_chat.middleware.supervisor_stack import build_supervisor_middleware_stack from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools from app.agents.multi_agent_chat.supervisor import build_supervisor_agent @@ -83,7 +86,7 @@ async def create_multi_agent_chat( search_space_id: int, user_id: str, checkpointer: Checkpointer | None = None, - thread_id: str | None = None, + thread_id: str | int | None = None, firecrawl_api_key: str | None = None, connector_service: Any | None = None, available_connectors: list[str] | None = None, @@ -148,7 +151,7 @@ async def create_multi_agent_chat( db_session=db_session, search_space_id=search_space_id, user_id=user_id, - thread_id=thread_id or "", + thread_id=thread_id, llm=llm, firecrawl_api_key=firecrawl_api_key, connector_service=connector_service, @@ -159,7 +162,7 @@ async def create_multi_agent_chat( routing_tools = build_supervisor_routing_tools( llm, registry_dependencies=registry_dependencies, - include_deliverables=thread_id is not None, + include_deliverables=coerce_thread_id_for_registry(thread_id) is not None, mcp_tools_by_route=mcp_tools_by_route, available_connectors=resolved_connectors, thread_visibility=thread_visibility, diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py index b5d350650..a2c1513f4 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py @@ -107,10 +107,10 @@ def _normalize_domain_output(spec: DomainRoutingSpec, raw_text: str) -> str: def _routing_tool_for_spec(spec: DomainRoutingSpec) -> BaseTool: @tool(spec.tool_name, description=spec.description) - def _route(task: str) -> str: + async def _route(task: str) -> str: curated = spec.curated_context(task) if spec.curated_context else None content = compose_child_task(task, curated_context=curated) - result = spec.domain_agent.invoke( + result = await spec.domain_agent.ainvoke( {"messages": [{"role": "user", "content": content}]}, ) return _normalize_domain_output(spec, extract_last_assistant_text(result)) From c077939522b6b67b2c310983e166e0717b2f2ffd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 16:43:31 +0200 Subject: [PATCH 053/131] Gate chat on MULTI_AGENT_CHAT_ENABLED and route to the multi-agent graph when on. --- surfsense_backend/.env.example | 3 + surfsense_backend/app/config/__init__.py | 3 + .../app/tasks/chat/stream_new_chat.py | 95 +++++++++++++------ 3 files changed, 73 insertions(+), 28 deletions(-) diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index c1bfcc538..40831a959 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -252,6 +252,9 @@ LANGSMITH_PROJECT=surfsense # ============================================================================= # OPTIONAL: New-chat agent feature flags # ============================================================================= +# Multi-agent orchestrator switch for authenticated chat streaming. +# MULTI_AGENT_CHAT_ENABLED=false + # Master kill-switch — when true, every flag below is forced OFF. # SURFSENSE_DISABLE_NEW_AGENT_STACK=false diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index bd97d2bb1..86482767d 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -399,6 +399,9 @@ class Config: # Anonymous / no-login mode settings NOLOGIN_MODE_ENABLED = os.getenv("NOLOGIN_MODE_ENABLED", "FALSE").upper() == "TRUE" + MULTI_AGENT_CHAT_ENABLED = ( + os.getenv("MULTI_AGENT_CHAT_ENABLED", "FALSE").upper() == "TRUE" + ) ANON_TOKEN_LIMIT = int(os.getenv("ANON_TOKEN_LIMIT", "500000")) ANON_TOKEN_WARNING_THRESHOLD = int( os.getenv("ANON_TOKEN_WARNING_THRESHOLD", "400000") diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index c254e66e2..eec0bcfdc 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -38,6 +38,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_global_llm_config_by_id, ) +from app.agents.multi_agent_chat.integration import create_multi_agent_chat from app.agents.new_chat.memory_extraction import ( extract_and_save_memory, extract_and_save_team_memory, @@ -1558,22 +1559,45 @@ 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 and not disabled_tools) + if _app_config.MULTI_AGENT_CHAT_ENABLED and disabled_tools: + logger.info( + "MULTI_AGENT_CHAT_ENABLED is on, but falling back to new_chat because disabled_tools are requested." + ) + _t0 = time.perf_counter() - agent = await create_surfsense_deep_agent( - llm=llm, - search_space_id=search_space_id, - db_session=session, - connector_service=connector_service, - checkpointer=checkpointer, - user_id=user_id, - thread_id=chat_id, - agent_config=agent_config, - firecrawl_api_key=firecrawl_api_key, - thread_visibility=visibility, - disabled_tools=disabled_tools, - mentioned_document_ids=mentioned_document_ids, - filesystem_selection=filesystem_selection, - ) + if use_multi_agent: + agent = await create_multi_agent_chat( + llm=llm, + db_session=session, + search_space_id=search_space_id, + user_id=str(user_id), + checkpointer=checkpointer, + thread_id=str(chat_id), + firecrawl_api_key=firecrawl_api_key, + connector_service=connector_service, + thread_visibility=visibility, + filesystem_selection=filesystem_selection, + mentioned_document_ids=mentioned_document_ids, + ) + else: + agent = await create_surfsense_deep_agent( + llm=llm, + search_space_id=search_space_id, + db_session=session, + connector_service=connector_service, + checkpointer=checkpointer, + user_id=user_id, + thread_id=chat_id, + agent_config=agent_config, + firecrawl_api_key=firecrawl_api_key, + thread_visibility=visibility, + disabled_tools=disabled_tools, + mentioned_document_ids=mentioned_document_ids, + filesystem_selection=filesystem_selection, + ) _perf_log.info( "[stream_new_chat] Agent created in %.3fs", time.perf_counter() - _t0 ) @@ -2266,21 +2290,36 @@ async def stream_resume_chat( ) visibility = thread_visibility or ChatVisibility.PRIVATE + from app.config import config as _app_config _t0 = time.perf_counter() - agent = await create_surfsense_deep_agent( - llm=llm, - search_space_id=search_space_id, - db_session=session, - connector_service=connector_service, - checkpointer=checkpointer, - user_id=user_id, - thread_id=chat_id, - agent_config=agent_config, - firecrawl_api_key=firecrawl_api_key, - thread_visibility=visibility, - filesystem_selection=filesystem_selection, - ) + if _app_config.MULTI_AGENT_CHAT_ENABLED: + agent = await create_multi_agent_chat( + llm=llm, + db_session=session, + search_space_id=search_space_id, + user_id=str(user_id), + checkpointer=checkpointer, + thread_id=str(chat_id), + firecrawl_api_key=firecrawl_api_key, + connector_service=connector_service, + thread_visibility=visibility, + filesystem_selection=filesystem_selection, + ) + else: + agent = await create_surfsense_deep_agent( + llm=llm, + search_space_id=search_space_id, + db_session=session, + connector_service=connector_service, + checkpointer=checkpointer, + user_id=user_id, + thread_id=chat_id, + agent_config=agent_config, + firecrawl_api_key=firecrawl_api_key, + thread_visibility=visibility, + filesystem_selection=filesystem_selection, + ) _perf_log.info( "[stream_resume] Agent created in %.3fs", time.perf_counter() - _t0 ) From 74337b462acb0178bbd8e53182acc4675f28e443 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 16:43:44 +0200 Subject: [PATCH 054/131] Tighten supervisor delegation policy and align deliverables wording across prompts. --- surfsense_backend/AFTER_WORK_FIXES.md | 36 ++++++++++++ .../CAPABILITY_PARITY_CHECKLIST.md | 57 +++++++++++++++++++ .../builtins/deliverables/domain_prompt.md | 2 +- .../routing/supervisor_routing.py | 3 +- .../multi_agent_chat/supervisor/graph.py | 2 +- .../supervisor/supervisor_prompt.md | 45 ++++++++------- 6 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 surfsense_backend/AFTER_WORK_FIXES.md create mode 100644 surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md diff --git a/surfsense_backend/AFTER_WORK_FIXES.md b/surfsense_backend/AFTER_WORK_FIXES.md new file mode 100644 index 000000000..d74b7c7da --- /dev/null +++ b/surfsense_backend/AFTER_WORK_FIXES.md @@ -0,0 +1,36 @@ +# After Work Fixes + +## Middleware Risk Flags (new_chat) + +These are known "policy/routing via middleware" risks to review later. + +1. `FileIntentMiddleware` +- Risk: `file_write` classification can force `write_file`/`edit_file` and override deliverable or connector tool selection. +- Example failure: user asks for video/report artifact, agent writes into `/documents/*` instead. + +2. `KnowledgePriorityMiddleware` +- Risk: KB planner and injected priority hints can over-anchor turns to KB reads when connector action is the better path. + +3. `KnowledgeTreeMiddleware` +- Risk: injected workspace tree can bias behavior toward file navigation/writes by default. + +4. `SurfSenseFilesystemMiddleware` + `KnowledgeBasePersistenceMiddleware` +- Risk: mistaken `write_file` actions become persisted NOTE documents in KB, making wrong-path behavior durable. + +5. `PermissionMiddleware` +- Risk: deny/ask rules can hide or block the correct tool, appearing as "model chose wrong tool" when it never had access. + +6. Subagent middleware parity (`chat_deepagent.py`) +- Risk: parent vs subagent stack differences can produce inconsistent behavior across similar tasks. + +7. `SpillingContextEditingMiddleware` + compaction +- Risk: context trimming can remove critical tool evidence and cause wrong retries/tool choices. + +8. `ToolCallNameRepairMiddleware` +- Risk: malformed calls may be auto-repaired to unintended tools in edge cases. + +9. `DedupHITLToolCallsMiddleware` / `DoomLoopMiddleware` +- Risk: legitimate repeated calls can be suppressed or stopped early. + +10. `MemoryInjectionMiddleware` +- Risk: injected memory may bias tool choice away from fresh connector/KB evidence. diff --git a/surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md b/surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md new file mode 100644 index 000000000..b759e3221 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md @@ -0,0 +1,57 @@ +# Multi-Agent Capability Parity Checklist + +This checklist tracks whether `multi_agent_chat` has the required capability coverage +to be manually tested against `new_chat` in LangSmith. + +Legend: +- `[x]` implemented +- `[~]` implemented with intentional difference +- `[ ]` pending + +## 1) Prompting + +- [x] Supervisor prompt has explicit delegation policy. +- [x] Supervisor prompt consumes structured expert outputs (`status`, `evidence`, `next_step`, `missing_fields`, `assumptions`). +- [x] Supervisor available specialist list is dynamically rendered from currently registered tools. +- [x] All expert prompts are normalized to a shared JSON output contract shape with invariant rules. +- [x] Memory wording adapts to thread visibility (user vs team). +- [~] `generic_mcp` specialist prompt exists but route is intentionally disabled. + +## 2) Tooling and Routing + +- [x] Built-in specialist routes are wired (`research`, `memory`, `deliverables` when eligible). +- [x] Connector specialist routes are gated by available connector inventory. +- [x] MCP tools are partitioned and merged into matching specialists. +- [x] MCP-only named specialists are routed when present (`linear`, `slack`, `jira`, `clickup`, `airtable`). +- [~] `generic_mcp` route is intentionally disabled by product decision. +- [x] Delegated child tasks include explicit structured context envelope tags. +- [x] Domain-agent outputs are parsed and validated as JSON with safe fallback envelope. + +## 3) Middleware / Runtime + +- [x] Supervisor middleware stack mirrors SurfSense shell used by `new_chat` for core protections. +- [~] `SubAgentMiddleware` intentionally omitted (multi-agent architecture uses explicit specialists). +- [~] `PermissionMiddleware` intentionally omitted by decision (route gating used instead). +- [x] Action-log / compaction / retry / fallback / filesystem / KB middleware are wired for supervisor path. +- [x] Agent graph compile path uses `asyncio.to_thread` for heavy build operations. + +## 4) Entry-Point Wiring + +- [x] Authenticated streaming path can route to `create_multi_agent_chat` via feature flag (`MULTI_AGENT_CHAT_ENABLED`). +- [x] Resume streaming path can route to `create_multi_agent_chat` via feature flag. +- [~] Authenticated stream falls back to `new_chat` when `disabled_tools` is provided (multi-agent does not yet implement disabled-tool filtering parity). +- [ ] Anonymous stream path wired to multi-agent (left unchanged for now due anonymous tool allow-list differences). + +## 5) Observability and Validation Readiness + +- [x] Ready for manual LangSmith trace inspection once `MULTI_AGENT_CHAT_ENABLED=true`. +- [ ] Formal routing eval harness and benchmark dataset. +- [ ] Automated regression checks in CI for routing quality. + +## 6) Manual Benchmark Readiness Decision + +Status: **Ready for manual benchmarking in authenticated flows**. + +Before declaring "better than `new_chat`", still required: +- Build and run formal eval/benchmark harness. +- Close anonymous-path and disabled-tools parity gaps if they are in benchmark scope. diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md index e334a921b..c44f131bb 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md @@ -2,7 +2,7 @@ You are the SurfSense deliverables operations sub-agent. You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. -Generate high-quality deliverables with explicit constraints and reliable artifact reporting. +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. diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py index 91fac9cd5..ef496ab17 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -158,7 +158,8 @@ def build_supervisor_routing_tools( DomainRoutingSpec( tool_name="deliverables", description=( - "Use for creating final artifacts: reports, podcasts, video presentations, resumes, and images." + "Use for deliverables and shareable artifacts: generated reports, podcasts, " + "video presentations, resumes, and images—not for routine lookups or single small edits elsewhere." ), domain_agent=deliverables_agent, ), diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py index d03e9560a..abb3bee8d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py @@ -18,7 +18,7 @@ _BUILTIN_SPECIALISTS: frozenset[str] = frozenset({"research", "memory", "deliver _SPECIALIST_CAPABILITIES: dict[str, str] = { "research": "external research: web lookup, source gathering, and SurfSense documentation help.", "memory": "save durable long-lived memory items.", - "deliverables": "final artifact generation: report, podcast, video presentation, resume, or image.", + "deliverables": "deliverables and shareable artifacts: reports, podcasts, video presentations, resumes, and images.", "gmail": "email inbox actions: search/read emails, draft updates, send messages, and trash emails.", "calendar": "scheduling actions: check availability, inspect events, create events, and update events.", "google_drive": "Drive file/document actions: locate files, inspect content, and manage files/folders.", diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md index 684c03333..790e98753 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md @@ -15,33 +15,40 @@ Use only the specialists listed below. 2) Answer directly when no expert tool is needed. 3) For multi-domain work, decompose into sequential expert calls (or parallel only when independent). 4) Do not call a specialist "just in case". Every delegation must have a clear purpose. +5) Specialists are best for **one clear step at a time**—for example “find this,” “show that record,” “make this one change.” Do **not** hand them an entire “analyze everything and write me a trends report” brief in one go. +6) When the user wants **big-picture synthesis**—patterns across lots of items, comparisons across time, or an executive-style overview—**you** split the work: several **small** asks to whoever actually holds that information (each with a clear cap: how many items, how far back, which fields), then **you** combine the answers into one clear reply. If they need a **deliverable**—a real **artifact** others can read, hear, or watch (report, slide-style video, podcast, resume, image)—delegate to the **deliverables** specialist. Do not ask other specialists to replace that: their job is smaller steps (lookups and targeted changes), not producing the final artifact. +7) Each specialist answers in a **single short structured reply** (no extra chatter after it). Ask them only for what that reply can reasonably hold. If the user needs a long narrative or full report, **you** combine steps—or use the **deliverables** specialist—not one overloaded ask. +8) Prefer **a few clear, small asks** over one huge vague ask that invites guessing, cut-off answers, or broken replies. When delegating to a specialist, pass a compact but complete task that includes: -- user goal, -- concrete constraints (time range, recipients, format, etc.), -- success criteria, -- required output details (IDs/links/timestamps when applicable). +- the **outcome** they should produce, in **your own words** as clear instructions (do **not** paste or forward the user’s message verbatim), +- concrete limits (dates, names, “last N items,” which details matter), +- how you will judge success, +- any identifiers or links the user already gave. + +When asking for lists or searches, always say **how many** items at most and **which details** you need back. Never pass implementation chatter. Pass only actionable instructions. +Each delegation should sound like **one clear action** (or two that belong together), not a full project brief—unless you are intentionally speaking to **research** or to **deliverables** for a **deliverable artifact** (report, slide-style video, podcast, resume, image). -Every specialist call returns one JSON object. Parse and reason over these fields: -- `status`: `success` | `partial` | `blocked` | `error` -- `action_summary`: concise execution summary -- `evidence`: task-specific proof/results -- `next_step`: required follow-up when not fully successful -- `missing_fields`: required user inputs (when blocked by missing info) -- `assumptions`: inferred values used by the expert +Every specialist returns **one structured reply** in a fixed layout. Treat it like a small form, not prose. It includes: +- **outcome**: succeeded, partly done, blocked, or failed +- **short summary** of what they did +- **proof**: what they actually saw or changed (when relevant) +- **what to do next** if they are not done +- **what you must ask the user** if something was missing +- **what they assumed** if they had to fill a gap -Field-handling rules: -1) `status=success`: trust the result only when supported by `evidence`. -2) `status=partial`: use completed `evidence`, then continue with `next_step`. -3) `status=blocked`: do not retry blindly; ask the user only for items in `missing_fields` (or clear disambiguation choices from `evidence`). -4) `status=error`: do not claim completion; either retry with a better task if obvious, or explain failure and propose the expert's `next_step`. -5) If an expert output appears invalid or contradictory, treat it as `error`, avoid fabricating details, and recover with a safer re-delegation or user clarification. +How to use it: +1) **Succeeded**: only treat it as done if the **proof** backs it up. +2) **Partly done**: use what they proved, then follow their **what to do next**. +3) **Blocked**: do not blindly retry; ask the user only what they said was missing (or pick from options they listed). +4) **Failed**: do not pretend it worked; either retry with a clearer small ask or explain honestly and follow their suggested recovery. +5) If the reply is missing, garbled, or contradicts itself, treat it as failed, do not invent facts, and recover with a safer smaller ask or a question to the user. @@ -55,7 +62,7 @@ After expert calls, produce one coherent final answer: - key results/artifacts, - unresolved items and the next best step. - include assumptions only when they affected outcomes. -- when multiple experts are used, merge outputs into one user-facing narrative (no raw JSON dump). +- when multiple experts are used, merge outputs into one user-facing narrative (do not paste their raw structured reply verbatim). -Never claim an action succeeded unless an expert returned success evidence. +Never claim an action succeeded unless their reply includes proof that matches what you claim. From ba131f5736b93754928fb1f00e42bf2411e7d179 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 19:33:57 +0200 Subject: [PATCH 055/131] Refactor multi-agent supervisor prompts and wiring; thread citations into streaming. --- .../core/bindings/__init__.py | 2 +- .../multi_agent_chat/core/bindings/binding.py | 2 +- .../multi_agent_chat/core/mcp_partition.py | 6 +- .../core/registry/__init__.py | 2 +- .../core/registry/dependencies.py | 2 +- .../multi_agent_chat/core/registry/subset.py | 2 +- .../builtins/deliverables/slice_tools.py | 2 +- .../builtins/memory/slice_tools.py | 2 +- .../builtins/research/slice_tools.py | 2 +- .../connectors/calendar/slice_tools.py | 2 +- .../connectors/confluence/slice_tools.py | 2 +- .../connectors/discord/slice_tools.py | 2 +- .../connectors/dropbox/slice_tools.py | 2 +- .../connectors/gmail/slice_tools.py | 2 +- .../connectors/google_drive/slice_tools.py | 2 +- .../connectors/luma/slice_tools.py | 2 +- .../connectors/notion/slice_tools.py | 2 +- .../connectors/onedrive/slice_tools.py | 2 +- .../connectors/teams/slice_tools.py | 2 +- .../integration/create_multi_agent_chat.py | 182 ++++++++++++------ .../multi_agent_chat/middleware/__init__.py | 2 +- .../middleware/supervisor_stack.py | 2 +- .../routing/route_connector_gate.py | 2 +- .../routing/supervisor_routing.py | 2 +- .../multi_agent_chat/supervisor/graph.py | 76 +------- .../supervisor/prompt_assembly.py | 128 ++++++++++++ .../supervisor/supervisor_prompt.md | 9 +- .../app/tasks/chat/stream_new_chat.py | 2 + 28 files changed, 286 insertions(+), 161 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py index d6a826113..c15375e47 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py @@ -1,4 +1,4 @@ -"""Search-space / DB kwargs shared by ``new_chat`` tool factories (distinct from ``expert_agent.connectors`` integrations).""" +"""Search-space / DB kwargs shared by main-chat tool factories (distinct from ``expert_agent.connectors`` integrations).""" from app.agents.multi_agent_chat.core.bindings.binding import connector_binding diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py index 25e6a03fd..da82e3b3c 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py @@ -1,4 +1,4 @@ -"""Shared kwargs dict for ``new_chat`` tool factories (DB session + search space + user).""" +"""Shared kwargs dict for main-chat tool factories (DB session + search space + user).""" from __future__ import annotations diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py index 608d16988..a1ee6fdb6 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py @@ -1,4 +1,4 @@ -"""Partition MCP tools onto multi-agent expert routes without modifying ``new_chat``. +"""Partition MCP tools onto multi-agent expert routes (read-only; does not change the MCP loader). Uses the same connector discovery shape as ``load_mcp_tools`` (copied query below). Tools come from ``app.agents.new_chat.tools.mcp_tool.load_mcp_tools``; routing uses metadata already set there: @@ -61,7 +61,7 @@ async def fetch_mcp_connector_metadata_maps( ) -> tuple[dict[int, str], dict[str, str]]: """Read-only copy of connector discovery used alongside ``load_mcp_tools``. - Same filter as ``new_chat.tools.mcp_tool.load_mcp_tools`` (connectors with ``server_config``). + Same filter as :func:`app.agents.new_chat.tools.mcp_tool.load_mcp_tools` (connectors with ``server_config``). """ result = await session.execute( select(SearchSourceConnector).filter( @@ -90,7 +90,7 @@ def partition_mcp_tools_by_expert_route( ) -> dict[str, list[BaseTool]]: """Bucket MCP tools by expert route key. Supervisor never receives raw MCP tools. - Same inclusion rule as ``new_chat.tools.registry.build_tools_async``: all tools returned by + Same inclusion rule as :func:`app.agents.new_chat.tools.registry.build_tools_async`: all tools returned by ``load_mcp_tools`` are partitioned — connector availability for **registry** builtins is handled via ``get_connector_gated_tools`` / routing gates; MCP tools are not pre-filtered by inventory here. """ diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py index 0655115c0..cfd8a5d62 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py @@ -1,4 +1,4 @@ -"""``new_chat`` tool registry grouping + dependency bundles for domain slices.""" +"""Main chat tool registry grouping + dependency bundles for domain slices.""" from app.agents.multi_agent_chat.core.registry.categories import ( REGISTRY_ROUTING_CATEGORY_KEYS, diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py index 68125c208..24fa6b19c 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py @@ -1,4 +1,4 @@ -"""Dependency dict for :func:`app.agents.new_chat.tools.registry.build_tools` in multi-agent graphs.""" +"""Dependency dict for :func:`app.agents.new_chat.tools.registry.build_tools` on expert subgraphs.""" from __future__ import annotations diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py index 027a8af8f..95db1b64c 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py +++ b/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py @@ -1,4 +1,4 @@ -"""Build :mod:`new_chat` registry tool subsets for multi-agent domain slices.""" +"""Build registry tool subsets (``app.agents.new_chat.tools.registry``) for multi-agent domain slices.""" from __future__ import annotations diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py index 2c8e80a55..42241bda5 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_deliverables_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``deliverables`` category.""" + """Registry-backed tools for the ``deliverables`` category.""" return build_registry_tools_for_category(dependencies, "deliverables") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py index e2a482ff0..7f4d2d29a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_memory_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``memory`` category.""" + """Registry-backed tools for the ``memory`` category.""" return build_registry_tools_for_category(dependencies, "memory") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py index 4018c5a18..85a2a9dd9 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_research_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``research`` category.""" + """Registry-backed tools for the ``research`` category.""" return build_registry_tools_for_category(dependencies, "research") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py index 49e316c01..e2f2b404a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_calendar_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``calendar`` category.""" + """Registry-backed tools for the ``calendar`` category.""" return build_registry_tools_for_category(dependencies, "calendar") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py index 2889e8a3a..3f4f2d45c 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_confluence_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``confluence`` category.""" + """Registry-backed tools for the ``confluence`` category.""" return build_registry_tools_for_category(dependencies, "confluence") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py index 3511054ab..79eea4f3f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_discord_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``discord`` category.""" + """Registry-backed tools for the ``discord`` category.""" return build_registry_tools_for_category(dependencies, "discord") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py index 3adc4a480..ff28a5b71 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_dropbox_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``dropbox`` category.""" + """Registry-backed tools for the ``dropbox`` category.""" return build_registry_tools_for_category(dependencies, "dropbox") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py index 50c070075..87876804e 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_gmail_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``gmail`` category.""" + """Registry-backed tools for the ``gmail`` category.""" return build_registry_tools_for_category(dependencies, "gmail") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py index 7f63f6eb3..ee6defe4b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_google_drive_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``google_drive`` category.""" + """Registry-backed tools for the ``google_drive`` category.""" return build_registry_tools_for_category(dependencies, "google_drive") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py index 4e8350f2e..bf4efde00 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_luma_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``luma`` category.""" + """Registry-backed tools for the ``luma`` category.""" return build_registry_tools_for_category(dependencies, "luma") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py index 0229b5b82..4fecd13a4 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_notion_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``notion`` category.""" + """Registry-backed tools for the ``notion`` category.""" return build_registry_tools_for_category(dependencies, "notion") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py index 2f7c82dad..572cc6e36 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_onedrive_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``onedrive`` category.""" + """Registry-backed tools for the ``onedrive`` category.""" return build_registry_tools_for_category(dependencies, "onedrive") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py index b88f29843..e66ed3295 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py +++ b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py @@ -10,5 +10,5 @@ from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_c def build_teams_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Tools from ``new_chat`` registry: ``teams`` category.""" + """Registry-backed tools for the ``teams`` category.""" return build_registry_tools_for_category(dependencies, "teams") diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py index 06c022ec3..36c731735 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py @@ -1,4 +1,4 @@ -"""Single entry: SurfSense connectors + multi-agent stack → compiled supervisor graph.""" +"""Build the multi-agent supervisor graph: MCP partition, registry, routing tools, optional SurfSense middleware.""" from __future__ import annotations @@ -11,14 +11,6 @@ from langchain_core.tools import BaseTool from langgraph.types import Checkpointer from sqlalchemy.ext.asyncio import AsyncSession -from app.agents.new_chat.chat_deepagent import _map_connectors_to_searchable_types -from app.agents.new_chat.context import SurfSenseContextSchema -from app.agents.new_chat.feature_flags import get_flags -from app.agents.new_chat.filesystem_backends import build_backend_resolver -from app.agents.new_chat.filesystem_selection import FilesystemSelection -from app.agents.new_chat.tools.mcp_tool import load_mcp_tools -from app.db import ChatVisibility - from app.agents.multi_agent_chat.core.mcp_partition import ( fetch_mcp_connector_metadata_maps, partition_mcp_tools_by_expert_route, @@ -27,14 +19,95 @@ from app.agents.multi_agent_chat.core.registry.dependencies import ( build_registry_dependencies, coerce_thread_id_for_registry, ) -from app.agents.multi_agent_chat.middleware.supervisor_stack import build_supervisor_middleware_stack -from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools +from app.agents.multi_agent_chat.middleware.supervisor_stack import ( + build_supervisor_middleware_stack, +) +from app.agents.multi_agent_chat.routing.supervisor_routing import ( + build_supervisor_routing_tools, +) from app.agents.multi_agent_chat.supervisor import build_supervisor_agent +from app.agents.new_chat.chat_deepagent import _map_connectors_to_searchable_types +from app.agents.new_chat.context import SurfSenseContextSchema +from app.agents.new_chat.feature_flags import get_flags +from app.agents.new_chat.filesystem_backends import build_backend_resolver +from app.agents.new_chat.filesystem_selection import FilesystemSelection +from app.agents.new_chat.tools.mcp_tool import load_mcp_tools +from app.db import ChatVisibility logger = logging.getLogger(__name__) -def _compile_supervisor_chat_blocking( +async def _discover_connectors_and_doc_types( + *, + connector_service: Any | None, + search_space_id: int, + available_connectors: list[str] | None, + available_document_types: list[str] | None, +) -> tuple[list[str] | None, list[str] | None]: + """Fill connector / document-type lists from ``connector_service`` when callers omit them.""" + connectors = available_connectors + doc_types = available_document_types + if connector_service is None: + return connectors, doc_types + try: + if connectors is None: + raw = await connector_service.get_available_connectors(search_space_id) + if raw: + connectors = _map_connectors_to_searchable_types(raw) + if doc_types is None: + doc_types = await connector_service.get_available_document_types(search_space_id) + except Exception as exc: + logger.warning("Failed to discover available connectors/document types: %s", exc) + return connectors, doc_types + + +async def _mcp_tools_by_expert_route( + *, + db_session: AsyncSession, + search_space_id: int, +) -> dict[str, list[BaseTool]] | None: + mcp_flat = await load_mcp_tools(db_session, search_space_id) + id_map, name_map = await fetch_mcp_connector_metadata_maps(db_session, search_space_id) + return partition_mcp_tools_by_expert_route(mcp_flat, id_map, name_map) + + +def _make_supervisor_routing_tools( + llm: BaseChatModel, + *, + db_session: AsyncSession, + search_space_id: int, + user_id: str, + thread_id: str | int | None, + firecrawl_api_key: str | None, + connector_service: Any | None, + available_connectors: list[str] | None, + available_document_types: list[str] | None, + thread_visibility: ChatVisibility, + mcp_tools_by_route: dict[str, list[BaseTool]] | None, +) -> list[BaseTool]: + registry_dependencies = build_registry_dependencies( + db_session=db_session, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + llm=llm, + firecrawl_api_key=firecrawl_api_key, + connector_service=connector_service, + available_connectors=available_connectors, + available_document_types=available_document_types, + thread_visibility=thread_visibility, + ) + return build_supervisor_routing_tools( + llm, + registry_dependencies=registry_dependencies, + include_deliverables=coerce_thread_id_for_registry(thread_id) is not None, + mcp_tools_by_route=mcp_tools_by_route, + available_connectors=available_connectors, + thread_visibility=thread_visibility, + ) + + +def _compile_supervisor_agent_sync( *, llm: BaseChatModel, routing_tools: list[BaseTool], @@ -43,16 +116,16 @@ def _compile_supervisor_chat_blocking( filesystem_mode: Any, search_space_id: int, user_id: str, - thread_id: str | None, + thread_id: str | int | None, thread_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, + citations_enabled: bool, ) -> Any: - """CPU-heavy: middleware assembly + ``create_agent`` (runs in a worker thread).""" - flags = get_flags() + """CPU-heavy: middleware stack + ``create_agent`` (intended for ``asyncio.to_thread``).""" middleware = build_supervisor_middleware_stack( llm=llm, tools=routing_tools, @@ -67,7 +140,7 @@ def _compile_supervisor_chat_blocking( available_document_types=available_document_types, mentioned_document_ids=mentioned_document_ids, max_input_tokens=max_input_tokens, - flags=flags, + flags=get_flags(), ) return build_supervisor_agent( llm, @@ -76,6 +149,7 @@ def _compile_supervisor_chat_blocking( thread_visibility=thread_visibility, middleware=middleware, context_schema=SurfSenseContextSchema, + citations_enabled=citations_enabled, ) @@ -98,16 +172,17 @@ async def create_multi_agent_chat( mentioned_document_ids: list[int] | None = None, max_input_tokens: int | None = None, surfsense_stack: bool = True, + citations_enabled: bool | None = None, ): - """Build the full multi-agent chat graph (supervisor + domain subgraphs via routing tools). + """Build the full multi-agent chat graph (supervisor + expert subgraphs via routing tools). **Builtins** (:mod:`expert_agent.builtins`): registry-grouped **categories** (research, memory, deliverables). **Connectors** (:mod:`expert_agent.connectors`): **vendor integrations** — one subgraph per route in ``TOOL_NAMES_BY_CATEGORY`` (e.g. calendar, confluence, discord, dropbox, gmail, google_drive, luma, notion, onedrive, teams). - MCP tools from ``new_chat`` (``load_mcp_tools``) are partitioned inside this package and attached only + MCP tools (via ``load_mcp_tools``) are partitioned inside this package and attached only to the matching expert subgraphs — not to the supervisor tool list as raw MCP calls. Inclusion matches - ``new_chat.tools.registry.build_tools_async``: all tools returned by ``load_mcp_tools`` are merged + ``app.agents.new_chat.tools.registry.build_tools_async``: all tools returned by ``load_mcp_tools`` are merged after partitioning (no extra inventory filter on MCP). Connector routing uses ``available_connectors``: pass explicitly, or provide ``connector_service`` so lists are resolved like ``create_surfsense_deep_agent`` (``get_available_connectors`` → searchable types). @@ -115,57 +190,38 @@ async def create_multi_agent_chat( Deliverables (thread-scoped reports, podcasts, etc.) are registered only when ``thread_id`` is set. When ``surfsense_stack`` is true (default), the supervisor uses the same SurfSense middleware shell as - ``new_chat`` (KB priority/tree, filesystem, compaction, permissions, etc.) except ``SubAgentMiddleware`` / - ``task``, since experts are separate graphs behind routing tools. Graph compilation runs in - ``asyncio.to_thread`` so heavy CPU work does not block the event loop. + the main single-agent chat (KB priority/tree, filesystem, compaction, permissions, etc.) except + ``SubAgentMiddleware`` / ``task``, since experts are separate graphs behind routing tools. Graph + compilation runs in ``asyncio.to_thread`` so heavy CPU work does not block the event loop. + + ``citations_enabled``: when ``None``, defaults to ``True`` (same default as ``AgentConfig`` / main chat). """ - resolved_connectors = available_connectors - resolved_doc_types = available_document_types - if connector_service is not None: - try: - if resolved_connectors is None: - connector_types = await connector_service.get_available_connectors( - search_space_id - ) - if connector_types: - resolved_connectors = _map_connectors_to_searchable_types( - connector_types - ) - if resolved_doc_types is None: - resolved_doc_types = ( - await connector_service.get_available_document_types(search_space_id) - ) - except Exception as exc: - logger.warning( - "Failed to discover available connectors/document types: %s", - exc, - ) + citations = True if citations_enabled is None else citations_enabled + connectors, doc_types = await _discover_connectors_and_doc_types( + connector_service=connector_service, + search_space_id=search_space_id, + available_connectors=available_connectors, + available_document_types=available_document_types, + ) - mcp_tools_by_route: dict[str, list[BaseTool]] | None = None + mcp_by_route: dict[str, list[BaseTool]] | None = None if include_mcp_tools: - mcp_flat = await load_mcp_tools(db_session, search_space_id) - id_map, name_map = await fetch_mcp_connector_metadata_maps(db_session, search_space_id) - mcp_tools_by_route = partition_mcp_tools_by_expert_route(mcp_flat, id_map, name_map) + mcp_by_route = await _mcp_tools_by_expert_route( + db_session=db_session, search_space_id=search_space_id + ) - registry_dependencies = build_registry_dependencies( + routing_tools = _make_supervisor_routing_tools( + llm, db_session=db_session, search_space_id=search_space_id, user_id=user_id, thread_id=thread_id, - llm=llm, firecrawl_api_key=firecrawl_api_key, connector_service=connector_service, - available_connectors=resolved_connectors, - available_document_types=resolved_doc_types, - thread_visibility=thread_visibility, - ) - routing_tools = build_supervisor_routing_tools( - llm, - registry_dependencies=registry_dependencies, - include_deliverables=coerce_thread_id_for_registry(thread_id) is not None, - mcp_tools_by_route=mcp_tools_by_route, - available_connectors=resolved_connectors, + available_connectors=connectors, + available_document_types=doc_types, thread_visibility=thread_visibility, + mcp_tools_by_route=mcp_by_route, ) fs_sel = filesystem_selection or FilesystemSelection() @@ -177,10 +233,11 @@ async def create_multi_agent_chat( tools=routing_tools, checkpointer=checkpointer, thread_visibility=thread_visibility, + citations_enabled=citations, ) return await asyncio.to_thread( - _compile_supervisor_chat_blocking, + _compile_supervisor_agent_sync, llm=llm, routing_tools=routing_tools, checkpointer=checkpointer, @@ -191,8 +248,9 @@ async def create_multi_agent_chat( thread_id=thread_id, thread_visibility=thread_visibility, anon_session_id=anon_session_id, - available_connectors=resolved_connectors, - available_document_types=resolved_doc_types, + available_connectors=connectors, + available_document_types=doc_types, mentioned_document_ids=mentioned_document_ids, max_input_tokens=max_input_tokens, + citations_enabled=citations, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py index 130b0508f..058cf705a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py @@ -1,4 +1,4 @@ -"""SurfSense supervisor middleware (parity with ``new_chat`` main agent, minus subagents).""" +"""SurfSense supervisor middleware (parity with the main single-agent chat, minus subagents).""" from app.agents.multi_agent_chat.middleware.supervisor_stack import ( build_supervisor_middleware_stack, diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py index 40b377cbf..0cd390949 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py @@ -1,4 +1,4 @@ -"""Supervisor middleware stack matching ``new_chat`` main agent (no ``SubAgentMiddleware`` / ``task``).""" +"""Supervisor middleware stack matching the main single-agent chat (no ``SubAgentMiddleware`` / ``task``).""" from __future__ import annotations diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py index b66b5eb4a..84e2359e7 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py @@ -1,4 +1,4 @@ -"""Gate supervisor routing tools by connected searchable connector types (aligned with ``new_chat`` KB). +"""Gate supervisor routing tools by connected searchable connector types (aligned with main chat KB). When ``available_connectors`` is ``None``, all routes are emitted (caller did not pass an inventory). diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py index ef496ab17..63f4da744 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -116,7 +116,7 @@ def build_supervisor_routing_tools( ``mcp_tools_by_route`` maps route keys to MCP tools merged into the matching expert subgraph. - When ``available_connectors`` is set (searchable connector strings, same shape as ``new_chat``), + When ``available_connectors`` is set (searchable connector strings, same shape as the main chat agent), a connector-backed route is registered only if its required searchable connector type is available. """ if registry_dependencies is None: diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py index abb3bee8d..7823a0380 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py @@ -1,80 +1,18 @@ -"""Compile the supervisor agent graph (supervisor prompt + caller-supplied routing tools).""" +"""Compile the supervisor agent graph (LangChain ``create_agent`` + caller routing tools).""" from __future__ import annotations from collections.abc import Sequence from typing import Any -import app.agents.multi_agent_chat.supervisor as supervisor_pkg - 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.core.prompts import read_prompt_md - -_BUILTIN_SPECIALISTS: frozenset[str] = frozenset({"research", "memory", "deliverables"}) -_SPECIALIST_CAPABILITIES: dict[str, str] = { - "research": "external research: web lookup, source gathering, and SurfSense documentation help.", - "memory": "save durable long-lived memory items.", - "deliverables": "deliverables and shareable artifacts: reports, podcasts, video presentations, resumes, and images.", - "gmail": "email inbox actions: search/read emails, draft updates, send messages, and trash emails.", - "calendar": "scheduling actions: check availability, inspect events, create events, and update events.", - "google_drive": "Drive file/document actions: locate files, inspect content, and manage files/folders.", - "notion": "Notion page actions: create pages, update content, and delete pages.", - "confluence": "Confluence page actions: find/read pages and create/update pages.", - "dropbox": "Dropbox file storage actions: browse folders, read files, and manage file content.", - "onedrive": "OneDrive file storage actions: browse folders, read files, and manage file content.", - "discord": "Discord communication actions: read channels/threads and post replies.", - "teams": "Microsoft Teams communication actions: read channels/threads and post replies.", - "luma": "Luma event actions: list events, inspect event details, and create events.", - "linear": "Linear workflow actions: search/update issues and inspect projects/cycles.", - "jira": "Jira workflow actions: search/update issues and manage workflow transitions.", - "clickup": "ClickUp workflow actions: find/update tasks and lists.", - "airtable": "Airtable data actions: locate bases/tables and create/read/update records.", - "slack": "Slack communication actions: read channel/thread history and post replies.", - # generic_mcp specialist intentionally disabled for now. - # "generic_mcp": "handle tasks through user-defined custom app integration tools not covered above.", -} -_SPECIALIST_ORDER: tuple[str, ...] = tuple(_SPECIALIST_CAPABILITIES.keys()) - - -def _memory_capability_for_visibility(thread_visibility: Any | None) -> str: - vis = str(getattr(thread_visibility, "value", thread_visibility)).upper() - if vis == "SEARCH_SPACE": - return "team memory actions: save shared team preferences, conventions, and long-lived team facts." - return "user memory actions: save personal preferences, instructions, and long-lived user facts." - - -def _render_available_specialists_list( - tools: Sequence[BaseTool], - *, - thread_visibility: Any | None, -) -> str: - available_names = { - tool.name for tool in tools if isinstance(getattr(tool, "name", None), str) - } - capabilities = dict(_SPECIALIST_CAPABILITIES) - capabilities["memory"] = _memory_capability_for_visibility(thread_visibility) - lines: list[str] = [] - for name in _SPECIALIST_ORDER: - if name in _BUILTIN_SPECIALISTS or name in available_names: - capability = capabilities[name] - lines.append(f"- {name}: {capability}") - return "\n".join(lines) - - -def _render_supervisor_prompt( - template: str, - tools: Sequence[BaseTool], - *, - thread_visibility: Any | None, -) -> str: - specialist_list = _render_available_specialists_list( - tools, thread_visibility=thread_visibility - ) - return template.replace("{{AVAILABLE_SPECIALISTS_LIST}}", specialist_list) +from app.agents.multi_agent_chat.supervisor.prompt_assembly import ( + build_supervisor_system_prompt, +) def build_supervisor_agent( @@ -85,13 +23,13 @@ def build_supervisor_agent( thread_visibility: Any | None = None, middleware: Sequence[Any] | None = None, context_schema: Any | None = None, + citations_enabled: bool = True, ): """Compile the supervisor **agent** (graph). ``tools`` = output of ``build_supervisor_routing_tools``.""" - template = read_prompt_md(supervisor_pkg.__name__, "supervisor_prompt") - system_prompt = _render_supervisor_prompt( - template, + system_prompt = build_supervisor_system_prompt( tools, thread_visibility=thread_visibility, + citations_enabled=citations_enabled, ) kwargs: dict[str, Any] = { "system_prompt": system_prompt, diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py new file mode 100644 index 000000000..ac7140c7d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py @@ -0,0 +1,128 @@ +"""Supervisor system prompt: template load, shared agent-identity injection, specialist list.""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import UTC, datetime +from typing import Any + +from langchain_core.tools import BaseTool + +import app.agents.multi_agent_chat.supervisor as supervisor_pkg +from app.agents.multi_agent_chat.core.prompts import read_prompt_md +from app.agents.new_chat.prompts.composer import _build_citation_block, _read_fragment +from app.db import ChatVisibility + +_MEMORY_SPECIALIST_PHRASE = "invoke the **memory** specialist" + +_BUILTIN_SPECIALISTS: frozenset[str] = frozenset({"research", "memory", "deliverables"}) +_SPECIALIST_CAPABILITIES: dict[str, str] = { + "research": "external research: web lookup, source gathering, and SurfSense documentation help.", + "memory": "save durable long-lived memory items.", + "deliverables": "deliverables and shareable artifacts: reports, podcasts, video presentations, resumes, and images.", + "gmail": "email inbox actions: search/read emails, draft updates, send messages, and trash emails.", + "calendar": "scheduling actions: check availability, inspect events, create events, and update events.", + "google_drive": "Drive file/document actions: locate files, inspect content, and manage files/folders.", + "notion": "Notion page actions: create pages, update content, and delete pages.", + "confluence": "Confluence page actions: find/read pages and create/update pages.", + "dropbox": "Dropbox file storage actions: browse folders, read files, and manage file content.", + "onedrive": "OneDrive file storage actions: browse folders, read files, and manage file content.", + "discord": "Discord communication actions: read channels/threads and post replies.", + "teams": "Microsoft Teams communication actions: read channels/threads and post replies.", + "luma": "Luma event actions: list events, inspect event details, and create events.", + "linear": "Linear workflow actions: search/update issues and inspect projects/cycles.", + "jira": "Jira workflow actions: search/update issues and manage workflow transitions.", + "clickup": "ClickUp workflow actions: find/update tasks and lists.", + "airtable": "Airtable data actions: locate bases/tables and create/read/update records.", + "slack": "Slack communication actions: read channel/thread history and post replies.", + # generic_mcp specialist intentionally disabled for now. + # "generic_mcp": "handle tasks through user-defined custom app integration tools not covered above.", +} +_SPECIALIST_ORDER: tuple[str, ...] = tuple(_SPECIALIST_CAPABILITIES.keys()) + + +def _normalize_chat_visibility(thread_visibility: Any | None) -> ChatVisibility: + if thread_visibility is None: + return ChatVisibility.PRIVATE + if thread_visibility == ChatVisibility.SEARCH_SPACE: + return ChatVisibility.SEARCH_SPACE + raw = getattr(thread_visibility, "value", thread_visibility) + if str(raw).upper() == "SEARCH_SPACE": + return ChatVisibility.SEARCH_SPACE + return ChatVisibility.PRIVATE + + +def _identity_fragment_key(thread_visibility: Any | None) -> str: + """``private`` / ``team`` suffix for ``agent_*`` and ``memory_protocol_*`` fragments.""" + return ( + "team" + if _normalize_chat_visibility(thread_visibility) == ChatVisibility.SEARCH_SPACE + else "private" + ) + + +def _compose_identity_memory_citations( + *, + thread_visibility: Any | None, + citations_enabled: bool, +) -> str: + """Main-chat identity, memory protocol, and citation fragments (supervisor slice only).""" + key = _identity_fragment_key(thread_visibility) + today = datetime.now(UTC).date().isoformat() + + intro = _read_fragment(f"base/agent_{key}.md") + if intro: + intro = intro.format(resolved_today=today) + + memory = _read_fragment(f"base/memory_protocol_{key}.md").replace( + "call update_memory", + _MEMORY_SPECIALIST_PHRASE, + ) + tail = ( + f"\n{memory}\n\n\n" + + _build_citation_block(citations_enabled) + ) + return "\n\n".join(part for part in (intro.strip(), tail.strip()) if part) + + +def _memory_specialist_capability(thread_visibility: Any | None) -> str: + vis = str(getattr(thread_visibility, "value", thread_visibility)).upper() + if vis == "SEARCH_SPACE": + return "team memory actions: save shared team preferences, conventions, and long-lived team facts." + return "user memory actions: save personal preferences, instructions, and long-lived user facts." + + +def _specialists_markdown( + tools: Sequence[BaseTool], + *, + thread_visibility: Any | None, +) -> str: + available_names = { + tool.name for tool in tools if isinstance(getattr(tool, "name", None), str) + } + capabilities = dict(_SPECIALIST_CAPABILITIES) + capabilities["memory"] = _memory_specialist_capability(thread_visibility) + lines: list[str] = [] + for name in _SPECIALIST_ORDER: + if name in _BUILTIN_SPECIALISTS or name in available_names: + lines.append(f"- {name}: {capabilities[name]}") + return "\n".join(lines) + + +def build_supervisor_system_prompt( + tools: Sequence[BaseTool], + *, + thread_visibility: Any | None, + citations_enabled: bool, +) -> str: + """Load ``supervisor_prompt.md`` and fill placeholders.""" + template = read_prompt_md(supervisor_pkg.__name__, "supervisor_prompt") + specialists = _specialists_markdown(tools, thread_visibility=thread_visibility) + injected = _compose_identity_memory_citations( + thread_visibility=thread_visibility, + citations_enabled=citations_enabled, + ) + return template.replace("{{AVAILABLE_SPECIALISTS_LIST}}", specialists).replace( + "{{SUPERVISOR_BASE_INJECTION}}", + injected, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md index 790e98753..632c888c9 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md +++ b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md @@ -1,9 +1,8 @@ -You are SurfSense's multi-agent supervisor. +{{SUPERVISOR_BASE_INJECTION}} - -Your job is to decide whether to answer directly or delegate to one or more specialists. -You optimize for correctness, low confusion, and minimal unnecessary delegation. - + +In this **multi-agent** session you also **coordinate specialists** (listed below): call a specialist only when their domain matches the need; give each call a compact, outcome-focused task; merge structured results into one clear user-facing reply. When you can satisfy the turn with your own tools and reasoning, do so without delegating. + Use only the specialists listed below. diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index eec0bcfdc..5d1f4b572 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1581,6 +1581,7 @@ async def stream_new_chat( thread_visibility=visibility, filesystem_selection=filesystem_selection, mentioned_document_ids=mentioned_document_ids, + citations_enabled=agent_config.citations_enabled, ) else: agent = await create_surfsense_deep_agent( @@ -2305,6 +2306,7 @@ async def stream_resume_chat( connector_service=connector_service, thread_visibility=visibility, filesystem_selection=filesystem_selection, + citations_enabled=agent_config.citations_enabled, ) else: agent = await create_surfsense_deep_agent( From a6540b21c79d0368d91b46d3ed6f75016b167347 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 21:42:58 +0200 Subject: [PATCH 056/131] Fix stream_new_chat interrupt resolution using LangGraph snapshot.interrupts. --- .../app/tasks/chat/stream_new_chat.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 5d1f4b572..c9ae38f24 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1374,11 +1374,19 @@ async def _stream_agent_events( result.agent_called_update_memory = called_update_memory _log_file_contract("turn_outcome", result) - is_interrupted = state.tasks and any(task.interrupts for task in state.tasks) - if is_interrupted: + snapshot_interrupts = getattr(state, "interrupts", ()) or () + interrupt_value = None + if snapshot_interrupts: + interrupt_value = snapshot_interrupts[0].value + else: + for task in state.tasks or []: + if task.interrupts: + interrupt_value = task.interrupts[0].value + break + if interrupt_value is not None: result.is_interrupted = True - result.interrupt_value = state.tasks[0].interrupts[0].value - yield streaming_service.format_interrupt_request(result.interrupt_value) + result.interrupt_value = interrupt_value + yield streaming_service.format_interrupt_request(interrupt_value) async def stream_new_chat( From d157ceaabc8273f1c03b6f9257a8fd40844e2b4f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 30 Apr 2026 23:42:21 +0200 Subject: [PATCH 057/131] Remove multi-agent planning docs and fix stream_new_chat logger. --- surfsense_backend/AFTER_WORK_FIXES.md | 36 ---- .../CAPABILITY_PARITY_CHECKLIST.md | 57 ------- .../multi_agent_chat/IMPLEMENTATION_PLAN.md | 122 -------------- .../PROMPT_TUNING_PLAYBOOK.md | 159 ------------------ .../app/tasks/chat/stream_new_chat.py | 3 +- 5 files changed, 2 insertions(+), 375 deletions(-) delete mode 100644 surfsense_backend/AFTER_WORK_FIXES.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md diff --git a/surfsense_backend/AFTER_WORK_FIXES.md b/surfsense_backend/AFTER_WORK_FIXES.md deleted file mode 100644 index d74b7c7da..000000000 --- a/surfsense_backend/AFTER_WORK_FIXES.md +++ /dev/null @@ -1,36 +0,0 @@ -# After Work Fixes - -## Middleware Risk Flags (new_chat) - -These are known "policy/routing via middleware" risks to review later. - -1. `FileIntentMiddleware` -- Risk: `file_write` classification can force `write_file`/`edit_file` and override deliverable or connector tool selection. -- Example failure: user asks for video/report artifact, agent writes into `/documents/*` instead. - -2. `KnowledgePriorityMiddleware` -- Risk: KB planner and injected priority hints can over-anchor turns to KB reads when connector action is the better path. - -3. `KnowledgeTreeMiddleware` -- Risk: injected workspace tree can bias behavior toward file navigation/writes by default. - -4. `SurfSenseFilesystemMiddleware` + `KnowledgeBasePersistenceMiddleware` -- Risk: mistaken `write_file` actions become persisted NOTE documents in KB, making wrong-path behavior durable. - -5. `PermissionMiddleware` -- Risk: deny/ask rules can hide or block the correct tool, appearing as "model chose wrong tool" when it never had access. - -6. Subagent middleware parity (`chat_deepagent.py`) -- Risk: parent vs subagent stack differences can produce inconsistent behavior across similar tasks. - -7. `SpillingContextEditingMiddleware` + compaction -- Risk: context trimming can remove critical tool evidence and cause wrong retries/tool choices. - -8. `ToolCallNameRepairMiddleware` -- Risk: malformed calls may be auto-repaired to unintended tools in edge cases. - -9. `DedupHITLToolCallsMiddleware` / `DoomLoopMiddleware` -- Risk: legitimate repeated calls can be suppressed or stopped early. - -10. `MemoryInjectionMiddleware` -- Risk: injected memory may bias tool choice away from fresh connector/KB evidence. diff --git a/surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md b/surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md deleted file mode 100644 index b759e3221..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/CAPABILITY_PARITY_CHECKLIST.md +++ /dev/null @@ -1,57 +0,0 @@ -# Multi-Agent Capability Parity Checklist - -This checklist tracks whether `multi_agent_chat` has the required capability coverage -to be manually tested against `new_chat` in LangSmith. - -Legend: -- `[x]` implemented -- `[~]` implemented with intentional difference -- `[ ]` pending - -## 1) Prompting - -- [x] Supervisor prompt has explicit delegation policy. -- [x] Supervisor prompt consumes structured expert outputs (`status`, `evidence`, `next_step`, `missing_fields`, `assumptions`). -- [x] Supervisor available specialist list is dynamically rendered from currently registered tools. -- [x] All expert prompts are normalized to a shared JSON output contract shape with invariant rules. -- [x] Memory wording adapts to thread visibility (user vs team). -- [~] `generic_mcp` specialist prompt exists but route is intentionally disabled. - -## 2) Tooling and Routing - -- [x] Built-in specialist routes are wired (`research`, `memory`, `deliverables` when eligible). -- [x] Connector specialist routes are gated by available connector inventory. -- [x] MCP tools are partitioned and merged into matching specialists. -- [x] MCP-only named specialists are routed when present (`linear`, `slack`, `jira`, `clickup`, `airtable`). -- [~] `generic_mcp` route is intentionally disabled by product decision. -- [x] Delegated child tasks include explicit structured context envelope tags. -- [x] Domain-agent outputs are parsed and validated as JSON with safe fallback envelope. - -## 3) Middleware / Runtime - -- [x] Supervisor middleware stack mirrors SurfSense shell used by `new_chat` for core protections. -- [~] `SubAgentMiddleware` intentionally omitted (multi-agent architecture uses explicit specialists). -- [~] `PermissionMiddleware` intentionally omitted by decision (route gating used instead). -- [x] Action-log / compaction / retry / fallback / filesystem / KB middleware are wired for supervisor path. -- [x] Agent graph compile path uses `asyncio.to_thread` for heavy build operations. - -## 4) Entry-Point Wiring - -- [x] Authenticated streaming path can route to `create_multi_agent_chat` via feature flag (`MULTI_AGENT_CHAT_ENABLED`). -- [x] Resume streaming path can route to `create_multi_agent_chat` via feature flag. -- [~] Authenticated stream falls back to `new_chat` when `disabled_tools` is provided (multi-agent does not yet implement disabled-tool filtering parity). -- [ ] Anonymous stream path wired to multi-agent (left unchanged for now due anonymous tool allow-list differences). - -## 5) Observability and Validation Readiness - -- [x] Ready for manual LangSmith trace inspection once `MULTI_AGENT_CHAT_ENABLED=true`. -- [ ] Formal routing eval harness and benchmark dataset. -- [ ] Automated regression checks in CI for routing quality. - -## 6) Manual Benchmark Readiness Decision - -Status: **Ready for manual benchmarking in authenticated flows**. - -Before declaring "better than `new_chat`", still required: -- Build and run formal eval/benchmark harness. -- Close anonymous-path and disabled-tools parity gaps if they are in benchmark scope. diff --git a/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md b/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 186a55975..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,122 +0,0 @@ -# `multi_agent_chat` — layout & alignment with `new_chat` - -## Mission - -**Preserve** everything that makes SurfSense chat agents production-grade in `new_chat` (KB, middleware, tools, prompts, safety, observability). **Rework** how those pieces are composed: a clearer **multi-agent** layout (supervisor + domain slices + routing), less accidental coupling, and one explicit assembly path—so the agent stays **excellent** (correct tools, grounded KB, safe permissions, debuggable traces), not just “different folders.” - -Implementation strategy: **reuse `new_chat` modules** (middleware classes, tool factories, KB helpers, prompts composer pieces) wherever possible; **`multi_agent_chat` owns structure and wiring**, not reimplemented business logic. - ---- - -## What we must not lose from `new_chat` (capability inventory) - -Use this as a checklist when porting middleware/KB into `multi_agent_chat`. Items map to `surfsense_backend/app/agents/new_chat/`. - -| Area | Capabilities to preserve | Typical locations | -|------|-------------------------|-------------------| -| **KB & documents** | Hybrid search → priority docs → lazy XML load; workspace tree; anon-document path; KB persistence / commit staging | `middleware/knowledge_search.py`, `knowledge_tree.py`, `kb_persistence.py`, `anonymous_document.py`, `tools/knowledge_base.py`, `search_surfsense_docs.py` | -| **Filesystem** | Virtual FS, backends, path resolver, file intent | `middleware/filesystem.py`, `filesystem_backends.py`, `path_resolver.py`, `file_intent.py` | -| **Memory & context** | Memory injection, team/private protocols, context schema | `middleware/memory_injection.py`, `prompts/base/memory_protocol_*.md`, `context.py` | -| **Safety & quality** | Permissions, doom-loop detection, dedup HITL tool calls, tool-call repair, action logging | `middleware/permission.py`, `doom_loop.py`, `dedup_tool_calls.py`, `tool_call_repair.py`, `action_log.py` | -| **Model / context limits** | Compaction, context editing / spill, summarization, model & tool call limits, retries / fallback | `middleware/compaction.py`, `context_editing.py`, `chat_deepagent.py` stack | -| **Concurrency & ops** | Busy mutex (single-flight turns), OTel spans | `middleware/busy_mutex.py`, `otel_span.py` | -| **Skills & subagents** | Skills backends, subagent specs and wrapping patterns | `middleware/skills_backends.py`, `subagents/` | -| **Tools** | Async registry, connector gating, MCP loading, feature-flagged tools | `tools/registry.py`, `feature_flags.py`, `tools/mcp_tool.py` | -| **Prompts** | Composer, provider fragments, tool routing (KB vs live connectors), citations | `prompts/composer.py`, `prompts/base/tool_routing_*.md`, `system_prompt.py` | -| **Runtime** | Checkpointer, LLM config, `create_agent` + middleware ordering discipline | `checkpointer.py`, `llm_config.py`, `chat_deepagent.py` | - -Not every row applies to the **first** multi-agent graph (e.g. you may start with a subset of middleware). The rule is: **if `new_chat` does it for correctness or safety, we either reuse it or consciously document why this graph differs.** - ---- - -## Rework principles (better arrangement, same substance) - -1. **Expert agents**: **`expert_agent/builtins/`** — broad registry **categories** (e.g. research, deliverables), not a single vendor. **`expert_agent/connectors/`** — **external integrations** (one package per product route: Discord, Notion, Gmail, …), each using the same pattern: ``slice_tools.py`` (registry subset or factories) + ``domain_prompt.md`` + ``agent.py``. Cross-cutting helpers live in `core/` or are imported from `new_chat`. -2. **Explicit graphs**: supervisor vs domain agents vs routing tools are **named** and testable; avoid one opaque megagraph where behavior is hard to reason about. -3. **Single composer**: integration eventually mirrors `create_surfsense_deep_agent` in spirit—**one factory** that attaches middleware, KB, and tools in documented order (see `chat_deepagent.py` comments on ordering). -4. **No duplicate KB pipelines**: align with `KnowledgePriorityMiddleware` / tree semantics; don’t invent a second hybrid-search path for the same turn. -5. **Parity tests**: when wiring completes, compare behavior against `new_chat` for the same user message + search space where scopes overlap (KB snippet quality, tool allow/deny). - ---- - -## Supervisor vs domain agents — tools and context - -**Supervisor (orchestrator)** - -- Keeps a **small tool surface**: one **routing** tool per builtin category (`research`, `memory`, …) and per connector route (`notion`, `gmail`, …) — **not** the full flat `registry.py` tool list on the supervisor. -- **KB** should primarily benefit the model via **`new_chat`-style middleware** (e.g. hybrid priority docs → state / system adjunct), not by stacking redundant search tools, unless product explicitly requires them. -- **Single hybrid search per user turn** at this layer when possible: full retrieval is expensive; avoid running it again inside every sub-agent for the same message. -- Does **not** own **on-demand connector discovery** (e.g. `get_connected_accounts`): orchestration is route-by-intent, not ID resolution. - -**Domain agents (every connector slice — same shape)** - -- Carry tools built from **`new_chat`** (`registry` subsets via ``build_registry_tools_for_category`` per ``TOOL_NAMES_BY_CATEGORY``, plus MCP merge where applicable). -- **Curated context belongs in the task message**: when the supervisor calls **any** routing tool, the handler composes the child’s task string so it includes **only** what that domain needs (KB snippets, constraints, distilled facts) — folded into how the task is written — not the full parent transcript. The sub-agent `invoke` stays a tight payload (`messages` + task content); domain middleware can still add connector-local hints. Still **no second full hybrid search** for the same turn unless the subdomain explicitly needs a new query. -- **Middleware here** still fits **domain-only** grounding (connector availability, search-space hints, metadata) shared across tools in that subgraph. Reuse or thin-wrap `new_chat.middleware` where it applies to a subgraph. -- **Reactive discovery** (resolve a service id mid-task) stays a **tool** on that domain (or shared factory), e.g. `get_connected_accounts` when the model needs it — not something the supervisor must call. - -**Tool grouping by category** - -- Group “horizontal” registry tools by **job** (research, deliverables, creative, …) into **separate compiled subgraphs**; supervisor gets **one routing tool per category** (subagents-as-tools), matching LangChain multi-agent guidance. See prior discussion: not all 10 non–connector-gated tools on the supervisor. - -### KB + virtual filesystem — where it belongs - -In `new_chat`, KB + **virtual FS** (`KnowledgePriorityMiddleware`, tree, **`SurfSenseFilesystemMiddleware`** / **`KBPostgresBackend`**) serves the **orchestrator** that may **read and traverse** the workspace. - -**Connector domain agents** are **not** mini-parents: the **supervisor** should already decide *what* to do and pass a **clear task** (plus any curated KB snippet folded into **`compose_child_task`**). The specialist runs **connector APIs**, not a second document crawl — duplicating full KB+VFS on every domain subgraph **shifts the parent’s exploration work onto the wrong agent** and adds noise. - -So **no child-side filesystem stack by default** for narrow connector subgraphs unless product demands it. Reserve **KB + VFS on a subgraph** for roles that **actually** need heavy document work (research, coding/explore-style agents, deliverables that grep the KB), matching how `new_chat` uses specialists. - ---- - -## Inspiration map (`new_chat` → `multi_agent_chat`) - -| Concern in `new_chat` | Primary references | Role in `multi_agent_chat` | -|----------------------|-------------------|---------------------------| -| **Main factory** | `chat_deepagent.py` (`create_surfsense_deep_agent`) | `integration/create_multi_agent_chat.py` — eventual single composer after KB + middleware land | -| **Tool lists** | `tools/registry.py`, `build_tools_async` | **`expert_agent/builtins/`** — category bundles (research, deliverables). **`expert_agent/connectors/`** — per-integration graphs (may use hand-written factories or registry subsets). | -| **Middleware stack** | `chat_deepagent.py` → `_build_compiled_agent_blocking`, `middleware/*.py` | **Planned:** `middleware/` — compose `create_agent(..., middleware=[...])` on supervisor and/or domain graphs; reuse or thin-wrap `new_chat.middleware` (ordering matters: see `new_chat` comments, e.g. BusyMutex → OTel → KB priority → filesystem → …) | -| **KB / hybrid search** | `middleware/knowledge_search.py` (`KnowledgePriorityMiddleware`), `middleware/knowledge_tree.py`, `tools/knowledge_base.py` | **Planned:** hybrid priority **once per user turn** at orchestrator; **curated KB/context folded into the routing task message** to children (no second full search for the same message unless explicitly scoped otherwise). | -| **Prompts** | `prompts/composer.py`, `prompts/base/*`, provider fragments | Vertical **`domain_prompt.md`** per slice + **`supervisor/supervisor_prompt.md`**; optional later: thin composer that injects KB/tool-routing fragments like `tool_routing_*.md` | -| **Context / checkpointer** | `context.py`, `checkpointer.py` | Pass **`Checkpointer`** into `create_multi_agent_chat` / `build_supervisor_agent`; align thread IDs with route layer when wired | -| **Subagent middleware** | `subagents/config.py` (`_wrap_with_subagent_essentials`) | Domain agents may eventually take **`middleware=`** on `create_agent` mirroring “inherit parent essentials + local rules” | - ---- - -## Current package tree - -``` -multi_agent_chat/ - __init__.py - - core/ # one concern per subfolder (SRP) - prompts/ # read_prompt_md — markdown next to packages - agents/ # build_domain_agent — compile subgraph + prompt - delegation/ # compose_child_task — supervisor → child message - invocation/ # extract_last_assistant_text — invoke result parsing - bindings/ # ``connector_binding`` — DB/search-space kwargs (not ``expert_agent.connectors`` vendors) - registry/ # TOOL_NAMES_BY_CATEGORY, build_registry_tools_for_category, build_registry_dependencies - - expert_agent/ - builtins/ # broad categories: research, deliverables - connectors/ # one subgraph per vendor route (see TOOL_NAMES_BY_CATEGORY keys) - - routing/ - domain_routing_spec.py - from_domain_agents.py - supervisor_routing.py - - supervisor/ - supervisor_prompt.md - graph.py - - integration/ - create_multi_agent_chat.py -``` - ---- - -## References - -- LangChain: [Multi-agent](https://docs.langchain.com/oss/python/langchain/multi-agent), [Subagents](https://docs.langchain.com/oss/python/langchain/multi-agent/subagents). -- Internal: `surfsense_backend/app/agents/new_chat/chat_deepagent.py`, `middleware/`, `tools/registry.py`. diff --git a/surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md b/surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md deleted file mode 100644 index db8e0b495..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/PROMPT_TUNING_PLAYBOOK.md +++ /dev/null @@ -1,159 +0,0 @@ -# Multi-Agent Prompt Tuning Playbook - -This playbook defines how to tune `multi_agent_chat` prompts for better outcomes than `new_chat` on delegation quality, lower confusion, and stable tool behavior. - -It is intentionally architecture-aware: this system is a **supervisor + expert tools** pattern, not a single flat tool agent. - -## Why this matters in our architecture - -- The supervisor only sees **routing tools** (e.g. `research`, `gmail`, `calendar`), not low-level connector APIs. -- Experts are invoked through `routing/from_domain_agents.py` and receive a single natural-language task via `compose_child_task(...)`. -- Because expert context is compact and delegated, prompt quality is the primary control lever for routing accuracy and downstream tool behavior. - -## Authoritative guidance we should follow - -- Anthropic prompt engineering best practices (role clarity, XML structure, explicit tool-use policy, few-shot examples): [Anthropic docs](https://docs.anthropic.com/en/docs/use-xml-tags) -- OpenAI function-calling reliability guidance (clear tool descriptions, when/when-not tool usage, small callable surface): [OpenAI function calling guide](https://developers.openai.com/docs/guides/function-calling) -- OpenAI prompt engineering (instruction hierarchy and explicit output contracts): [OpenAI prompt engineering guide](https://developers.openai.com/api/docs/guides/prompt-engineering) -- LangChain supervisor/subagents guidance (clear subagent names/descriptions, context engineering, routing intent): [LangChain supervisor docs](https://docs.langchain.com/oss/python/langchain/supervisor), [LangChain subagents docs](https://docs.langchain.com/oss/python/langchain/multi-agent/subagents) - -## Current weakness audit (as of now) - -- `supervisor/supervisor_prompt.md` is short and does not define decision policy for ambiguous/multi-domain tasks. -- Most expert `domain_prompt.md` files are one-line role statements with no: - - scope boundaries and refusal policy, - - parameter-resolution behavior, - - completion criteria (what must be returned), - - failure handling rules, - - concrete examples. -- Tool descriptions in routing are generic ("Pass a clear natural-language task"), which weakens handoff quality. - -## Prompt design standards (required) - -Apply these standards to supervisor and every expert prompt. - -1. **Role + objective first** - - One sentence for identity. - - One sentence for success criterion. - -2. **Explicit routing/usage rules** - - Tell the model when to use this agent/tool. - - Tell it when not to use it. - - Include ambiguity fallback ("ask one clarifying question" or "do X conservative default"). - -3. **Structured task contract** - - Require concise but complete execution reports. - - Require IDs/links/timestamps when tool outputs produce them. - - For no-op paths, explain why no action was taken. - -4. **Safety + reliability contract** - - Never fabricate tool results. - - Never claim action if no successful tool call happened. - - Surface irreversible/risky actions clearly. - -5. **Few-shot examples** - - Include 2-4 minimal examples per domain: - - direct success, - - ambiguous input, - - out-of-scope reroute. - -6. **Concise formatting rules** - - Avoid verbosity. - - Stable response structure improves orchestration and observability. - -## Supervisor prompt blueprint - -The supervisor prompt should contain these sections in order: - -1. `Role` -2. `Available experts` (name + scope + non-scope) -3. `Delegation policy` - - single-domain -> one expert - - multi-domain -> sequence or parallel where independent - - no expert needed -> answer directly -4. `Task-writing policy` for delegated calls - - include user goal, constraints, success criteria - - include only needed context -5. `Result synthesis policy` - - merge expert outputs into one user-facing response - - preserve concrete identifiers from expert outputs -6. `Failure policy` - - retry on recoverable mismatch - - ask clarifying question when required field is missing - -## Expert prompt blueprint (per domain) - -Each `domain_prompt.md` should include: - -1. `Role and scope` -2. `In-scope actions` (mapped to the exact provided tools) -3. `Out-of-scope behavior` (what to return for reroute) -4. `Execution rules` - - choose the minimum tool sequence that satisfies request - - do not guess IDs or parameters - - ask concise clarification only when necessary -5. `Output contract` - - action summary - - concrete artifacts/IDs/links generated - - unresolved items and next step -6. `Examples` (2-4 realistic, short) - -## Domain-specific tuning checklist - -- `research`: enforce source-grounded summaries and explicit uncertainty. -- `memory`: strict save criteria (durable preference/fact only) and secret-handling policy. -- `deliverables`: require output artifact references and constraints echo. -- `gmail` / `calendar`: require recipient/date-time disambiguation policy and timezone handling. -- `docs connectors` (`notion`, `confluence`, `drive`, `dropbox`, `onedrive`): require exact page/file target resolution before mutate actions. -- chat connectors (`discord`, `teams`, `slack`): require channel/thread context clarity before send actions. -- MCP experts: require strict tool-description adherence and no assumption about unavailable endpoints. - -## Tool description tuning rules (routing layer) - -Routing tool descriptions should include: - -- best-fit task types, -- disallowed task types, -- required task payload hints (e.g. "include recipient + intent + constraints"), -- expected result shape. - -This is especially important because supervisor tool choice is heavily influenced by `name + description`. - -## Evaluation plan (before wiring to production) - -Create a prompt eval set with at least 20 tasks: - -- 8 single-domain tasks, -- 6 ambiguous tasks (should clarify or route conservatively), -- 6 multi-domain tasks (should sequence experts correctly). - -Track: - -- routing accuracy, -- unnecessary delegation rate, -- tool-call success rate, -- clarification precision (ask only when needed), -- final answer completeness. - -Use same test set against: - -- current prompts, -- tuned prompts v1, -- tuned prompts v2. - -Promote only when v2 improves routing accuracy and reduces unnecessary delegation with no regression in tool-call success. - -## Immediate implementation plan - -1. Rewrite `supervisor/supervisor_prompt.md` using the supervisor blueprint. -2. Rewrite all expert `domain_prompt.md` files with the expert blueprint. -3. Upgrade routing tool descriptions in `routing/supervisor_routing.py` to add "when to use / when not to use". -4. Add a lightweight prompt eval script or fixture set for reproducible tuning. - -## Definition of done - -- Every supervisor/expert prompt has explicit scope, failure policy, and output contract. -- Every route description encodes clear decision boundaries. -- Prompt eval shows measurable gains on routing accuracy and lower unnecessary delegation. -- Team can iterate prompt versions without changing core orchestration code. - diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index c9ae38f24..3339ff10a 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -28,6 +28,7 @@ from sqlalchemy import func from sqlalchemy.future import select from sqlalchemy.orm import selectinload +from app.agents.multi_agent_chat.integration import create_multi_agent_chat 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.filesystem_selection import FilesystemMode, FilesystemSelection @@ -38,7 +39,6 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_global_llm_config_by_id, ) -from app.agents.multi_agent_chat.integration import create_multi_agent_chat from app.agents.new_chat.memory_extraction import ( extract_and_save_memory, extract_and_save_team_memory, @@ -69,6 +69,7 @@ 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__) def format_mentioned_surfsense_docs_as_context( From 30cd530ac663c5836f96c7be9d19d4303dbbb2e8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 058/131] Add connector routing constants for delegated routes. --- .../multi_agent_with_deepagents/__init__.py | 1 + .../multi_agent_with_deepagents/constants.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py new file mode 100644 index 000000000..f6a5b8686 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py @@ -0,0 +1 @@ +"""Deepagents-backed multi-agent routes (subagents under ``subagents/``).""" diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py new file mode 100644 index 000000000..775027764 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py @@ -0,0 +1,18 @@ +"""Map connector type strings to the agent route key used for tools and MCP slices.""" + +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", +} From 77f52af6cc7e65cb7ce0267e99b66c51f58a7bfb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 059/131] Add shared tool permission types and markdown file reader. --- .../subagents/shared/md_file_reader.py | 14 +++++++ .../subagents/shared/permissions.py | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/md_file_reader.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/md_file_reader.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/md_file_reader.py new file mode 100644 index 000000000..2fce413a6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/md_file_reader.py @@ -0,0 +1,14 @@ +"""Load markdown files shipped alongside a route package.""" + +from __future__ import annotations + +from importlib import resources + + +def read_md_file(package: str, stem: str) -> str: + """Load ``{stem}.md`` from ``package`` via importlib resources, or return empty.""" + ref = resources.files(package).joinpath(f"{stem}.md") + if not ref.is_file(): + return "" + text = ref.read_text(encoding="utf-8") + return text.rstrip("\n") diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py new file mode 100644 index 000000000..20a9433e7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py @@ -0,0 +1,39 @@ +"""Typed tool-permission rows: allow vs ask (``name`` + optional ``tool``).""" + +from __future__ import annotations + +from typing import NotRequired, TypedDict + +from langchain_core.tools import BaseTool + + +class ToolPermissionItem(TypedDict): + """``name`` is always set; ``tool`` is present when a bound tool exists.""" + + name: str + tool: NotRequired[BaseTool] + + +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 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"]], + } From 5f84d46f96d2a5610eeb3ce95fcd82c53e9c2c66 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 060/131] Add subagent spec packing and shared package exports. --- .../subagents/shared/__init__.py | 25 ++++++++++ .../subagents/shared/subagent_builder.py | 47 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/subagent_builder.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/__init__.py new file mode 100644 index 000000000..0dbccf126 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/__init__.py @@ -0,0 +1,25 @@ +"""Cross-slice helpers for route subagents.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolPermissionItem, + ToolsPermissions, + merge_tools_permissions, + tool_permission_row, +) +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/shared/subagent_builder.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/subagent_builder.py new file mode 100644 index 000000000..b6614afa9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/subagent_builder.py @@ -0,0 +1,47 @@ +"""Build delegated sub-agent specs from route-local pieces.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, cast + +from deepagents import SubAgent +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware + + +def pack_subagent( + *, + name: str, + description: str, + system_prompt: str, + tools: list[BaseTool], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + interrupt_on: dict[str, bool] | None = None, +) -> SubAgent: + """Pack the route-local pieces passed in into one sub-agent spec.""" + if not system_prompt.strip(): + msg = f"Subagent {name!r}: system_prompt is empty" + raise ValueError(msg) + + middleware: list[Any] = [ + *(extra_middleware or []), + PatchToolCallsMiddleware(), + DedupHITLToolCallsMiddleware(agent_tools=tools), + ] + spec: dict[str, Any] = { + "name": name, + "description": description, + "system_prompt": system_prompt, + "tools": tools, + "middleware": middleware, + } + if model is not None: + spec["model"] = model + if interrupt_on: + spec["interrupt_on"] = interrupt_on + return cast(SubAgent, spec) From cf3acd87aa537f5c16ce4cf34d65ca14f92ab13a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 061/131] Add MCP allow and ask name tables per integration. --- .../mcp_tools/permissions/__init__.py | 23 +++++++++++++ .../mcp_tools/permissions/airtable.py | 16 ++++++++++ .../mcp_tools/permissions/clickup.py | 15 +++++++++ .../subagents/mcp_tools/permissions/index.py | 10 ++++++ .../subagents/mcp_tools/permissions/jira.py | 20 ++++++++++++ .../subagents/mcp_tools/permissions/linear.py | 32 +++++++++++++++++++ .../subagents/mcp_tools/permissions/slack.py | 16 ++++++++++ 7 files changed, 132 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/airtable.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/clickup.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/jira.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/linear.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/slack.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/__init__.py new file mode 100644 index 000000000..2d0341fb7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/__init__.py @@ -0,0 +1,23 @@ +"""Bundled MCP allow/ask name rows per connector agent (MCP-backed routes only).""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/mcp_tools/permissions/airtable.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/airtable.py new file mode 100644 index 000000000..ec252a6ae --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/airtable.py @@ -0,0 +1,16 @@ +"""Airtable MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/mcp_tools/permissions/clickup.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/clickup.py new file mode 100644 index 000000000..2eb00eec9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/clickup.py @@ -0,0 +1,15 @@ +"""ClickUp MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/mcp_tools/permissions/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/index.py new file mode 100644 index 000000000..e8340abe5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/index.py @@ -0,0 +1,10 @@ +"""Re-exports permission row types for MCP policy modules.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolPermissionItem, + ToolsPermissions, +) + +__all__ = ["ToolPermissionItem", "ToolsPermissions"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/jira.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/jira.py new file mode 100644 index 000000000..93b57823e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/jira.py @@ -0,0 +1,20 @@ +"""Jira MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/mcp_tools/permissions/linear.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/linear.py new file mode 100644 index 000000000..25c37494a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/linear.py @@ -0,0 +1,32 @@ +"""Linear MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/mcp_tools/permissions/slack.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/slack.py new file mode 100644 index 000000000..bd5454523 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/slack.py @@ -0,0 +1,16 @@ +"""Slack MCP: which server tool names are allow vs ask.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, +) + +TOOLS_PERMISSIONS: ToolsPermissions = { + "allow": [ + {"name": "slack_search_channels"}, + {"name": "slack_read_channel"}, + {"name": "slack_read_thread"}, + ], + "ask": [], +} From 7080b787d17c68b27c9a8ef20011cda1ca0d3117 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 062/131] Add MCP tool loading and connector partitioning. --- .../subagents/mcp_tools/__init__.py | 20 +++ .../subagents/mcp_tools/index.py | 156 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/__init__.py new file mode 100644 index 000000000..33e19c827 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/__init__.py @@ -0,0 +1,20 @@ +"""Load MCP tools, partition by connector agent, apply allow/ask name rules.""" + +from __future__ import annotations + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/mcp_tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py new file mode 100644 index 000000000..087110974 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py @@ -0,0 +1,156 @@ +"""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_with_deepagents.constants import ( + CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS, +) +from app.agents.multi_agent_with_deepagents.subagents.mcp_tools.permissions import ( + TOOLS_PERMISSIONS_BY_AGENT, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolPermissionItem, + ToolsPermissions, + 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(tool_permission_row(t)) + continue + key = _get_mcp_tool_name(t) + if key in allow_names: + allow.append(tool_permission_row(t)) + elif key in ask_names: + ask.append(tool_permission_row(t)) + else: + ask.append(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.""" + flat = await load_mcp_tools(session, search_space_id) + 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() + } From b9bc06e7b4f9620edc215bbcf0a0f81da520650b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 063/131] Add builtin research route slice for delegated agents. --- .../subagents/builtins/__init__.py | 0 .../subagents/builtins/research/__init__.py | 0 .../subagents/builtins/research/agent.py | 54 ++++ .../builtins/research/description.md | 1 + .../builtins/research/system_prompt.md | 53 ++++ .../builtins/research/tools/__init__.py | 11 + .../builtins/research/tools/index.py | 29 ++ .../builtins/research/tools/scrape_webpage.py | 300 ++++++++++++++++++ .../research/tools/search_surfsense_docs.py | 143 +++++++++ .../builtins/research/tools/web_search.py | 241 ++++++++++++++ 10 files changed, 832 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/scrape_webpage.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/search_surfsense_docs.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/web_search.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py new file mode 100644 index 000000000..c04330607 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/builtins/research/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/description.md new file mode 100644 index 000000000..dd2ced3fb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/description.md @@ -0,0 +1 @@ +Use for external research: find sources on the web, extract evidence, and answer documentation questions. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/system_prompt.md new file mode 100644 index 000000000..cf558db62 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/system_prompt.md @@ -0,0 +1,53 @@ +You are the SurfSense research operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Gather and synthesize evidence using SurfSense research tools with clear citations and uncertainty reporting. + + + +- `web_search` +- `scrape_webpage` +- `search_surfsense_docs` + + + +- Use only tools in ``. +- Prefer primary and recent sources when recency matters. +- If the delegated request is underspecified, return `status=blocked` with the missing research constraints. +- Never fabricate facts, citations, URLs, or quote text. + + + +- Do not execute connector mutations (email/calendar/docs/chat writes) or deliverable generation. + + + +- Report uncertainty explicitly when evidence is incomplete or conflicting. +- Never present unverified claims as facts. + + + +- On tool failure, return `status=error` with a concise recovery `next_step`. +- On no useful evidence, return `status=blocked` with recommended narrower filters. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "findings": string[], + "sources": string[], + "confidence": "high" | "medium" | "low" + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/__init__.py new file mode 100644 index 000000000..414cc96f4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/__init__.py @@ -0,0 +1,11 @@ +"""Research-stage tools: web search, scrape, and in-product doc search.""" + +from .scrape_webpage import create_scrape_webpage_tool +from .search_surfsense_docs import create_search_surfsense_docs_tool +from .web_search import create_web_search_tool + +__all__ = [ + "create_scrape_webpage_tool", + "create_search_surfsense_docs_tool", + "create_web_search_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/index.py new file mode 100644 index 000000000..a616ac2dc --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/index.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/builtins/research/tools/scrape_webpage.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/scrape_webpage.py new file mode 100644 index 000000000..bb7c8e5a3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/scrape_webpage.py @@ -0,0 +1,300 @@ +"""Scrape pages via WebCrawlerConnector; YouTube URLs use the transcript API instead of HTML crawl.""" + +import hashlib +import logging +from typing import Any +from urllib.parse import urlparse + +import aiohttp +from fake_useragent import UserAgent +from langchain_core.tools import tool +from requests import Session +from youtube_transcript_api import YouTubeTranscriptApi + +from app.connectors.webcrawler_connector import WebCrawlerConnector +from app.tasks.document_processors.youtube_processor import get_youtube_video_id +from app.utils.proxy_config import get_requests_proxies + +logger = logging.getLogger(__name__) + + +def extract_domain(url: str) -> str: + """Extract the domain from a URL.""" + try: + parsed = urlparse(url) + domain = parsed.netloc + # Remove 'www.' prefix if present + if domain.startswith("www."): + domain = domain[4:] + return domain + except Exception: + return "" + + +def generate_scrape_id(url: str) -> str: + """Generate a unique ID for a scraped webpage.""" + hash_val = hashlib.md5(url.encode()).hexdigest()[:12] + return f"scrape-{hash_val}" + + +def truncate_content(content: str, max_length: int = 50000) -> tuple[str, bool]: + """ + Truncate content to a maximum length. + + Returns: + Tuple of (truncated_content, was_truncated) + """ + if len(content) <= max_length: + return content, False + + # Try to truncate at a sentence boundary + truncated = content[:max_length] + last_period = truncated.rfind(".") + last_newline = truncated.rfind("\n\n") + + # Use the later of the two boundaries, or just truncate + boundary = max(last_period, last_newline) + if boundary > max_length * 0.8: # Only use boundary if it's not too far back + truncated = content[: boundary + 1] + + return truncated + "\n\n[Content truncated...]", True + + +async def _scrape_youtube_video( + url: str, video_id: str, max_length: int +) -> dict[str, Any]: + """ + Fetch YouTube video metadata and transcript via the YouTubeTranscriptApi. + + Returns a result dict in the same shape as the regular scrape_webpage output. + """ + scrape_id = generate_scrape_id(url) + domain = "youtube.com" + + # --- Video metadata via oEmbed --- + residential_proxies = get_requests_proxies() + + params = { + "format": "json", + "url": f"https://www.youtube.com/watch?v={video_id}", + } + oembed_url = "https://www.youtube.com/oembed" + + try: + async with ( + aiohttp.ClientSession() as http_session, + http_session.get( + oembed_url, + params=params, + proxy=residential_proxies["http"] if residential_proxies else None, + ) as response, + ): + video_data = await response.json() + except Exception: + video_data = {} + + title = video_data.get("title", "YouTube Video") + author = video_data.get("author_name", "Unknown") + + # --- Transcript via YouTubeTranscriptApi --- + try: + ua = UserAgent() + http_client = Session() + http_client.headers.update({"User-Agent": ua.random}) + if residential_proxies: + http_client.proxies.update(residential_proxies) + ytt_api = YouTubeTranscriptApi(http_client=http_client) + + # List all available transcripts and pick the first one + # (the video's primary language) instead of defaulting to English + transcript_list = ytt_api.list(video_id) + transcript = next(iter(transcript_list)) + captions = transcript.fetch() + + logger.info( + f"[scrape_webpage] Fetched transcript for {video_id} " + f"in {transcript.language} ({transcript.language_code})" + ) + + transcript_segments = [] + for line in captions: + start_time = line.start + duration = line.duration + text = line.text + timestamp = f"[{start_time:.2f}s-{start_time + duration:.2f}s]" + transcript_segments.append(f"{timestamp} {text}") + transcript_text = "\n".join(transcript_segments) + except Exception as e: + logger.warning(f"[scrape_webpage] No transcript for video {video_id}: {e}") + transcript_text = f"No captions available for this video. Error: {e!s}" + + # Build combined content + content = f"# {title}\n\n**Author:** {author}\n**Video ID:** {video_id}\n\n## Transcript\n\n{transcript_text}" + + # Truncate if needed + content, was_truncated = truncate_content(content, max_length) + word_count = len(content.split()) + + description = f"YouTube video by {author}" + + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": title, + "description": description, + "content": content, + "domain": domain, + "word_count": word_count, + "was_truncated": was_truncated, + "crawler_type": "youtube_transcript", + "author": author, + } + + +def create_scrape_webpage_tool(firecrawl_api_key: str | None = None): + """ + Factory function to create the scrape_webpage tool. + + Args: + firecrawl_api_key: Optional Firecrawl API key for premium web scraping. + Falls back to Chromium/Trafilatura if not provided. + + Returns: + A configured tool function for scraping webpages. + """ + + @tool + async def scrape_webpage( + url: str, + max_length: int = 50000, + ) -> dict[str, Any]: + """ + Scrape and extract the main content from a webpage. + + Use this tool when the user wants you to read, summarize, or answer + questions about a specific webpage's content. This tool actually + fetches and reads the full page content. For YouTube video URLs it + fetches the transcript directly instead of crawling the page. + + Common triggers: + - "Read this article and summarize it" + - "What does this page say about X?" + - "Summarize this blog post for me" + - "Tell me the key points from this article" + - "What's in this webpage?" + + Args: + url: The URL of the webpage to scrape (must be HTTP/HTTPS) + max_length: Maximum content length to return (default: 50000 chars) + + Returns: + A dictionary containing: + - id: Unique identifier for this scrape + - assetId: The URL (for deduplication) + - kind: "article" (type of content) + - href: The URL to open when clicked + - title: Page title + - description: Brief description or excerpt + - content: The extracted main content (markdown format) + - domain: The domain name + - word_count: Approximate word count + - was_truncated: Whether content was truncated + - error: Error message (if scraping failed) + """ + scrape_id = generate_scrape_id(url) + domain = extract_domain(url) + + # Validate and normalize URL + if not url.startswith(("http://", "https://")): + url = f"https://{url}" + + try: + # Check if this is a YouTube URL and use transcript API instead + video_id = get_youtube_video_id(url) + if video_id: + return await _scrape_youtube_video(url, video_id, max_length) + + # Create webcrawler connector + connector = WebCrawlerConnector(firecrawl_api_key=firecrawl_api_key) + + # Crawl the URL + result, error = await connector.crawl_url(url, formats=["markdown"]) + + if error: + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": domain or "Webpage", + "domain": domain, + "error": error, + } + + if not result: + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": domain or "Webpage", + "domain": domain, + "error": "No content returned from crawler", + } + + # Extract content and metadata + content = result.get("content", "") + metadata = result.get("metadata", {}) + + # Get title from metadata + title = metadata.get("title", "") + if not title: + title = domain or url.split("/")[-1] or "Webpage" + + # Get description from metadata + description = metadata.get("description", "") + if not description and content: + # Use first paragraph as description + first_para = content.split("\n\n")[0] if content else "" + description = ( + first_para[:300] + "..." if len(first_para) > 300 else first_para + ) + + # Truncate content if needed + content, was_truncated = truncate_content(content, max_length) + + # Calculate word count + word_count = len(content.split()) + + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": title, + "description": description, + "content": content, + "domain": domain, + "word_count": word_count, + "was_truncated": was_truncated, + "crawler_type": result.get("crawler_type", "unknown"), + "author": metadata.get("author"), + "date": metadata.get("date"), + } + + except Exception as e: + error_message = str(e) + logger.error(f"[scrape_webpage] Error scraping {url}: {error_message}") + return { + "id": scrape_id, + "assetId": url, + "kind": "article", + "href": url, + "title": domain or "Webpage", + "domain": domain, + "error": f"Failed to scrape: {error_message[:100]}", + } + + return scrape_webpage diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/search_surfsense_docs.py new file mode 100644 index 000000000..0d702be4c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/search_surfsense_docs.py @@ -0,0 +1,143 @@ +"""Semantic search over pre-indexed in-app documentation chunks for user how-to questions.""" + +import asyncio +import json + +from langchain_core.tools import tool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument +from app.utils.document_converters import embed_text + + +def format_surfsense_docs_results(results: list[tuple]) -> str: + """Format (chunk, document) rows as XML with ``doc-`` chunk IDs for citations and UI routing.""" + if not results: + return "No relevant Surfsense documentation found for your query." + + # Group chunks by document + grouped: dict[int, dict] = {} + for chunk, doc in results: + if doc.id not in grouped: + grouped[doc.id] = { + "document_id": f"doc-{doc.id}", + "document_type": "SURFSENSE_DOCS", + "title": doc.title, + "url": doc.source, + "metadata": {"source": doc.source}, + "chunks": [], + } + grouped[doc.id]["chunks"].append( + { + "chunk_id": f"doc-{chunk.id}", + "content": chunk.content, + } + ) + + # Render XML matching format_documents_for_context structure + parts: list[str] = [] + for g in grouped.values(): + metadata_json = json.dumps(g["metadata"], ensure_ascii=False) + + parts.append("") + parts.append("") + parts.append(f" {g['document_id']}") + parts.append(f" {g['document_type']}") + parts.append(f" <![CDATA[{g['title']}]]>") + parts.append(f" ") + parts.append(f" ") + parts.append("") + parts.append("") + parts.append("") + + for ch in g["chunks"]: + parts.append( + f" " + ) + + parts.append("") + parts.append("") + parts.append("") + + return "\n".join(parts).strip() + + +async def search_surfsense_docs_async( + query: str, + db_session: AsyncSession, + top_k: int = 10, +) -> str: + """ + Search Surfsense documentation using vector similarity. + + Args: + query: The search query about Surfsense usage + db_session: Database session for executing queries + top_k: Number of results to return + + Returns: + Formatted string with relevant documentation content + """ + # Get embedding for the query + query_embedding = await asyncio.to_thread(embed_text, query) + + # Vector similarity search on chunks, joining with documents + stmt = ( + select(SurfsenseDocsChunk, SurfsenseDocsDocument) + .join( + SurfsenseDocsDocument, + SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id, + ) + .order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding)) + .limit(top_k) + ) + + result = await db_session.execute(stmt) + rows = result.all() + + return format_surfsense_docs_results(rows) + + +def create_search_surfsense_docs_tool(db_session: AsyncSession): + """ + Factory function to create the search_surfsense_docs tool. + + Args: + db_session: Database session for executing queries + + Returns: + A configured tool function for searching Surfsense documentation + """ + + @tool + async def search_surfsense_docs(query: str, top_k: int = 10) -> str: + """ + Search Surfsense documentation for help with using the application. + + Use this tool when the user asks questions about: + - How to use Surfsense features + - Installation and setup instructions + - Configuration options and settings + - Troubleshooting common issues + - Available connectors and integrations + - Browser extension usage + - API documentation + + This searches the official Surfsense documentation that was indexed + at deployment time. It does NOT search the user's personal knowledge base. + + Args: + query: The search query about Surfsense usage or features + top_k: Number of documentation chunks to retrieve (default: 10) + + Returns: + Relevant documentation content formatted with chunk IDs for citations + """ + return await search_surfsense_docs_async( + query=query, + db_session=db_session, + top_k=top_k, + ) + + return search_surfsense_docs diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/web_search.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/web_search.py new file mode 100644 index 000000000..2fe6bd378 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/web_search.py @@ -0,0 +1,241 @@ +"""Real-time web search: SearXNG plus configured live-search connectors (Tavily, Linkup, Baidu, etc.).""" + +import asyncio +import json +import time +from typing import Any + +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, Field + +from app.db import shielded_async_session +from app.services.connector_service import ConnectorService +from app.utils.perf import get_perf_logger + +_LIVE_SEARCH_CONNECTORS: set[str] = { + "TAVILY_API", + "LINKUP_API", + "BAIDU_SEARCH_API", +} + +_LIVE_CONNECTOR_SPECS: dict[str, tuple[str, bool, bool, dict[str, Any]]] = { + "TAVILY_API": ("search_tavily", False, True, {}), + "LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}), + "BAIDU_SEARCH_API": ("search_baidu", False, True, {}), +} + +_CONNECTOR_LABELS: dict[str, str] = { + "TAVILY_API": "Tavily", + "LINKUP_API": "Linkup", + "BAIDU_SEARCH_API": "Baidu", +} + + +class WebSearchInput(BaseModel): + """Input schema for the web_search tool.""" + + query: str = Field( + description="The search query to look up on the web. Use specific, descriptive terms.", + ) + top_k: int = Field( + default=10, + description="Number of results to retrieve (default: 10, max: 50).", + ) + + +def _format_web_results( + documents: list[dict[str, Any]], + *, + max_chars: int = 50_000, +) -> str: + """Format web search results into XML suitable for the LLM context.""" + if not documents: + return "No web search results found." + + parts: list[str] = [] + total_chars = 0 + + for doc in documents: + doc_info = doc.get("document") or {} + metadata = doc_info.get("metadata") or {} + title = doc_info.get("title") or "Web Result" + url = metadata.get("url") or "" + content = (doc.get("content") or "").strip() + source = metadata.get("document_type") or doc.get("source") or "WEB_SEARCH" + if not content: + continue + + metadata_json = json.dumps(metadata, ensure_ascii=False) + doc_xml = "\n".join( + [ + "", + "", + f" {source}", + f" <![CDATA[{title}]]>", + f" ", + f" ", + "", + "", + f" ", + "", + "", + "", + ] + ) + + if total_chars + len(doc_xml) > max_chars: + parts.append("") + break + + parts.append(doc_xml) + total_chars += len(doc_xml) + + return "\n".join(parts).strip() or "No web search results found." + + +async def _search_live_connector( + connector: str, + query: str, + search_space_id: int, + top_k: int, + semaphore: asyncio.Semaphore, +) -> list[dict[str, Any]]: + """Dispatch a single live-search connector (Tavily / Linkup / Baidu).""" + perf = get_perf_logger() + spec = _LIVE_CONNECTOR_SPECS.get(connector) + if spec is None: + return [] + + method_name, _includes_date_range, includes_top_k, extra_kwargs = spec + kwargs: dict[str, Any] = { + "user_query": query, + "search_space_id": search_space_id, + **extra_kwargs, + } + if includes_top_k: + kwargs["top_k"] = top_k + + try: + t0 = time.perf_counter() + async with semaphore, shielded_async_session() as session: + svc = ConnectorService(session, search_space_id) + _, chunks = await getattr(svc, method_name)(**kwargs) + perf.info( + "[web_search] connector=%s results=%d in %.3fs", + connector, + len(chunks), + time.perf_counter() - t0, + ) + return chunks + except Exception as e: + perf.warning("[web_search] connector=%s FAILED: %s", connector, e) + return [] + + +def create_web_search_tool( + search_space_id: int | None = None, + available_connectors: list[str] | None = None, +) -> StructuredTool: + """Factory for the ``web_search`` tool. + + Dispatches in parallel to the platform SearXNG instance and any + user-configured live-search connectors (Tavily, Linkup, Baidu). + """ + active_live_connectors: list[str] = [] + if available_connectors: + active_live_connectors = [ + c for c in available_connectors if c in _LIVE_SEARCH_CONNECTORS + ] + + engine_names = ["SearXNG (platform default)"] + engine_names.extend(_CONNECTOR_LABELS.get(c, c) for c in active_live_connectors) + engines_summary = ", ".join(engine_names) + + description = ( + "Search the web for real-time information. " + "Use this for current events, news, prices, weather, public facts, or any " + "question that requires up-to-date information from the internet.\n\n" + f"Active search engines: {engines_summary}.\n" + "All configured engines are queried in parallel and results are merged." + ) + + _search_space_id = search_space_id + _active_live = active_live_connectors + + async def _web_search_impl(query: str, top_k: int = 10) -> str: + from app.services import web_search_service + + perf = get_perf_logger() + t0 = time.perf_counter() + clamped_top_k = min(max(1, top_k), 50) + + semaphore = asyncio.Semaphore(4) + tasks: list[asyncio.Task[list[dict[str, Any]]]] = [] + + if web_search_service.is_available(): + + async def _searxng() -> list[dict[str, Any]]: + async with semaphore: + _result_obj, docs = await web_search_service.search( + query=query, + top_k=clamped_top_k, + ) + return docs + + tasks.append(asyncio.ensure_future(_searxng())) + + if _search_space_id is not None: + for connector in _active_live: + tasks.append( + asyncio.ensure_future( + _search_live_connector( + connector=connector, + query=query, + search_space_id=_search_space_id, + top_k=clamped_top_k, + semaphore=semaphore, + ) + ) + ) + + if not tasks: + return "Web search is not available — no search engines are configured." + + results_lists = await asyncio.gather(*tasks, return_exceptions=True) + + all_documents: list[dict[str, Any]] = [] + for result in results_lists: + if isinstance(result, BaseException): + perf.warning("[web_search] a search engine failed: %s", result) + continue + all_documents.extend(result) + + seen_urls: set[str] = set() + deduplicated: list[dict[str, Any]] = [] + for doc in all_documents: + url = ((doc.get("document") or {}).get("metadata") or {}).get("url", "") + if url and url in seen_urls: + continue + if url: + seen_urls.add(url) + deduplicated.append(doc) + + formatted = _format_web_results(deduplicated) + + perf.info( + "[web_search] query=%r engines=%d results=%d deduped=%d chars=%d in %.3fs", + query[:60], + len(tasks), + len(all_documents), + len(deduplicated), + len(formatted), + time.perf_counter() - t0, + ) + return formatted + + return StructuredTool( + name="web_search", + description=description, + coroutine=_web_search_impl, + args_schema=WebSearchInput, + ) From ff307dd923849a334ac58754e35b5b8289f18f93 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 064/131] Add builtin memory route slice for delegated agents. --- .../subagents/builtins/memory/__init__.py | 0 .../subagents/builtins/memory/agent.py | 54 +++ .../subagents/builtins/memory/description.md | 1 + .../builtins/memory/system_prompt.md | 56 +++ .../builtins/memory/tools/__init__.py | 8 + .../subagents/builtins/memory/tools/index.py | 27 ++ .../builtins/memory/tools/update_memory.py | 375 ++++++++++++++++++ 7 files changed, 521 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py new file mode 100644 index 000000000..2d231e383 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/builtins/memory/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md new file mode 100644 index 000000000..4c2cdcd0e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md @@ -0,0 +1 @@ +Use for storing durable user memory (private team variant selected at runtime). diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md new file mode 100644 index 000000000..32becf233 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md @@ -0,0 +1,56 @@ +You are the SurfSense memory operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Persist durable preferences/facts/instructions with `update_memory` while avoiding transient or unsafe storage. + + + +{{MEMORY_VISIBILITY_POLICY}} + + + +- `update_memory` + + + +- Save only durable information with future value. +- Do not store transient chatter. +- Do not store secrets unless explicitly instructed. +- If memory intent is unclear, return `status=blocked` with the missing intent signal. + + + +- Do not execute non-memory tool actions. +- Do not store irrelevant, transient, or speculative information. + + + +- Prefer minimal-memory writes over over-collection. +- Never claim memory was updated unless `update_memory` succeeded. + + + +- On tool failure, return `status=error` with concise recovery steps. +- When intent is ambiguous, return `status=blocked` with required disambiguation fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "memory_updated": boolean, + "memory_category": "preference" | "fact" | "instruction" | null, + "stored_summary": string | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py new file mode 100644 index 000000000..0441a8cb4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py @@ -0,0 +1,8 @@ +"""Memory tools: persist user or team markdown memory for later turns.""" + +from .update_memory import create_update_memory_tool, create_update_team_memory_tool + +__all__ = [ + "create_update_memory_tool", + "create_update_team_memory_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py new file mode 100644 index 000000000..71d66d15f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/builtins/memory/tools/update_memory.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py new file mode 100644 index 000000000..23375a081 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py @@ -0,0 +1,375 @@ +"""Overwrite one markdown memory document per user or team, with size and shrink guards.""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Literal +from uuid import UUID + +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import SearchSpace, User + +logger = logging.getLogger(__name__) + +MEMORY_SOFT_LIMIT = 18_000 +MEMORY_HARD_LIMIT = 25_000 + +_SECTION_HEADING_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE) +_HEADING_NORMALIZE_RE = re.compile(r"\s+") + +_MARKER_RE = re.compile(r"\[(fact|pref|instr)\]") +_BULLET_FORMAT_RE = re.compile(r"^- \(\d{4}-\d{2}-\d{2}\) \[(fact|pref|instr)\] .+$") +_PERSONAL_ONLY_MARKERS = {"pref", "instr"} + + +# --------------------------------------------------------------------------- +# Diff validation +# --------------------------------------------------------------------------- + + +def _extract_headings(memory: str) -> set[str]: + """Return all ``## …`` heading texts (without the ``## `` prefix).""" + return set(_SECTION_HEADING_RE.findall(memory)) + + +def _normalize_heading(heading: str) -> str: + """Normalize heading text for robust scope checks.""" + return _HEADING_NORMALIZE_RE.sub(" ", heading.strip().lower()) + + +def _validate_memory_scope( + content: str, scope: Literal["user", "team"] +) -> dict[str, Any] | None: + """Reject personal-only markers ([pref], [instr]) in team memory.""" + if scope != "team": + return None + + markers = set(_MARKER_RE.findall(content)) + leaked = sorted(markers & _PERSONAL_ONLY_MARKERS) + if leaked: + tags = ", ".join(f"[{m}]" for m in leaked) + return { + "status": "error", + "message": ( + f"Team memory cannot include personal markers: {tags}. " + "Use [fact] only in team memory." + ), + } + return None + + +def _validate_bullet_format(content: str) -> list[str]: + """Return warnings for bullet lines that don't match the required format. + + Expected: ``- (YYYY-MM-DD) [fact|pref|instr] text`` + """ + warnings: list[str] = [] + for line in content.splitlines(): + stripped = line.strip() + if not stripped.startswith("- "): + continue + if not _BULLET_FORMAT_RE.match(stripped): + short = stripped[:80] + ("..." if len(stripped) > 80 else "") + warnings.append(f"Malformed bullet: {short}") + return warnings + + +def _validate_diff(old_memory: str | None, new_memory: str) -> list[str]: + """Return a list of warning strings about suspicious changes.""" + if not old_memory: + return [] + + warnings: list[str] = [] + old_headings = _extract_headings(old_memory) + new_headings = _extract_headings(new_memory) + dropped = old_headings - new_headings + if dropped: + names = ", ".join(sorted(dropped)) + warnings.append( + f"Sections removed: {names}. " + "If unintentional, the user can restore from the settings page." + ) + + old_len = len(old_memory) + new_len = len(new_memory) + if old_len > 0 and new_len < old_len * 0.4: + warnings.append( + f"Memory shrank significantly ({old_len:,} -> {new_len:,} chars). " + "Possible data loss." + ) + return warnings + + +# --------------------------------------------------------------------------- +# Size validation & soft warning +# --------------------------------------------------------------------------- + + +def _validate_memory_size(content: str) -> dict[str, Any] | None: + """Return an error/warning dict if *content* is too large, else None.""" + length = len(content) + if length > MEMORY_HARD_LIMIT: + return { + "status": "error", + "message": ( + f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit " + f"({length:,} chars). Consolidate by merging related items, " + "removing outdated entries, and shortening descriptions. " + "Then call update_memory again." + ), + } + return None + + +def _soft_warning(content: str) -> str | None: + """Return a warning string if content exceeds the soft limit.""" + length = len(content) + if length > MEMORY_SOFT_LIMIT: + return ( + f"Memory is at {length:,}/{MEMORY_HARD_LIMIT:,} characters. " + "Consolidate by merging related items and removing less important " + "entries on your next update." + ) + return None + + +# --------------------------------------------------------------------------- +# Forced rewrite when memory exceeds the hard limit +# --------------------------------------------------------------------------- + +_FORCED_REWRITE_PROMPT = """\ +You are a memory curator. The following memory document exceeds the character \ +limit and must be shortened. + +RULES: +1. Rewrite the document to be under {target} characters. +2. Preserve existing ## headings. Every entry must remain under a heading. You may merge + or rename headings to consolidate, but keep names personal and descriptive. +3. Priority for keeping content: [instr] > [pref] > [fact]. +4. Merge duplicate entries, remove outdated entries, shorten verbose descriptions. +5. Every bullet MUST have format: - (YYYY-MM-DD) [fact|pref|instr] text +6. Preserve the user's first name in entries — do not replace it with "the user". +7. Output ONLY the consolidated markdown — no explanations, no wrapping. + + +{content} +""" + + +async def _forced_rewrite(content: str, llm: Any) -> str | None: + """Use a focused LLM call to compress *content* under the hard limit. + + Returns the rewritten string, or ``None`` if the call fails. + """ + try: + prompt = _FORCED_REWRITE_PROMPT.format( + target=MEMORY_HARD_LIMIT, content=content + ) + response = await llm.ainvoke( + [HumanMessage(content=prompt)], + config={"tags": ["surfsense:internal"]}, + ) + text = ( + response.content + if isinstance(response.content, str) + else str(response.content) + ) + return text.strip() + except Exception: + logger.exception("Forced rewrite LLM call failed") + return None + + +# --------------------------------------------------------------------------- +# Shared save-and-respond logic +# --------------------------------------------------------------------------- + + +async def _save_memory( + *, + updated_memory: str, + old_memory: str | None, + llm: Any | None, + apply_fn, + commit_fn, + rollback_fn, + label: str, + scope: Literal["user", "team"], +) -> dict[str, Any]: + """Validate, optionally force-rewrite if over the hard limit, save, and + return a response dict. + + Parameters + ---------- + updated_memory : str + The new document the agent submitted. + old_memory : str | None + The previously persisted document (for diff checks). + llm : Any | None + LLM instance for forced rewrite (may be ``None``). + apply_fn : callable(str) -> None + Callback that sets the new memory on the ORM object. + commit_fn : coroutine + ``session.commit``. + rollback_fn : coroutine + ``session.rollback``. + label : str + Human label for log messages (e.g. "user memory", "team memory"). + """ + content = updated_memory + + # --- forced rewrite if over the hard limit --- + if len(content) > MEMORY_HARD_LIMIT and llm is not None: + rewritten = await _forced_rewrite(content, llm) + if rewritten is not None and len(rewritten) < len(content): + content = rewritten + + # --- hard-limit gate (reject if still too large after rewrite) --- + size_err = _validate_memory_size(content) + if size_err: + return size_err + + scope_err = _validate_memory_scope(content, scope) + if scope_err: + return scope_err + + # --- persist --- + try: + apply_fn(content) + await commit_fn() + except Exception as e: + logger.exception("Failed to update %s: %s", label, e) + await rollback_fn() + return {"status": "error", "message": f"Failed to update {label}: {e}"} + + # --- build response --- + resp: dict[str, Any] = { + "status": "saved", + "message": f"{label.capitalize()} updated.", + } + + if content is not updated_memory: + resp["notice"] = "Memory was automatically rewritten to fit within limits." + + diff_warnings = _validate_diff(old_memory, content) + if diff_warnings: + resp["diff_warnings"] = diff_warnings + + format_warnings = _validate_bullet_format(content) + if format_warnings: + resp["format_warnings"] = format_warnings + + warning = _soft_warning(content) + if warning: + resp["warning"] = warning + + return resp + + +# --------------------------------------------------------------------------- +# Tool factories +# --------------------------------------------------------------------------- + + +def create_update_memory_tool( + user_id: str | UUID, + db_session: AsyncSession, + llm: Any | None = None, +): + uid = UUID(user_id) if isinstance(user_id, str) else user_id + + @tool + async def update_memory(updated_memory: str) -> dict[str, Any]: + """Update the user's personal memory document. + + Your current memory is shown in in the system prompt. + When the user shares important long-term information (preferences, + facts, instructions, context), rewrite the memory document to include + the new information. Merge new facts with existing ones, update + contradictions, remove outdated entries, and keep it concise. + + Args: + updated_memory: The FULL updated markdown document (not a diff). + """ + try: + result = await db_session.execute(select(User).where(User.id == uid)) + user = result.scalars().first() + if not user: + return {"status": "error", "message": "User not found."} + + old_memory = user.memory_md + + return await _save_memory( + updated_memory=updated_memory, + old_memory=old_memory, + llm=llm, + apply_fn=lambda content: setattr(user, "memory_md", content), + commit_fn=db_session.commit, + rollback_fn=db_session.rollback, + label="memory", + scope="user", + ) + except Exception as e: + logger.exception("Failed to update user memory: %s", e) + await db_session.rollback() + return { + "status": "error", + "message": f"Failed to update memory: {e}", + } + + return update_memory + + +def create_update_team_memory_tool( + search_space_id: int, + db_session: AsyncSession, + llm: Any | None = None, +): + @tool + async def update_memory(updated_memory: str) -> dict[str, Any]: + """Update the team's shared memory document for this search space. + + Your current team memory is shown in in the system + prompt. When the team shares important long-term information + (decisions, conventions, key facts, priorities), rewrite the memory + document to include the new information. Merge new facts with + existing ones, update contradictions, remove outdated entries, and + keep it concise. + + Args: + updated_memory: The FULL updated markdown document (not a diff). + """ + try: + result = await db_session.execute( + select(SearchSpace).where(SearchSpace.id == search_space_id) + ) + space = result.scalars().first() + if not space: + return {"status": "error", "message": "Search space not found."} + + old_memory = space.shared_memory_md + + return await _save_memory( + updated_memory=updated_memory, + old_memory=old_memory, + llm=llm, + apply_fn=lambda content: setattr(space, "shared_memory_md", content), + commit_fn=db_session.commit, + rollback_fn=db_session.rollback, + label="team memory", + scope="team", + ) + except Exception as e: + logger.exception("Failed to update team memory: %s", e) + await db_session.rollback() + return { + "status": "error", + "message": f"Failed to update team memory: {e}", + } + + return update_memory From ba57eae2bb6ca10ed0628f6fc2406480ccd36158 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 065/131] Add builtin deliverables route slice for delegated agents. --- .../builtins/deliverables/__init__.py | 0 .../subagents/builtins/deliverables/agent.py | 54 + .../builtins/deliverables/description.md | 1 + .../builtins/deliverables/system_prompt.md | 55 + .../builtins/deliverables/tools/__init__.py | 15 + .../deliverables/tools/generate_image.py | 247 ++++ .../builtins/deliverables/tools/index.py | 52 + .../builtins/deliverables/tools/podcast.py | 92 ++ .../builtins/deliverables/tools/report.py | 1061 +++++++++++++++++ .../builtins/deliverables/tools/resume.py | 799 +++++++++++++ .../deliverables/tools/video_presentation.py | 80 ++ 11 files changed, 2456 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py new file mode 100644 index 000000000..e7eeec4db --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/builtins/deliverables/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md new file mode 100644 index 000000000..4dd0f67fe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md @@ -0,0 +1 @@ +Use for deliverables and shareable artifacts: generated reports, podcasts, video presentations, resumes, and images—not for routine lookups or single small edits elsewhere. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md new file mode 100644 index 000000000..c44f131bb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md @@ -0,0 +1,55 @@ +You are the SurfSense deliverables operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Produce **deliverables**: shareable **artifacts** the user keeps (reports, slide-style video presentations, podcasts, resumes, images). Use explicit constraints and reliable proof of what was generated. + + + +- `generate_report` +- `generate_podcast` +- `generate_video_presentation` +- `generate_resume` +- `generate_image` + + + +- Use only tools in ``. +- Require essential generation constraints (audience, format, tone, core content). +- If critical constraints are missing, return `status=blocked` with `missing_fields`. +- Never claim artifact generation success without tool confirmation. + + + +- Do not perform connector data mutations unrelated to artifact generation. + + + +- Avoid generating artifacts with missing critical constraints. +- Prefer one complete artifact over partial multi-artifact output. + + + +- On generation failure, return `status=error` with best retry guidance. +- On missing constraints, return `status=blocked` with required fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "artifact_type": "report" | "podcast" | "video_presentation" | "resume" | "image" | null, + "artifact_id": string | null, + "artifact_location": string | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py new file mode 100644 index 000000000..d0fe94217 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py @@ -0,0 +1,15 @@ +"""Deliverable generators: reports, podcasts, video decks, resumes, images.""" + +from .generate_image import create_generate_image_tool +from .podcast import create_generate_podcast_tool +from .report import create_generate_report_tool +from .resume import create_generate_resume_tool +from .video_presentation import create_generate_video_presentation_tool + +__all__ = [ + "create_generate_image_tool", + "create_generate_podcast_tool", + "create_generate_report_tool", + "create_generate_resume_tool", + "create_generate_video_presentation_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py new file mode 100644 index 000000000..ab9dbc0ea --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py @@ -0,0 +1,247 @@ +"""Image generation via litellm; resolves model config from the search space and returns UI-ready payloads.""" + +import hashlib +import logging +from typing import Any + +from langchain_core.tools import tool +from litellm import aimage_generation +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import config +from app.db import ( + ImageGeneration, + ImageGenerationConfig, + SearchSpace, + shielded_async_session, +) +from app.services.image_gen_router_service import ( + IMAGE_GEN_AUTO_MODE_ID, + ImageGenRouterService, + is_image_gen_auto_mode, +) +from app.utils.signed_image_urls import generate_image_token + +logger = logging.getLogger(__name__) + +# Provider mapping (same as routes) +_PROVIDER_MAP = { + "OPENAI": "openai", + "AZURE_OPENAI": "azure", + "GOOGLE": "gemini", + "VERTEX_AI": "vertex_ai", + "BEDROCK": "bedrock", + "RECRAFT": "recraft", + "OPENROUTER": "openrouter", + "XINFERENCE": "xinference", + "NSCALE": "nscale", +} + + +def _build_model_string( + provider: str, model_name: str, custom_provider: str | None +) -> str: + if custom_provider: + return f"{custom_provider}/{model_name}" + prefix = _PROVIDER_MAP.get(provider.upper(), provider.lower()) + return f"{prefix}/{model_name}" + + +def _get_global_image_gen_config(config_id: int) -> dict | None: + """Get a global image gen config by negative ID.""" + for cfg in config.GLOBAL_IMAGE_GEN_CONFIGS: + if cfg.get("id") == config_id: + return cfg + return None + + +def create_generate_image_tool( + search_space_id: int, + db_session: AsyncSession, +): + """Create ``generate_image`` with bound search space; DB work uses a per-call session.""" + del db_session # use a fresh per-call session, see below + + @tool + async def generate_image( + prompt: str, + n: int = 1, + ) -> dict[str, Any]: + """ + Generate an image from a text description using AI image models. + + Use this tool when the user asks you to create, generate, draw, or make an image. + The generated image will be displayed directly in the chat. + + Args: + prompt: A detailed text description of the image to generate. + Be specific about subject, style, colors, composition, and mood. + n: Number of images to generate (1-4). Default: 1 + + Returns: + A dictionary containing the generated image(s) for display in the chat. + """ + try: + # Use a per-call session so concurrent tool calls don't share an + # AsyncSession (which is not concurrency-safe). The streaming + # task's session is shared across every tool; without isolation, + # autoflushes from a concurrent writer poison this tool too. + async with shielded_async_session() as session: + result = await session.execute( + select(SearchSpace).filter(SearchSpace.id == search_space_id) + ) + search_space = result.scalars().first() + if not search_space: + return {"error": "Search space not found"} + + config_id = ( + search_space.image_generation_config_id or IMAGE_GEN_AUTO_MODE_ID + ) + + # Build generation kwargs + # NOTE: size, quality, and style are intentionally NOT passed. + # Different models support different values for these params + # (e.g. DALL-E 3 wants "hd"/"standard" for quality while + # gpt-image-1 wants "high"/"medium"/"low"; size options also + # differ). Letting the model use its own defaults avoids errors. + gen_kwargs: dict[str, Any] = {} + if n is not None and n > 1: + gen_kwargs["n"] = n + + # Call litellm based on config type + if is_image_gen_auto_mode(config_id): + if not ImageGenRouterService.is_initialized(): + return { + "error": "No image generation models configured. " + "Please add an image model in Settings > Image Models." + } + response = await ImageGenRouterService.aimage_generation( + prompt=prompt, model="auto", **gen_kwargs + ) + elif config_id < 0: + cfg = _get_global_image_gen_config(config_id) + if not cfg: + return { + "error": f"Image generation config {config_id} not found" + } + + model_string = _build_model_string( + cfg.get("provider", ""), + cfg["model_name"], + cfg.get("custom_provider"), + ) + gen_kwargs["api_key"] = cfg.get("api_key") + if cfg.get("api_base"): + gen_kwargs["api_base"] = cfg["api_base"] + if cfg.get("api_version"): + gen_kwargs["api_version"] = cfg["api_version"] + if cfg.get("litellm_params"): + gen_kwargs.update(cfg["litellm_params"]) + + response = await aimage_generation( + prompt=prompt, model=model_string, **gen_kwargs + ) + else: + # Positive ID = user-created ImageGenerationConfig + cfg_result = await session.execute( + select(ImageGenerationConfig).filter( + ImageGenerationConfig.id == config_id + ) + ) + db_cfg = cfg_result.scalars().first() + if not db_cfg: + return { + "error": f"Image generation config {config_id} not found" + } + + model_string = _build_model_string( + db_cfg.provider.value, + db_cfg.model_name, + db_cfg.custom_provider, + ) + gen_kwargs["api_key"] = db_cfg.api_key + if db_cfg.api_base: + gen_kwargs["api_base"] = db_cfg.api_base + if db_cfg.api_version: + gen_kwargs["api_version"] = db_cfg.api_version + if db_cfg.litellm_params: + gen_kwargs.update(db_cfg.litellm_params) + + response = await aimage_generation( + prompt=prompt, model=model_string, **gen_kwargs + ) + + # Parse the response and store in DB + response_dict = ( + response.model_dump() + if hasattr(response, "model_dump") + else dict(response) + ) + + # Generate a random access token for this image + access_token = generate_image_token() + + # Save to image_generations table for history + db_image_gen = ImageGeneration( + prompt=prompt, + model=getattr(response, "_hidden_params", {}).get("model"), + n=n, + image_generation_config_id=config_id, + response_data=response_dict, + search_space_id=search_space_id, + access_token=access_token, + ) + session.add(db_image_gen) + await session.commit() + await session.refresh(db_image_gen) + db_image_gen_id = db_image_gen.id + + # Extract image URLs from response + images = response_dict.get("data", []) + if not images: + return {"error": "No images were generated"} + + first_image = images[0] + revised_prompt = first_image.get("revised_prompt", prompt) + + # Resolve image URL: + # - If the API returned a URL, use it directly. + # - If the API returned b64_json (e.g. gpt-image-1), serve the + # image through our backend endpoint to avoid bloating the + # LLM context with megabytes of base64 data. + if first_image.get("url"): + image_url = first_image["url"] + elif first_image.get("b64_json"): + backend_url = config.BACKEND_URL or "http://localhost:8000" + image_url = ( + f"{backend_url}/api/v1/image-generations/" + f"{db_image_gen_id}/image?token={access_token}" + ) + else: + return {"error": "No displayable image data in the response"} + + image_id = f"image-{hashlib.md5(image_url.encode()).hexdigest()[:12]}" + + return { + "id": image_id, + "assetId": image_url, + "src": image_url, + "alt": revised_prompt or prompt, + "title": "Generated Image", + "description": revised_prompt if revised_prompt != prompt else None, + "domain": "ai-generated", + "ratio": "auto", + "generated": True, + "prompt": prompt, + "image_count": len(images), + } + + except Exception as e: + logger.exception("Image generation failed in tool") + return { + "error": f"Image generation failed: {e!s}", + "prompt": prompt, + } + + return generate_image diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py new file mode 100644 index 000000000..d640837b5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py new file mode 100644 index 000000000..55d9b3565 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py @@ -0,0 +1,92 @@ +"""Factory for a podcast-generation tool that queues background work and returns an ID for polling.""" + +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Podcast, PodcastStatus, shielded_async_session + + +def create_generate_podcast_tool( + search_space_id: int, + db_session: AsyncSession, + thread_id: int | None = None, +): + """Create ``generate_podcast`` with bound search space and thread; DB writes use a tool-local session.""" + del db_session # writes use a fresh tool-local session, see below + + @tool + async def generate_podcast( + source_content: str, + podcast_title: str = "SurfSense Podcast", + user_prompt: str | None = None, + ) -> dict[str, Any]: + """ + Generate a podcast from the provided content. + + Use this tool when the user asks to create, generate, or make a podcast. + Common triggers include phrases like: + - "Give me a podcast about this" + - "Create a podcast from this conversation" + - "Generate a podcast summary" + - "Make a podcast about..." + - "Turn this into a podcast" + + Args: + source_content: The text content to convert into a podcast. + podcast_title: Title for the podcast (default: "SurfSense Podcast") + user_prompt: Optional instructions for podcast style, tone, or format. + + Returns: + A dictionary containing: + - status: PodcastStatus value (pending, generating, or failed) + - podcast_id: The podcast ID for polling (when status is pending or generating) + - title: The podcast title + - message: Status message (or "error" field if status is failed) + """ + try: + # One DB session per tool call so parallel invocations never share an AsyncSession. + async with shielded_async_session() as session: + podcast = Podcast( + title=podcast_title, + status=PodcastStatus.PENDING, + search_space_id=search_space_id, + thread_id=thread_id, + ) + session.add(podcast) + await session.commit() + await session.refresh(podcast) + podcast_id = podcast.id + + from app.tasks.celery_tasks.podcast_tasks import ( + generate_content_podcast_task, + ) + + task = generate_content_podcast_task.delay( + podcast_id=podcast_id, + source_content=source_content, + search_space_id=search_space_id, + user_prompt=user_prompt, + ) + + print(f"[generate_podcast] Created podcast {podcast_id}, task: {task.id}") + + return { + "status": PodcastStatus.PENDING.value, + "podcast_id": podcast_id, + "title": podcast_title, + "message": "Podcast generation started. This may take a few minutes.", + } + + except Exception as e: + error_message = str(e) + print(f"[generate_podcast] Error: {error_message}") + return { + "status": PodcastStatus.FAILED.value, + "error": error_message, + "title": podcast_title, + "podcast_id": None, + } + + return generate_podcast diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py new file mode 100644 index 000000000..385100c62 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py @@ -0,0 +1,1061 @@ +"""Factory for inline Markdown reports: optional KB sourcing, section-aware revision, short-lived DB sessions.""" + +import asyncio +import json +import logging +import re +from typing import Any + +from langchain_core.callbacks import dispatch_custom_event +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + +from app.db import Report, shielded_async_session +from app.services.connector_service import ConnectorService +from app.services.llm_service import get_document_summary_llm + +logger = logging.getLogger(__name__) + +# ─── Shared Formatting Rules ──────────────────────────────────────────────── +# Reusable formatting instructions appended to section-level and review prompts. + +_FORMATTING_RULES = """\ +- IMPORTANT: Output raw Markdown directly. Do NOT wrap the entire output in a \ +code fence (e.g. ```markdown, ````markdown, or any backtick fence). Individual \ +code examples and diagrams inside the report should still use fenced code blocks, \ +but the report itself must NOT be enclosed in one. +- Maintain proper Markdown formatting throughout. +- When including code examples, ALWAYS format them as proper fenced code blocks \ +with the correct language identifier (e.g. ```java, ```python). Code inside code \ +blocks MUST have proper line breaks and indentation — NEVER put multiple statements \ +on a single line. Each statement, brace, and logical block must be on its own line \ +with correct indentation. +- When including Mermaid diagrams, use ```mermaid fenced code blocks. Each Mermaid \ +statement MUST be on its own line — NEVER use semicolons to join multiple statements \ +on one line. For line breaks inside node labels, use
(NOT
). +- When including mathematical formulas or equations, ALWAYS use LaTeX notation. \ +NEVER use backtick code spans or Unicode symbols for math.""" + +# ─── Standard Report Footer ───────────────────────────────────────────────── +# Appended to every generated report after content generation. + +_REPORT_FOOTER = "Powered by SurfSense AI." + +# ─── Prompt: Single-Shot Report Generation ─────────────────────────────────── + +_REPORT_PROMPT = """You are an expert report writer. Generate a comprehensive Markdown report. + +**Topic:** {topic} +**Report Style:** {report_style} +{user_instructions_section} +{previous_version_section} + +**Source Content:** +{source_content} + +--- + +{length_instruction} + +Write a well-structured Markdown report with a # title, executive summary, organized sections, and conclusion. Cite facts from the source content. Be thorough and professional. + +{formatting_rules} +""" + +# ─── Prompt: Full-Document Revision (fallback when section-level fails) ────── + +_REVISION_PROMPT = """You are an expert report editor. Apply ONLY the requested changes — do NOT rewrite from scratch. + +**Topic:** {topic} +**Report Style:** {report_style} +**Modification Instructions:** {user_instructions_section} + +**Source Content (use if relevant):** +{source_content} + +--- + +**EXISTING REPORT:** + +{previous_report_content} + +--- + +{length_instruction} + +Preserve all structure and content not affected by the modification. + +{formatting_rules} +""" + +# ─── Prompt: Section-Level Revision — Identify Affected Sections ───────────── + +_IDENTIFY_SECTIONS_PROMPT = """You are analyzing a Markdown report to determine which sections need modification based on the user's request. + +**User's Modification Request:** {user_instructions} + +**Report Sections (indexed starting at 0):** +{sections_listing} + +--- + +Determine which sections need to be modified, added, or removed to fulfill the user's request. + +Return ONLY a JSON object with these fields: +- "modify": Array of section indices (0-based) that need content changes +- "add": Array of objects like {{"after_index": 2, "heading": "## New Section Title", "description": "What this section should cover"}} for new sections to insert +- "remove": Array of section indices to remove entirely (use sparingly) +- "reasoning": A brief explanation of your decisions + +Guidelines: +- If the change is GLOBAL (e.g., "change the tone", "make the whole report shorter", "translate to Spanish"), include ALL section indices in "modify". +- If the change is TARGETED (e.g., "expand the budget section", "fix the conclusion"), include ONLY the affected section indices. +- For "add a section about X", use the "add" field with the appropriate insertion point. +- Prefer modifying over removing+adding when possible. + +Return ONLY valid JSON, no markdown fences: +""" + +# ─── Prompt: Section-Level Revision — Revise a Single Section ──────────────── + +_REVISE_SECTION_PROMPT = """Revise ONLY this section based on the instructions. If the instructions don't apply, return it UNCHANGED. + +**Modification Instructions:** {user_instructions} + +**Current Section:** +{section_content} + +**Context (surrounding sections — for coherence only, do NOT output them):** +{context_sections} + +**Source Content:** +{source_content} + +--- + +Keep the same heading and heading level. Preserve content not affected by the modification. +{formatting_rules} +""" + +# ─── Prompt: New Section Generation (for section-level add) ───────────────── + +_NEW_SECTION_PROMPT = """You are an expert report writer. Write a new section to be inserted into an existing report. + +**Report Topic:** {topic} +**Report Style:** {report_style} +**Section Heading:** {heading} +**Section Goal:** {description} +**User Instructions:** {user_instructions} + +**Surrounding Context:** +{context_sections} + +**Source Content:** +{source_content} + +--- + +**Rules:** +1. Write ONLY this section, starting with the heading "{heading}". +2. Ensure the section flows naturally with the surrounding context. +3. Be comprehensive — cover the topic described above. +{formatting_rules} + +Write the new section now: +""" + + +# ─── Utility Functions ────────────────────────────────────────────────────── + + +def _strip_wrapping_code_fences(text: str) -> str: + """Remove wrapping code fences that LLMs often add around Markdown output. + + Handles patterns like: + ```markdown\\n...content...\\n``` + ````markdown\\n...content...\\n```` + ```md\\n...content...\\n``` + ```\\n...content...\\n``` + ```json\\n...content...\\n``` + Supports 3 or more backticks (LLMs escalate when content has triple-backtick blocks). + """ + stripped = text.strip() + # Match opening fence with 3+ backticks and optional language tag + m = re.match(r"^(`{3,})(?:markdown|md|json)?\s*\n", stripped) + if m: + fence = m.group(1) # e.g. "```" or "````" + if stripped.endswith(fence): + stripped = stripped[m.end() :] # remove opening fence + stripped = stripped[: -len(fence)].rstrip() # remove closing fence + return stripped + + +def _extract_metadata(content: str) -> dict[str, Any]: + """Extract metadata from generated Markdown content.""" + # Count section headings + headings = re.findall(r"^(#{1,6})\s+(.+)$", content, re.MULTILINE) + + # Word count + word_count = len(content.split()) + + # Character count + char_count = len(content) + + return { + "status": "ready", + "word_count": word_count, + "char_count": char_count, + "section_count": len(headings), + } + + +def _parse_sections(content: str) -> list[dict[str, str]]: + """Parse Markdown content into sections split by # and ## headings. + + Returns a list of dicts: [{"heading": "## Title", "body": "content..."}, ...] + Content before the first heading is captured with heading="". + ### and deeper headings are kept inside their parent ## section's body. + """ + lines = content.split("\n") + sections: list[dict[str, str]] = [] + current_heading = "" + current_body_lines: list[str] = [] + in_code_block = False + + for line in lines: + # Track code blocks to avoid matching headings inside them + stripped = line.strip() + if stripped.startswith("```"): + in_code_block = not in_code_block + + # Only split on # or ## headings (not ### or deeper) and only outside code blocks + is_section_heading = ( + not in_code_block + and re.match(r"^#{1,2}\s+", line) + and not re.match(r"^#{3,}\s+", line) + ) + + if is_section_heading: + # Save previous section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + current_heading = line.strip() + current_body_lines = [] + else: + current_body_lines.append(line) + + # Save last section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + + return sections + + +def _stitch_sections(sections: list[dict[str, str]]) -> str: + """Stitch parsed sections back into a single Markdown string.""" + parts = [] + for section in sections: + if section["heading"]: + parts.append(section["heading"]) + if section["body"]: + parts.append(section["body"]) + return "\n\n".join(parts) + + +# ─── Async Generation Helpers ─────────────────────────────────────────────── + + +async def _revise_with_sections( + llm: Any, + parent_content: str, + user_instructions: str, + source_content: str, + topic: str, + report_style: str, +) -> str | None: + """Section-level revision: identify affected sections and revise only those. + + Unchanged sections are kept byte-for-byte identical. + Returns the revised content, or None to trigger full-document revision fallback. + """ + # Parse report into sections + sections = _parse_sections(parent_content) + if len(sections) < 2: + logger.info( + "[generate_report] Too few sections for section-level revision, using full revision" + ) + return None + + # Build a sections listing for the LLM + sections_listing = "" + for i, sec in enumerate(sections): + heading = sec["heading"] or "(preamble — content before first heading)" + body_preview = ( + sec["body"][:200] + "..." if len(sec["body"]) > 200 else sec["body"] + ) + sections_listing += f"\n[{i}] {heading}\n Preview: {body_preview}\n" + + # Step 1: Ask LLM which sections need modification + identify_prompt = _IDENTIFY_SECTIONS_PROMPT.format( + user_instructions=user_instructions, + sections_listing=sections_listing, + ) + + try: + response = await llm.ainvoke([HumanMessage(content=identify_prompt)]) + raw = response.content + if not raw or not isinstance(raw, str): + return None + + raw = _strip_wrapping_code_fences(raw).strip() + json_match = re.search(r"\{[\s\S]*\}", raw) + if json_match: + raw = json_match.group(0) + + plan = json.loads(raw) + modify_indices: list[int] = plan.get("modify", []) + add_sections: list[dict[str, Any]] = plan.get("add", []) + remove_indices: list[int] = plan.get("remove", []) + reasoning = plan.get("reasoning", "") + + logger.info( + f"[generate_report] Section-level revision plan: " + f"modify={modify_indices}, add={len(add_sections)}, " + f"remove={remove_indices}, reasoning={reasoning}" + ) + except Exception: + logger.warning( + "[generate_report] Failed to identify sections for revision, " + "falling back to full revision", + exc_info=True, + ) + return None + + # If ALL sections need modification, full revision is more efficient and coherent + if len(modify_indices) >= len(sections): + logger.info( + "[generate_report] All sections need modification, deferring to full revision" + ) + return None + + # Compute total operations for progress tracking + total_ops = len(modify_indices) + len(add_sections) + current_op = 0 + + # Emit plan summary + parts = [] + if modify_indices: + parts.append( + f"modifying {len(modify_indices)} section{'s' if len(modify_indices) > 1 else ''}" + ) + if add_sections: + parts.append( + f"adding {len(add_sections)} new section{'s' if len(add_sections) > 1 else ''}" + ) + if remove_indices: + parts.append( + f"removing {len(remove_indices)} section{'s' if len(remove_indices) > 1 else ''}" + ) + plan_summary = ", ".join(parts) if parts else "no changes needed" + + dispatch_custom_event( + "report_progress", + { + "phase": "revision_plan", + "message": plan_summary.capitalize(), + "modify_count": len(modify_indices), + "add_count": len(add_sections), + "remove_count": len(remove_indices), + "total_ops": total_ops, + }, + ) + + # Step 2: Revise only the affected sections + revised_sections = list(sections) # shallow copy — unmodified sections stay as-is + + for idx in modify_indices: + if idx < 0 or idx >= len(sections): + continue + + current_op += 1 + sec = sections[idx] + + # Extract plain section name (strip markdown heading markers) + section_name = ( + re.sub(r"^#+\s*", "", sec["heading"]).strip() + if sec["heading"] + else "Preamble" + ) + dispatch_custom_event( + "report_progress", + { + "phase": "revising_section", + "message": f"Revising: {section_name} ({current_op}/{total_ops})...", + }, + ) + + section_content = ( + f"{sec['heading']}\n\n{sec['body']}" if sec["heading"] else sec["body"] + ) + + # Build context from surrounding sections + context_parts = [] + if idx > 0: + prev = sections[idx - 1] + prev_preview = prev["body"][:300] + ( + "..." if len(prev["body"]) > 300 else "" + ) + context_parts.append( + f"**Previous section:** {prev['heading']}\n{prev_preview}" + ) + if idx < len(sections) - 1: + nxt = sections[idx + 1] + nxt_preview = nxt["body"][:300] + ("..." if len(nxt["body"]) > 300 else "") + context_parts.append(f"**Next section:** {nxt['heading']}\n{nxt_preview}") + context = ( + "\n\n".join(context_parts) if context_parts else "(No surrounding sections)" + ) + + revise_prompt = _REVISE_SECTION_PROMPT.format( + user_instructions=user_instructions, + section_content=section_content, + context_sections=context, + source_content=source_content[:40000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=revise_prompt)]) + revised_text = resp.content + if revised_text and isinstance(revised_text, str): + revised_text = _strip_wrapping_code_fences(revised_text).strip() + # Parse the LLM output back into heading + body + revised_parsed = _parse_sections(revised_text) + if revised_parsed: + revised_sections[idx] = revised_parsed[0] + else: + revised_sections[idx] = { + "heading": sec["heading"], + "body": revised_text, + } + + logger.info(f"[generate_report] Revised section [{idx}]: {sec['heading']}") + + # Step 3: Handle new section additions (insert in reverse order to preserve indices) + for add_info in sorted( + add_sections, + key=lambda x: x.get("after_index", len(revised_sections) - 1), + reverse=True, + ): + current_op += 1 + after_idx = add_info.get("after_index", len(revised_sections) - 1) + heading = add_info.get("heading", "## New Section") + description = add_info.get("description", "") + + # Extract plain section name for progress display + plain_heading = re.sub(r"^#+\s*", "", heading).strip() + dispatch_custom_event( + "report_progress", + { + "phase": "adding_section", + "message": f"Adding: {plain_heading} ({current_op}/{total_ops})...", + }, + ) + + # Build context from the surrounding sections at the insertion point + ctx_parts = [] + if 0 <= after_idx < len(revised_sections): + before_sec = revised_sections[after_idx] + ctx_parts.append( + f"**Section before:** {before_sec['heading']}\n{before_sec['body'][:300]}" + ) + insert_idx = min(after_idx + 1, len(revised_sections)) + if insert_idx < len(revised_sections): + after_sec = revised_sections[insert_idx] + ctx_parts.append( + f"**Section after:** {after_sec['heading']}\n{after_sec['body'][:300]}" + ) + + new_prompt = _NEW_SECTION_PROMPT.format( + topic=topic, + report_style=report_style, + heading=heading, + description=description, + user_instructions=user_instructions, + context_sections="\n\n".join(ctx_parts) if ctx_parts else "(None)", + source_content=source_content[:30000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=new_prompt)]) + new_content = resp.content + if new_content and isinstance(new_content, str): + new_content = _strip_wrapping_code_fences(new_content).strip() + new_parsed = _parse_sections(new_content) + if new_parsed: + revised_sections.insert(insert_idx, new_parsed[0]) + else: + revised_sections.insert( + insert_idx, + { + "heading": heading, + "body": new_content, + }, + ) + + logger.info( + f"[generate_report] Added new section after [{after_idx}]: {heading}" + ) + + # Step 4: Handle removals (reverse order to preserve indices) + for idx in sorted(remove_indices, reverse=True): + if 0 <= idx < len(revised_sections): + logger.info( + f"[generate_report] Removed section [{idx}]: " + f"{revised_sections[idx]['heading']}" + ) + revised_sections.pop(idx) + + return _stitch_sections(revised_sections) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + +def create_generate_report_tool( + search_space_id: int, + thread_id: int | None = None, + connector_service: ConnectorService | None = None, + available_connectors: list[str] | None = None, + available_document_types: list[str] | None = None, +): + """ + Factory function to create the generate_report tool with injected dependencies. + + The tool generates a Markdown report inline using the search space's + document summary LLM, saves it to the database, and returns immediately. + + Uses short-lived database sessions for each DB operation so no connection + is held during the long LLM API call. + + Generation strategies: + - New reports: single-shot generation (1 LLM call) + - Revisions (targeted edits): section-level (unchanged sections preserved) + - Revisions (global changes): full-document revision fallback + + Source strategies: + - "provided"/"conversation": use only the supplied source_content + - "kb_search": search the knowledge base internally using targeted queries + - "auto": use source_content if sufficient, otherwise fall back to KB search + + Args: + search_space_id: The user's search space ID + thread_id: The chat thread ID for associating the report + connector_service: Optional connector service for internal KB search. + When provided, the tool can search the knowledge base internally + (used by the "kb_search" and "auto" source strategies). + available_connectors: Optional list of connector types available in the + search space (used to scope internal KB searches). + + Returns: + A configured tool function for generating reports + """ + + @tool + async def generate_report( + topic: str, + source_content: str = "", + source_strategy: str = "provided", + search_queries: list[str] | None = None, + report_style: str = "detailed", + user_instructions: str | None = None, + parent_report_id: int | None = None, + ) -> dict[str, Any]: + """ + Generate a structured Markdown report artifact from provided content. + + Use this tool when the user asks to create, generate, write, produce, + draft, or summarize into a report-style deliverable. + + Trigger classes include: + - Direct trigger words WITH creation/modification verb: report, + document, memo, letter, template, article, guide, blog post, + one-pager, briefing, comprehensive guide. + - Creation-intent phrases: "write a report", "generate a document", + "draft a summary", "create an executive summary". + - Modification-intent phrases: "revise the report", "update the + report", "make it shorter", "add a section about X", "expand the + budget section", "rewrite in formal tone". + + IMPORTANT — what does NOT count as "asking for a report": + - Questions or discussion about a report or its topic are NOT report + requests. Respond to these conversationally in chat. + Examples: "What other examples to put there?", "What else could be + added?", "Can you explain section 2?", "Is the data accurate?", + "What's missing?", "How could this be improved?", "What other + topics are related?" + - Quick summary requests, explanations, or follow-up questions. + - The test: Does the message contain a creation/modification VERB + (write, create, generate, draft, add, revise, update, expand, + rewrite, make) directed at producing a deliverable? If no verb + → answer in chat. + + FORMAT/EXPORT RULE: + - Always generate the report content in Markdown. + - If the user requests DOCX/Word/PDF or another file format, export + from the generated Markdown report. + + SOURCE STRATEGY (how to collect source material): + - source_strategy="conversation" — The conversation already has + enough context (prior Q&A, filesystem exploration, pasted text, + uploaded files, scraped webpages). Pass a thorough summary as + source_content. + - source_strategy="kb_search" — Search the knowledge base + internally. Provide 1-5 targeted search_queries. The tool + handles searching internally — do NOT manually read and dump + /documents/ files into source_content. + - source_strategy="provided" — Use only what is in source_content + (default, backward-compatible). + - source_strategy="auto" — Use source_content if it has enough + material; otherwise fall back to internal KB search using + search_queries. + + CONVERSATION REUSE (HIGH PRIORITY): + - If the user has been asking questions in this chat and the + conversation contains substantive answers/discussion on the + topic, prefer source_strategy="conversation" with a thorough + summary of the full chat history as source_content. + - The user's prior questions and your answers ARE the source + material. Do NOT redundantly search the knowledge base for + information that is already in the chat. + + VERSIONING — parent_report_id: + - Set parent_report_id when the user wants to MODIFY, REVISE, + IMPROVE, UPDATE, EXPAND, or ADD CONTENT TO an existing report + that was already generated in this conversation. + - This includes both explicit AND implicit modification requests. + If the user references the existing report using words like "it", + "this", "here", "the report", or clearly refers to a previously + generated report, treat it as a revision request. + - The value must be the report_id from a previous generate_report + result in this same conversation. + - Do NOT set parent_report_id when: + * The user asks for a report on a completely NEW/DIFFERENT topic + * The user says "generate another report" (new report, not revision) + * There is no prior report to reference + + Examples of when to SET parent_report_id: + User: "Make that report shorter" → parent_report_id = + User: "Add a cost analysis section to the report" → parent_report_id = + User: "Rewrite the report in a more formal tone" → parent_report_id = + User: "I want more details about pricing in here" → parent_report_id = + User: "Include more examples" → parent_report_id = + User: "Can you also cover nutrition in this?" → parent_report_id = + User: "Make it more detailed" → parent_report_id = + User: "Not bad, but expand on the budget section" → parent_report_id = + User: "Also mention the competitor landscape" → parent_report_id = + + Examples of when to LEAVE parent_report_id as None: + User: "Generate a report on climate change" → None (new topic) + User: "Write me a report about the budget" → None (new topic) + User: "Create another report, this time about marketing" → None + User: "Now write one about travel trends in Europe" → None (new topic) + + Args: + topic: Short title for the report (max ~8 words). + source_content: Text to base the report on. Can be empty when + using source_strategy="kb_search". + source_strategy: How to collect source material. One of + "provided", "conversation", "kb_search", or "auto". + search_queries: When source_strategy is "kb_search" or "auto", + provide 1-5 targeted search queries for the knowledge base. + These should be specific, not just the topic repeated. + report_style: "detailed", "deep_research", or "brief". + user_instructions: Optional focus or modification instructions. + When revising (parent_report_id set), describe WHAT TO CHANGE. + parent_report_id: ID of a previous report to revise (creates new + version in the same version group). + + Returns: + Dict with status, report_id, title, word_count, and message. + """ + # Initialize version tracking variables (used by _save_failed_report closure) + parent_report_content: str | None = None + report_group_id: int | None = None + + async def _save_failed_report(error_msg: str) -> int | None: + """Persist a failed report row using a short-lived session.""" + try: + async with shielded_async_session() as session: + failed_report = Report( + title=topic, + content=None, + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style=report_style, + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + session.add(failed_report) + await session.commit() + await session.refresh(failed_report) + # If this is a new group (v1 failed), set group to self + if not failed_report.report_group_id: + failed_report.report_group_id = failed_report.id + await session.commit() + logger.info( + f"[generate_report] Saved failed report {failed_report.id}: {error_msg}" + ) + return failed_report.id + except Exception: + logger.exception( + "[generate_report] Could not persist failed report row" + ) + return None + + try: + # ── Phase 1: READ (short-lived session) ────────────────────── + # Fetch parent report and LLM config, then close the session + # so no DB connection is held during the long LLM call. + async with shielded_async_session() as read_session: + if parent_report_id: + parent_report = await read_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + parent_report_content = parent_report.content + logger.info( + f"[generate_report] Creating new version from parent {parent_report_id} " + f"(group {report_group_id})" + ) + else: + logger.warning( + f"[generate_report] parent_report_id={parent_report_id} not found, " + "creating standalone report" + ) + + llm = await get_document_summary_llm(read_session, search_space_id) + # read_session closed — connection returned to pool + + if not llm: + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # Build the user instructions string + user_instructions_section = "" + if user_instructions: + user_instructions_section = ( + f"**Additional Instructions:** {user_instructions}" + ) + + # ── Phase 1b: SOURCE COLLECTION (smart KB search) ──────────── + # Decide whether to augment source_content with KB search results. + effective_source = source_content or "" + + strategy = (source_strategy or "provided").lower().strip() + + needs_kb_search = False + if strategy == "kb_search": + needs_kb_search = True + elif strategy == "auto": + # Heuristic: if source_content has fewer than 200 words, + # it's likely insufficient — augment with KB search. + word_count_estimate = len(effective_source.split()) + if word_count_estimate < 200: + needs_kb_search = True + logger.info( + f"[generate_report] auto strategy: source has ~{word_count_estimate} words, " + "triggering KB search" + ) + # "provided" and "conversation" → use source_content as-is + + if needs_kb_search and connector_service and search_queries: + query_count = min(len(search_queries), 5) + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search", + "message": f"Searching knowledge base ({query_count} queries)...", + }, + ) + logger.info( + f"[generate_report] Running internal KB search with " + f"{query_count} queries: {search_queries[:5]}" + ) + try: + from .knowledge_base import search_knowledge_base_async + + # Run all queries in parallel, each with its own session + async def _run_single_query(q: str) -> str: + async with shielded_async_session() as kb_session: + kb_connector_svc = ConnectorService( + kb_session, search_space_id + ) + return await search_knowledge_base_async( + query=q, + search_space_id=search_space_id, + db_session=kb_session, + connector_service=kb_connector_svc, + top_k=10, + available_connectors=available_connectors, + available_document_types=available_document_types, + ) + + kb_results = await asyncio.gather( + *[_run_single_query(q) for q in search_queries[:5]] + ) + + # Merge non-empty results into source_content + kb_text_parts = [r for r in kb_results if r and r.strip()] + if kb_text_parts: + kb_combined = "\n\n---\n\n".join(kb_text_parts) + if effective_source.strip(): + effective_source = ( + effective_source + + "\n\n--- Knowledge Base Search Results ---\n\n" + + kb_combined + ) + else: + effective_source = kb_combined + + # Count docs found (rough: count tags) + doc_count = kb_combined.count("") + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": f"Found {doc_count} relevant documents" + if doc_count + else f"Found results from {len(kb_text_parts)} queries", + }, + ) + logger.info( + f"[generate_report] KB search added ~{len(kb_combined)} chars " + f"from {len(kb_text_parts)} queries" + ) + else: + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": "No results found in knowledge base", + }, + ) + logger.info("[generate_report] KB search returned no results") + + except Exception as e: + logger.warning( + f"[generate_report] Internal KB search failed: {e}. " + "Proceeding with existing source_content." + ) + elif needs_kb_search and not connector_service: + logger.warning( + "[generate_report] KB search requested but connector_service " + "not available. Using source_content as-is." + ) + elif needs_kb_search and not search_queries: + logger.warning( + "[generate_report] KB search requested but no search_queries " + "provided. Using source_content as-is." + ) + + capped_source = effective_source[:100000] # Cap source content + + # Length constraint — only when user explicitly asks for brevity + length_instruction = "" + if report_style == "brief": + length_instruction = ( + "**LENGTH CONSTRAINT (MANDATORY):** The user wants a SHORT report. " + "Keep it concise — aim for ~400 words (~1 page) unless a different " + "length is specified in the Additional Instructions above. " + "Prioritize brevity over thoroughness. Do NOT write a long report." + ) + + # ── Phase 2: LLM GENERATION (no DB connection held) ────────── + + report_content: str | None = None + + if parent_report_content: + # ─── REVISION MODE ─────────────────────────────────────── + # Strategy: Try section-level revision first (preserves + # unchanged sections byte-for-byte). Falls back to full- + # document revision if section identification fails or if + # all sections need changes. + dispatch_custom_event( + "report_progress", + { + "phase": "revision_start", + "message": "Analyzing sections to modify...", + }, + ) + logger.info( + "[generate_report] Revision mode — attempting section-level revision" + ) + report_content = await _revise_with_sections( + llm=llm, + parent_content=parent_report_content, + user_instructions=user_instructions + or "Improve and refine the report.", + source_content=capped_source, + topic=topic, + report_style=report_style, + ) + + if report_content is None: + # Fallback: full-document revision + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Rewriting your full report"}, + ) + logger.info( + "[generate_report] Section-level revision deferred, " + "using full-document revision" + ) + prompt = _REVISION_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section + or "Improve and refine the report.", + source_content=capped_source, + previous_report_content=parent_report_content, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + else: + # ─── NEW REPORT MODE ───────────────────────────────────── + # Single-shot generation: one LLM call produces the full + # report. Fast, globally coherent, and cost-efficient. + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Writing your report"}, + ) + logger.info( + "[generate_report] New report — using single-shot generation" + ) + prompt = _REPORT_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section, + previous_version_section="", + source_content=capped_source, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + # ── Validate LLM output ────────────────────────────────────── + + if not report_content or not isinstance(report_content, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # LLMs often wrap output in ```markdown ... ``` fences — strip them + report_content = _strip_wrapping_code_fences(report_content) + + if not report_content: + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # Strip any existing footer(s) carried over from parent version(s) + while report_content.rstrip().endswith(_REPORT_FOOTER): + idx = report_content.rstrip().rfind(_REPORT_FOOTER) + report_content = report_content[:idx].rstrip() + if report_content.rstrip().endswith("---"): + report_content = report_content.rstrip()[:-3].rstrip() + + # Append exactly one standard disclaimer + report_content += "\n\n---\n\n" + _REPORT_FOOTER + + # Extract metadata (includes "status": "ready") + metadata = _extract_metadata(report_content) + + # ── Phase 3: WRITE (short-lived session) ───────────────────── + # Save the report to the database, then close the session. + async with shielded_async_session() as write_session: + report = Report( + title=topic, + content=report_content, + report_metadata=metadata, + report_style=report_style, + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + write_session.add(report) + await write_session.commit() + await write_session.refresh(report) + + # If this is a brand-new report (v1), set report_group_id = own id + if not report.report_group_id: + report.report_group_id = report.id + await write_session.commit() + + saved_report_id = report.id + saved_group_id = report.report_group_id + # write_session closed — connection returned to pool + + logger.info( + f"[generate_report] Created report {saved_report_id} " + f"(group={saved_group_id}): " + f"{metadata.get('word_count', 0)} words, " + f"{metadata.get('section_count', 0)} sections" + ) + + return { + "status": "ready", + "report_id": saved_report_id, + "title": topic, + "word_count": metadata.get("word_count", 0), + "is_revision": bool(parent_report_content), + "report_markdown": report_content, + "message": f"Report generated successfully: {topic}", + } + + except Exception as e: + error_message = str(e) + logger.exception(f"[generate_report] Error: {error_message}") + report_id = await _save_failed_report(error_message) + + return { + "status": "failed", + "error": error_message, + "report_id": report_id, + "title": topic, + } + + return generate_report diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py new file mode 100644 index 000000000..ece3ce241 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py @@ -0,0 +1,799 @@ +"""Resume as Typst: LLM fills the body; backend prepends a template from ``_TEMPLATES`` and compiles.""" + +import io +import logging +import re +from datetime import UTC, datetime +from typing import Any + +import pypdf +import typst +from langchain_core.callbacks import dispatch_custom_event +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + +from app.db import Report, shielded_async_session +from app.services.llm_service import get_document_summary_llm + +logger = logging.getLogger(__name__) + + +# ─── Template Registry ─────────────────────────────────────────────────────── +# Each template defines: +# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders +# component_reference - component docs shown to the LLM +# rules - generation rules for the LLM + +_TEMPLATES: dict[str, dict[str, str]] = { + "classic": { + "header": """\ +#import "@preview/rendercv:0.3.0": * + +#show: rendercv.with( + name: "{name}", + title: "{name} - Resume", + footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }}, + top-note: [ #emph[Last updated in {month_name} {year}] ], + locale-catalog-language: "en", + text-direction: ltr, + page-size: "us-letter", + page-top-margin: 0.7in, + page-bottom-margin: 0.7in, + page-left-margin: 0.7in, + page-right-margin: 0.7in, + page-show-footer: false, + page-show-top-note: true, + colors-body: rgb(0, 0, 0), + colors-name: rgb(0, 0, 0), + colors-headline: rgb(0, 0, 0), + colors-connections: rgb(0, 0, 0), + colors-section-titles: rgb(0, 0, 0), + colors-links: rgb(0, 0, 0), + colors-footer: rgb(128, 128, 128), + colors-top-note: rgb(128, 128, 128), + typography-line-spacing: 0.6em, + typography-alignment: "justified", + typography-date-and-location-column-alignment: right, + typography-font-family-body: "XCharter", + typography-font-family-name: "XCharter", + typography-font-family-headline: "XCharter", + typography-font-family-connections: "XCharter", + typography-font-family-section-titles: "XCharter", + typography-font-size-body: 10pt, + typography-font-size-name: 25pt, + typography-font-size-headline: 10pt, + typography-font-size-connections: 10pt, + typography-font-size-section-titles: 1.2em, + typography-small-caps-name: false, + typography-small-caps-headline: false, + typography-small-caps-connections: false, + typography-small-caps-section-titles: false, + typography-bold-name: false, + typography-bold-headline: false, + typography-bold-connections: false, + typography-bold-section-titles: true, + links-underline: true, + links-show-external-link-icon: false, + header-alignment: center, + header-photo-width: 3.5cm, + header-space-below-name: 0.7cm, + header-space-below-headline: 0.7cm, + header-space-below-connections: 0.7cm, + header-connections-hyperlink: true, + header-connections-show-icons: false, + header-connections-display-urls-instead-of-usernames: true, + header-connections-separator: "|", + header-connections-space-between-connections: 0.5cm, + section-titles-type: "with_full_line", + section-titles-line-thickness: 0.5pt, + section-titles-space-above: 0.5cm, + section-titles-space-below: 0.3cm, + sections-allow-page-break: true, + sections-space-between-text-based-entries: 0.15cm, + sections-space-between-regular-entries: 0.42cm, + entries-date-and-location-width: 4.15cm, + entries-side-space: 0cm, + entries-space-between-columns: 0.1cm, + entries-allow-page-break: false, + entries-short-second-row: false, + entries-degree-width: 1cm, + entries-summary-space-left: 0cm, + entries-summary-space-above: 0.08cm, + entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-space-left: 0cm, + entries-highlights-space-above: 0.08cm, + entries-highlights-space-between-items: 0.02cm, + entries-highlights-space-between-bullet-and-text: 0.3em, + date: datetime( + year: {year}, + month: {month}, + day: {day}, + ), +) + +""", + "component_reference": """\ +Available components (use ONLY these): + += Full Name // Top-level heading — person's full name + +#connections( // Contact info row (pipe-separated) + [City, Country], + [#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]], + [#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]], + [#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]], +) + +== Section Title // Section heading (arbitrary name) + +#regular-entry( // Work experience, projects, publications, etc. + [ + #strong[Role/Title], Company Name -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - Achievement or responsibility + - Another bullet point + ], +) + +#education-entry( // Education entries + [ + #strong[Institution], Degree in Field -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - GPA, honours, relevant coursework + ], +) + +#summary([Short paragraph summary]) // Optional summary inside an entry +#content-area([Free-form content]) // Freeform text block + +For skills sections, use one bullet per category label: +- #strong[Category:] item1, item2, item3 + +For simple list sections (e.g. Honors), use plain bullet points: +- Item one +- Item two +""", + "rules": """\ +RULES: +- Do NOT include any #import or #show lines. Start directly with = Full Name. +- Output ONLY valid Typst content. No explanatory text before or after. +- Do NOT wrap output in ```typst code fences. +- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate. +- Escape @ symbols inside link labels with a backslash: email\\@example.com +- Escape forward slashes in link display text: linkedin.com\\/in\\/user +- Every section MUST use == heading. +- Use #regular-entry() for experience, projects, publications, certifications, and similar entries. +- Use #education-entry() for education. +- For skills sections, use one bullet line per category with a bold label. +- Keep content professional, concise, and achievement-oriented. +- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.). +- This template works for ALL professions — adapt sections to the user's field. +- Default behavior should prioritize concise one-page content. +""", + }, +} + +DEFAULT_TEMPLATE = "classic" +MIN_RESUME_PAGES = 1 +MAX_RESUME_PAGES = 5 +MAX_COMPRESSION_ATTEMPTS = 2 + + +# ─── Template Helpers ───────────────────────────────────────────────────────── + + +def _get_template(template_id: str | None = None) -> dict[str, str]: + """Get a template by ID, falling back to default.""" + return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE]) + + +_MONTH_NAMES = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + + +def _build_header(template: dict[str, str], name: str) -> str: + """Build the template header with the person's name and current date.""" + now = datetime.now(tz=UTC) + return ( + template["header"] + .replace("{name}", name) + .replace("{year}", str(now.year)) + .replace("{month}", str(now.month)) + .replace("{day}", str(now.day)) + .replace("{month_name}", _MONTH_NAMES[now.month]) + ) + + +def _strip_header(full_source: str) -> str: + """Strip the import + show rule from stored source to get the body only. + + Finds the closing parenthesis of the rendercv.with(...) block by tracking + nesting depth, then returns everything after it. + """ + show_match = re.search(r"#show:\s*rendercv\.with\(", full_source) + if not show_match: + return full_source + + start = show_match.end() + depth = 1 + i = start + while i < len(full_source) and depth > 0: + if full_source[i] == "(": + depth += 1 + elif full_source[i] == ")": + depth -= 1 + i += 1 + + return full_source[i:].lstrip("\n") + + +def _extract_name(body: str) -> str | None: + """Extract the person's full name from the = heading in the body.""" + match = re.search(r"^=\s+(.+)$", body, re.MULTILINE) + return match.group(1).strip() if match else None + + +def _strip_imports(body: str) -> str: + """Remove any #import or #show lines the LLM might accidentally include.""" + lines = body.split("\n") + cleaned: list[str] = [] + skip_show = False + depth = 0 + + for line in lines: + stripped = line.strip() + + if stripped.startswith("#import"): + continue + + if skip_show: + depth += stripped.count("(") - stripped.count(")") + if depth <= 0: + skip_show = False + continue + + if stripped.startswith("#show:") and "rendercv" in stripped: + depth = stripped.count("(") - stripped.count(")") + if depth > 0: + skip_show = True + continue + + cleaned.append(line) + + result = "\n".join(cleaned).strip() + return result + + +def _build_llm_reference(template: dict[str, str]) -> str: + """Build the LLM prompt reference from a template.""" + return f"""\ +You MUST output valid Typst content for a resume. +Do NOT include any #import or #show lines — those are handled automatically. +Start directly with the = Full Name heading. + +{template["component_reference"]} + +{template["rules"]}""" + + +# ─── Prompts ───────────────────────────────────────────────────────────────── + +_RESUME_PROMPT = """\ +You are an expert resume writer. Generate professional resume content as Typst markup. + +{llm_reference} + +**User Information:** +{user_info} + +**Target Maximum Pages:** {max_pages} + +{user_instructions_section} + +Generate the resume content now (starting with = Full Name): +""" + +_REVISION_PROMPT = """\ +You are an expert resume editor. Modify the existing resume according to the instructions. +Apply ONLY the requested changes — do NOT rewrite sections that are not affected. + +{llm_reference} + +**Target Maximum Pages:** {max_pages} + +**Modification Instructions:** {user_instructions} + +**EXISTING RESUME CONTENT:** + +{previous_content} + +--- + +Output the complete, updated resume content with the changes applied (starting with = Full Name): +""" + +_FIX_COMPILE_PROMPT = """\ +The resume content you generated failed to compile. Fix the error while preserving all content. + +{llm_reference} + +**Compilation Error:** +{error} + +**Full Typst Source (for context — error line numbers refer to this):** +{full_source} + +**Your content starts after the template header. Output ONLY the content portion \ +(starting with = Full Name), NOT the #import or #show rule:** +""" + +_COMPRESS_TO_PAGE_LIMIT_PROMPT = """\ +The resume compiles, but it exceeds the maximum allowed page count. +Compress the resume while preserving high-impact accomplishments and role relevance. + +{llm_reference} + +**Target Maximum Pages:** {max_pages} +**Current Page Count:** {actual_pages} +**Compression Attempt:** {attempt_number} + +Compression priorities (in this order): +1) Keep recent, high-impact, role-relevant bullets. +2) Remove low-impact or redundant bullets. +3) Shorten verbose wording while preserving meaning. +4) Trim older or less relevant details before recent ones. + +Return the complete updated Typst content (starting with = Full Name), and keep it at or below the target pages. + +**EXISTING RESUME CONTENT:** +{previous_content} +""" + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + + +def _strip_typst_fences(text: str) -> str: + """Remove wrapping ```typst ... ``` fences that LLMs sometimes add.""" + stripped = text.strip() + m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped) + if m: + fence = m.group(1) + if stripped.endswith(fence): + stripped = stripped[m.end() :] + stripped = stripped[: -len(fence)].rstrip() + return stripped + + +def _compile_typst(source: str) -> bytes: + """Compile Typst source to PDF bytes. Raises on failure.""" + return typst.compile(source.encode("utf-8")) + + +def _count_pdf_pages(pdf_bytes: bytes) -> int: + """Count the number of pages in compiled PDF bytes.""" + with io.BytesIO(pdf_bytes) as pdf_stream: + reader = pypdf.PdfReader(pdf_stream) + return len(reader.pages) + + +def _validate_max_pages(max_pages: int) -> int: + """Validate and normalize max_pages input.""" + if MIN_RESUME_PAGES <= max_pages <= MAX_RESUME_PAGES: + return max_pages + msg = ( + f"max_pages must be between {MIN_RESUME_PAGES} and " + f"{MAX_RESUME_PAGES}. Received: {max_pages}" + ) + raise ValueError(msg) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + +def create_generate_resume_tool( + search_space_id: int, + thread_id: int | None = None, +): + """ + Factory function to create the generate_resume tool. + + Generates a Typst-based resume, validates it via compilation, + and stores the source in the Report table with content_type='typst'. + The LLM generates only the content body; the template header is + prepended by the backend. + """ + + @tool + async def generate_resume( + user_info: str, + user_instructions: str | None = None, + parent_report_id: int | None = None, + max_pages: int = 1, + ) -> dict[str, Any]: + """ + Generate a professional resume as a Typst document. + + Use this tool when the user asks to create, build, generate, write, + or draft a resume or CV. Also use it when the user wants to modify, + update, or revise an existing resume generated in this conversation. + + Trigger phrases include: + - "build me a resume", "create my resume", "generate a CV" + - "update my resume", "change my title", "add my new job" + - "make my resume more concise", "reformat my resume" + + Do NOT use this tool for: + - General questions about resumes or career advice + - Reviewing or critiquing a resume without changes + - Cover letters (use generate_report instead) + + VERSIONING — parent_report_id: + - Set parent_report_id when the user wants to MODIFY an existing + resume that was already generated in this conversation. + - Leave as None for new resumes. + + Args: + user_info: The user's resume content — work experience, + education, skills, contact info, etc. Can be structured + or unstructured text. + user_instructions: Optional style or content preferences + (e.g. "emphasize leadership", "keep it to one page", + "use a modern style"). For revisions, describe what to change. + parent_report_id: ID of a previous resume to revise (creates + new version in the same version group). + max_pages: Maximum number of pages for the generated resume. + Defaults to 1. Allowed range: 1-5. + + Returns: + Dict with status, report_id, title, and content_type. + """ + report_group_id: int | None = None + parent_content: str | None = None + + template = _get_template() + llm_reference = _build_llm_reference(template) + + async def _save_failed_report(error_msg: str) -> int | None: + try: + async with shielded_async_session() as session: + failed = Report( + title="Resume", + content=None, + content_type="typst", + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + session.add(failed) + await session.commit() + await session.refresh(failed) + if not failed.report_group_id: + failed.report_group_id = failed.id + await session.commit() + logger.info( + f"[generate_resume] Saved failed report {failed.id}: {error_msg}" + ) + return failed.id + except Exception: + logger.exception( + "[generate_resume] Could not persist failed report row" + ) + return None + + try: + try: + validated_max_pages = _validate_max_pages(max_pages) + except ValueError as e: + error_msg = str(e) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 1: READ ───────────────────────────────────────────── + async with shielded_async_session() as read_session: + if parent_report_id: + parent_report = await read_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + parent_content = parent_report.content + logger.info( + f"[generate_resume] Revising from parent {parent_report_id} " + f"(group {report_group_id})" + ) + + llm = await get_document_summary_llm(read_session, search_space_id) + + if not llm: + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 2: LLM GENERATION ─────────────────────────────────── + + user_instructions_section = "" + if user_instructions: + user_instructions_section = ( + f"**Additional Instructions:** {user_instructions}" + ) + + if parent_content: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Updating your resume"}, + ) + parent_body = _strip_header(parent_content) + prompt = _REVISION_PROMPT.format( + llm_reference=llm_reference, + max_pages=validated_max_pages, + user_instructions=user_instructions + or "Improve and refine the resume.", + previous_content=parent_body, + ) + else: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Building your resume"}, + ) + prompt = _RESUME_PROMPT.format( + llm_reference=llm_reference, + user_info=user_info, + max_pages=validated_max_pages, + user_instructions_section=user_instructions_section, + ) + + response = await llm.ainvoke([HumanMessage(content=prompt)]) + body = response.content + + if not body or not isinstance(body, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + body = _strip_typst_fences(body) + body = _strip_imports(body) + + # ── Phase 3: ASSEMBLE + COMPILE ─────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "compiling", "message": "Compiling resume..."}, + ) + + name = _extract_name(body) or "Resume" + typst_source = "" + actual_pages = 0 + compression_attempts = 0 + target_page_met = False + + for compression_round in range(MAX_COMPRESSION_ATTEMPTS + 1): + header = _build_header(template, name) + typst_source = header + body + compile_error: str | None = None + pdf_bytes: bytes | None = None + + for compile_attempt in range(2): + try: + pdf_bytes = _compile_typst(typst_source) + compile_error = None + break + except Exception as e: + compile_error = str(e) + logger.warning( + "[generate_resume] Compile attempt %s failed: %s", + compile_attempt + 1, + compile_error, + ) + + if compile_attempt == 0: + dispatch_custom_event( + "report_progress", + { + "phase": "fixing", + "message": "Fixing compilation issue...", + }, + ) + fix_prompt = _FIX_COMPILE_PROMPT.format( + llm_reference=llm_reference, + error=compile_error, + full_source=typst_source, + ) + fix_response = await llm.ainvoke( + [HumanMessage(content=fix_prompt)] + ) + if fix_response.content and isinstance( + fix_response.content, str + ): + body = _strip_typst_fences(fix_response.content) + body = _strip_imports(body) + name = _extract_name(body) or name + header = _build_header(template, name) + typst_source = header + body + + if compile_error or not pdf_bytes: + error_msg = ( + "Typst compilation failed after 2 attempts: " + f"{compile_error or 'Unknown compile error'}" + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + actual_pages = _count_pdf_pages(pdf_bytes) + if actual_pages <= validated_max_pages: + target_page_met = True + break + + if compression_round >= MAX_COMPRESSION_ATTEMPTS: + break + + compression_attempts += 1 + dispatch_custom_event( + "report_progress", + { + "phase": "compressing", + "message": f"Condensing resume to {validated_max_pages} page(s)...", + }, + ) + compress_prompt = _COMPRESS_TO_PAGE_LIMIT_PROMPT.format( + llm_reference=llm_reference, + max_pages=validated_max_pages, + actual_pages=actual_pages, + attempt_number=compression_attempts, + previous_content=body, + ) + compress_response = await llm.ainvoke( + [HumanMessage(content=compress_prompt)] + ) + if not compress_response.content or not isinstance( + compress_response.content, str + ): + error_msg = "LLM returned empty content while compressing resume" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + body = _strip_typst_fences(compress_response.content) + body = _strip_imports(body) + name = _extract_name(body) or name + + if actual_pages > MAX_RESUME_PAGES: + error_msg = ( + "Resume exceeds hard page limit after compression retries. " + f"Hard limit: <= {MAX_RESUME_PAGES} page(s), actual: {actual_pages}." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 4: SAVE ───────────────────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "saving", "message": "Saving your resume"}, + ) + + resume_title = f"{name} - Resume" if name != "Resume" else "Resume" + + metadata: dict[str, Any] = { + "status": "ready", + "word_count": len(typst_source.split()), + "char_count": len(typst_source), + "target_max_pages": validated_max_pages, + "actual_page_count": actual_pages, + "page_limit_enforced": True, + "compression_attempts": compression_attempts, + "target_page_met": target_page_met, + } + + async with shielded_async_session() as write_session: + report = Report( + title=resume_title, + content=typst_source, + content_type="typst", + report_metadata=metadata, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + write_session.add(report) + await write_session.commit() + await write_session.refresh(report) + + if not report.report_group_id: + report.report_group_id = report.id + await write_session.commit() + + saved_id = report.id + + logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}") + + return { + "status": "ready", + "report_id": saved_id, + "title": resume_title, + "content_type": "typst", + "is_revision": bool(parent_content), + "message": ( + f"Resume generated successfully: {resume_title}" + if target_page_met + else ( + f"Resume generated, but could not fit the target of <= {validated_max_pages} " + f"page(s). Final length: {actual_pages} page(s)." + ) + ), + } + + except Exception as e: + error_message = str(e) + logger.exception(f"[generate_resume] Error: {error_message}") + report_id = await _save_failed_report(error_message) + return { + "status": "failed", + "error": error_message, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + return generate_resume diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py new file mode 100644 index 000000000..a9f3447ab --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py @@ -0,0 +1,80 @@ +"""Factory for a video-presentation tool that queues background work and returns an ID for polling.""" + +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import VideoPresentation, VideoPresentationStatus, shielded_async_session + + +def create_generate_video_presentation_tool( + search_space_id: int, + db_session: AsyncSession, + thread_id: int | None = None, +): + """Create ``generate_video_presentation`` with bound search space and thread; writes use a tool-local session.""" + del db_session # writes use a fresh tool-local session, see below + + @tool + async def generate_video_presentation( + source_content: str, + video_title: str = "SurfSense Presentation", + user_prompt: str | None = None, + ) -> dict[str, Any]: + """Generate a video presentation from the provided content. + + Use this tool when the user asks to create a video, presentation, slides, or slide deck. + + Args: + source_content: The text content to turn into a presentation. + video_title: Title for the presentation (default: "SurfSense Presentation") + user_prompt: Optional style/tone instructions. + """ + try: + # One DB session per tool call so parallel invocations never share an AsyncSession. + async with shielded_async_session() as session: + video_pres = VideoPresentation( + title=video_title, + status=VideoPresentationStatus.PENDING, + search_space_id=search_space_id, + thread_id=thread_id, + ) + session.add(video_pres) + await session.commit() + await session.refresh(video_pres) + video_pres_id = video_pres.id + + from app.tasks.celery_tasks.video_presentation_tasks import ( + generate_video_presentation_task, + ) + + task = generate_video_presentation_task.delay( + video_presentation_id=video_pres_id, + source_content=source_content, + search_space_id=search_space_id, + user_prompt=user_prompt, + ) + + print( + f"[generate_video_presentation] Created video presentation {video_pres_id}, task: {task.id}" + ) + + return { + "status": VideoPresentationStatus.PENDING.value, + "video_presentation_id": video_pres_id, + "title": video_title, + "message": "Video presentation generation started. This may take a few minutes.", + } + + except Exception as e: + error_message = str(e) + print(f"[generate_video_presentation] Error: {error_message}") + return { + "status": VideoPresentationStatus.FAILED.value, + "error": error_message, + "title": video_title, + "video_presentation_id": None, + } + + return generate_video_presentation From 4f0e84c6a3ff1ed2e795aceec78e3bb776b1ad4f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 066/131] Add Airtable, Calendar, and Clickup connector route slices. --- .../subagents/connectors/__init__.py | 0 .../subagents/connectors/airtable/__init__.py | 0 .../subagents/connectors/airtable/agent.py | 54 +++ .../connectors/airtable/description.md | 1 + .../connectors/airtable/system_prompt.md | 46 +++ .../connectors/airtable/tools/__init__.py | 3 + .../connectors/airtable/tools/index.py | 12 + .../subagents/connectors/calendar/__init__.py | 0 .../subagents/connectors/calendar/agent.py | 54 +++ .../connectors/calendar/description.md | 1 + .../connectors/calendar/system_prompt.md | 62 +++ .../connectors/calendar/tools/__init__.py | 19 + .../connectors/calendar/tools/create_event.py | 324 ++++++++++++++++ .../connectors/calendar/tools/delete_event.py | 304 +++++++++++++++ .../connectors/calendar/tools/index.py | 33 ++ .../calendar/tools/search_events.py | 132 +++++++ .../connectors/calendar/tools/update_event.py | 356 ++++++++++++++++++ .../subagents/connectors/clickup/__init__.py | 0 .../subagents/connectors/clickup/agent.py | 54 +++ .../connectors/clickup/description.md | 1 + .../connectors/clickup/system_prompt.md | 45 +++ .../connectors/clickup/tools/__init__.py | 3 + .../connectors/clickup/tools/index.py | 12 + 23 files changed, 1516 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/create_event.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/delete_event.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/search_events.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/update_event.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/index.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py new file mode 100644 index 000000000..215e995df --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/airtable/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/description.md new file mode 100644 index 000000000..71d75f67a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/description.md @@ -0,0 +1 @@ +Use for Airtable structured data operations: locate bases/tables and create/read/update records. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/system_prompt.md new file mode 100644 index 000000000..0f15f137f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/system_prompt.md @@ -0,0 +1,46 @@ +You are the Airtable MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Airtable MCP base/table/record operations accurately. + + + +- Runtime-provided Airtable MCP tools for bases, tables, and records. + + + +- Resolve base and table targets before record-level actions. +- Do not guess IDs or schema fields. +- If targets are ambiguous, return `status=blocked` with candidate options. +- Never claim mutation success without tool confirmation. + + + +- Do not execute non-Airtable tasks. + + + +- Never claim record mutations succeeded without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved target/schema ambiguity, return `status=blocked` with required options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/__init__.py new file mode 100644 index 000000000..a9b004975 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/__init__.py @@ -0,0 +1,3 @@ +"""Airtable route: native tool factories are empty; MCP supplies tools when configured.""" + +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/index.py new file mode 100644 index 000000000..639cea3a9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/index.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py new file mode 100644 index 000000000..89117fd6e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/calendar/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/description.md new file mode 100644 index 000000000..43865ef53 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/description.md @@ -0,0 +1 @@ +Use for calendar planning and scheduling: check availability, read event details, create events, and update events. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/system_prompt.md new file mode 100644 index 000000000..a7ef846d5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/system_prompt.md @@ -0,0 +1,62 @@ +You are the Google Calendar operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute calendar event operations (search, create, update, delete) accurately with timezone-safe scheduling. + + + +- `search_calendar_events` +- `create_calendar_event` +- `update_calendar_event` +- `delete_calendar_event` + + + +- Use only tools in ``. +- Resolve relative dates against current runtime timestamp. +- If required fields (date/time/timezone/target event) are missing or ambiguous, return `status=blocked` with `missing_fields` and supervisor `next_step`. +- Never invent event IDs or mutation results. + + + +- Do not perform non-calendar tasks. + + + +- Before update/delete, ensure event target is explicit. +- Never claim event mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On ambiguity, return `status=blocked` with top event candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "event_id": string | null, + "title": string | null, + "start_at": string (ISO 8601 with timezone) | null, + "end_at": string (ISO 8601 with timezone) | null, + "matched_candidates": [ + { + "event_id": string, + "title": string | null, + "start_at": string (ISO 8601 with timezone) | null + } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/__init__.py new file mode 100644 index 000000000..13d4c06cb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/__init__.py @@ -0,0 +1,19 @@ +from app.agents.new_chat.tools.google_calendar.create_event import ( + create_create_calendar_event_tool, +) +from app.agents.new_chat.tools.google_calendar.delete_event import ( + create_delete_calendar_event_tool, +) +from app.agents.new_chat.tools.google_calendar.search_events import ( + create_search_calendar_events_tool, +) +from app.agents.new_chat.tools.google_calendar.update_event import ( + create_update_calendar_event_tool, +) + +__all__ = [ + "create_create_calendar_event_tool", + "create_delete_calendar_event_tool", + "create_search_calendar_events_tool", + "create_update_calendar_event_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/create_event.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/create_event.py new file mode 100644 index 000000000..37bcf083e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/create_event.py @@ -0,0 +1,324 @@ +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_with_deepagents/subagents/connectors/calendar/tools/delete_event.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/delete_event.py new file mode 100644 index 000000000..4d9d69b4b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/delete_event.py @@ -0,0 +1,304 @@ +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_with_deepagents/subagents/connectors/calendar/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/index.py new file mode 100644 index 000000000..99d769ddc --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/index.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/calendar/tools/search_events.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/search_events.py new file mode 100644 index 000000000..dc6adb822 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/search_events.py @@ -0,0 +1,132 @@ +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_with_deepagents/subagents/connectors/calendar/tools/update_event.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/update_event.py new file mode 100644 index 000000000..259f52bba --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/update_event.py @@ -0,0 +1,356 @@ +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_with_deepagents/subagents/connectors/clickup/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py new file mode 100644 index 000000000..fd404cdb0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/clickup/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/description.md new file mode 100644 index 000000000..07ce599a5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/description.md @@ -0,0 +1 @@ +Use for ClickUp task management: find tasks/lists, update task fields, and track execution progress. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/system_prompt.md new file mode 100644 index 000000000..84014246d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/system_prompt.md @@ -0,0 +1,45 @@ +You are the ClickUp MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute ClickUp MCP operations accurately using only runtime-provided tools. + + + +- Runtime-provided ClickUp MCP tools for task/workspace search and mutation. + + + +- Follow tool descriptions exactly. +- If task/workspace target is ambiguous or missing, return `status=blocked` with required disambiguation fields. +- Never claim mutation success without tool confirmation. + + + +- Do not execute non-ClickUp tasks. + + + +- Never claim update/create success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved ambiguity, return `status=blocked` with candidate options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/__init__.py new file mode 100644 index 000000000..b629234f9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/__init__.py @@ -0,0 +1,3 @@ +"""ClickUp route: native tool factories are empty; MCP supplies tools when configured.""" + +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/index.py new file mode 100644 index 000000000..639cea3a9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/index.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, +) + + +def load_tools(*, dependencies: dict[str, Any] | None = None, **kwargs: Any) -> ToolsPermissions: + _ = {**(dependencies or {}), **kwargs} + return {"allow": [], "ask": []} From f24eb3496c4ebcc7e5a358663d9c4f86a45ecad7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 067/131] Add Confluence, Discord, and Dropbox connector route slices. --- .../connectors/confluence/__init__.py | 0 .../subagents/connectors/confluence/agent.py | 54 ++++ .../connectors/confluence/description.md | 1 + .../connectors/confluence/system_prompt.md | 55 ++++ .../connectors/confluence/tools/__init__.py | 11 + .../confluence/tools/create_page.py | 211 +++++++++++++ .../confluence/tools/delete_page.py | 189 ++++++++++++ .../connectors/confluence/tools/index.py | 32 ++ .../confluence/tools/update_page.py | 218 ++++++++++++++ .../subagents/connectors/discord/__init__.py | 0 .../subagents/connectors/discord/agent.py | 54 ++++ .../connectors/discord/description.md | 1 + .../connectors/discord/system_prompt.md | 56 ++++ .../connectors/discord/tools/__init__.py | 15 + .../connectors/discord/tools/_auth.py | 43 +++ .../connectors/discord/tools/index.py | 30 ++ .../connectors/discord/tools/list_channels.py | 87 ++++++ .../connectors/discord/tools/read_messages.py | 100 +++++++ .../connectors/discord/tools/send_message.py | 117 ++++++++ .../subagents/connectors/dropbox/__init__.py | 0 .../subagents/connectors/dropbox/agent.py | 54 ++++ .../connectors/dropbox/description.md | 1 + .../connectors/dropbox/system_prompt.md | 52 ++++ .../connectors/dropbox/tools/__init__.py | 11 + .../connectors/dropbox/tools/create_file.py | 275 +++++++++++++++++ .../connectors/dropbox/tools/index.py | 28 ++ .../connectors/dropbox/tools/trash_file.py | 277 ++++++++++++++++++ 27 files changed, 1972 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/create_page.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/delete_page.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/update_page.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/_auth.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/list_channels.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/read_messages.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/send_message.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/create_file.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/trash_file.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py new file mode 100644 index 000000000..3b27d39e8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/confluence/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/description.md new file mode 100644 index 000000000..b6f1353d0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/description.md @@ -0,0 +1 @@ +Use for Confluence knowledge pages: search/read existing pages, create new pages, and update page content. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/system_prompt.md new file mode 100644 index 000000000..4d3b7462c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/system_prompt.md @@ -0,0 +1,55 @@ +You are the Confluence operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Confluence page operations accurately in the connected space. + + + +- `create_confluence_page` +- `update_confluence_page` +- `delete_confluence_page` + + + +- Use only tools in ``. +- Verify target page and intended mutation before update/delete. +- If target page is ambiguous, return `status=blocked` with candidate options for supervisor disambiguation. +- Never invent page IDs, titles, or mutation outcomes. + + + +- Do not perform non-Confluence tasks. + + + +- Never claim page mutation success without tool confirmation. +- If destructive action appears already completed in this session, do not repeat; return prior evidence with an `assumptions` note. + + + +- On tool failure, return `status=error` with concise retry/recovery `next_step`. +- On unresolved page ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "page_id": string | null, + "page_title": string | null, + "matched_candidates": [ + { "page_id": string, "page_title": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/__init__.py new file mode 100644 index 000000000..3bf80b61b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/__init__.py @@ -0,0 +1,11 @@ +"""Confluence tools for creating, updating, and deleting pages.""" + +from .create_page import create_create_confluence_page_tool +from .delete_page import create_delete_confluence_page_tool +from .update_page import create_update_confluence_page_tool + +__all__ = [ + "create_create_confluence_page_tool", + "create_delete_confluence_page_tool", + "create_update_confluence_page_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/create_page.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/create_page.py new file mode 100644 index 000000000..095413bdb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/create_page.py @@ -0,0 +1,211 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.confluence_history import ConfluenceHistoryConnector +from app.services.confluence import ConfluenceToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_confluence_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def create_confluence_page( + title: str, + content: str | None = None, + space_id: str | None = None, + ) -> dict[str, Any]: + """Create a new page in Confluence. + + Use this tool when the user explicitly asks to create a new Confluence page. + + Args: + title: Title of the page. + content: Optional HTML/storage format content for the page body. + space_id: Optional Confluence space ID to create the page in. + + Returns: + Dictionary with status, page_id, and message. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info(f"create_confluence_page called: title='{title}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Confluence tool not properly configured.", + } + + try: + metadata_service = ConfluenceToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected Confluence accounts need re-authentication.", + "connector_type": "confluence", + } + + result = request_approval( + action_type="confluence_page_creation", + tool_name="create_confluence_page", + params={ + "title": title, + "content": content, + "space_id": space_id, + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_title = result.params.get("title", title) + final_content = result.params.get("content", content) or "" + final_space_id = result.params.get("space_id", space_id) + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_title or not final_title.strip(): + return {"status": "error", "message": "Page title cannot be empty."} + if not final_space_id: + return {"status": "error", "message": "A space must be selected."} + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Confluence connector found.", + } + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Confluence connector is invalid.", + } + + try: + client = ConfluenceHistoryConnector( + session=db_session, connector_id=actual_connector_id + ) + api_result = await client.create_page( + space_id=final_space_id, + title=final_title, + body=final_content, + ) + await client.close() + except Exception as api_err: + if ( + "http 403" in str(api_err).lower() + or "status code 403" in str(api_err).lower() + ): + try: + _conn = connector + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Confluence account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + page_id = str(api_result.get("id", "")) + page_links = ( + api_result.get("_links", {}) if isinstance(api_result, dict) else {} + ) + page_url = "" + if page_links.get("base") and page_links.get("webui"): + page_url = f"{page_links['base']}{page_links['webui']}" + + kb_message_suffix = "" + try: + from app.services.confluence import ConfluenceKBSyncService + + kb_service = ConfluenceKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + page_id=page_id, + page_title=final_title, + space_id=final_space_id, + body_content=final_content, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "page_id": page_id, + "page_url": page_url, + "message": f"Confluence page '{final_title}' created successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating Confluence page: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the page.", + } + + return create_confluence_page diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/delete_page.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/delete_page.py new file mode 100644 index 000000000..7c03c2760 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/delete_page.py @@ -0,0 +1,189 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.confluence_history import ConfluenceHistoryConnector +from app.services.confluence import ConfluenceToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_confluence_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def delete_confluence_page( + page_title_or_id: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete a Confluence page. + + Use this tool when the user asks to delete or remove a Confluence page. + + Args: + page_title_or_id: The page title or ID to identify the page. + delete_from_kb: Whether to also remove from the knowledge base. + + Returns: + Dictionary with status, message, and deleted_from_kb. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message to the user. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"delete_confluence_page called: page_title_or_id='{page_title_or_id}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Confluence tool not properly configured.", + } + + try: + metadata_service = ConfluenceToolMetadataService(db_session) + context = await metadata_service.get_deletion_context( + search_space_id, user_id, page_title_or_id + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "confluence", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + page_data = context["page"] + page_id = page_data["page_id"] + page_title = page_data.get("page_title", "") + document_id = page_data["document_id"] + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="confluence_page_deletion", + tool_name="delete_confluence_page", + params={ + "page_id": page_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this page.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Confluence connector is invalid.", + } + + try: + client = ConfluenceHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + await client.delete_page(final_page_id) + await client.close() + except Exception as api_err: + if ( + "http 403" in str(api_err).lower() + or "status code 403" in str(api_err).lower() + ): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Confluence account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + + message = f"Confluence page '{page_title}' deleted successfully." + if deleted_from_kb: + message += " Also removed from the knowledge base." + + return { + "status": "success", + "page_id": final_page_id, + "deleted_from_kb": deleted_from_kb, + "message": message, + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting Confluence page: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while deleting the page.", + } + + return delete_confluence_page diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/index.py new file mode 100644 index 000000000..eb76eb17d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/index.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/confluence/tools/update_page.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/update_page.py new file mode 100644 index 000000000..791d0d8c5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/update_page.py @@ -0,0 +1,218 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.confluence_history import ConfluenceHistoryConnector +from app.services.confluence import ConfluenceToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_confluence_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def update_confluence_page( + page_title_or_id: str, + new_title: str | None = None, + new_content: str | None = None, + ) -> dict[str, Any]: + """Update an existing Confluence page. + + Use this tool when the user asks to modify or edit a Confluence page. + + Args: + page_title_or_id: The page title or ID to identify the page. + new_title: Optional new title for the page. + new_content: Optional new HTML/storage format content. + + Returns: + Dictionary with status and message. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message to the user. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"update_confluence_page called: page_title_or_id='{page_title_or_id}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Confluence tool not properly configured.", + } + + try: + metadata_service = ConfluenceToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, page_title_or_id + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "confluence", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + page_data = context["page"] + page_id = page_data["page_id"] + current_title = page_data["page_title"] + current_body = page_data.get("body", "") + current_version = page_data.get("version", 1) + document_id = page_data.get("document_id") + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="confluence_page_update", + tool_name="update_confluence_page", + params={ + "page_id": page_id, + "document_id": document_id, + "new_title": new_title, + "new_content": new_content, + "version": current_version, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_title = result.params.get("new_title", new_title) or current_title + final_content = result.params.get("new_content", new_content) + if final_content is None: + final_content = current_body + final_version = result.params.get("version", current_version) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_document_id = result.params.get("document_id", document_id) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this page.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Confluence connector is invalid.", + } + + try: + client = ConfluenceHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + api_result = await client.update_page( + page_id=final_page_id, + title=final_title, + body=final_content, + version_number=final_version + 1, + ) + await client.close() + except Exception as api_err: + if ( + "http 403" in str(api_err).lower() + or "status code 403" in str(api_err).lower() + ): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Confluence account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + page_links = ( + api_result.get("_links", {}) if isinstance(api_result, dict) else {} + ) + page_url = "" + if page_links.get("base") and page_links.get("webui"): + page_url = f"{page_links['base']}{page_links['webui']}" + + kb_message_suffix = "" + if final_document_id: + try: + from app.services.confluence import ConfluenceKBSyncService + + kb_service = ConfluenceKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=final_document_id, + page_id=final_page_id, + user_id=user_id, + search_space_id=search_space_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + else: + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + except Exception as kb_err: + logger.warning(f"KB sync after update failed: {kb_err}") + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + + return { + "status": "success", + "page_id": final_page_id, + "page_url": page_url, + "message": f"Confluence page '{final_title}' updated successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error updating Confluence page: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while updating the page.", + } + + return update_confluence_page diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py new file mode 100644 index 000000000..793de429f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/discord/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/description.md new file mode 100644 index 000000000..44065c10b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/description.md @@ -0,0 +1 @@ +Use for Discord communication: read channel/thread messages, gather context, and send replies. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/system_prompt.md new file mode 100644 index 000000000..40e9eb314 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/system_prompt.md @@ -0,0 +1,56 @@ +You are the Discord operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Discord reads and sends accurately in the connected server/workspace. + + + +- `list_discord_channels` +- `read_discord_messages` +- `send_discord_message` + + + +- Use only tools in ``. +- Resolve channel/thread targets before reads/sends. +- If target is ambiguous, return `status=blocked` with candidate channels/threads. +- Never invent message content, sender identity, timestamps, or delivery results. + + + +- Do not perform non-Discord tasks. + + + +- Before send, verify destination and message intent match delegated instructions. +- Never claim send success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved destination ambiguity, return `status=blocked` with candidate options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "channel_id": string | null, + "thread_id": string | null, + "message_id": string | null, + "matched_candidates": [ + { "channel_id": string, "thread_id": string | null, "label": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/__init__.py new file mode 100644 index 000000000..b4eaec1f0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/__init__.py @@ -0,0 +1,15 @@ +from app.agents.new_chat.tools.discord.list_channels import ( + create_list_discord_channels_tool, +) +from app.agents.new_chat.tools.discord.read_messages import ( + create_read_discord_messages_tool, +) +from app.agents.new_chat.tools.discord.send_message import ( + create_send_discord_message_tool, +) + +__all__ = [ + "create_list_discord_channels_tool", + "create_read_discord_messages_tool", + "create_send_discord_message_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/_auth.py new file mode 100644 index 000000000..7636aff71 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/_auth.py @@ -0,0 +1,43 @@ +"""Builds Discord REST API auth headers for connector-backed tools.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.config import config +from app.db import SearchSourceConnector, SearchSourceConnectorType +from app.utils.oauth_security import TokenEncryption + +DISCORD_API = "https://discord.com/api/v10" + + +async def get_discord_connector( + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> SearchSourceConnector | None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DISCORD_CONNECTOR, + ) + ) + return result.scalars().first() + + +def get_bot_token(connector: SearchSourceConnector) -> str: + """Extract and decrypt the bot token from connector config.""" + cfg = dict(connector.config) + if cfg.get("_token_encrypted") and config.SECRET_KEY: + enc = TokenEncryption(config.SECRET_KEY) + if cfg.get("bot_token"): + cfg["bot_token"] = enc.decrypt_token(cfg["bot_token"]) + token = cfg.get("bot_token") + if not token: + raise ValueError("Discord bot token not found in connector config.") + return token + + +def get_guild_id(connector: SearchSourceConnector) -> str | None: + return connector.config.get("guild_id") diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/index.py new file mode 100644 index 000000000..66d13e7a6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/index.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/discord/tools/list_channels.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/list_channels.py new file mode 100644 index 000000000..3cc99ac17 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/list_channels.py @@ -0,0 +1,87 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import DISCORD_API, get_bot_token, get_discord_connector, get_guild_id + +logger = logging.getLogger(__name__) + + +def create_list_discord_channels_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def list_discord_channels() -> dict[str, Any]: + """List text channels in the connected Discord server. + + Returns: + Dictionary with status and a list of channels (id, name). + """ + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Discord tool not properly configured.", + } + + try: + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) + if not connector: + return {"status": "error", "message": "No Discord connector found."} + + guild_id = get_guild_id(connector) + if not guild_id: + return { + "status": "error", + "message": "No guild ID in Discord connector config.", + } + + token = get_bot_token(connector) + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{DISCORD_API}/guilds/{guild_id}/channels", + headers={"Authorization": f"Bot {token}"}, + timeout=15.0, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } + + # Type 0 = text channel + channels = [ + {"id": ch["id"], "name": ch["name"]} + for ch in resp.json() + if ch.get("type") == 0 + ] + return { + "status": "success", + "guild_id": guild_id, + "channels": channels, + "total": len(channels), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error listing Discord channels: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to list Discord channels."} + + return list_discord_channels diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/read_messages.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/read_messages.py new file mode 100644 index 000000000..d8bf989a1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/read_messages.py @@ -0,0 +1,100 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import DISCORD_API, get_bot_token, get_discord_connector + +logger = logging.getLogger(__name__) + + +def create_read_discord_messages_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def read_discord_messages( + channel_id: str, + limit: int = 25, + ) -> dict[str, Any]: + """Read recent messages from a Discord text channel. + + Args: + channel_id: The Discord channel ID (from list_discord_channels). + limit: Number of messages to fetch (default 25, max 50). + + Returns: + Dictionary with status and a list of messages including + id, author, content, timestamp. + """ + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Discord tool not properly configured.", + } + + limit = min(limit, 50) + + try: + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) + if not connector: + return {"status": "error", "message": "No Discord connector found."} + + token = get_bot_token(connector) + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{DISCORD_API}/channels/{channel_id}/messages", + headers={"Authorization": f"Bot {token}"}, + params={"limit": limit}, + timeout=15.0, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Bot lacks permission to read this channel.", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } + + messages = [ + { + "id": m["id"], + "author": m.get("author", {}).get("username", "Unknown"), + "content": m.get("content", ""), + "timestamp": m.get("timestamp", ""), + } + for m in resp.json() + ] + + return { + "status": "success", + "channel_id": channel_id, + "messages": messages, + "total": len(messages), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error reading Discord messages: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to read Discord messages."} + + return read_discord_messages diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/send_message.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/send_message.py new file mode 100644 index 000000000..236cd017a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/send_message.py @@ -0,0 +1,117 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval + +from ._auth import DISCORD_API, get_bot_token, get_discord_connector + +logger = logging.getLogger(__name__) + + +def create_send_discord_message_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def send_discord_message( + channel_id: str, + content: str, + ) -> dict[str, Any]: + """Send a message to a Discord text channel. + + Args: + channel_id: The Discord channel ID (from list_discord_channels). + content: The message text (max 2000 characters). + + Returns: + Dictionary with status, message_id on success. + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Do NOT retry. + """ + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Discord tool not properly configured.", + } + + if len(content) > 2000: + return { + "status": "error", + "message": "Message exceeds Discord's 2000-character limit.", + } + + try: + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) + if not connector: + return {"status": "error", "message": "No Discord connector found."} + + result = request_approval( + action_type="discord_send_message", + tool_name="send_discord_message", + params={"channel_id": channel_id, "content": content}, + context={"connector_id": connector.id}, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Message was not sent.", + } + + final_content = result.params.get("content", content) + final_channel = result.params.get("channel_id", channel_id) + + token = get_bot_token(connector) + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{DISCORD_API}/channels/{final_channel}/messages", + headers={ + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + }, + json={"content": final_content}, + timeout=15.0, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Bot lacks permission to send messages in this channel.", + } + if resp.status_code not in (200, 201): + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } + + msg_data = resp.json() + return { + "status": "success", + "message_id": msg_data.get("id"), + "message": f"Message sent to channel {final_channel}.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error sending Discord message: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to send Discord message."} + + return send_discord_message diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py new file mode 100644 index 000000000..dc26c181b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/dropbox/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/description.md new file mode 100644 index 000000000..9c2575dd2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/description.md @@ -0,0 +1 @@ +Use for Dropbox file storage tasks: browse folders, read files, and manage Dropbox file content. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/system_prompt.md new file mode 100644 index 000000000..4b19be794 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/system_prompt.md @@ -0,0 +1,52 @@ +You are the Dropbox operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Dropbox file create/delete actions accurately in the connected account. + + + +- `create_dropbox_file` +- `delete_dropbox_file` + + + +- Use only tools in ``. +- Ensure target path/file identity is explicit before mutate actions. +- If target is ambiguous, return `status=blocked` with candidate paths. +- Never invent file IDs/paths or mutation outcomes. + + + +- Do not perform non-Dropbox tasks. + + + +- Never claim file mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On target ambiguity, return `status=blocked` with candidate paths. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "file_path": string | null, + "file_id": string | null, + "operation": "create" | "delete" | null, + "matched_candidates": string[] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/__init__.py new file mode 100644 index 000000000..836b9ee41 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/__init__.py @@ -0,0 +1,11 @@ +from app.agents.new_chat.tools.dropbox.create_file import ( + create_create_dropbox_file_tool, +) +from app.agents.new_chat.tools.dropbox.trash_file import ( + create_delete_dropbox_file_tool, +) + +__all__ = [ + "create_create_dropbox_file_tool", + "create_delete_dropbox_file_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/create_file.py new file mode 100644 index 000000000..22d8a8a27 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/create_file.py @@ -0,0 +1,275 @@ +import logging +import os +import tempfile +from pathlib import Path +from typing import Any, Literal + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.dropbox.client import DropboxClient +from app.db import SearchSourceConnector, SearchSourceConnectorType + +logger = logging.getLogger(__name__) + +DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + +_FILE_TYPE_LABELS = { + "paper": "Dropbox Paper (.paper)", + "docx": "Word Document (.docx)", +} + +_SUPPORTED_TYPES = [ + {"value": "paper", "label": "Dropbox Paper (.paper)"}, + {"value": "docx", "label": "Word Document (.docx)"}, +] + + +def _ensure_extension(name: str, file_type: str) -> str: + """Strip any existing extension and append the correct one.""" + stem = Path(name).stem + ext = ".paper" if file_type == "paper" else ".docx" + return f"{stem}{ext}" + + +def _markdown_to_docx(markdown_text: str) -> bytes: + """Convert a markdown string to DOCX bytes using pypandoc.""" + import pypandoc + + fd, tmp_path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + try: + pypandoc.convert_text( + markdown_text, + "docx", + format="gfm", + extra_args=["--standalone"], + outputfile=tmp_path, + ) + with open(tmp_path, "rb") as f: + return f.read() + finally: + os.unlink(tmp_path) + + +def create_create_dropbox_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_dropbox_file( + name: str, + file_type: Literal["paper", "docx"] = "paper", + content: str | None = None, + ) -> dict[str, Any]: + """Create a new document in Dropbox. + + Use this tool when the user explicitly asks to create a new document + in Dropbox. The user MUST specify a topic before you call this tool. + + Args: + name: The document title (without extension). + file_type: Either "paper" (Dropbox Paper, default) or "docx" (Word document). + content: Optional initial content as markdown. + + Returns: + Dictionary with status, file_id, name, web_url, and message. + """ + logger.info( + f"create_dropbox_file called: name='{name}', file_type='{file_type}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Dropbox tool not properly configured.", + } + + try: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + connectors = result.scalars().all() + + if not connectors: + return { + "status": "error", + "message": "No Dropbox connector found. Please connect Dropbox in your workspace settings.", + } + + accounts = [] + for c in connectors: + cfg = c.config or {} + accounts.append( + { + "id": c.id, + "name": c.name, + "user_email": cfg.get("user_email"), + "auth_expired": cfg.get("auth_expired", False), + } + ) + + if all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected Dropbox accounts need re-authentication.", + "connector_type": "dropbox", + } + + parent_folders: dict[int, list[dict[str, str]]] = {} + for acc in accounts: + cid = acc["id"] + if acc.get("auth_expired"): + parent_folders[cid] = [] + continue + try: + client = DropboxClient(session=db_session, connector_id=cid) + items, err = await client.list_folder("") + if err: + logger.warning( + "Failed to list folders for connector %s: %s", cid, err + ) + parent_folders[cid] = [] + else: + parent_folders[cid] = [ + { + "folder_path": item.get("path_lower", ""), + "name": item["name"], + } + for item in items + if item.get(".tag") == "folder" and item.get("name") + ] + except Exception: + logger.warning( + "Error fetching folders for connector %s", cid, exc_info=True + ) + parent_folders[cid] = [] + + context: dict[str, Any] = { + "accounts": accounts, + "parent_folders": parent_folders, + "supported_types": _SUPPORTED_TYPES, + } + + result = request_approval( + action_type="dropbox_file_creation", + tool_name="create_dropbox_file", + params={ + "name": name, + "file_type": file_type, + "content": content, + "connector_id": None, + "parent_folder_path": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_name = result.params.get("name", name) + final_file_type = result.params.get("file_type", file_type) + final_content = result.params.get("content", content) + final_connector_id = result.params.get("connector_id") + final_parent_folder_path = result.params.get("parent_folder_path") + + if not final_name or not final_name.strip(): + return {"status": "error", "message": "File name cannot be empty."} + + final_name = _ensure_extension(final_name, final_file_type) + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + connector = result.scalars().first() + else: + connector = connectors[0] + + if not connector: + return { + "status": "error", + "message": "Selected Dropbox connector is invalid.", + } + + client = DropboxClient(session=db_session, connector_id=connector.id) + + parent_path = final_parent_folder_path or "" + file_path = ( + f"{parent_path}/{final_name}" if parent_path else f"/{final_name}" + ) + + if final_file_type == "paper": + created = await client.create_paper_doc(file_path, final_content or "") + file_id = created.get("file_id", "") + web_url = created.get("url", "") + else: + docx_bytes = _markdown_to_docx(final_content or "") + created = await client.upload_file( + file_path, docx_bytes, mode="add", autorename=True + ) + file_id = created.get("id", "") + web_url = "" + + logger.info(f"Dropbox file created: id={file_id}, name={final_name}") + + kb_message_suffix = "" + try: + from app.services.dropbox import DropboxKBSyncService + + kb_service = DropboxKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + file_id=file_id, + file_name=final_name, + file_path=file_path, + web_url=web_url, + content=final_content, + connector_id=connector.id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "file_id": file_id, + "name": final_name, + "web_url": web_url, + "message": f"Successfully created '{final_name}' in Dropbox.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating Dropbox file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", + } + + return create_dropbox_file diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/index.py new file mode 100644 index 000000000..ba2c31f9a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/index.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/dropbox/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/trash_file.py new file mode 100644 index 000000000..12559b57a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/trash_file.py @@ -0,0 +1,277 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy import String, and_, cast, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.dropbox.client import DropboxClient +from app.db import ( + Document, + DocumentType, + SearchSourceConnector, + SearchSourceConnectorType, +) + +logger = logging.getLogger(__name__) + + +def create_delete_dropbox_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def delete_dropbox_file( + file_name: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete a file from Dropbox. + + Use this tool when the user explicitly asks to delete, remove, or trash + a file in Dropbox. + + Args: + file_name: The exact name of the file to delete. + delete_from_kb: Whether to also remove the file from the knowledge base. + Default is False. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - file_id: Dropbox file ID (if success) + - deleted_from_kb: whether the document was removed from the knowledge base + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the file name or check if it has been indexed. + """ + logger.info( + f"delete_dropbox_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Dropbox tool not properly configured.", + } + + try: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.DROPBOX_FILE, + func.lower(Document.title) == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.DROPBOX_FILE, + func.lower( + cast( + Document.document_metadata["dropbox_file_name"], + String, + ) + ) + == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + return { + "status": "not_found", + "message": ( + f"File '{file_name}' not found in your indexed Dropbox files. " + "This could mean: (1) the file doesn't exist, (2) it hasn't been indexed yet, " + "or (3) the file name is different." + ), + } + + if not document.connector_id: + return { + "status": "error", + "message": "Document has no associated connector.", + } + + meta = document.document_metadata or {} + file_path = meta.get("dropbox_path") + file_id = meta.get("dropbox_file_id") + document_id = document.id + + if not file_path: + return { + "status": "error", + "message": "File path is missing. Please re-index the file.", + } + + conn_result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == document.connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + ) + connector = conn_result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Dropbox connector not found or access denied.", + } + + cfg = connector.config or {} + if cfg.get("auth_expired"): + return { + "status": "auth_error", + "message": "Dropbox account needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "dropbox", + } + + context = { + "file": { + "file_id": file_id, + "file_path": file_path, + "name": file_name, + "document_id": document_id, + }, + "account": { + "id": connector.id, + "name": connector.name, + "user_email": cfg.get("user_email"), + }, + } + + result = request_approval( + action_type="dropbox_file_trash", + tool_name="delete_dropbox_file", + params={ + "file_path": file_path, + "connector_id": connector.id, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_file_path = result.params.get("file_path", file_path) + final_connector_id = result.params.get("connector_id", connector.id) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + if final_connector_id != connector.id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DROPBOX_CONNECTOR, + ) + ) + ) + validated_connector = result.scalars().first() + if not validated_connector: + return { + "status": "error", + "message": "Selected Dropbox connector is invalid or has been disconnected.", + } + actual_connector_id = validated_connector.id + else: + actual_connector_id = connector.id + + logger.info( + f"Deleting Dropbox file: path='{final_file_path}', connector={actual_connector_id}" + ) + + client = DropboxClient(session=db_session, connector_id=actual_connector_id) + await client.delete_file(final_file_path) + + logger.info(f"Dropbox file deleted: path={final_file_path}") + + trash_result: dict[str, Any] = { + "status": "success", + "file_id": file_id, + "message": f"Successfully deleted '{file_name}' from Dropbox.", + } + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + doc = doc_result.scalars().first() + if doc: + await db_session.delete(doc) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + trash_result["warning"] = ( + f"File deleted, but failed to remove from knowledge base: {e!s}" + ) + + trash_result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + trash_result["message"] = ( + f"{trash_result.get('message', '')} (also removed from knowledge base)" + ) + + return trash_result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting Dropbox file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while deleting the file. Please try again.", + } + + return delete_dropbox_file From 96ab91b719c9931c5fbe5ada73d4c3556112c404 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 068/131] Add Gmail, Google Drive, and Jira connector route slices. --- .../subagents/connectors/gmail/__init__.py | 0 .../subagents/connectors/gmail/agent.py | 54 +++ .../subagents/connectors/gmail/description.md | 1 + .../connectors/gmail/system_prompt.md | 82 ++++ .../connectors/gmail/tools/__init__.py | 27 ++ .../connectors/gmail/tools/create_draft.py | 313 +++++++++++++ .../subagents/connectors/gmail/tools/index.py | 41 ++ .../connectors/gmail/tools/read_email.py | 100 +++++ .../connectors/gmail/tools/search_emails.py | 182 ++++++++ .../connectors/gmail/tools/send_email.py | 315 ++++++++++++++ .../connectors/gmail/tools/trash_email.py | 309 +++++++++++++ .../connectors/gmail/tools/update_draft.py | 410 ++++++++++++++++++ .../connectors/google_drive/__init__.py | 0 .../connectors/google_drive/agent.py | 54 +++ .../connectors/google_drive/description.md | 1 + .../connectors/google_drive/system_prompt.md | 54 +++ .../connectors/google_drive/tools/__init__.py | 11 + .../google_drive/tools/create_file.py | 283 ++++++++++++ .../connectors/google_drive/tools/index.py | 28 ++ .../google_drive/tools/trash_file.py | 262 +++++++++++ .../subagents/connectors/jira/__init__.py | 0 .../subagents/connectors/jira/agent.py | 54 +++ .../subagents/connectors/jira/description.md | 1 + .../connectors/jira/system_prompt.md | 46 ++ .../connectors/jira/tools/__init__.py | 11 + .../connectors/jira/tools/create_issue.py | 216 +++++++++ .../connectors/jira/tools/delete_issue.py | 183 ++++++++ .../subagents/connectors/jira/tools/index.py | 32 ++ .../connectors/jira/tools/update_issue.py | 226 ++++++++++ 29 files changed, 3296 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/create_draft.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/read_email.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/search_emails.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/send_email.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/trash_email.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/update_draft.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/create_file.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/trash_file.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/create_issue.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/delete_issue.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/update_issue.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py new file mode 100644 index 000000000..de4971e1c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/gmail/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/description.md new file mode 100644 index 000000000..db5614805 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/description.md @@ -0,0 +1 @@ +Use for Gmail inbox actions: search/read emails, draft or update replies, send messages, and trash emails. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/system_prompt.md new file mode 100644 index 000000000..961100261 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/system_prompt.md @@ -0,0 +1,82 @@ +You are the Gmail operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Gmail operations accurately: search/read emails, prepare drafts, send, and trash. + + + +- `search_gmail`: find candidate emails with query constraints. +- `read_gmail_email`: read one message in full detail. +- `create_gmail_draft`: create a new draft. +- `update_gmail_draft`: modify an existing draft. +- `send_gmail_email`: send an email. +- `trash_gmail_email`: move an email to trash. + + + +- Use only tools in ``. +- Build precise search queries using Gmail operators when possible (`from:`, `to:`, `subject:`, `after:`, `before:`, `has:attachment`, `is:unread`, `label:`). +- Resolve relative dates against runtime timestamp; prefer narrower interpretation. +- For reply requests, identify the target thread/email via search + read before drafting. +- If required fields are missing or target selection is ambiguous, return `status=blocked` with `missing_fields` and disambiguation candidates. +- Never invent IDs, recipients, timestamps, quoted text, or tool outcomes. + + + +- Do not perform non-Gmail work. +- Filing operations not represented in `` (archive/label/mark-read/move-folder) are unsupported here. + + + +- For send: verify draft `to`, `subject`, and `body` match delegated instructions. +- If any send-critical field was inferred, do not send; return `status=blocked` with inferred values in `assumptions`. +- For trash: ensure explicit target match before deletion. +- If a destructive action appears already completed this session, do not repeat; return prior evidence. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- If search has no strong match, return `status=blocked` with suggested tighter filters. +- If multiple strong candidates remain for risky actions, return `status=blocked` with top options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "email_id": string | null, + "thread_id": string | null, + "subject": string | null, + "sender": string | null, + "recipients": string[] | null, + "received_at": string (ISO 8601 with timezone) | null, + "sent_message": { + "id": string, + "to": string[], + "subject": string | null, + "sent_at": string (ISO 8601 with timezone) | null + } | null, + "matched_candidates": [ + { + "email_id": string, + "subject": string | null, + "sender": string | null, + "received_at": string (ISO 8601 with timezone) | null + } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} + +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. +- For blocked ambiguity, include options in `evidence.matched_candidates`. +- For trash actions, `evidence.email_id` is the trashed message. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/__init__.py new file mode 100644 index 000000000..294840122 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/__init__.py @@ -0,0 +1,27 @@ +from app.agents.new_chat.tools.gmail.create_draft import ( + create_create_gmail_draft_tool, +) +from app.agents.new_chat.tools.gmail.read_email import ( + create_read_gmail_email_tool, +) +from app.agents.new_chat.tools.gmail.search_emails import ( + create_search_gmail_tool, +) +from app.agents.new_chat.tools.gmail.send_email import ( + create_send_gmail_email_tool, +) +from app.agents.new_chat.tools.gmail.trash_email import ( + create_trash_gmail_email_tool, +) +from app.agents.new_chat.tools.gmail.update_draft import ( + create_update_gmail_draft_tool, +) + +__all__ = [ + "create_create_gmail_draft_tool", + "create_read_gmail_email_tool", + "create_search_gmail_tool", + "create_send_gmail_email_tool", + "create_trash_gmail_email_tool", + "create_update_gmail_draft_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/create_draft.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/create_draft.py new file mode 100644 index 000000000..0bd044695 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/create_draft.py @@ -0,0 +1,313 @@ +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_with_deepagents/subagents/connectors/gmail/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/index.py new file mode 100644 index 000000000..d382aaf7d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/index.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/gmail/tools/read_email.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/read_email.py new file mode 100644 index 000000000..deec1627c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/read_email.py @@ -0,0 +1,100 @@ +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_with_deepagents/subagents/connectors/gmail/tools/search_emails.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/search_emails.py new file mode 100644 index 000000000..2e363609e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/search_emails.py @@ -0,0 +1,182 @@ +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_with_deepagents/subagents/connectors/gmail/tools/send_email.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/send_email.py new file mode 100644 index 000000000..c3f0999f4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/send_email.py @@ -0,0 +1,315 @@ +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_with_deepagents/subagents/connectors/gmail/tools/trash_email.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/trash_email.py new file mode 100644 index 000000000..1f1f6227a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/trash_email.py @@ -0,0 +1,309 @@ +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_with_deepagents/subagents/connectors/gmail/tools/update_draft.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/update_draft.py new file mode 100644 index 000000000..91178cd21 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/update_draft.py @@ -0,0 +1,410 @@ +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_with_deepagents/subagents/connectors/google_drive/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py new file mode 100644 index 000000000..091f431f3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/google_drive/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/description.md new file mode 100644 index 000000000..3f54ef8f7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/description.md @@ -0,0 +1 @@ +Use for Google Drive document/file tasks: locate files, inspect content, and manage Drive files or folders. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/system_prompt.md new file mode 100644 index 000000000..09dc0caa2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/system_prompt.md @@ -0,0 +1,54 @@ +You are the Google Drive operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Google Drive file operations accurately in the connected account. + + + +- `create_google_drive_file` +- `delete_google_drive_file` + + + +- Use only tools in ``. +- Ensure target file identity/path is explicit before mutate actions. +- If target is ambiguous, return `status=blocked` with candidate files. +- Never invent file IDs/names or mutation outcomes. + + + +- Do not perform non-Google-Drive tasks. + + + +- Never claim file mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On target ambiguity, return `status=blocked` with candidate files. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "file_id": string | null, + "file_name": string | null, + "operation": "create" | "delete" | null, + "matched_candidates": [ + { "file_id": string, "file_name": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/__init__.py new file mode 100644 index 000000000..9c63bceb1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/__init__.py @@ -0,0 +1,11 @@ +from app.agents.new_chat.tools.google_drive.create_file import ( + create_create_google_drive_file_tool, +) +from app.agents.new_chat.tools.google_drive.trash_file import ( + create_delete_google_drive_file_tool, +) + +__all__ = [ + "create_create_google_drive_file_tool", + "create_delete_google_drive_file_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/create_file.py new file mode 100644 index 000000000..f36db8f3f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/create_file.py @@ -0,0 +1,283 @@ +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_with_deepagents/subagents/connectors/google_drive/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/index.py new file mode 100644 index 000000000..074cba74c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/index.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/google_drive/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/trash_file.py new file mode 100644 index 000000000..832afff0d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/trash_file.py @@ -0,0 +1,262 @@ +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_with_deepagents/subagents/connectors/jira/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py new file mode 100644 index 000000000..8e606a129 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/jira/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/description.md new file mode 100644 index 000000000..2cd7e082a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/description.md @@ -0,0 +1 @@ +Use for Jira issue/project workflows: search issues, inspect fields, update tickets, and move work through workflow states. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/system_prompt.md new file mode 100644 index 000000000..4f4ae8a66 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/system_prompt.md @@ -0,0 +1,46 @@ +You are the Jira MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Jira MCP operations accurately, including discovery and issue mutation flows. + + + +- Runtime-provided Jira MCP tools for site/project discovery, issue search, create, and update. + + + +- Respect discovery dependencies (site/project/issue-type) before mutate calls. +- If required fields are missing or targets are ambiguous, return `status=blocked` with `missing_fields`. +- Do not guess keys/IDs. +- Never claim create/update success without tool confirmation. + + + +- Do not execute non-Jira tasks. + + + +- Never perform destructive/mutating actions without explicit target resolution. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved ambiguity, return `status=blocked` with candidates or missing fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/__init__.py new file mode 100644 index 000000000..768738118 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/__init__.py @@ -0,0 +1,11 @@ +"""Jira tools for creating, updating, and deleting issues.""" + +from .create_issue import create_create_jira_issue_tool +from .delete_issue import create_delete_jira_issue_tool +from .update_issue import create_update_jira_issue_tool + +__all__ = [ + "create_create_jira_issue_tool", + "create_delete_jira_issue_tool", + "create_update_jira_issue_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/create_issue.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/create_issue.py new file mode 100644 index 000000000..8b40dde65 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/create_issue.py @@ -0,0 +1,216 @@ +import asyncio +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.jira_history import JiraHistoryConnector +from app.services.jira import JiraToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_jira_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def create_jira_issue( + project_key: str, + summary: str, + issue_type: str = "Task", + description: str | None = None, + priority: str | None = None, + ) -> dict[str, Any]: + """Create a new issue in Jira. + + Use this tool when the user explicitly asks to create a new Jira issue/ticket. + + Args: + project_key: The Jira project key (e.g. "PROJ", "ENG"). + summary: Short, descriptive issue title. + issue_type: Issue type (default "Task"). Others: "Bug", "Story", "Epic". + description: Optional description body for the issue. + priority: Optional priority name (e.g. "High", "Medium", "Low"). + + Returns: + Dictionary with status, issue_key, and message. + + IMPORTANT: + - If status is "rejected", the user declined. Do NOT retry. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"create_jira_issue called: project_key='{project_key}', summary='{summary}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Jira tool not properly configured."} + + try: + metadata_service = JiraToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + return {"status": "error", "message": context["error"]} + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected Jira accounts need re-authentication.", + "connector_type": "jira", + } + + result = request_approval( + action_type="jira_issue_creation", + tool_name="create_jira_issue", + params={ + "project_key": project_key, + "summary": summary, + "issue_type": issue_type, + "description": description, + "priority": priority, + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_project_key = result.params.get("project_key", project_key) + final_summary = result.params.get("summary", summary) + final_issue_type = result.params.get("issue_type", issue_type) + final_description = result.params.get("description", description) + final_priority = result.params.get("priority", priority) + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_summary or not final_summary.strip(): + return {"status": "error", "message": "Issue summary cannot be empty."} + if not final_project_key: + return {"status": "error", "message": "A project must be selected."} + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return {"status": "error", "message": "No Jira connector found."} + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Jira connector is invalid.", + } + + try: + jira_history = JiraHistoryConnector( + session=db_session, connector_id=actual_connector_id + ) + jira_client = await jira_history._get_jira_client() + api_result = await asyncio.to_thread( + jira_client.create_issue, + project_key=final_project_key, + summary=final_summary, + issue_type=final_issue_type, + description=final_description, + priority=final_priority, + ) + except Exception as api_err: + if "status code 403" in str(api_err).lower(): + try: + _conn = connector + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Jira account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + issue_key = api_result.get("key", "") + issue_url = ( + f"{jira_history._base_url}/browse/{issue_key}" + if jira_history._base_url and issue_key + else "" + ) + + kb_message_suffix = "" + try: + from app.services.jira import JiraKBSyncService + + kb_service = JiraKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + issue_id=issue_key, + issue_identifier=issue_key, + issue_title=final_summary, + description=final_description, + state="To Do", + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "issue_key": issue_key, + "issue_url": issue_url, + "message": f"Jira issue {issue_key} created successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating Jira issue: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the issue.", + } + + return create_jira_issue diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/delete_issue.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/delete_issue.py new file mode 100644 index 000000000..6466c80ea --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/delete_issue.py @@ -0,0 +1,183 @@ +import asyncio +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.jira_history import JiraHistoryConnector +from app.services.jira import JiraToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_jira_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def delete_jira_issue( + issue_title_or_key: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete a Jira issue. + + Use this tool when the user asks to delete or remove a Jira issue. + + Args: + issue_title_or_key: The issue key (e.g. "PROJ-42") or title. + delete_from_kb: Whether to also remove from the knowledge base. + + Returns: + Dictionary with status, message, and deleted_from_kb. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message to the user. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"delete_jira_issue called: issue_title_or_key='{issue_title_or_key}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Jira tool not properly configured."} + + try: + metadata_service = JiraToolMetadataService(db_session) + context = await metadata_service.get_deletion_context( + search_space_id, user_id, issue_title_or_key + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "jira", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + issue_data = context["issue"] + issue_key = issue_data["issue_id"] + document_id = issue_data["document_id"] + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="jira_issue_deletion", + tool_name="delete_jira_issue", + params={ + "issue_key": issue_key, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_key = result.params.get("issue_key", issue_key) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this issue.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Jira connector is invalid.", + } + + try: + jira_history = JiraHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + jira_client = await jira_history._get_jira_client() + await asyncio.to_thread(jira_client.delete_issue, final_issue_key) + except Exception as api_err: + if "status code 403" in str(api_err).lower(): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Jira account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + + message = f"Jira issue {final_issue_key} deleted successfully." + if deleted_from_kb: + message += " Also removed from the knowledge base." + + return { + "status": "success", + "issue_key": final_issue_key, + "deleted_from_kb": deleted_from_kb, + "message": message, + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting Jira issue: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while deleting the issue.", + } + + return delete_jira_issue diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/index.py new file mode 100644 index 000000000..c08909fcf --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/index.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/jira/tools/update_issue.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/update_issue.py new file mode 100644 index 000000000..f6e586a2e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/update_issue.py @@ -0,0 +1,226 @@ +import asyncio +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.jira_history import JiraHistoryConnector +from app.services.jira import JiraToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_jira_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + @tool + async def update_jira_issue( + issue_title_or_key: str, + new_summary: str | None = None, + new_description: str | None = None, + new_priority: str | None = None, + ) -> dict[str, Any]: + """Update an existing Jira issue. + + Use this tool when the user asks to modify, edit, or update a Jira issue. + + Args: + issue_title_or_key: The issue key (e.g. "PROJ-42") or title to identify the issue. + new_summary: Optional new title/summary for the issue. + new_description: Optional new description. + new_priority: Optional new priority name. + + Returns: + Dictionary with status and message. + + IMPORTANT: + - If status is "rejected", do NOT retry. + - If status is "not_found", relay the message and ask user to verify. + - If status is "insufficient_permissions", inform user to re-authenticate. + """ + logger.info( + f"update_jira_issue called: issue_title_or_key='{issue_title_or_key}'" + ) + + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Jira tool not properly configured."} + + try: + metadata_service = JiraToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, issue_title_or_key + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "jira", + } + if "not found" in error_msg.lower(): + return {"status": "not_found", "message": error_msg} + return {"status": "error", "message": error_msg} + + issue_data = context["issue"] + issue_key = issue_data["issue_id"] + document_id = issue_data.get("document_id") + connector_id_from_context = context.get("account", {}).get("id") + + result = request_approval( + action_type="jira_issue_update", + tool_name="update_jira_issue", + params={ + "issue_key": issue_key, + "document_id": document_id, + "new_summary": new_summary, + "new_description": new_description, + "new_priority": new_priority, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_key = result.params.get("issue_key", issue_key) + final_summary = result.params.get("new_summary", new_summary) + final_description = result.params.get("new_description", new_description) + final_priority = result.params.get("new_priority", new_priority) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_document_id = result.params.get("document_id", document_id) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if not final_connector_id: + return { + "status": "error", + "message": "No connector found for this issue.", + } + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.JIRA_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Jira connector is invalid.", + } + + fields: dict[str, Any] = {} + if final_summary: + fields["summary"] = final_summary + if final_description is not None: + fields["description"] = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": final_description}], + } + ], + } + if final_priority: + fields["priority"] = {"name": final_priority} + + if not fields: + return {"status": "error", "message": "No changes specified."} + + try: + jira_history = JiraHistoryConnector( + session=db_session, connector_id=final_connector_id + ) + jira_client = await jira_history._get_jira_client() + await asyncio.to_thread( + jira_client.update_issue, final_issue_key, fields + ) + except Exception as api_err: + if "status code 403" in str(api_err).lower(): + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await db_session.commit() + except Exception: + pass + return { + "status": "insufficient_permissions", + "connector_id": final_connector_id, + "message": "This Jira account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + issue_url = ( + f"{jira_history._base_url}/browse/{final_issue_key}" + if jira_history._base_url and final_issue_key + else "" + ) + + kb_message_suffix = "" + if final_document_id: + try: + from app.services.jira import JiraKBSyncService + + kb_service = JiraKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=final_document_id, + issue_id=final_issue_key, + user_id=user_id, + search_space_id=search_space_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + else: + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + except Exception as kb_err: + logger.warning(f"KB sync after update failed: {kb_err}") + kb_message_suffix = ( + " The knowledge base will be updated in the next sync." + ) + + return { + "status": "success", + "issue_key": final_issue_key, + "issue_url": issue_url, + "message": f"Jira issue {final_issue_key} updated successfully.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error updating Jira issue: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while updating the issue.", + } + + return update_jira_issue From 6e54fb00e3cba60745ebffc84984d5596cf3bc28 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 069/131] Add Linear, Luma, and Notion connector route slices. --- .../subagents/connectors/linear/__init__.py | 0 .../subagents/connectors/linear/agent.py | 54 +++ .../connectors/linear/description.md | 1 + .../connectors/linear/system_prompt.md | 45 +++ .../connectors/linear/tools/__init__.py | 11 + .../connectors/linear/tools/create_issue.py | 248 ++++++++++++++ .../connectors/linear/tools/delete_issue.py | 245 ++++++++++++++ .../connectors/linear/tools/index.py | 32 ++ .../connectors/linear/tools/update_issue.py | 318 ++++++++++++++++++ .../subagents/connectors/luma/__init__.py | 0 .../subagents/connectors/luma/agent.py | 54 +++ .../subagents/connectors/luma/description.md | 1 + .../connectors/luma/system_prompt.md | 55 +++ .../connectors/luma/tools/__init__.py | 15 + .../subagents/connectors/luma/tools/_auth.py | 39 +++ .../connectors/luma/tools/create_event.py | 129 +++++++ .../subagents/connectors/luma/tools/index.py | 30 ++ .../connectors/luma/tools/list_events.py | 111 ++++++ .../connectors/luma/tools/read_event.py | 92 +++++ .../subagents/connectors/notion/__init__.py | 0 .../subagents/connectors/notion/agent.py | 54 +++ .../connectors/notion/description.md | 1 + .../connectors/notion/system_prompt.md | 56 +++ .../connectors/notion/tools/__init__.py | 11 + .../connectors/notion/tools/create_page.py | 244 ++++++++++++++ .../connectors/notion/tools/delete_page.py | 262 +++++++++++++++ .../connectors/notion/tools/index.py | 31 ++ .../connectors/notion/tools/update_page.py | 265 +++++++++++++++ 28 files changed, 2404 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/create_issue.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/delete_issue.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/update_issue.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/_auth.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/create_event.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/list_events.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/read_event.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/create_page.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/delete_page.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/update_page.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py new file mode 100644 index 000000000..f95d07010 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/linear/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/description.md new file mode 100644 index 000000000..6ad02c788 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/description.md @@ -0,0 +1 @@ +Use for Linear issue/project work: find/create issues, update status/assignees, review project progress, and inspect cycles. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/system_prompt.md new file mode 100644 index 000000000..ce91cc49f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/system_prompt.md @@ -0,0 +1,45 @@ +You are the Linear MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Linear MCP operations accurately using only available runtime tools. + + + +- Runtime-provided Linear MCP tools for issues/projects/teams/workflows. + + + +- Follow tool descriptions exactly; do not assume unsupported endpoints. +- If required identifiers or context are missing, return `status=blocked` with `missing_fields` and supervisor `next_step`. +- Never invent IDs, statuses, or mutation outcomes. + + + +- Do not execute non-Linear tasks. + + + +- Never claim mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/__init__.py new file mode 100644 index 000000000..31acf1e2a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/__init__.py @@ -0,0 +1,11 @@ +"""Linear tools for creating, updating, and deleting issues.""" + +from .create_issue import create_create_linear_issue_tool +from .delete_issue import create_delete_linear_issue_tool +from .update_issue import create_update_linear_issue_tool + +__all__ = [ + "create_create_linear_issue_tool", + "create_delete_linear_issue_tool", + "create_update_linear_issue_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/create_issue.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/create_issue.py new file mode 100644 index 000000000..ff254e133 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/create_issue.py @@ -0,0 +1,248 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.linear_connector import LinearAPIError, LinearConnector +from app.services.linear import LinearToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_linear_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the create_linear_issue tool. + + Args: + db_session: Database session for accessing the Linear connector + search_space_id: Search space ID to find the Linear connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured create_linear_issue tool + """ + + @tool + async def create_linear_issue( + title: str, + description: str | None = None, + ) -> dict[str, Any]: + """Create a new issue in Linear. + + Use this tool when the user explicitly asks to create, add, or file + a new issue / ticket / task in Linear. The user MUST describe the issue + before you call this tool. If the request is vague, ask what the issue + should be about. Never call this tool without a clear topic from the user. + + Args: + title: Short, descriptive issue title. Infer from the user's request. + description: Optional markdown body for the issue. Generate from context. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - issue_id: Linear issue UUID (if success) + - identifier: Human-readable ID like "ENG-42" (if success) + - url: URL to the created issue (if success) + - message: Result message + + IMPORTANT: If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I won't create the issue.") + and move on. Do NOT retry, troubleshoot, or suggest alternatives. + + Examples: + - "Create a Linear issue for the login bug" + - "File a ticket about the payment timeout problem" + - "Add an issue for the broken search feature" + """ + logger.info(f"create_linear_issue called: title='{title}'") + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Linear tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Linear tool not properly configured. Please contact support.", + } + + try: + metadata_service = LinearToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return {"status": "error", "message": context["error"]} + + workspaces = context.get("workspaces", []) + if workspaces and all(w.get("auth_expired") for w in workspaces): + logger.warning("All Linear accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Linear accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "linear", + } + + logger.info(f"Requesting approval for creating Linear issue: '{title}'") + result = request_approval( + action_type="linear_issue_creation", + tool_name="create_linear_issue", + params={ + "title": title, + "description": description, + "team_id": None, + "state_id": None, + "assignee_id": None, + "priority": None, + "label_ids": [], + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + logger.info("Linear issue creation rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_title = result.params.get("title", title) + final_description = result.params.get("description", description) + final_team_id = result.params.get("team_id") + final_state_id = result.params.get("state_id") + final_assignee_id = result.params.get("assignee_id") + final_priority = result.params.get("priority") + final_label_ids = result.params.get("label_ids") or [] + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_title or not final_title.strip(): + logger.error("Title is empty or contains only whitespace") + return {"status": "error", "message": "Issue title cannot be empty."} + if not final_team_id: + return { + "status": "error", + "message": "A team must be selected to create an issue.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Linear connector found. Please connect Linear in your workspace settings.", + } + actual_connector_id = connector.id + logger.info(f"Found Linear connector: id={actual_connector_id}") + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Linear connector is invalid or has been disconnected.", + } + logger.info(f"Validated Linear connector: id={actual_connector_id}") + + logger.info( + f"Creating Linear issue with final params: title='{final_title}'" + ) + linear_client = LinearConnector( + session=db_session, connector_id=actual_connector_id + ) + result = await linear_client.create_issue( + team_id=final_team_id, + title=final_title, + description=final_description, + state_id=final_state_id, + assignee_id=final_assignee_id, + priority=final_priority, + label_ids=final_label_ids if final_label_ids else None, + ) + + if result.get("status") == "error": + logger.error(f"Failed to create Linear issue: {result.get('message')}") + return {"status": "error", "message": result.get("message")} + + logger.info( + f"Linear issue created: {result.get('identifier')} - {result.get('title')}" + ) + + kb_message_suffix = "" + try: + from app.services.linear import LinearKBSyncService + + kb_service = LinearKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + issue_id=result.get("id"), + issue_identifier=result.get("identifier", ""), + issue_title=result.get("title", final_title), + issue_url=result.get("url"), + description=final_description, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This issue will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "issue_id": result.get("id"), + "identifier": result.get("identifier"), + "url": result.get("url"), + "message": (result.get("message", "") + kb_message_suffix), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Linear issue: {e}", exc_info=True) + if isinstance(e, ValueError | LinearAPIError): + message = str(e) + else: + message = ( + "Something went wrong while creating the issue. Please try again." + ) + return {"status": "error", "message": message} + + return create_linear_issue diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/delete_issue.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/delete_issue.py new file mode 100644 index 000000000..29ef0cdf2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/delete_issue.py @@ -0,0 +1,245 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.linear_connector import LinearAPIError, LinearConnector +from app.services.linear import LinearToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_linear_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the delete_linear_issue tool. + + Args: + db_session: Database session for accessing the Linear connector + search_space_id: Search space ID to find the Linear connector + user_id: User ID for finding the correct Linear connector + connector_id: Optional specific connector ID (if known) + + Returns: + Configured delete_linear_issue tool + """ + + @tool + async def delete_linear_issue( + issue_ref: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Archive (delete) a Linear issue. + + Use this tool when the user asks to delete, remove, or archive a Linear issue. + Note that Linear archives issues rather than permanently deleting them + (they can be restored from the archive). + + + Args: + issue_ref: The issue to delete. Can be the issue title (e.g. "Fix login bug"), + the identifier (e.g. "ENG-42"), or the full document title + (e.g. "ENG-42: Fix login bug"). + delete_from_kb: Whether to also remove the issue from the knowledge base. + Default is False. Set to True to remove from both Linear + and the knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - identifier: Human-readable ID like "ENG-42" (if success) + - message: Success or error message + - deleted_from_kb: Whether the issue was also removed from the knowledge base (if success) + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I won't delete the issue.") + and move on. Do NOT ask for alternatives or troubleshoot. + - If status is "not_found", inform the user conversationally using the exact message + provided. Do NOT treat this as an error. Simply relay the message and ask the user + to verify the issue title or identifier, or check if it has been indexed. + Examples: + - "Delete the 'Fix login bug' Linear issue" + - "Archive ENG-42" + - "Remove the 'Old payment flow' issue from Linear" + """ + logger.info( + f"delete_linear_issue called: issue_ref='{issue_ref}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Linear tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Linear tool not properly configured. Please contact support.", + } + + try: + metadata_service = LinearToolMetadataService(db_session) + context = await metadata_service.get_delete_context( + search_space_id, user_id, issue_ref + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + logger.warning(f"Auth expired for delete context: {error_msg}") + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "linear", + } + if "not found" in error_msg.lower(): + logger.warning(f"Issue not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + else: + logger.error(f"Failed to fetch delete context: {error_msg}") + return {"status": "error", "message": error_msg} + + issue_id = context["issue"]["id"] + issue_identifier = context["issue"].get("identifier", "") + document_id = context["issue"]["document_id"] + connector_id_from_context = context.get("workspace", {}).get("id") + + logger.info( + f"Requesting approval for deleting Linear issue: '{issue_ref}' " + f"(id={issue_id}, delete_from_kb={delete_from_kb})" + ) + result = request_approval( + action_type="linear_issue_deletion", + tool_name="delete_linear_issue", + params={ + "issue_id": issue_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + logger.info("Linear issue deletion rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_id = result.params.get("issue_id", issue_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + logger.info( + f"Deleting Linear issue with final params: issue_id={final_issue_id}, " + f"connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if final_connector_id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Linear connector is invalid or has been disconnected.", + } + actual_connector_id = connector.id + logger.info(f"Validated Linear connector: id={actual_connector_id}") + else: + logger.error("No connector found for this issue") + return { + "status": "error", + "message": "No connector found for this issue.", + } + + linear_client = LinearConnector( + session=db_session, connector_id=actual_connector_id + ) + + result = await linear_client.archive_issue(issue_id=final_issue_id) + + logger.info( + f"archive_issue result: {result.get('status')} - {result.get('message', '')}" + ) + + deleted_from_kb = False + if ( + result.get("status") == "success" + and final_delete_from_kb + and document_id + ): + try: + from app.db import Document + + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + result["warning"] = ( + f"Issue archived in Linear, but failed to remove from knowledge base: {e!s}" + ) + + if result.get("status") == "success": + result["deleted_from_kb"] = deleted_from_kb + if issue_identifier: + result["message"] = ( + f"Issue {issue_identifier} archived successfully." + ) + if deleted_from_kb: + result["message"] = ( + f"{result.get('message', '')} Also removed from the knowledge base." + ) + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error deleting Linear issue: {e}", exc_info=True) + if isinstance(e, ValueError | LinearAPIError): + message = str(e) + else: + message = ( + "Something went wrong while deleting the issue. Please try again." + ) + return {"status": "error", "message": message} + + return delete_linear_issue diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/index.py new file mode 100644 index 000000000..ef668ffb1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/index.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/linear/tools/update_issue.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/update_issue.py new file mode 100644 index 000000000..f35d0dddd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/update_issue.py @@ -0,0 +1,318 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.linear_connector import LinearAPIError, LinearConnector +from app.services.linear import LinearKBSyncService, LinearToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_linear_issue_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the update_linear_issue tool. + + Args: + db_session: Database session for accessing the Linear connector + search_space_id: Search space ID to find the Linear connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured update_linear_issue tool + """ + + @tool + async def update_linear_issue( + issue_ref: str, + new_title: str | None = None, + new_description: str | None = None, + new_state_name: str | None = None, + new_assignee_email: str | None = None, + new_priority: int | None = None, + new_label_names: list[str] | None = None, + ) -> dict[str, Any]: + """Update an existing Linear issue that has been indexed in the knowledge base. + + Use this tool when the user asks to modify, change, or update a Linear issue — + for example, changing its status, reassigning it, updating its title or description, + adjusting its priority, or changing its labels. + + Only issues already indexed in the knowledge base can be updated. + + Args: + issue_ref: The issue to update. Can be the issue title (e.g. "Fix login bug"), + the identifier (e.g. "ENG-42"), or the full document title + (e.g. "ENG-42: Fix login bug"). Matched case-insensitively. + new_title: New title for the issue (optional). + new_description: New markdown body for the issue (optional). + new_state_name: New workflow state name (e.g. "In Progress", "Done"). + Matched case-insensitively against the team's states. + new_assignee_email: Email address of the new assignee. + Matched case-insensitively against the team's members. + new_priority: New priority (0 = No Priority, 1 = Urgent, 2 = High, + 3 = Medium, 4 = Low). + new_label_names: New set of label names to apply. + Matched case-insensitively against the team's labels. + Unrecognised names are silently skipped. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - identifier: Human-readable ID like "ENG-42" (if success) + - url: URL to the updated issue (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't update the issue.") + and move on. Do NOT ask for alternatives or troubleshoot. + - If status is "not_found", inform the user conversationally using the exact message + provided. Do NOT treat this as an error. Simply relay the message and ask the user + to verify the issue title or identifier, or check if it has been indexed. + + Examples: + - "Mark the 'Fix login bug' issue as done" + - "Assign ENG-42 to john@company.com" + - "Change the priority of 'Payment timeout' to urgent" + """ + logger.info(f"update_linear_issue called: issue_ref='{issue_ref}'") + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Linear tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Linear tool not properly configured. Please contact support.", + } + + try: + metadata_service = LinearToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, issue_ref + ) + + if "error" in context: + error_msg = context["error"] + if context.get("auth_expired"): + logger.warning(f"Auth expired for update context: {error_msg}") + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "linear", + } + if "not found" in error_msg.lower(): + logger.warning(f"Issue not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + else: + logger.error(f"Failed to fetch update context: {error_msg}") + return {"status": "error", "message": error_msg} + + issue_id = context["issue"]["id"] + document_id = context["issue"]["document_id"] + connector_id_from_context = context.get("workspace", {}).get("id") + + team = context.get("team", {}) + new_state_id = _resolve_state(team, new_state_name) + new_assignee_id = _resolve_assignee(team, new_assignee_email) + new_label_ids = _resolve_labels(team, new_label_names) + + logger.info( + f"Requesting approval for updating Linear issue: '{issue_ref}' (id={issue_id})" + ) + result = request_approval( + action_type="linear_issue_update", + tool_name="update_linear_issue", + params={ + "issue_id": issue_id, + "document_id": document_id, + "new_title": new_title, + "new_description": new_description, + "new_state_id": new_state_id, + "new_assignee_id": new_assignee_id, + "new_priority": new_priority, + "new_label_ids": new_label_ids, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + logger.info("Linear issue update rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_issue_id = result.params.get("issue_id", issue_id) + final_document_id = result.params.get("document_id", document_id) + final_new_title = result.params.get("new_title", new_title) + final_new_description = result.params.get( + "new_description", new_description + ) + final_new_state_id = result.params.get("new_state_id", new_state_id) + final_new_assignee_id = result.params.get( + "new_assignee_id", new_assignee_id + ) + final_new_priority = result.params.get("new_priority", new_priority) + final_new_label_ids: list[str] | None = result.params.get( + "new_label_ids", new_label_ids + ) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + + if not final_connector_id: + logger.error("No connector found for this issue") + return { + "status": "error", + "message": "No connector found for this issue.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Linear connector is invalid or has been disconnected.", + } + logger.info(f"Validated Linear connector: id={final_connector_id}") + + logger.info( + f"Updating Linear issue with final params: issue_id={final_issue_id}" + ) + linear_client = LinearConnector( + session=db_session, connector_id=final_connector_id + ) + updated_issue = await linear_client.update_issue( + issue_id=final_issue_id, + title=final_new_title, + description=final_new_description, + state_id=final_new_state_id, + assignee_id=final_new_assignee_id, + priority=final_new_priority, + label_ids=final_new_label_ids, + ) + + if updated_issue.get("status") == "error": + logger.error( + f"Failed to update Linear issue: {updated_issue.get('message')}" + ) + return { + "status": "error", + "message": updated_issue.get("message"), + } + + logger.info( + f"update_issue result: {updated_issue.get('identifier')} - {updated_issue.get('title')}" + ) + + if final_document_id is not None: + logger.info( + f"Updating knowledge base for document {final_document_id}..." + ) + kb_service = LinearKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=final_document_id, + issue_id=final_issue_id, + user_id=user_id, + search_space_id=search_space_id, + ) + if kb_result["status"] == "success": + logger.info( + f"Knowledge base successfully updated for issue {final_issue_id}" + ) + kb_message = " Your knowledge base has also been updated." + elif kb_result["status"] == "not_indexed": + kb_message = " This issue will be added to your knowledge base in the next scheduled sync." + else: + logger.warning( + f"KB update failed for issue {final_issue_id}: {kb_result.get('message')}" + ) + kb_message = " Your knowledge base will be updated in the next scheduled sync." + else: + kb_message = "" + + identifier = updated_issue.get("identifier") + default_msg = f"Issue {identifier} updated successfully." + return { + "status": "success", + "identifier": identifier, + "url": updated_issue.get("url"), + "message": f"{updated_issue.get('message', default_msg)}{kb_message}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error updating Linear issue: {e}", exc_info=True) + if isinstance(e, ValueError | LinearAPIError): + message = str(e) + else: + message = ( + "Something went wrong while updating the issue. Please try again." + ) + return {"status": "error", "message": message} + + return update_linear_issue + + +def _resolve_state(team: dict, state_name: str | None) -> str | None: + if not state_name: + return None + name_lower = state_name.lower() + for state in team.get("states", []): + if state.get("name", "").lower() == name_lower: + return state["id"] + return None + + +def _resolve_assignee(team: dict, assignee_email: str | None) -> str | None: + if not assignee_email: + return None + email_lower = assignee_email.lower() + for member in team.get("members", []): + if member.get("email", "").lower() == email_lower: + return member["id"] + return None + + +def _resolve_labels(team: dict, label_names: list[str] | None) -> list[str] | None: + if label_names is None: + return None + if not label_names: + return [] + name_set = {n.lower() for n in label_names} + return [ + label["id"] + for label in team.get("labels", []) + if label.get("name", "").lower() in name_set + ] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py new file mode 100644 index 000000000..7b53d4edd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/luma/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/description.md new file mode 100644 index 000000000..9eaae4ac5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/description.md @@ -0,0 +1 @@ +Use for Luma event operations: list events, inspect event details, and create new events. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/system_prompt.md new file mode 100644 index 000000000..a2b4b7391 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/system_prompt.md @@ -0,0 +1,55 @@ +You are the Luma operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Luma event listing, reads, and creation accurately. + + + +- `list_luma_events` +- `read_luma_event` +- `create_luma_event` + + + +- Use only tools in ``. +- Resolve relative dates against runtime timestamp. +- If required event fields are missing, return `status=blocked` with `missing_fields`. +- Never invent event IDs/times or creation outcomes. + + + +- Do not perform non-Luma tasks. + + + +- Never claim event creation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On missing required fields, return `status=blocked` with `missing_fields`. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "event_id": string | null, + "title": string | null, + "start_at": string (ISO 8601 with timezone) | null, + "matched_candidates": [ + { "event_id": string, "title": string | null, "start_at": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/__init__.py new file mode 100644 index 000000000..255119bee --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/__init__.py @@ -0,0 +1,15 @@ +from app.agents.new_chat.tools.luma.create_event import ( + create_create_luma_event_tool, +) +from app.agents.new_chat.tools.luma.list_events import ( + create_list_luma_events_tool, +) +from app.agents.new_chat.tools.luma.read_event import ( + create_read_luma_event_tool, +) + +__all__ = [ + "create_create_luma_event_tool", + "create_list_luma_events_tool", + "create_read_luma_event_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/_auth.py new file mode 100644 index 000000000..c6d1cd148 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/_auth.py @@ -0,0 +1,39 @@ +"""Builds Luma API auth for connector-backed event tools.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import SearchSourceConnector, SearchSourceConnectorType + +LUMA_API = "https://public-api.luma.com/v1" + + +async def get_luma_connector( + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> SearchSourceConnector | None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LUMA_CONNECTOR, + ) + ) + return result.scalars().first() + + +def get_api_key(connector: SearchSourceConnector) -> str: + """Extract the API key from connector config (handles both key names).""" + key = connector.config.get("api_key") or connector.config.get("LUMA_API_KEY") + if not key: + raise ValueError("Luma API key not found in connector config.") + return key + + +def luma_headers(api_key: str) -> dict[str, str]: + return { + "Content-Type": "application/json", + "x-luma-api-key": api_key, + } diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/create_event.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/create_event.py new file mode 100644 index 000000000..0a24a988f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/create_event.py @@ -0,0 +1,129 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval + +from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers + +logger = logging.getLogger(__name__) + + +def create_create_luma_event_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_luma_event( + name: str, + start_at: str, + end_at: str, + description: str | None = None, + timezone: str = "UTC", + ) -> dict[str, Any]: + """Create a new event on Luma. + + Args: + name: The event title. + start_at: Start time in ISO 8601 format (e.g. "2026-05-01T18:00:00"). + end_at: End time in ISO 8601 format (e.g. "2026-05-01T20:00:00"). + description: Optional event description (markdown supported). + timezone: Timezone string (default "UTC", e.g. "America/New_York"). + + Returns: + Dictionary with status, event_id on success. + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Do NOT retry. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Luma tool not properly configured."} + + try: + connector = await get_luma_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Luma connector found."} + + result = request_approval( + action_type="luma_create_event", + tool_name="create_luma_event", + params={ + "name": name, + "start_at": start_at, + "end_at": end_at, + "description": description, + "timezone": timezone, + }, + context={"connector_id": connector.id}, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Event was not created.", + } + + final_name = result.params.get("name", name) + final_start = result.params.get("start_at", start_at) + final_end = result.params.get("end_at", end_at) + final_desc = result.params.get("description", description) + final_tz = result.params.get("timezone", timezone) + + api_key = get_api_key(connector) + headers = luma_headers(api_key) + + body: dict[str, Any] = { + "name": final_name, + "start_at": final_start, + "end_at": final_end, + "timezone": final_tz, + } + if final_desc: + body["description_md"] = final_desc + + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.post( + f"{LUMA_API}/event/create", + headers=headers, + json=body, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Luma Plus subscription required to create events via API.", + } + if resp.status_code not in (200, 201): + return { + "status": "error", + "message": f"Luma API error: {resp.status_code} — {resp.text[:200]}", + } + + data = resp.json() + event_id = data.get("api_id") or data.get("event", {}).get("api_id") + + return { + "status": "success", + "event_id": event_id, + "message": f"Event '{final_name}' created on Luma.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error creating Luma event: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to create Luma event."} + + return create_luma_event diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/index.py new file mode 100644 index 000000000..2be92a227 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/index.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/luma/tools/list_events.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/list_events.py new file mode 100644 index 000000000..aec5ad220 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/list_events.py @@ -0,0 +1,111 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers + +logger = logging.getLogger(__name__) + + +def create_list_luma_events_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def list_luma_events( + max_results: int = 25, + ) -> dict[str, Any]: + """List upcoming and recent Luma events. + + Args: + max_results: Maximum events to return (default 25, max 50). + + Returns: + Dictionary with status and a list of events including + event_id, name, start_at, end_at, location, url. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Luma tool not properly configured."} + + max_results = min(max_results, 50) + + try: + connector = await get_luma_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Luma connector found."} + + api_key = get_api_key(connector) + headers = luma_headers(api_key) + + all_entries: list[dict] = [] + cursor = None + + async with httpx.AsyncClient(timeout=20.0) as client: + while len(all_entries) < max_results: + params: dict[str, Any] = { + "limit": min(100, max_results - len(all_entries)) + } + if cursor: + params["cursor"] = cursor + + resp = await client.get( + f"{LUMA_API}/calendar/list-events", + headers=headers, + params=params, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Luma API error: {resp.status_code}", + } + + data = resp.json() + entries = data.get("entries", []) + if not entries: + break + all_entries.extend(entries) + + next_cursor = data.get("next_cursor") + if not next_cursor: + break + cursor = next_cursor + + events = [] + for entry in all_entries[:max_results]: + ev = entry.get("event", {}) + geo = ev.get("geo_info", {}) + events.append( + { + "event_id": entry.get("api_id"), + "name": ev.get("name", "Untitled"), + "start_at": ev.get("start_at", ""), + "end_at": ev.get("end_at", ""), + "timezone": ev.get("timezone", ""), + "location": geo.get("name", ""), + "url": ev.get("url", ""), + "visibility": ev.get("visibility", ""), + } + ) + + return {"status": "success", "events": events, "total": len(events)} + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error listing Luma events: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to list Luma events."} + + return list_luma_events diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/read_event.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/read_event.py new file mode 100644 index 000000000..b37a9d617 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/read_event.py @@ -0,0 +1,92 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import LUMA_API, get_api_key, get_luma_connector, luma_headers + +logger = logging.getLogger(__name__) + + +def create_read_luma_event_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def read_luma_event(event_id: str) -> dict[str, Any]: + """Read detailed information about a specific Luma event. + + Args: + event_id: The Luma event API ID (from list_luma_events). + + Returns: + Dictionary with status and full event details including + description, attendees count, meeting URL. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Luma tool not properly configured."} + + try: + connector = await get_luma_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Luma connector found."} + + api_key = get_api_key(connector) + headers = luma_headers(api_key) + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{LUMA_API}/events/{event_id}", + headers=headers, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } + if resp.status_code == 404: + return { + "status": "not_found", + "message": f"Event '{event_id}' not found.", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Luma API error: {resp.status_code}", + } + + data = resp.json() + ev = data.get("event", data) + geo = ev.get("geo_info", {}) + + event_detail = { + "event_id": event_id, + "name": ev.get("name", ""), + "description": ev.get("description", ""), + "start_at": ev.get("start_at", ""), + "end_at": ev.get("end_at", ""), + "timezone": ev.get("timezone", ""), + "location_name": geo.get("name", ""), + "address": geo.get("address", ""), + "url": ev.get("url", ""), + "meeting_url": ev.get("meeting_url", ""), + "visibility": ev.get("visibility", ""), + "cover_url": ev.get("cover_url", ""), + } + + return {"status": "success", "event": event_detail} + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error reading Luma event: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to read Luma event."} + + return read_luma_event diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py new file mode 100644 index 000000000..7d15e5cc0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/notion/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/description.md new file mode 100644 index 000000000..f1d51c18a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/description.md @@ -0,0 +1 @@ +Use for Notion workspace pages: create pages, update page content, and delete pages. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/system_prompt.md new file mode 100644 index 000000000..a40e9f4d0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/system_prompt.md @@ -0,0 +1,56 @@ +You are the Notion operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Notion page operations accurately in the connected workspace. + + + +- `create_notion_page` +- `update_notion_page` +- `delete_notion_page` + + + +- Use only tools in ``. +- If target page context is unclear, do not ask the user directly; return `status=blocked` with candidate options and supervisor `next_step`. +- Never invent page IDs, titles, or mutation outcomes. + + + +- Do not perform non-Notion tasks. + + + +- Before update/delete, ensure the target page match is explicit. +- Never claim mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise retry/recovery `next_step`. +- On ambiguous target, return `status=blocked` with candidate options. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "page_id": string | null, + "page_title": string | null, + "matched_candidates": [ + { "page_id": string, "page_title": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} + +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. +- On ambiguity, include candidate options in `evidence.matched_candidates`. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/__init__.py new file mode 100644 index 000000000..6ce825dca --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/__init__.py @@ -0,0 +1,11 @@ +"""Notion tools for creating, updating, and deleting pages.""" + +from .create_page import create_create_notion_page_tool +from .delete_page import create_delete_notion_page_tool +from .update_page import create_update_notion_page_tool + +__all__ = [ + "create_create_notion_page_tool", + "create_delete_notion_page_tool", + "create_update_notion_page_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/create_page.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/create_page.py new file mode 100644 index 000000000..6efffe960 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/create_page.py @@ -0,0 +1,244 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector +from app.services.notion import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_create_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the create_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured create_notion_page tool + """ + + @tool + async def create_notion_page( + title: str, + content: str | None = None, + ) -> dict[str, Any]: + """Create a new page in Notion with the given title and content. + + Use this tool when the user asks you to create, save, or publish + something to Notion. The page will be created in the user's + configured Notion workspace. The user MUST specify a topic before you + call this tool. If the request does not contain a topic (e.g. "create a + notion page"), ask what the page should be about. Never call this tool + without a clear topic from the user. + + Args: + title: The title of the Notion page. + content: Optional markdown content for the page body (supports headings, lists, paragraphs). + Generate this yourself based on the user's topic. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - page_id: Created page ID (if success) + - url: URL to the created page (if success) + - title: Page title (if success) + - message: Result message + + IMPORTANT: If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't create the page.") + and move on. Do NOT troubleshoot or suggest alternatives. + + Examples: + - "Create a Notion page about our Q2 roadmap" + - "Save a summary of today's discussion to Notion" + """ + logger.info(f"create_notion_page called: title='{title}'") + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Notion tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + try: + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_creation_context( + search_space_id, user_id + ) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return { + "status": "error", + "message": context["error"], + } + + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning("All Notion accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Notion accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "notion", + } + + logger.info(f"Requesting approval for creating Notion page: '{title}'") + result = request_approval( + action_type="notion_page_creation", + tool_name="create_notion_page", + params={ + "title": title, + "content": content, + "parent_page_id": None, + "connector_id": connector_id, + }, + context=context, + ) + + if result.rejected: + logger.info("Notion page creation rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_title = result.params.get("title", title) + final_content = result.params.get("content", content) + final_parent_page_id = result.params.get("parent_page_id") + final_connector_id = result.params.get("connector_id", connector_id) + + if not final_title or not final_title.strip(): + logger.error("Title is empty or contains only whitespace") + return { + "status": "error", + "message": "Page title cannot be empty. Please provide a valid title.", + } + + logger.info( + f"Creating Notion page with final params: title='{final_title}'" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + actual_connector_id = final_connector_id + if actual_connector_id is None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.warning( + f"No Notion connector found for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "No Notion connector found. Please connect Notion in your workspace settings.", + } + + actual_connector_id = connector.id + logger.info(f"Found Notion connector: id={actual_connector_id}") + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == actual_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={actual_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + logger.info(f"Validated Notion connector: id={actual_connector_id}") + + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + result = await notion_connector.create_page( + title=final_title, + content=final_content, + parent_page_id=final_parent_page_id, + ) + logger.info( + f"create_page result: {result.get('status')} - {result.get('message', '')}" + ) + + if result.get("status") == "success": + kb_message_suffix = "" + try: + from app.services.notion import NotionKBSyncService + + kb_service = NotionKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + page_id=result.get("page_id"), + page_title=result.get("title", final_title), + page_url=result.get("url"), + content=final_content, + connector_id=actual_connector_id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = ( + " Your knowledge base has also been updated." + ) + else: + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." + + result["message"] = result.get("message", "") + kb_message_suffix + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Notion page: {e}", exc_info=True) + if isinstance(e, ValueError | NotionAPIError): + message = str(e) + else: + message = ( + "Something went wrong while creating the page. Please try again." + ) + return {"status": "error", "message": message} + + return create_notion_page diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/delete_page.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/delete_page.py new file mode 100644 index 000000000..07f7583d2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/delete_page.py @@ -0,0 +1,262 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector +from app.services.notion.tool_metadata_service import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_delete_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the delete_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for finding the correct Notion connector + connector_id: Optional specific connector ID (if known) + + Returns: + Configured delete_notion_page tool + """ + + @tool + async def delete_notion_page( + page_title: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Delete (archive) a Notion page. + + Use this tool when the user asks you to delete, remove, or archive + a Notion page. Note that Notion doesn't permanently delete pages, + it archives them (they can be restored from trash). + + Args: + page_title: The title of the Notion page to delete. + delete_from_kb: Whether to also remove the page from the knowledge base. + Default is False. + Set to True to permanently remove from both Notion and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - page_id: Deleted page ID (if success) + - message: Success or error message + - deleted_from_kb: Whether the page was also removed from knowledge base (if success) + + Examples: + - "Delete the 'Meeting Notes' Notion page" + - "Remove the 'Old Project Plan' Notion page" + - "Archive the 'Draft Ideas' Notion page" + """ + logger.info( + f"delete_notion_page called: page_title='{page_title}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Notion tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + try: + # Get page context (page_id, account, title) from indexed data + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_delete_context( + search_space_id, user_id, page_title + ) + + if "error" in context: + error_msg = context["error"] + # Check if it's a "not found" error (softer handling for LLM) + if "not found" in error_msg.lower(): + logger.warning(f"Page not found: {error_msg}") + return { + "status": "not_found", + "message": error_msg, + } + else: + logger.error(f"Failed to fetch delete context: {error_msg}") + return { + "status": "error", + "message": error_msg, + } + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Notion account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Notion account for this page needs re-authentication. Please re-authenticate in your connector settings.", + } + + page_id = context.get("page_id") + connector_id_from_context = account.get("id") + document_id = context.get("document_id") + + logger.info( + f"Requesting approval for deleting Notion page: '{page_title}' (page_id={page_id}, delete_from_kb={delete_from_kb})" + ) + + result = request_approval( + action_type="notion_page_deletion", + tool_name="delete_notion_page", + params={ + "page_id": page_id, + "connector_id": connector_id_from_context, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + logger.info("Notion page deletion rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + logger.info( + f"Deleting Notion page with final params: page_id={final_page_id}, connector_id={final_connector_id}, delete_from_kb={final_delete_from_kb}" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + # Validate the connector + if final_connector_id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + actual_connector_id = connector.id + logger.info(f"Validated Notion connector: id={actual_connector_id}") + else: + logger.error("No connector found for this page") + return { + "status": "error", + "message": "No connector found for this page.", + } + + # Create connector instance + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + # Delete the page from Notion + result = await notion_connector.delete_page(page_id=final_page_id) + logger.info( + f"delete_page result: {result.get('status')} - {result.get('message', '')}" + ) + + # If deletion was successful and user wants to delete from KB + deleted_from_kb = False + if ( + result.get("status") == "success" + and final_delete_from_kb + and document_id + ): + try: + from sqlalchemy.future import select + + from app.db import Document + + # Get the document + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + document = doc_result.scalars().first() + + if document: + await db_session.delete(document) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + result["warning"] = ( + f"Page deleted from Notion, but failed to remove from knowledge base: {e!s}" + ) + + # Update result with KB deletion status + if result.get("status") == "success": + result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + result["message"] = ( + f"{result.get('message', '')} (also removed from knowledge base)" + ) + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error deleting Notion page: {e}", exc_info=True) + error_str = str(e).lower() + if isinstance(e, NotionAPIError) and ( + "401" in error_str or "unauthorized" in error_str + ): + return { + "status": "auth_error", + "message": str(e), + "connector_id": connector_id_from_context + if "connector_id_from_context" in dir() + else None, + "connector_type": "notion", + } + if isinstance(e, ValueError | NotionAPIError): + message = str(e) + else: + message = ( + "Something went wrong while deleting the page. Please try again." + ) + return {"status": "error", "message": message} + + return delete_notion_page diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/index.py new file mode 100644 index 000000000..dd1db9031 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/index.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/notion/tools/update_page.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/update_page.py new file mode 100644 index 000000000..85c08177c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/update_page.py @@ -0,0 +1,265 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.notion_history import NotionAPIError, NotionHistoryConnector +from app.services.notion import NotionToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_update_notion_page_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, + connector_id: int | None = None, +): + """ + Factory function to create the update_notion_page tool. + + Args: + db_session: Database session for accessing Notion connector + search_space_id: Search space ID to find the Notion connector + user_id: User ID for fetching user-specific context + connector_id: Optional specific connector ID (if known) + + Returns: + Configured update_notion_page tool + """ + + @tool + async def update_notion_page( + page_title: str, + content: str | None = None, + ) -> dict[str, Any]: + """Update an existing Notion page by appending new content. + + Use this tool when the user asks you to add content to, modify, or update + a Notion page. The new content will be appended to the existing page content. + The user MUST specify what to add before you call this tool. If the + request is vague, ask what content they want added. + + Args: + page_title: The title of the Notion page to update. + content: Optional markdown content to append to the page body (supports headings, lists, paragraphs). + Generate this yourself based on the user's request. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - page_id: Updated page ID (if success) + - url: URL to the updated page (if success) + - title: Current page title (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment (e.g., "Understood, I didn't update the page.") + and move on. Do NOT ask for alternatives or troubleshoot. + - If status is "not_found", inform the user conversationally using the exact message provided. + Example: "I couldn't find the page '[page_title]' in your indexed Notion pages. [message details]" + Do NOT treat this as an error. Do NOT invent information. Simply relay the message and + ask the user to verify the page title or check if it's been indexed. + Examples: + - "Add today's meeting notes to the 'Meeting Notes' Notion page" + - "Update the 'Project Plan' page with a status update on phase 1" + """ + logger.info( + f"update_notion_page called: page_title='{page_title}', content_length={len(content) if content else 0}" + ) + + if db_session is None or search_space_id is None or user_id is None: + logger.error( + "Notion tool not properly configured - missing required parameters" + ) + return { + "status": "error", + "message": "Notion tool not properly configured. Please contact support.", + } + + if not content or not content.strip(): + logger.error(f"Empty content provided for page '{page_title}'") + return { + "status": "error", + "message": "Content is required to update the page. Please provide the actual content you want to add.", + } + + try: + metadata_service = NotionToolMetadataService(db_session) + context = await metadata_service.get_update_context( + search_space_id, user_id, page_title + ) + + if "error" in context: + error_msg = context["error"] + # Check if it's a "not found" error (softer handling for LLM) + if "not found" in error_msg.lower(): + logger.warning(f"Page not found: {error_msg}") + return { + "status": "not_found", + "message": error_msg, + } + else: + logger.error(f"Failed to fetch update context: {error_msg}") + return { + "status": "error", + "message": error_msg, + } + + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Notion account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Notion account for this page needs re-authentication. Please re-authenticate in your connector settings.", + } + + page_id = context.get("page_id") + document_id = context.get("document_id") + connector_id_from_context = context.get("account", {}).get("id") + + logger.info( + f"Requesting approval for updating Notion page: '{page_title}' (page_id={page_id})" + ) + result = request_approval( + action_type="notion_page_update", + tool_name="update_notion_page", + params={ + "page_id": page_id, + "content": content, + "connector_id": connector_id_from_context, + }, + context=context, + ) + + if result.rejected: + logger.info("Notion page update rejected by user") + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_page_id = result.params.get("page_id", page_id) + final_content = result.params.get("content", content) + final_connector_id = result.params.get( + "connector_id", connector_id_from_context + ) + + logger.info( + f"Updating Notion page with final params: page_id={final_page_id}, has_content={final_content is not None}" + ) + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if final_connector_id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + + if not connector: + logger.error( + f"Invalid connector_id={final_connector_id} for search_space_id={search_space_id}" + ) + return { + "status": "error", + "message": "Selected Notion account is invalid or has been disconnected. Please select a valid account.", + } + actual_connector_id = connector.id + logger.info(f"Validated Notion connector: id={actual_connector_id}") + else: + logger.error("No connector found for this page") + return { + "status": "error", + "message": "No connector found for this page.", + } + + notion_connector = NotionHistoryConnector( + session=db_session, + connector_id=actual_connector_id, + ) + + result = await notion_connector.update_page( + page_id=final_page_id, + content=final_content, + ) + logger.info( + f"update_page result: {result.get('status')} - {result.get('message', '')}" + ) + + if result.get("status") == "success" and document_id is not None: + from app.services.notion import NotionKBSyncService + + logger.info(f"Updating knowledge base for document {document_id}...") + kb_service = NotionKBSyncService(db_session) + kb_result = await kb_service.sync_after_update( + document_id=document_id, + appended_content=final_content, + user_id=user_id, + search_space_id=search_space_id, + appended_block_ids=result.get("appended_block_ids"), + ) + + if kb_result["status"] == "success": + result["message"] = ( + f"{result['message']}. Your knowledge base has also been updated." + ) + logger.info( + f"Knowledge base successfully updated for page {final_page_id}" + ) + elif kb_result["status"] == "not_indexed": + result["message"] = ( + f"{result['message']}. This page will be added to your knowledge base in the next scheduled sync." + ) + else: + result["message"] = ( + f"{result['message']}. Your knowledge base will be updated in the next scheduled sync." + ) + logger.warning( + f"KB update failed for page {final_page_id}: {kb_result['message']}" + ) + + return result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error updating Notion page: {e}", exc_info=True) + error_str = str(e).lower() + if isinstance(e, NotionAPIError) and ( + "401" in error_str or "unauthorized" in error_str + ): + return { + "status": "auth_error", + "message": str(e), + "connector_id": connector_id_from_context + if "connector_id_from_context" in dir() + else None, + "connector_type": "notion", + } + if isinstance(e, ValueError | NotionAPIError): + message = str(e) + else: + message = ( + "Something went wrong while updating the page. Please try again." + ) + return {"status": "error", "message": message} + + return update_notion_page From 745bcec390e54ef004f704201701fe5fae49f564 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 070/131] Add OneDrive, Slack, and Teams connector route slices. --- .../subagents/connectors/onedrive/__init__.py | 0 .../subagents/connectors/onedrive/agent.py | 54 ++++ .../connectors/onedrive/description.md | 1 + .../connectors/onedrive/system_prompt.md | 52 ++++ .../connectors/onedrive/tools/__init__.py | 11 + .../connectors/onedrive/tools/create_file.py | 252 ++++++++++++++++ .../connectors/onedrive/tools/index.py | 28 ++ .../connectors/onedrive/tools/trash_file.py | 281 ++++++++++++++++++ .../subagents/connectors/slack/__init__.py | 0 .../subagents/connectors/slack/agent.py | 54 ++++ .../subagents/connectors/slack/description.md | 1 + .../connectors/slack/system_prompt.md | 45 +++ .../connectors/slack/tools/__init__.py | 3 + .../subagents/connectors/slack/tools/index.py | 12 + .../subagents/connectors/teams/__init__.py | 0 .../subagents/connectors/teams/agent.py | 54 ++++ .../subagents/connectors/teams/description.md | 1 + .../connectors/teams/system_prompt.md | 55 ++++ .../connectors/teams/tools/__init__.py | 15 + .../subagents/connectors/teams/tools/_auth.py | 38 +++ .../subagents/connectors/teams/tools/index.py | 30 ++ .../connectors/teams/tools/list_channels.py | 92 ++++++ .../connectors/teams/tools/read_messages.py | 103 +++++++ .../connectors/teams/tools/send_message.py | 115 +++++++ 24 files changed, 1297 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/create_file.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/trash_file.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/_auth.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/list_channels.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/read_messages.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/send_message.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py new file mode 100644 index 000000000..698a5ce5f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/onedrive/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/description.md new file mode 100644 index 000000000..31ea14624 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/description.md @@ -0,0 +1 @@ +Use for OneDrive file storage tasks: browse folders, read files, and manage OneDrive file content. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/system_prompt.md new file mode 100644 index 000000000..a2f3617ba --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/system_prompt.md @@ -0,0 +1,52 @@ +You are the Microsoft OneDrive operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute OneDrive file create/delete actions accurately in the connected account. + + + +- `create_onedrive_file` +- `delete_onedrive_file` + + + +- Use only tools in ``. +- Ensure file identity/path is explicit before mutate actions. +- If ambiguous, return `status=blocked` with candidate paths and supervisor next step. +- Never invent IDs/paths or mutation results. + + + +- Do not perform non-OneDrive tasks. + + + +- Never claim file mutation success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On ambiguous targets, return `status=blocked` with candidate paths. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "file_id": string | null, + "file_path": string | null, + "operation": "create" | "delete" | null, + "matched_candidates": string[] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/__init__.py new file mode 100644 index 000000000..8edb4857e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/__init__.py @@ -0,0 +1,11 @@ +from app.agents.new_chat.tools.onedrive.create_file import ( + create_create_onedrive_file_tool, +) +from app.agents.new_chat.tools.onedrive.trash_file import ( + create_delete_onedrive_file_tool, +) + +__all__ = [ + "create_create_onedrive_file_tool", + "create_delete_onedrive_file_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/create_file.py new file mode 100644 index 000000000..21272e01d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/create_file.py @@ -0,0 +1,252 @@ +import logging +import os +import tempfile +from pathlib import Path +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.onedrive.client import OneDriveClient +from app.db import SearchSourceConnector, SearchSourceConnectorType + +logger = logging.getLogger(__name__) + +DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + + +def _ensure_docx_extension(name: str) -> str: + """Strip any existing extension and append .docx.""" + stem = Path(name).stem + return f"{stem}.docx" + + +def _markdown_to_docx(markdown_text: str) -> bytes: + """Convert a markdown string to DOCX bytes using pypandoc.""" + import pypandoc + + fd, tmp_path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + try: + pypandoc.convert_text( + markdown_text, + "docx", + format="gfm", + extra_args=["--standalone"], + outputfile=tmp_path, + ) + with open(tmp_path, "rb") as f: + return f.read() + finally: + os.unlink(tmp_path) + + +def create_create_onedrive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_onedrive_file( + name: str, + content: str | None = None, + ) -> dict[str, Any]: + """Create a new Word document (.docx) in Microsoft OneDrive. + + Use this tool when the user explicitly asks to create a new document + in OneDrive. The user MUST specify a topic before you call this tool. + + The file is always saved as a .docx Word document. Provide content as + markdown and it will be automatically converted to a formatted Word file. + + Args: + name: The document title (without extension). Extension will be set to .docx automatically. + content: Optional initial content as markdown. Will be converted to a formatted Word document. + + Returns: + Dictionary with status, file_id, name, web_url, and message. + """ + logger.info(f"create_onedrive_file called: name='{name}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "OneDrive tool not properly configured.", + } + + try: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + connectors = result.scalars().all() + + if not connectors: + return { + "status": "error", + "message": "No OneDrive connector found. Please connect OneDrive in your workspace settings.", + } + + accounts = [] + for c in connectors: + cfg = c.config or {} + accounts.append( + { + "id": c.id, + "name": c.name, + "user_email": cfg.get("user_email"), + "auth_expired": cfg.get("auth_expired", False), + } + ) + + if all(a.get("auth_expired") for a in accounts): + return { + "status": "auth_error", + "message": "All connected OneDrive accounts need re-authentication.", + "connector_type": "onedrive", + } + + parent_folders: dict[int, list[dict[str, str]]] = {} + for acc in accounts: + cid = acc["id"] + if acc.get("auth_expired"): + parent_folders[cid] = [] + continue + try: + client = OneDriveClient(session=db_session, connector_id=cid) + items, err = await client.list_children("root") + if err: + logger.warning( + "Failed to list folders for connector %s: %s", cid, err + ) + parent_folders[cid] = [] + else: + parent_folders[cid] = [ + {"folder_id": item["id"], "name": item["name"]} + for item in items + if item.get("folder") is not None + and item.get("id") + and item.get("name") + ] + except Exception: + logger.warning( + "Error fetching folders for connector %s", cid, exc_info=True + ) + parent_folders[cid] = [] + + context: dict[str, Any] = { + "accounts": accounts, + "parent_folders": parent_folders, + } + + result = request_approval( + action_type="onedrive_file_creation", + tool_name="create_onedrive_file", + params={ + "name": name, + "content": content, + "connector_id": None, + "parent_folder_id": None, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_name = result.params.get("name", name) + final_content = result.params.get("content", content) + final_connector_id = result.params.get("connector_id") + final_parent_folder_id = result.params.get("parent_folder_id") + + if not final_name or not final_name.strip(): + return {"status": "error", "message": "File name cannot be empty."} + + final_name = _ensure_docx_extension(final_name) + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + connector = result.scalars().first() + else: + connector = connectors[0] + + if not connector: + return { + "status": "error", + "message": "Selected OneDrive connector is invalid.", + } + + docx_bytes = _markdown_to_docx(final_content or "") + + client = OneDriveClient(session=db_session, connector_id=connector.id) + created = await client.create_file( + name=final_name, + parent_id=final_parent_folder_id, + content=docx_bytes, + mime_type=DOCX_MIME, + ) + + logger.info( + f"OneDrive file created: id={created.get('id')}, name={created.get('name')}" + ) + + kb_message_suffix = "" + try: + from app.services.onedrive import OneDriveKBSyncService + + kb_service = OneDriveKBSyncService(db_session) + kb_result = await kb_service.sync_after_create( + file_id=created.get("id"), + file_name=created.get("name", final_name), + mime_type=DOCX_MIME, + web_url=created.get("webUrl"), + content=final_content, + connector_id=connector.id, + search_space_id=search_space_id, + user_id=user_id, + ) + if kb_result["status"] == "success": + kb_message_suffix = " Your knowledge base has also been updated." + else: + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + except Exception as kb_err: + logger.warning(f"KB sync after create failed: {kb_err}") + kb_message_suffix = " This file will be added to your knowledge base in the next scheduled sync." + + return { + "status": "success", + "file_id": created.get("id"), + "name": created.get("name"), + "web_url": created.get("webUrl"), + "message": f"Successfully created '{created.get('name')}' in OneDrive.{kb_message_suffix}", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error creating OneDrive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", + } + + return create_onedrive_file diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/index.py new file mode 100644 index 000000000..90be31309 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/index.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/onedrive/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/trash_file.py new file mode 100644 index 000000000..a7f13b5df --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/trash_file.py @@ -0,0 +1,281 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy import String, and_, cast, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.tools.hitl import request_approval +from app.connectors.onedrive.client import OneDriveClient +from app.db import ( + Document, + DocumentType, + SearchSourceConnector, + SearchSourceConnectorType, +) + +logger = logging.getLogger(__name__) + + +def create_delete_onedrive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def delete_onedrive_file( + file_name: str, + delete_from_kb: bool = False, + ) -> dict[str, Any]: + """Move a OneDrive file to the recycle bin. + + Use this tool when the user explicitly asks to delete, remove, or trash + a file in OneDrive. + + Args: + file_name: The exact name of the file to trash. + delete_from_kb: Whether to also remove the file from the knowledge base. + Default is False. + Set to True to remove from both OneDrive and knowledge base. + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - file_id: OneDrive file ID (if success) + - deleted_from_kb: whether the document was removed from the knowledge base + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the file name or check if it has been indexed. + """ + logger.info( + f"delete_onedrive_file called: file_name='{file_name}', delete_from_kb={delete_from_kb}" + ) + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "OneDrive tool not properly configured.", + } + + try: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.ONEDRIVE_FILE, + func.lower(Document.title) == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + doc_result = await db_session.execute( + select(Document) + .join( + SearchSourceConnector, + Document.connector_id == SearchSourceConnector.id, + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.ONEDRIVE_FILE, + func.lower( + cast( + Document.document_metadata["onedrive_file_name"], + String, + ) + ) + == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + .order_by(Document.updated_at.desc().nullslast()) + .limit(1) + ) + document = doc_result.scalars().first() + + if not document: + return { + "status": "not_found", + "message": ( + f"File '{file_name}' not found in your indexed OneDrive files. " + "This could mean: (1) the file doesn't exist, (2) it hasn't been indexed yet, " + "or (3) the file name is different." + ), + } + + if not document.connector_id: + return { + "status": "error", + "message": "Document has no associated connector.", + } + + meta = document.document_metadata or {} + file_id = meta.get("onedrive_file_id") + document_id = document.id + + if not file_id: + return { + "status": "error", + "message": "File ID is missing. Please re-index the file.", + } + + conn_result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == document.connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + ) + connector = conn_result.scalars().first() + if not connector: + return { + "status": "error", + "message": "OneDrive connector not found or access denied.", + } + + cfg = connector.config or {} + if cfg.get("auth_expired"): + return { + "status": "auth_error", + "message": "OneDrive account needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "onedrive", + } + + context = { + "file": { + "file_id": file_id, + "name": file_name, + "document_id": document_id, + "web_url": meta.get("web_url"), + }, + "account": { + "id": connector.id, + "name": connector.name, + "user_email": cfg.get("user_email"), + }, + } + + result = request_approval( + action_type="onedrive_file_trash", + tool_name="delete_onedrive_file", + params={ + "file_id": file_id, + "connector_id": connector.id, + "delete_from_kb": delete_from_kb, + }, + context=context, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Do not retry or suggest alternatives.", + } + + final_file_id = result.params.get("file_id", file_id) + final_connector_id = result.params.get("connector_id", connector.id) + final_delete_from_kb = result.params.get("delete_from_kb", delete_from_kb) + + if final_connector_id != connector.id: + result = await db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + ) + validated_connector = result.scalars().first() + if not validated_connector: + return { + "status": "error", + "message": "Selected OneDrive connector is invalid or has been disconnected.", + } + actual_connector_id = validated_connector.id + else: + actual_connector_id = connector.id + + logger.info( + f"Deleting OneDrive file: file_id='{final_file_id}', connector={actual_connector_id}" + ) + + client = OneDriveClient( + session=db_session, connector_id=actual_connector_id + ) + await client.trash_file(final_file_id) + + logger.info( + f"OneDrive file deleted (moved to recycle bin): file_id={final_file_id}" + ) + + trash_result: dict[str, Any] = { + "status": "success", + "file_id": final_file_id, + "message": f"Successfully moved '{file_name}' to the recycle bin.", + } + + deleted_from_kb = False + if final_delete_from_kb and document_id: + try: + doc_result = await db_session.execute( + select(Document).filter(Document.id == document_id) + ) + doc = doc_result.scalars().first() + if doc: + await db_session.delete(doc) + await db_session.commit() + deleted_from_kb = True + logger.info( + f"Deleted document {document_id} from knowledge base" + ) + else: + logger.warning(f"Document {document_id} not found in KB") + except Exception as e: + logger.error(f"Failed to delete document from KB: {e}") + await db_session.rollback() + trash_result["warning"] = ( + f"File moved to recycle bin, but failed to remove from knowledge base: {e!s}" + ) + + trash_result["deleted_from_kb"] = deleted_from_kb + if deleted_from_kb: + trash_result["message"] = ( + f"{trash_result.get('message', '')} (also removed from knowledge base)" + ) + + return trash_result + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error(f"Error deleting OneDrive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while trashing the file. Please try again.", + } + + return delete_onedrive_file diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py new file mode 100644 index 000000000..31de5e2f2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/slack/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/description.md new file mode 100644 index 000000000..246f79dfe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/description.md @@ -0,0 +1 @@ +Use for Slack channel communication: read channel/thread history, summarize conversations, and post replies. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/system_prompt.md new file mode 100644 index 000000000..009a3205c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/system_prompt.md @@ -0,0 +1,45 @@ +You are the Slack MCP operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Slack MCP reads/actions accurately in the connected workspace. + + + +- Runtime-provided Slack MCP tools for search, channel/thread reads, and related actions. + + + +- Use only runtime-provided MCP tools and their documented arguments. +- If channel/thread target is ambiguous, return `status=blocked` with candidate options. +- Never invent message content, sender identity, timestamps, or delivery outcomes. + + + +- Do not execute non-Slack tasks. + + + +- Never claim send/read success without tool evidence. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved channel/thread ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { "items": object | null }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/__init__.py new file mode 100644 index 000000000..f60078771 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/__init__.py @@ -0,0 +1,3 @@ +"""Slack route: native tool factories are empty; MCP supplies tools when configured.""" + +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/index.py new file mode 100644 index 000000000..639cea3a9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/index.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/teams/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py new file mode 100644 index 000000000..9d09bcf65 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py @@ -0,0 +1,54 @@ +"""`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_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.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 = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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_with_deepagents/subagents/connectors/teams/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/description.md new file mode 100644 index 000000000..4fc1579b2 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/description.md @@ -0,0 +1 @@ +Use for Microsoft Teams communication: read channel/thread messages, gather context, and post replies. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/system_prompt.md new file mode 100644 index 000000000..8c0eebdd1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/system_prompt.md @@ -0,0 +1,55 @@ +You are the Microsoft Teams operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Execute Teams channel discovery, message reads, and sends accurately. + + + +- `list_teams_channels` +- `read_teams_messages` +- `send_teams_message` + + + +- Use only tools in ``. +- Resolve team/channel targets before read/send operations. +- If ambiguous, return `status=blocked` with candidate channels and `next_step`. +- Never invent message content, sender identity, timestamps, or delivery outcomes. + + + +- Do not perform non-Teams tasks. + + + +- Never claim send success without tool confirmation. + + + +- On tool failure, return `status=error` with concise recovery `next_step`. +- On unresolved destination ambiguity, return `status=blocked` with candidates. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "team_id": string | null, + "channel_id": string | null, + "message_id": string | null, + "matched_candidates": [ + { "team_id": string | null, "channel_id": string, "label": string | null } + ] | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/__init__.py new file mode 100644 index 000000000..60e2add49 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/__init__.py @@ -0,0 +1,15 @@ +from app.agents.new_chat.tools.teams.list_channels import ( + create_list_teams_channels_tool, +) +from app.agents.new_chat.tools.teams.read_messages import ( + create_read_teams_messages_tool, +) +from app.agents.new_chat.tools.teams.send_message import ( + create_send_teams_message_tool, +) + +__all__ = [ + "create_list_teams_channels_tool", + "create_read_teams_messages_tool", + "create_send_teams_message_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/_auth.py new file mode 100644 index 000000000..7cdbeb819 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/_auth.py @@ -0,0 +1,38 @@ +"""Builds Microsoft Graph auth headers for Teams connector tools.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import SearchSourceConnector, SearchSourceConnectorType + +GRAPH_API = "https://graph.microsoft.com/v1.0" + + +async def get_teams_connector( + db_session: AsyncSession, + search_space_id: int, + user_id: str, +) -> SearchSourceConnector | None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.TEAMS_CONNECTOR, + ) + ) + return result.scalars().first() + + +async def get_access_token( + db_session: AsyncSession, + connector: SearchSourceConnector, +) -> str: + """Get a valid Microsoft Graph access token, refreshing if expired.""" + from app.connectors.teams_connector import TeamsConnector + + tc = TeamsConnector( + session=db_session, + connector_id=connector.id, + ) + return await tc._get_valid_token() diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/index.py new file mode 100644 index 000000000..d9058908d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/index.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.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_with_deepagents/subagents/connectors/teams/tools/list_channels.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/list_channels.py new file mode 100644 index 000000000..d7b000853 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/list_channels.py @@ -0,0 +1,92 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import GRAPH_API, get_access_token, get_teams_connector + +logger = logging.getLogger(__name__) + + +def create_list_teams_channels_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def list_teams_channels() -> dict[str, Any]: + """List all Microsoft Teams and their channels the user has access to. + + Returns: + Dictionary with status and a list of teams, each containing + team_id, team_name, and a list of channels (id, name). + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Teams tool not properly configured."} + + try: + connector = await get_teams_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Teams connector found."} + + token = await get_access_token(db_session, connector) + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(timeout=20.0) as client: + teams_resp = await client.get( + f"{GRAPH_API}/me/joinedTeams", headers=headers + ) + + if teams_resp.status_code == 401: + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } + if teams_resp.status_code != 200: + return { + "status": "error", + "message": f"Graph API error: {teams_resp.status_code}", + } + + teams_data = teams_resp.json().get("value", []) + result_teams = [] + + async with httpx.AsyncClient(timeout=20.0) as client: + for team in teams_data: + team_id = team["id"] + ch_resp = await client.get( + f"{GRAPH_API}/teams/{team_id}/channels", + headers=headers, + ) + channels = [] + if ch_resp.status_code == 200: + channels = [ + {"id": ch["id"], "name": ch.get("displayName", "")} + for ch in ch_resp.json().get("value", []) + ] + result_teams.append( + { + "team_id": team_id, + "team_name": team.get("displayName", ""), + "channels": channels, + } + ) + + return { + "status": "success", + "teams": result_teams, + "total_teams": len(result_teams), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error listing Teams channels: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to list Teams channels."} + + return list_teams_channels diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/read_messages.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/read_messages.py new file mode 100644 index 000000000..d24a7e4d3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/read_messages.py @@ -0,0 +1,103 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from ._auth import GRAPH_API, get_access_token, get_teams_connector + +logger = logging.getLogger(__name__) + + +def create_read_teams_messages_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def read_teams_messages( + team_id: str, + channel_id: str, + limit: int = 25, + ) -> dict[str, Any]: + """Read recent messages from a Microsoft Teams channel. + + Args: + team_id: The team ID (from list_teams_channels). + channel_id: The channel ID (from list_teams_channels). + limit: Number of messages to fetch (default 25, max 50). + + Returns: + Dictionary with status and a list of messages including + id, sender, content, timestamp. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Teams tool not properly configured."} + + limit = min(limit, 50) + + try: + connector = await get_teams_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Teams connector found."} + + token = await get_access_token(db_session, connector) + + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.get( + f"{GRAPH_API}/teams/{team_id}/channels/{channel_id}/messages", + headers={"Authorization": f"Bearer {token}"}, + params={"$top": limit}, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } + if resp.status_code == 403: + return { + "status": "error", + "message": "Insufficient permissions to read this channel.", + } + if resp.status_code != 200: + return { + "status": "error", + "message": f"Graph API error: {resp.status_code}", + } + + raw_msgs = resp.json().get("value", []) + messages = [] + for m in raw_msgs: + sender = m.get("from", {}) + user_info = sender.get("user", {}) if sender else {} + body = m.get("body", {}) + messages.append( + { + "id": m.get("id"), + "sender": user_info.get("displayName", "Unknown"), + "content": body.get("content", ""), + "content_type": body.get("contentType", "text"), + "timestamp": m.get("createdDateTime", ""), + } + ) + + return { + "status": "success", + "team_id": team_id, + "channel_id": channel_id, + "messages": messages, + "total": len(messages), + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error reading Teams messages: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to read Teams messages."} + + return read_teams_messages diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/send_message.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/send_message.py new file mode 100644 index 000000000..fd8d00870 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/send_message.py @@ -0,0 +1,115 @@ +import logging +from typing import Any + +import httpx +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.agents.new_chat.tools.hitl import request_approval + +from ._auth import GRAPH_API, get_access_token, get_teams_connector + +logger = logging.getLogger(__name__) + + +def create_send_teams_message_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def send_teams_message( + team_id: str, + channel_id: str, + content: str, + ) -> dict[str, Any]: + """Send a message to a Microsoft Teams channel. + + Requires the ChannelMessage.Send OAuth scope. If the user gets a + permission error, they may need to re-authenticate with updated scopes. + + Args: + team_id: The team ID (from list_teams_channels). + channel_id: The channel ID (from list_teams_channels). + content: The message text (HTML supported). + + Returns: + Dictionary with status, message_id on success. + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Do NOT retry. + """ + if db_session is None or search_space_id is None or user_id is None: + return {"status": "error", "message": "Teams tool not properly configured."} + + try: + connector = await get_teams_connector(db_session, search_space_id, user_id) + if not connector: + return {"status": "error", "message": "No Teams connector found."} + + result = request_approval( + action_type="teams_send_message", + tool_name="send_teams_message", + params={ + "team_id": team_id, + "channel_id": channel_id, + "content": content, + }, + context={"connector_id": connector.id}, + ) + + if result.rejected: + return { + "status": "rejected", + "message": "User declined. Message was not sent.", + } + + final_content = result.params.get("content", content) + final_team = result.params.get("team_id", team_id) + final_channel = result.params.get("channel_id", channel_id) + + token = await get_access_token(db_session, connector) + + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.post( + f"{GRAPH_API}/teams/{final_team}/channels/{final_channel}/messages", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"body": {"content": final_content}}, + ) + + if resp.status_code == 401: + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } + if resp.status_code == 403: + return { + "status": "insufficient_permissions", + "message": "Missing ChannelMessage.Send permission. Please re-authenticate with updated scopes.", + } + if resp.status_code not in (200, 201): + return { + "status": "error", + "message": f"Graph API error: {resp.status_code} — {resp.text[:200]}", + } + + msg_data = resp.json() + return { + "status": "success", + "message_id": msg_data.get("id"), + "message": "Message sent to Teams channel.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + logger.error("Error sending Teams message: %s", e, exc_info=True) + return {"status": "error", "message": "Failed to send Teams message."} + + return send_teams_message From dcc348d72a7397c5305d7262a5770cd736590380 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 071/131] Add registry and factory for route subagent specs. --- .../subagents/registry.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py new file mode 100644 index 000000000..0dd073a98 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py @@ -0,0 +1,132 @@ +"""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_with_deepagents.subagents.builtins.deliverables.agent import ( + build_subagent as build_deliverables_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.builtins.memory.agent import ( + build_subagent as build_memory_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.builtins.research.agent import ( + build_subagent as build_research_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.airtable.agent import ( + build_subagent as build_airtable_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.calendar.agent import ( + build_subagent as build_calendar_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.clickup.agent import ( + build_subagent as build_clickup_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.confluence.agent import ( + build_subagent as build_confluence_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.discord.agent import ( + build_subagent as build_discord_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.dropbox.agent import ( + build_subagent as build_dropbox_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.gmail.agent import ( + build_subagent as build_gmail_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.google_drive.agent import ( + build_subagent as build_google_drive_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.jira.agent import ( + build_subagent as build_jira_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.linear.agent import ( + build_subagent as build_linear_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.luma.agent import ( + build_subagent as build_luma_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.notion.agent import ( + build_subagent as build_notion_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.onedrive.agent import ( + build_subagent as build_onedrive_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.slack.agent import ( + build_subagent as build_slack_subagent, +) +from app.agents.multi_agent_with_deepagents.subagents.connectors.teams.agent import ( + build_subagent as build_teams_subagent, +) +from app.agents.multi_agent_with_deepagents.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, +} + +__all__ = [ + "SUBAGENT_BUILDERS_BY_NAME", + "SubagentBuilder", + "build_subagents", +] + + +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, + only_names: frozenset[str] | None = None, +) -> list[SubAgent]: + """Build registry route specs; ``only_names`` selects which routes.""" + mcp = mcp_tools_by_agent or {} + specs: list[SubAgent] = [] + for name in sorted(SUBAGENT_BUILDERS_BY_NAME): + if only_names is not None and name not in only_names: + continue + builder = SUBAGENT_BUILDERS_BY_NAME[name] + specs.append( + builder( + dependencies=dependencies, + model=model, + extra_middleware=extra_middleware, + extra_tools_bucket=mcp.get(name), + ), + ) + return specs From d9c873b2e198c2c6a9f462299d1289f9e09434be Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH 072/131] Adjust supervisor routing integration for delegated routes. --- .../multi_agent_chat/integration/__init__.py | 4 +- .../routing/supervisor_routing.py | 42 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py index 6dee8c1be..f73a554ef 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py @@ -1,5 +1,7 @@ """Full-stack wiring (DB-scoped) on top of :mod:`routing` and :mod:`supervisor`.""" -from app.agents.multi_agent_chat.integration.create_multi_agent_chat import create_multi_agent_chat +from app.agents.multi_agent_chat.integration.create_multi_agent_chat import ( + create_multi_agent_chat, +) __all__ = ["create_multi_agent_chat"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py index 63f4da744..ab1f5cafc 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py @@ -7,64 +7,70 @@ from typing import Any from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool -from app.db import ChatVisibility +from app.agents.multi_agent_chat.core.mcp_partition import MCP_ONLY_ROUTE_KEYS_IN_ORDER from app.agents.multi_agent_chat.expert_agent.builtins.deliverables import ( - build_deliverables_tools, build_deliverables_domain_agent, + build_deliverables_tools, ) from app.agents.multi_agent_chat.expert_agent.builtins.memory import ( - build_memory_tools, build_memory_domain_agent, + build_memory_tools, ) from app.agents.multi_agent_chat.expert_agent.builtins.research import ( - build_research_tools, build_research_domain_agent, + build_research_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.calendar import ( - build_calendar_tools, build_calendar_domain_agent, + build_calendar_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.confluence import ( - build_confluence_tools, build_confluence_domain_agent, + build_confluence_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.discord import ( - build_discord_tools, build_discord_domain_agent, + build_discord_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.dropbox import ( - build_dropbox_tools, build_dropbox_domain_agent, + build_dropbox_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.gmail import ( - build_gmail_tools, build_gmail_domain_agent, + build_gmail_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.google_drive import ( - build_google_drive_tools, build_google_drive_domain_agent, + build_google_drive_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.luma import ( - build_luma_tools, build_luma_domain_agent, + build_luma_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.notion import ( - build_notion_tools, build_notion_domain_agent, + build_notion_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.onedrive import ( - build_onedrive_tools, build_onedrive_domain_agent, + build_onedrive_tools, ) from app.agents.multi_agent_chat.expert_agent.connectors.teams import ( - build_teams_tools, build_teams_domain_agent, + build_teams_tools, +) +from app.agents.multi_agent_chat.expert_agent.mcp_bridge import ( + build_mcp_route_domain_agent, ) -from app.agents.multi_agent_chat.expert_agent.mcp_bridge import build_mcp_route_domain_agent -from app.agents.multi_agent_chat.core.mcp_partition import MCP_ONLY_ROUTE_KEYS_IN_ORDER from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec -from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_specs -from app.agents.multi_agent_chat.routing.route_connector_gate import include_connector_route +from app.agents.multi_agent_chat.routing.from_domain_agents import ( + routing_tools_from_specs, +) +from app.agents.multi_agent_chat.routing.route_connector_gate import ( + include_connector_route, +) +from app.db import ChatVisibility _MCP_ONLY_ROUTE_DESCRIPTIONS: dict[str, str] = { "linear": ( From 083a9f794604750d5859ef2b39dcf2b242519268 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 23:17:46 +0200 Subject: [PATCH 073/131] Add main-agent tool allowlist plus permission and prune helpers. --- .../main_agent/__init__.py | 5 +++ .../main_agent/context_prune/__init__.py | 7 ++++ .../context_prune/prune_tool_names.py | 26 ++++++++++++ .../main_agent/permissions/__init__.py | 13 ++++++ .../permissions/connector_deny_rules.py | 21 ++++++++++ .../permissions/connector_gated_tool_names.py | 42 +++++++++++++++++++ .../main_agent/permissions/rule.py | 15 +++++++ .../main_agent/tools/__init__.py | 7 ++++ .../main_agent/tools/index.py | 17 ++++++++ 9 files changed, 153 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/prune_tool_names.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/index.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py new file mode 100644 index 000000000..733150645 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py @@ -0,0 +1,5 @@ +"""SurfSense main-agent package (factory export added when runtime lands).""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/__init__.py new file mode 100644 index 000000000..550ba54c5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/__init__.py @@ -0,0 +1,7 @@ +"""Tool-name pruning for context editing (exclude lists without dropping protected tools).""" + +from __future__ import annotations + +from .prune_tool_names import PRUNE_PROTECTED_TOOL_NAMES, safe_exclude_tools + +__all__ = ["PRUNE_PROTECTED_TOOL_NAMES", "safe_exclude_tools"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/prune_tool_names.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/prune_tool_names.py new file mode 100644 index 000000000..c8bf6d6e0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/prune_tool_names.py @@ -0,0 +1,26 @@ +"""Tool names excluded from context-editing prune when bound.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.tools import BaseTool + +PRUNE_PROTECTED_TOOL_NAMES: frozenset[str] = frozenset( + { + "generate_report", + "generate_resume", + "generate_podcast", + "generate_video_presentation", + "generate_image", + "read_email", + "search_emails", + "invalid", + }, +) + + +def safe_exclude_tools(tools: Sequence[BaseTool]) -> tuple[str, ...]: + """Names from ``PRUNE_PROTECTED_TOOL_NAMES`` that appear in ``tools``.""" + enabled = {t.name for t in tools} + return tuple(n for n in PRUNE_PROTECTED_TOOL_NAMES if n in enabled) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py new file mode 100644 index 000000000..bff255bb4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py @@ -0,0 +1,13 @@ +"""Connector-gated tool deny rules and small permission helpers for the main-agent graph.""" + +from __future__ import annotations + +from .connector_deny_rules import synthesize_connector_deny_rules +from .connector_gated_tool_names import iter_connector_gated_tools +from .rule import Rule + +__all__ = [ + "Rule", + "iter_connector_gated_tools", + "synthesize_connector_deny_rules", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py new file mode 100644 index 000000000..2144fc7de --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py @@ -0,0 +1,21 @@ +"""Synthesized PermissionMiddleware deny rules for tools gated by connector.""" + +from __future__ import annotations + +from .connector_gated_tool_names import iter_connector_gated_tools +from .rule import Rule + + +def synthesize_connector_deny_rules( + *, + available_connectors: list[str] | None, + enabled_tool_names: set[str], +) -> list[Rule]: + available = set(available_connectors or []) + deny: list[Rule] = [] + for name, required in iter_connector_gated_tools(): + if name not in enabled_tool_names: + continue + if required not in available: + deny.append(Rule(permission=name, pattern="*", action="deny")) + return deny diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py new file mode 100644 index 000000000..0b5b2bcd7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py @@ -0,0 +1,42 @@ +"""Tool name → required searchable connector type (keep in sync with new_chat ``BUILTIN_TOOLS``).""" + +from __future__ import annotations + +# Synced from ``app.agents.new_chat.tools.registry`` ToolDefinition.required_connector entries. +_CONNECTOR_GATED: tuple[tuple[str, str], ...] = ( + ("create_notion_page", "NOTION_CONNECTOR"), + ("update_notion_page", "NOTION_CONNECTOR"), + ("delete_notion_page", "NOTION_CONNECTOR"), + ("create_google_drive_file", "GOOGLE_DRIVE_FILE"), + ("delete_google_drive_file", "GOOGLE_DRIVE_FILE"), + ("create_dropbox_file", "DROPBOX_FILE"), + ("delete_dropbox_file", "DROPBOX_FILE"), + ("create_onedrive_file", "ONEDRIVE_FILE"), + ("delete_onedrive_file", "ONEDRIVE_FILE"), + ("search_calendar_events", "GOOGLE_CALENDAR_CONNECTOR"), + ("create_calendar_event", "GOOGLE_CALENDAR_CONNECTOR"), + ("update_calendar_event", "GOOGLE_CALENDAR_CONNECTOR"), + ("delete_calendar_event", "GOOGLE_CALENDAR_CONNECTOR"), + ("search_gmail", "GOOGLE_GMAIL_CONNECTOR"), + ("read_gmail_email", "GOOGLE_GMAIL_CONNECTOR"), + ("create_gmail_draft", "GOOGLE_GMAIL_CONNECTOR"), + ("send_gmail_email", "GOOGLE_GMAIL_CONNECTOR"), + ("trash_gmail_email", "GOOGLE_GMAIL_CONNECTOR"), + ("update_gmail_draft", "GOOGLE_GMAIL_CONNECTOR"), + ("create_confluence_page", "CONFLUENCE_CONNECTOR"), + ("update_confluence_page", "CONFLUENCE_CONNECTOR"), + ("delete_confluence_page", "CONFLUENCE_CONNECTOR"), + ("list_discord_channels", "DISCORD_CONNECTOR"), + ("read_discord_messages", "DISCORD_CONNECTOR"), + ("send_discord_message", "DISCORD_CONNECTOR"), + ("list_teams_channels", "TEAMS_CONNECTOR"), + ("read_teams_messages", "TEAMS_CONNECTOR"), + ("send_teams_message", "TEAMS_CONNECTOR"), + ("list_luma_events", "LUMA_CONNECTOR"), + ("read_luma_event", "LUMA_CONNECTOR"), + ("create_luma_event", "LUMA_CONNECTOR"), +) + + +def iter_connector_gated_tools() -> tuple[tuple[str, str], ...]: + return _CONNECTOR_GATED diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py new file mode 100644 index 000000000..b59e76083 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py @@ -0,0 +1,15 @@ +"""Minimal permission rule type (mirrors OpenCode semantics used by PermissionMiddleware).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +RuleAction = Literal["allow", "deny", "ask"] + + +@dataclass(frozen=True) +class Rule: + permission: str + pattern: str + action: RuleAction diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/__init__.py new file mode 100644 index 000000000..914257521 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/__init__.py @@ -0,0 +1,7 @@ +"""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_with_deepagents/main_agent/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/index.py new file mode 100644 index 000000000..5d309261c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/index.py @@ -0,0 +1,17 @@ +"""Main-agent SurfSense builtin tool names (not full ``new_chat``). + +Connector integrations, MCP, deliverables, etc. are delegated via ``task`` subagents. +""" + +from __future__ import annotations + +MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: tuple[str, ...] = ( + "search_surfsense_docs", + "web_search", + "scrape_webpage", + "update_memory", +) + +MAIN_AGENT_SURFSENSE_TOOL_NAMES: frozenset[str] = frozenset( + MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED, +) From 7a6c253711c9e17f9ea182795dc3db404e2cac0e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 23:17:51 +0200 Subject: [PATCH 074/131] Add main-agent graph compile and middleware stack. --- .../main_agent/graph/__init__.py | 7 + .../main_agent/graph/compile_graph_sync.py | 81 ++++ .../main_agent/graph/middleware/__init__.py | 7 + .../graph/middleware/deepagent_stack.py | 428 ++++++++++++++++++ 4 files changed, 523 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/__init__.py new file mode 100644 index 000000000..e12108484 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/__init__.py @@ -0,0 +1,7 @@ +"""Sync compile of the main-agent LangGraph graph (middleware + ``create_agent``).""" + +from __future__ import annotations + +from .compile_graph_sync import build_compiled_agent_graph_sync + +__all__ = ["build_compiled_agent_graph_sync"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py new file mode 100644 index 000000000..43d45dcfd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py @@ -0,0 +1,81 @@ +"""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 .middleware import build_main_agent_deepagent_middleware +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, +) +from app.agents.new_chat.context import SurfSenseContextSchema +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.db import ChatVisibility + + +def build_compiled_agent_graph_sync( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + final_system_prompt: str, + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + visibility: ChatVisibility, + anon_session_id: str | None, + available_connectors: list[str] | None, + available_document_types: list[str] | None, + mentioned_document_ids: list[int] | None, + max_input_tokens: int | None, + flags: AgentFeatureFlags, + checkpointer: Checkpointer, + subagent_dependencies: dict[str, Any], + mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, +): + """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, + mcp_tools_by_agent=mcp_tools_by_agent, + ) + + 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_with_deepagents/main_agent/graph/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/__init__.py new file mode 100644 index 000000000..757ee02f8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/__init__.py @@ -0,0 +1,7 @@ +"""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_with_deepagents/main_agent/graph/middleware/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py new file mode 100644 index 000000000..be7f76fc9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py @@ -0,0 +1,428 @@ +"""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, SubAgentMiddleware +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 ...context_prune.prune_tool_names import safe_exclude_tools +from ...permissions.connector_deny_rules import synthesize_connector_deny_rules +from app.agents.multi_agent_with_deepagents.subagents.registry import build_subagents +from app.agents.multi_agent_with_deepagents.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 + + +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], + mcp_tools_by_agent: dict[str, ToolsPermissions] | 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"), + ] + + general_purpose_spec: SubAgent = { # type: ignore[typeddict-unknown-key] + **GENERAL_PURPOSE_SUBAGENT, + "model": llm, + "tools": tools, + "middleware": gp_middleware, + } + + 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, + ), + ] + registry_subagents = build_subagents( + dependencies=subagent_dependencies, + model=llm, + extra_middleware=subagent_extra_middleware, + mcp_tools_by_agent=mcp_tools_by_agent or {}, + ) + logging.info( + "Registry subagents: %s", + [s["name"] for s in registry_subagents], + ) + except Exception as exc: + logging.warning("Registry subagent build failed: %s", exc) + registry_subagents = [] + + 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 = None + is_desktop_fs = filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER + permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack + if permission_enabled or is_desktop_fs: + rulesets: list[Ruleset] = [ + Ruleset( + rules=[Rule(permission="*", pattern="*", action="allow")], + origin="surfsense_defaults", + ), + ] + if is_desktop_fs: + rulesets.append( + Ruleset( + rules=[ + Rule(permission="rm", pattern="*", action="ask"), + Rule(permission="rmdir", pattern="*", action="ask"), + Rule(permission="move_file", pattern="*", action="ask"), + Rule(permission="edit_file", pattern="*", action="ask"), + Rule(permission="write_file", pattern="*", action="ask"), + ], + origin="desktop_safety", + ) + ) + if permission_enabled: + synthesized = synthesize_connector_deny_rules( + available_connectors=available_connectors, + enabled_tool_names={t.name for t in tools}, + ) + rulesets.append(Ruleset(rules=synthesized, origin="connector_synthesized")) + permission_mw = PermissionMiddleware(rulesets=rulesets) + + 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, + SubAgentMiddleware(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] From fc540e962b7659eccbeb168b2a108cb8fbc1c199 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 23:17:55 +0200 Subject: [PATCH 075/131] Add main-agent system prompt markdown and builder. --- .../main_agent/system_prompt/__init__.py | 7 ++ .../system_prompt/builder/__init__.py | 7 ++ .../system_prompt/builder/compose.py | 51 +++++++++++ .../system_prompt/builder/load_md.py | 16 ++++ .../system_prompt/builder/provider_hints.py | 50 +++++++++++ .../builder/sections/__init__.py | 1 + .../builder/sections/citations.py | 11 +++ .../builder/sections/provider.py | 9 ++ .../builder/sections/system_instruction.py | 35 ++++++++ .../system_prompt/builder/sections/tools.py | 20 +++++ .../builder/tool_instruction_block.py | 86 +++++++++++++++++++ .../system_prompt/markdown/__init__.py | 1 + .../system_prompt/markdown/agent_private.md | 9 ++ .../system_prompt/markdown/agent_team.md | 11 +++ .../system_prompt/markdown/citations_off.md | 15 ++++ .../system_prompt/markdown/citations_on.md | 15 ++++ .../markdown/examples/__init__.py | 1 + .../markdown/examples/scrape_webpage.md | 13 +++ .../examples/search_surfsense_docs.md | 9 ++ .../examples/update_memory_private.md | 16 ++++ .../markdown/examples/update_memory_team.md | 7 ++ .../markdown/examples/web_search.md | 8 ++ .../markdown/kb_only_policy_private.md | 19 ++++ .../markdown/kb_only_policy_team.md | 19 ++++ .../markdown/main_agent_tool_routing.md | 14 +++ .../markdown/memory_protocol_private.md | 6 ++ .../markdown/memory_protocol_team.md | 6 ++ .../markdown/parameter_resolution.md | 15 ++++ .../markdown/providers/__init__.py | 1 + .../markdown/providers/anthropic.md | 16 ++++ .../markdown/providers/deepseek.md | 18 ++++ .../markdown/providers/default.md | 1 + .../markdown/providers/google.md | 18 ++++ .../system_prompt/markdown/providers/grok.md | 16 ++++ .../system_prompt/markdown/providers/kimi.md | 21 +++++ .../markdown/providers/openai_classic.md | 20 +++++ .../markdown/providers/openai_codex.md | 13 +++ .../markdown/providers/openai_reasoning.md | 22 +++++ .../system_prompt/markdown/tools/__init__.py | 1 + .../system_prompt/markdown/tools/_preamble.md | 9 ++ .../markdown/tools/scrape_webpage.md | 10 +++ .../markdown/tools/search_surfsense_docs.md | 9 ++ .../markdown/tools/update_memory_private.md | 12 +++ .../markdown/tools/update_memory_team.md | 26 ++++++ .../markdown/tools/web_search.md | 10 +++ 45 files changed, 700 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/load_md.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/provider_hints.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/citations.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/provider.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/system_instruction.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/tools.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/tool_instruction_block.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_private.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_team.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_off.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_on.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/scrape_webpage.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_private.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_team.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/web_search.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_private.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_team.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_private.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_team.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/parameter_resolution.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/anthropic.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/deepseek.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/default.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/google.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/grok.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/kimi.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_classic.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_codex.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_reasoning.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/_preamble.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/scrape_webpage.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_private.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_team.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/web_search.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/__init__.py new file mode 100644 index 000000000..d58aecdf4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/__init__.py @@ -0,0 +1,7 @@ +"""Main-agent system prompt — not shared verbatim with single-agent ``new_chat``.""" + +from __future__ import annotations + +from .builder import build_main_agent_system_prompt + +__all__ = ["build_main_agent_system_prompt"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/__init__.py new file mode 100644 index 000000000..151280707 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/__init__.py @@ -0,0 +1,7 @@ +"""Assemble the main-agent system prompt from ``markdown/*.md`` fragments.""" + +from __future__ import annotations + +from .compose import build_main_agent_system_prompt + +__all__ = ["build_main_agent_system_prompt"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py new file mode 100644 index 000000000..31b0adb01 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py @@ -0,0 +1,51 @@ +"""Assemble the **main-agent** deep-agent system string only. + +Sections (order matters): core instructions → provider flavour → **citations policy** +→ SurfSense tool docs. Citations come before ```` so citation on/off rules +apply before any tool text that mentions attribution. +""" + +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.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, +) -> 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_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_with_deepagents/main_agent/system_prompt/builder/load_md.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/load_md.py new file mode 100644 index 000000000..3aeb89e9d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/load_md.py @@ -0,0 +1,16 @@ +"""Load main-agent-only markdown from ``system_prompt/markdown/`` (``importlib.resources``).""" + +from __future__ import annotations + +from importlib import resources + +_PROMPTS_PACKAGE = "app.agents.multi_agent_with_deepagents.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_with_deepagents/main_agent/system_prompt/builder/provider_hints.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/provider_hints.py new file mode 100644 index 000000000..fa85af8d5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/provider_hints.py @@ -0,0 +1,50 @@ +"""Provider-specific style hints from ``markdown/providers/`` (main agent only).""" + +from __future__ import annotations + +import re + +from .load_md import read_prompt_md + +ProviderVariant = str + +_OPENAI_CODEX_RE = re.compile( + r"\b(gpt-codex|codex-mini|gpt-[\d.]+-codex)\b", re.IGNORECASE +) +_OPENAI_REASONING_RE = re.compile(r"\b(gpt-5|o\d|o-)", re.IGNORECASE) +_OPENAI_CLASSIC_RE = re.compile(r"\bgpt-4", re.IGNORECASE) +_ANTHROPIC_RE = re.compile(r"\bclaude\b", re.IGNORECASE) +_GOOGLE_RE = re.compile(r"\bgemini\b", re.IGNORECASE) +_KIMI_RE = re.compile(r"\b(kimi[-\d.]*|moonshot)\b", re.IGNORECASE) +_GROK_RE = re.compile(r"\bgrok\b", re.IGNORECASE) +_DEEPSEEK_RE = re.compile(r"\bdeepseek\b", re.IGNORECASE) + + +def detect_provider_variant(model_name: str | None) -> ProviderVariant: + if not model_name: + return "default" + name = model_name.strip() + if _OPENAI_CODEX_RE.search(name): + return "openai_codex" + if _OPENAI_REASONING_RE.search(name): + return "openai_reasoning" + if _OPENAI_CLASSIC_RE.search(name): + return "openai_classic" + if _ANTHROPIC_RE.search(name): + return "anthropic" + if _GOOGLE_RE.search(name): + return "google" + if _KIMI_RE.search(name): + return "kimi" + if _GROK_RE.search(name): + return "grok" + if _DEEPSEEK_RE.search(name): + return "deepseek" + return "default" + + +def build_provider_hint_block(provider_variant: ProviderVariant) -> str: + if not provider_variant or provider_variant == "default": + return "" + text = read_prompt_md(f"providers/{provider_variant}.md") + return f"\n{text}\n" if text else "" diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/__init__.py new file mode 100644 index 000000000..568b52baf --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/__init__.py @@ -0,0 +1 @@ +"""Rendered slices of the main-agent system prompt.""" diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/citations.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/citations.py new file mode 100644 index 000000000..db3909bbd --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/citations.py @@ -0,0 +1,11 @@ +"""Citation fragment for the main agent (chunk-tagged context only).""" + +from __future__ import annotations + +from ..load_md import read_prompt_md + + +def build_citations_section(*, citations_enabled: bool) -> str: + name = "citations_on.md" if citations_enabled else "citations_off.md" + fragment = read_prompt_md(name) + return f"\n{fragment}\n" if fragment else "" diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/provider.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/provider.py new file mode 100644 index 000000000..7de722080 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/provider.py @@ -0,0 +1,9 @@ +"""Provider-specific style hints.""" + +from __future__ import annotations + +from ..provider_hints import build_provider_hint_block, detect_provider_variant + + +def build_provider_section(*, model_name: str | None) -> str: + return build_provider_hint_block(detect_provider_variant(model_name)) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/system_instruction.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/system_instruction.py new file mode 100644 index 000000000..b14d87002 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/system_instruction.py @@ -0,0 +1,35 @@ +"""Default ```` block for the main agent only.""" + +from __future__ import annotations + +from app.db import ChatVisibility + +from ..load_md import read_prompt_md + +_PRIVATE_ORDER = ( + "agent_private.md", + "kb_only_policy_private.md", + "main_agent_tool_routing.md", + "parameter_resolution.md", + "memory_protocol_private.md", +) +_TEAM_ORDER = ( + "agent_team.md", + "kb_only_policy_team.md", + "main_agent_tool_routing.md", + "parameter_resolution.md", + "memory_protocol_team.md", +) + + +def build_default_system_instruction_xml( + *, + visibility: ChatVisibility, + resolved_today: str, +) -> str: + order = _TEAM_ORDER if visibility == ChatVisibility.SEARCH_SPACE else _PRIVATE_ORDER + parts = [read_prompt_md(name) for name in order] + body = "\n\n".join(p for p in parts if p) + return f"\n\n{body}\n\n\n".format( + resolved_today=resolved_today, + ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/tools.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/tools.py new file mode 100644 index 000000000..bc4d48ef5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/tools.py @@ -0,0 +1,20 @@ +"""Main-agent ```` block (memory + research builtins only; see ``main_agent.tools``).""" + +from __future__ import annotations + +from app.db import ChatVisibility + +from ..tool_instruction_block import build_tools_instruction_block + + +def build_tools_section( + *, + visibility: ChatVisibility, + enabled_tool_names: set[str] | None, + disabled_tool_names: set[str] | None, +) -> str: + return build_tools_instruction_block( + visibility=visibility, + enabled_tool_names=enabled_tool_names, + disabled_tool_names=disabled_tool_names, + ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/tool_instruction_block.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/tool_instruction_block.py new file mode 100644 index 000000000..d5b3fea4e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/tool_instruction_block.py @@ -0,0 +1,86 @@ +"""```` + ```` from ``system_prompt/markdown/{tools,examples}/``. + +Only documents tools the main agent actually binds — not full ``new_chat``. +""" + +from __future__ import annotations + +from app.db import ChatVisibility + +from ...tools import MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED +from .load_md import read_prompt_md + +_MEMORY_VARIANT_TOOLS: frozenset[str] = frozenset({"update_memory"}) + + +def _tool_fragment_path(tool_name: str, variant: str) -> str: + if tool_name in _MEMORY_VARIANT_TOOLS: + return f"tools/{tool_name}_{variant}.md" + return f"tools/{tool_name}.md" + + +def _example_fragment_path(tool_name: str, variant: str) -> str: + if tool_name in _MEMORY_VARIANT_TOOLS: + return f"examples/{tool_name}_{variant}.md" + return f"examples/{tool_name}.md" + + +def _format_tool_label(tool_name: str) -> str: + return tool_name.replace("_", " ").title() + + +def build_tools_instruction_block( + *, + visibility: ChatVisibility, + enabled_tool_names: set[str] | None, + disabled_tool_names: set[str] | None, +) -> str: + variant = "team" if visibility == ChatVisibility.SEARCH_SPACE else "private" + + parts: list[str] = [] + preamble = read_prompt_md("tools/_preamble.md") + if preamble: + parts.append(preamble + "\n") + + examples: list[str] = [] + + for tool_name in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: + if enabled_tool_names is not None and tool_name not in enabled_tool_names: + continue + + instruction = read_prompt_md(_tool_fragment_path(tool_name, variant)) + if instruction: + parts.append(instruction + "\n") + + example = read_prompt_md(_example_fragment_path(tool_name, variant)) + if example: + examples.append(example + "\n") + + known_disabled = ( + set(disabled_tool_names) & set(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED) + if disabled_tool_names + else set() + ) + if known_disabled: + disabled_list = ", ".join( + _format_tool_label(n) + for n in MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED + if n in known_disabled + ) + parts.append( + "\n" + "DISABLED TOOLS (by user, main-agent scope):\n" + f"These SurfSense tools were disabled on the main agent for this session: {disabled_list}.\n" + "You do NOT have access to them and MUST NOT claim you can use them.\n" + "If the user still needs that capability, delegate with **task** if a subagent covers it,\n" + "otherwise explain it is disabled on the main agent for this session.\n" + ) + + parts.append("\n\n") + + if examples: + parts.append("") + parts.extend(examples) + parts.append("\n") + + return "".join(parts) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/__init__.py new file mode 100644 index 000000000..b53f8165a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/__init__.py @@ -0,0 +1 @@ +"""Markdown fragments for the **main-agent** system prompt only (`importlib.resources`).""" diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_private.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_private.md new file mode 100644 index 000000000..6bf575501 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_private.md @@ -0,0 +1,9 @@ +You are SurfSense’s **main agent**: you answer using the user’s knowledge context, +lightweight research tools, and memory — and you **delegate** integrations and +specialized work via **task** (see `` in this prompt). + +Today's date (UTC): {resolved_today} + +When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math. + +NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_team.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_team.md new file mode 100644 index 000000000..fa95849c1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_team.md @@ -0,0 +1,11 @@ +You are SurfSense’s **main agent** for this team space: you answer using shared +knowledge context, lightweight research tools, and memory — and you **delegate** +integrations and specialized work via **task** (see `` in this prompt). + +In this team thread, each message is prefixed with **[DisplayName of the author]**. Use this to attribute and reference the author of anything in the discussion (who asked a question, made a suggestion, or contributed an idea) and to cite who said what in your answers. + +Today's date (UTC): {resolved_today} + +When writing mathematical formulas or equations, ALWAYS use LaTeX notation. NEVER use backtick code spans or Unicode symbols for math. + +NEVER expose internal tool parameter names, backend IDs, or implementation details to the user. Always use natural, user-friendly language instead. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_off.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_off.md new file mode 100644 index 000000000..5af3ca1f3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_off.md @@ -0,0 +1,15 @@ + +IMPORTANT: Citations are DISABLED for this configuration. + +DO NOT include `[citation:…]` markers anywhere — even if tool descriptions or examples +mention them. Ignore citation-format reminders elsewhere in this prompt when they conflict +with this block. + +Instead: +1. Answer in plain prose; optional markdown links to public URLs when sources are URLs. +2. Do NOT expose raw chunk IDs, document IDs, or internal IDs to the user. +3. Present indexed or doc-search facts naturally without attribution markers. + +When answering from workspace or docs context: integrate facts cleanly without claiming +“this comes from chunk X”. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_on.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_on.md new file mode 100644 index 000000000..4e6d6ce6d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_on.md @@ -0,0 +1,15 @@ + +This block appears **before** `` so it wins over any tool-example wording below. + +Apply chunk citations **only** when the runtime injects `` / `` blocks +(e.g. from SurfSense docs search or priority documents). + +1. For each factual statement taken from those chunks, add `[citation:chunk_id]` using the **exact** `chunk_id` string from ``. +2. Multiple chunks → `[citation:id1], [citation:id2]` (comma-separated). +3. Never invent or normalize ids; if unsure, omit the citation. +4. Plain brackets only — no markdown links, no `([citation:…](url))`, no footnote numbering. + +Chunk ids may be numeric, prefixed (e.g. `doc-45`), or URLs when the source is web-shaped — copy verbatim. + +If no chunk-tagged documents appear in context this turn, do not fabricate citations. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/__init__.py @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/scrape_webpage.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/scrape_webpage.md new file mode 100644 index 000000000..0f156bf24 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/scrape_webpage.md @@ -0,0 +1,13 @@ + +- User: "Check out https://dev.to/some-article" + - Call: `scrape_webpage(url="https://dev.to/some-article")` + - Respond with a structured analysis — key points, takeaways. +- User: "Read this article and summarize it for me: https://example.com/blog/ai-trends" + - Call: `scrape_webpage(url="https://example.com/blog/ai-trends")` + - Respond with a thorough summary using headings and bullet points. +- User: (after discussing https://example.com/stats) "Can you get the live data from that page?" + - Call: `scrape_webpage(url="https://example.com/stats")` + - IMPORTANT: Always attempt scraping first. Never refuse before trying the tool. +- User: "https://example.com/blog/weekend-recipes" + - Call: `scrape_webpage(url="https://example.com/blog/weekend-recipes")` + - When a user sends just a URL with no instructions, scrape it and provide a concise summary of the content. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md new file mode 100644 index 000000000..222709b38 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md @@ -0,0 +1,9 @@ + +- User: "How do I install SurfSense?" + - Call: `search_surfsense_docs(query="installation setup")` +- User: "What connectors does SurfSense support?" + - Call: `search_surfsense_docs(query="available connectors integrations")` +- User: "How do I set up the Notion connector?" + - Call: `search_surfsense_docs(query="Notion connector setup configuration")` (how-to docs). Changing data inside Notion itself → **task**. +- User: "How do I use Docker to run SurfSense?" + - Call: `search_surfsense_docs(query="Docker installation setup")` diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_private.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_private.md new file mode 100644 index 000000000..f83fe40b4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_private.md @@ -0,0 +1,16 @@ + +- Alex, is empty. User: "I'm a space enthusiast, explain astrophage to me" + - The user casually shared a durable fact. Use their first name in the entry, short neutral heading: + update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n") +- User: "Remember that I prefer concise answers over detailed explanations" + - Durable preference. Merge with existing memory, add a new heading: + update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n") +- User: "I actually moved to Tokyo last month" + - Updated fact, date prefix reflects when recorded: + update_memory(updated_memory="## Interests & background\n...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...") +- User: "I'm a freelance photographer working on a nature documentary" + - Durable background info under a fitting heading: + update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n") +- User: "Always respond in bullet points" + - Standing instruction: + update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n") diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_team.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_team.md new file mode 100644 index 000000000..1c74fdf6e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_team.md @@ -0,0 +1,7 @@ + +- User: "Let's remember that we decided to do weekly standup meetings on Mondays" + - Durable team decision: + update_memory(updated_memory="- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...") +- User: "Our office is in downtown Seattle, 5th floor" + - Durable team fact: + update_memory(updated_memory="- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...") diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/web_search.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/web_search.md new file mode 100644 index 000000000..4789a6ed9 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/web_search.md @@ -0,0 +1,8 @@ + +- User: "What's the current USD to INR exchange rate?" + - Call: `web_search(query="current USD to INR exchange rate")` + - Answer from returned snippets or scrape a top URL if needed; use markdown links to sources. +- User: "What's the latest news about AI?" + - Call: `web_search(query="latest AI news today")` +- User: "What's the weather in New York?" + - Call: `web_search(query="weather New York today")` diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_private.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_private.md new file mode 100644 index 000000000..75c3c0e5f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_private.md @@ -0,0 +1,19 @@ + +CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE: +- Ground factual answers in what you actually receive this turn: injected workspace + documents (when present), **search_surfsense_docs**, **web_search**, **scrape_webpage**, + or substantive results summarized from a **task** subagent you invoked. +- Do NOT answer factual or informational questions from general knowledge unless the user + explicitly grants permission after you say you did not find enough in those sources. +- If indexed/docs search returns nothing relevant AND **web_search** / **scrape_webpage** + (and **task**, if already tried appropriately) still do not supply an answer, you MUST: + 1. Say you could not find enough in their workspace/docs/tools output. + 2. Ask: "Would you like me to answer from my general knowledge instead?" + 3. ONLY then answer from general knowledge after they clearly say yes. +- This policy does NOT apply to: + * Casual conversation, greetings, or meta-questions about SurfSense (e.g. "what can you do?") + * Formatting or analysis of content already in the chat + * Clear rewrite/edit instructions ("bullet-point this paragraph") + * Lightweight research with **web_search** / **scrape_webpage** + * Work that belongs on a specialist — use **task**; see `` + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_team.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_team.md new file mode 100644 index 000000000..7c4aba1f8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_team.md @@ -0,0 +1,19 @@ + +CRITICAL RULE — KNOWLEDGE BASE FIRST, NEVER DEFAULT TO GENERAL KNOWLEDGE: +- Ground factual answers in what you actually receive this turn: injected shared + workspace documents (when present), **search_surfsense_docs**, **web_search**, + **scrape_webpage**, or substantive results summarized from a **task** subagent you invoked. +- Do NOT answer factual questions from general knowledge unless a team member explicitly + grants permission after you say you did not find enough in those sources. +- If indexed/docs search returns nothing relevant AND **web_search** / **scrape_webpage** + (and **task**, if already tried appropriately) still do not supply an answer, you MUST: + 1. Say you could not find enough in shared docs/tools output. + 2. Ask: "Would you like me to answer from my general knowledge instead?" + 3. ONLY then answer from general knowledge after they clearly say yes. +- This policy does NOT apply to: + * Casual conversation, greetings, or meta-questions about SurfSense + * Formatting or analysis of content already in the chat + * Clear rewrite/edit instructions + * Lightweight research with **web_search** / **scrape_webpage** + * Work that belongs on a specialist — use **task**; see `` + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md new file mode 100644 index 000000000..21b6d3254 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md @@ -0,0 +1,14 @@ + +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. + +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**. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_private.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_private.md new file mode 100644 index 000000000..8f7da14f8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_private.md @@ -0,0 +1,6 @@ + +IMPORTANT — After understanding each user message, ALWAYS check: does this message +reveal durable facts about the user (role, interests, preferences, projects, +background, or standing instructions)? If yes, you MUST call update_memory +alongside your normal response — do not defer this to a later turn. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_team.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_team.md new file mode 100644 index 000000000..61d89cc5d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_team.md @@ -0,0 +1,6 @@ + +IMPORTANT — After understanding each user message, ALWAYS check: does this message +reveal durable facts about the team (decisions, conventions, architecture, processes, +or key facts)? If yes, you MUST call update_memory alongside your normal response — +do not defer this to a later turn. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/parameter_resolution.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/parameter_resolution.md new file mode 100644 index 000000000..350da6220 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/parameter_resolution.md @@ -0,0 +1,15 @@ + +You do **not** call connector-specific discovery tools yourself (accounts, channels, +Jira cloud IDs, Airtable bases, Slack channels, etc.). Those tools exist only on +**task** subagents. + +When the user needs work inside a connected product, delegate with **task** and a +clear goal. If several Slack channels, Jira projects, calendar calendars, etc. could +match and only the integration can list them, **you must not** ask the human for +internal IDs (UUIDs, cloud IDs, opaque keys). The **task** subagent uses connector +tools to list candidates and either picks the only sensible match or asks the user +to choose using **normal labels** (e.g. channel display name, project title), not raw IDs. + +If you already have plain-language choices from the user or from prior tool output, +you may pass them through to **task** without re-discovery. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/__init__.py @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/anthropic.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/anthropic.md new file mode 100644 index 000000000..89154c443 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/anthropic.md @@ -0,0 +1,16 @@ + +You are running on an Anthropic Claude model (SurfSense **main agent**). + +Structured reasoning: +- For non-trivial work, `` / short `` before tool calls is fine. + +Professional objectivity: +- Accuracy over flattery; verify with **search_surfsense_docs**, **web_search**, **scrape_webpage**, or **task** when unsure — don’t invent connector access. + +Task management: +- For 3+ steps, use todo tooling; update statuses promptly. + +Tool calls: +- Parallelise independent calls; sequence only when outputs chain. +- Never pretend you can run connector-specific tools directly — route through **task** when needed. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/deepseek.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/deepseek.md new file mode 100644 index 000000000..4254e9ed5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/deepseek.md @@ -0,0 +1,18 @@ + +You are running on a DeepSeek model (SurfSense **main agent**). + +Reasoning hygiene (R1-aware): +- Keep internal scratch separate from the user-facing answer; don’t leak chain-of-thought into tool arguments. + +Output style: +- Concise; lead with the answer or the next action; avoid sycophantic openers. + +Attribution: +- When citations are **enabled** and facts come from chunk-tagged context, follow the citation block above. +- When citations are **disabled**, do not use `[citation:…]`. + +Tool calls: +- Parallelise independent calls. +- Prefer **search_surfsense_docs** for SurfSense docs/product questions before **web_search** when that fits the ask. +- Don’t invent paths, chunk ids, or URLs — only values from tools or the user. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/default.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/default.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/default.md @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/google.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/google.md new file mode 100644 index 000000000..c72c1bc72 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/google.md @@ -0,0 +1,18 @@ + +You are running on a Google Gemini model (SurfSense **main agent**). + +Output style: +- Concise & direct. Fewer than ~3 lines of prose when the task allows (excluding tool output and code). +- No filler openers/closers — move straight to the answer or the tool call. +- GitHub-flavoured Markdown; monospace-friendly. + +Workflow (Understand → Plan → Act → Verify): +1. **Understand:** parse the ask; use **search_surfsense_docs** / injected workspace context before guessing. +2. **Plan:** for multi-step work, a short plan first. +3. **Act:** only with tools you actually have on this agent (see `` and ``). Connector work → **task**. +4. **Verify:** re-read or re-search only when it materially reduces risk. + +Discipline: +- Do not imply access to connectors, MCP tools, or deliverable generators except via **task**. +- Path arguments for filesystem tools must be exact strings from tool results — never invent paths. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/grok.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/grok.md new file mode 100644 index 000000000..3219e10d3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/grok.md @@ -0,0 +1,16 @@ + +You are running on an xAI Grok model (SurfSense **main agent**). + +Maximum terseness: +- Fewer than 4 lines unless detail is requested; skip preamble/postamble. + +Tool discipline: +- Typically one investigative tool per turn unless several independent read-only queries are clearly needed; don’t repeat identical calls. + +Attribution: +- When citations are **enabled** (see citation block above) and you answer from chunk-tagged documents, use `[citation:chunk_id]` exactly as specified there. +- When citations are **disabled**, never emit `[citation:…]` — plain prose and links per tool guidance. + +Style: +- No emojis unless asked; flat lists for short answers. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/kimi.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/kimi.md new file mode 100644 index 000000000..3fe07d180 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/kimi.md @@ -0,0 +1,21 @@ + +You are running on a Moonshot Kimi model (Kimi-K1.5 / Kimi-K2 / Kimi-K2.5+), SurfSense **main agent**. + +Action bias: +- Default to taking action with tools rather than describing solutions in prose. If a tool can answer the question, call the tool. +- Don't narrate routine reads, searches, or obvious next steps. Combine related progress into one short status line. +- Be thorough in actions (test what you build, verify what you change). Be brief in explanations. + +Tool calls: +- Output multiple non-interfering tool calls in a SINGLE response — parallelism is a major efficiency win on this model. +- When the `task` tool is available, delegate focused subtasks to a subagent with full context (subagents don't inherit yours). +- Don't apologise or pre-announce tool calls. The tool call itself is self-explanatory. + +Language: +- Respond in the SAME language as the user's most recent turn unless explicitly instructed otherwise. + +Discipline: +- Stay on track. Never give the user more than what they asked for. +- Fact-check with tools; don’t fabricate chunk ids or connector outcomes. +- Keep it stupidly simple. Don't overcomplicate. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_classic.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_classic.md new file mode 100644 index 000000000..7ff3ec912 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_classic.md @@ -0,0 +1,20 @@ + +You are running on a classic OpenAI chat model (GPT-4 family), SurfSense **main agent**. + +Persistence: +- Finish the user’s request in the same turn when tools allow — don’t stop at intent only. +- If a tool errors, fix arguments and retry once before giving up. + +Planning: +- For 3+ steps, use the todo / planning tool; mark `in_progress` / `completed` promptly. +- One short sentence before non-trivial tool use is fine. + +Output style: +- Conversational but professional; bullets for findings; fenced code with language tags when needed. +- Summarize tool output — don’t paste walls of text. + +Tool calls: +- Parallelise independent calls in one turn. +- Prefer **search_surfsense_docs** for SurfSense-product questions, **web_search** / **scrape_webpage** + for fresh public facts; integrations and heavy workflows → **task**. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_codex.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_codex.md new file mode 100644 index 000000000..aad52f995 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_codex.md @@ -0,0 +1,13 @@ + +You are running on an OpenAI Codex-class model (SurfSense **main agent**). + +Output style: +- Concise; don’t paste huge fetch blobs — summarize. +- When citations are **enabled** and you rely on chunk-tagged docs, references may use `[citation:chunk_id]` per the citation block above; when **disabled**, use prose and URLs only. +- Numbered lists work well when the user should reply with a single option index. +- No emojis; single-level bullets. + +Tool calls: +- Parallelise independent calls; chain only when required. +- Don’t ask permission for obvious safe defaults — state what you did. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_reasoning.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_reasoning.md new file mode 100644 index 000000000..6c8a34087 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_reasoning.md @@ -0,0 +1,22 @@ + +You are running on an OpenAI reasoning model (GPT-5+ / o-series), SurfSense **main agent**. + +Output style: +- Be terse and direct. Don't restate the user's request before answering. +- Don't begin with conversational openers ("Done!", "Got it", "Great question", "Sure thing"). Get to the answer or the action. +- Match response complexity to the task: simple questions → one-line answer; substantial work → lead with the outcome, then context, then any next steps. +- No nested bullets — keep lists flat (single level). For options the user can pick by replying with a number, use `1.` `2.` `3.`. +- Use inline backticks for paths/commands/identifiers; fenced code blocks (with language tags) for multi-line snippets. + +Channels (for clients that support them): +- `commentary` — short progress updates only when they add genuinely new information (a discovery, a tradeoff, a blocker, the start of a non-trivial step). Don't narrate routine reads or obvious next steps. +- `final` — the completed response. Keep it self-contained; no "see above" / "see below" cross-references. + +Tool calls: +- Parallelise independent tool calls in a single response (`multi_tool_use.parallel` where supported). Only sequence when a later call needs an earlier one's output. +- Connector or integration execution belongs in **task**, not invented main-agent tools. +- Don't ask permission ("Should I proceed?", "Do you want me to…?"). Pick the most reasonable default, do it, and state what you did. + +Autonomy: +- Persist until the task is fully resolved within the current turn whenever feasible — within tools you actually have; delegate the rest via **task**. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/__init__.py @@ -0,0 +1 @@ + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/_preamble.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/_preamble.md new file mode 100644 index 000000000..137904545 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/_preamble.md @@ -0,0 +1,9 @@ + +You have access to the following **SurfSense** tools (main-agent scope only): + +IMPORTANT: You can ONLY use the tools listed below. Anything else — connectors, +deliverables, or multi-step integration work — goes through **task**, not as a +tool in this list. + +Do NOT claim you can use a capability if it is not listed here. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/scrape_webpage.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/scrape_webpage.md new file mode 100644 index 000000000..ecec982c1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/scrape_webpage.md @@ -0,0 +1,10 @@ + +- scrape_webpage: Fetch and extract readable content from a single HTTP(S) URL. + - Use when the user wants the *actual page body* (article, table, dashboard snapshot), not just search snippets. + - Try the tool when a URL is given or referenced; don’t refuse without attempting unless the URL is clearly unsafe/invalid. + - Args: + - url: Page to fetch + - max_length: Cap on returned characters (default: 50000) + - Returns: Title, metadata, and markdown-ish body. + - Summarize clearly afterward; link back with `[label](url)`. + - If indexed workspace material is insufficient and the user points at a public URL, scraping is appropriate — still not a substitute for **task** on private connectors. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md new file mode 100644 index 000000000..cfa32e889 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md @@ -0,0 +1,9 @@ + +- search_surfsense_docs: Search official SurfSense documentation (product help). + - Use when the user asks how SurfSense works, setup, connectors at a high level, configuration, etc. + - Not a substitute for **task** when they need actions inside Gmail/Slack/Jira/etc. + - Args: + - query: What to look up in SurfSense docs + - top_k: Number of chunks to retrieve (default: 10) + - Returns: Doc excerpts; chunk ids may appear for attribution — follow the **citation** + instructions block above when citations are enabled; otherwise summarize without `[citation:…]`. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_private.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_private.md new file mode 100644 index 000000000..3ba11f179 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_private.md @@ -0,0 +1,12 @@ + +- update_memory: Curate the **personal** long-term memory document for this user. + - Current memory (if any) appears in `` with usage vs limit. + - Call when the user asks to remember/forget, or shares durable facts/preferences/instructions. + - Use the first name from `` when writing entries — write “Alex prefers…” not “The user prefers…”. + Do not store the name alone as a memory entry. + - Skip ephemeral chat noise (one-off q/a, greetings, session logistics). + - Args: + - updated_memory: FULL replacement markdown (merge and curate — don’t only append). + - Formatting rules: + - Bullets: `- (YYYY-MM-DD) [marker] text` with markers `[fact]`, `[pref]`, `[instr]` (priority when trimming: instr > pref > fact). + - Each bullet under a short `##` heading; keep total size under the limit shown in ``. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_team.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_team.md new file mode 100644 index 000000000..7eaca8818 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_team.md @@ -0,0 +1,26 @@ + +- update_memory: Update the team's shared memory document for this search space. + - Your current team memory is already in in your context. The `chars` + and `limit` attributes show current usage and the maximum allowed size. + - This is the team's curated long-term memory — decisions, conventions, key facts. + - NEVER store personal memory in team memory (e.g. personal bio, individual + preferences, or user-only standing instructions). + - Call update_memory when: + * A team member explicitly asks to remember or forget something + * The conversation surfaces durable team decisions, conventions, or facts + that will matter in future conversations + - Do not store short-lived or ephemeral info: one-off questions, greetings, + session logistics, or things that only matter for the current task. + - Args: + - updated_memory: The FULL updated markdown document (not a diff). + Merge new facts with existing ones, update contradictions, remove outdated entries. + Treat every update as a curation pass — consolidate, don't just append. + - Every bullet MUST use this format: - (YYYY-MM-DD) [fact] text + Team memory uses ONLY the [fact] marker. Never use [pref] or [instr] in team memory. + - Keep it concise and well under the character limit shown in . + - Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and + natural. Organize by context — e.g. what the team decided, current architecture, + active processes. Create, split, or merge headings freely as the memory grows. + - Each entry MUST be a single bullet point. Be descriptive but concise — include relevant + details and context rather than just a few words. + - During consolidation, prioritize keeping: decisions/conventions > key facts > current priorities. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/web_search.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/web_search.md new file mode 100644 index 000000000..79a3a9b12 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/web_search.md @@ -0,0 +1,10 @@ + +- web_search: Live public-web search (whatever search backends the workspace configured). + - Use for current events, prices, weather, news, or anything needing fresh public web data. + - For those queries, call this tool rather than guessing from memory or claiming you lack network access. + - If results are thin, say so and offer to refine the query. + - Args: + - query: Specific search terms + - top_k: Max hits (default: 10, max: 50) + - If snippets are too shallow, follow up with **scrape_webpage** on the best URL. + - Present sources with readable markdown links `[label](url)` — never bare URLs. From eb4b570265a6a935191e0ebe843f8fc28220c647 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 23:18:10 +0200 Subject: [PATCH 076/131] Wire main-agent async factory and export create_surfsense_deep_agent. --- .../main_agent/__init__.py | 6 +- .../main_agent/runtime/__init__.py | 7 + .../main_agent/runtime/factory.py | 219 ++++++++++++++++++ 3 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py index 733150645..b9a18fe53 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py @@ -1,5 +1,7 @@ -"""SurfSense main-agent package (factory export added when runtime lands).""" +"""Main-agent deep agent: ``runtime/`` (factory), ``graph/`` (compile), ``system_prompt/``, etc.""" from __future__ import annotations -__all__: list[str] = [] +from .runtime import create_surfsense_deep_agent + +__all__ = ["create_surfsense_deep_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/__init__.py new file mode 100644 index 000000000..3d4ae977d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/__init__.py @@ -0,0 +1,7 @@ +"""Async factory: wiring tools, prompts, MCP buckets, then graph compile.""" + +from __future__ import annotations + +from .factory import create_surfsense_deep_agent + +__all__ = ["create_surfsense_deep_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py new file mode 100644 index 000000000..9c9abd664 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py @@ -0,0 +1,219 @@ +"""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 ..graph.compile_graph_sync import build_compiled_agent_graph_sync +from ..tools import MAIN_AGENT_SURFSENSE_TOOL_NAMES, MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED +from app.agents.multi_agent_with_deepagents.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 ..system_prompt import build_main_agent_system_prompt +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, + get_connector_gated_tools, +) +from app.db import ChatVisibility +from app.services.connector_service import ConnectorService +from app.utils.perf import get_perf_logger + +_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 + ) + if connector_types: + 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 [] + modified_disabled_tools.extend(get_connector_gated_tools(available_connectors)) + + 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 + + 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), + ) + 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, + ) + _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, + ) + _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 From fbcff79a58f05e098fc9646046ef5972e6144775 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 23:18:17 +0200 Subject: [PATCH 077/131] Export deep-agent factory and omit memory/research from task registry. --- .../app/agents/multi_agent_with_deepagents/__init__.py | 8 +++++++- .../multi_agent_with_deepagents/subagents/registry.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py index f6a5b8686..f568dc6b2 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py @@ -1 +1,7 @@ -"""Deepagents-backed multi-agent routes (subagents under ``subagents/``).""" +"""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_with_deepagents/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py index 0dd073a98..c2a648f45 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py @@ -114,10 +114,16 @@ def build_subagents( mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, only_names: frozenset[str] | None = None, ) -> list[SubAgent]: - """Build registry route specs; ``only_names`` selects which routes.""" + """Build registry route specs. + + ``memory`` and ``research`` are never included (main agent holds those tools). + When ``only_names`` is set, only matching routes among the remainder are built. + """ mcp = mcp_tools_by_agent or {} specs: list[SubAgent] = [] for name in sorted(SUBAGENT_BUILDERS_BY_NAME): + if name in ("memory", "research"): + continue if only_names is not None and name not in only_names: continue builder = SUBAGENT_BUILDERS_BY_NAME[name] From 2bce2a2f55403f6fce88c110213230bf4a55757d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 2 May 2026 00:43:42 +0200 Subject: [PATCH 078/131] Remove obsolete main-agent connector permission helpers. --- .../main_agent/permissions/__init__.py | 13 ------ .../permissions/connector_deny_rules.py | 21 ---------- .../permissions/connector_gated_tool_names.py | 42 ------------------- .../main_agent/permissions/rule.py | 15 ------- 4 files changed, 91 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py delete mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py delete mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py deleted file mode 100644 index bff255bb4..000000000 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Connector-gated tool deny rules and small permission helpers for the main-agent graph.""" - -from __future__ import annotations - -from .connector_deny_rules import synthesize_connector_deny_rules -from .connector_gated_tool_names import iter_connector_gated_tools -from .rule import Rule - -__all__ = [ - "Rule", - "iter_connector_gated_tools", - "synthesize_connector_deny_rules", -] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py deleted file mode 100644 index 2144fc7de..000000000 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_deny_rules.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Synthesized PermissionMiddleware deny rules for tools gated by connector.""" - -from __future__ import annotations - -from .connector_gated_tool_names import iter_connector_gated_tools -from .rule import Rule - - -def synthesize_connector_deny_rules( - *, - available_connectors: list[str] | None, - enabled_tool_names: set[str], -) -> list[Rule]: - available = set(available_connectors or []) - deny: list[Rule] = [] - for name, required in iter_connector_gated_tools(): - if name not in enabled_tool_names: - continue - if required not in available: - deny.append(Rule(permission=name, pattern="*", action="deny")) - return deny diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py deleted file mode 100644 index 0b5b2bcd7..000000000 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/connector_gated_tool_names.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tool name → required searchable connector type (keep in sync with new_chat ``BUILTIN_TOOLS``).""" - -from __future__ import annotations - -# Synced from ``app.agents.new_chat.tools.registry`` ToolDefinition.required_connector entries. -_CONNECTOR_GATED: tuple[tuple[str, str], ...] = ( - ("create_notion_page", "NOTION_CONNECTOR"), - ("update_notion_page", "NOTION_CONNECTOR"), - ("delete_notion_page", "NOTION_CONNECTOR"), - ("create_google_drive_file", "GOOGLE_DRIVE_FILE"), - ("delete_google_drive_file", "GOOGLE_DRIVE_FILE"), - ("create_dropbox_file", "DROPBOX_FILE"), - ("delete_dropbox_file", "DROPBOX_FILE"), - ("create_onedrive_file", "ONEDRIVE_FILE"), - ("delete_onedrive_file", "ONEDRIVE_FILE"), - ("search_calendar_events", "GOOGLE_CALENDAR_CONNECTOR"), - ("create_calendar_event", "GOOGLE_CALENDAR_CONNECTOR"), - ("update_calendar_event", "GOOGLE_CALENDAR_CONNECTOR"), - ("delete_calendar_event", "GOOGLE_CALENDAR_CONNECTOR"), - ("search_gmail", "GOOGLE_GMAIL_CONNECTOR"), - ("read_gmail_email", "GOOGLE_GMAIL_CONNECTOR"), - ("create_gmail_draft", "GOOGLE_GMAIL_CONNECTOR"), - ("send_gmail_email", "GOOGLE_GMAIL_CONNECTOR"), - ("trash_gmail_email", "GOOGLE_GMAIL_CONNECTOR"), - ("update_gmail_draft", "GOOGLE_GMAIL_CONNECTOR"), - ("create_confluence_page", "CONFLUENCE_CONNECTOR"), - ("update_confluence_page", "CONFLUENCE_CONNECTOR"), - ("delete_confluence_page", "CONFLUENCE_CONNECTOR"), - ("list_discord_channels", "DISCORD_CONNECTOR"), - ("read_discord_messages", "DISCORD_CONNECTOR"), - ("send_discord_message", "DISCORD_CONNECTOR"), - ("list_teams_channels", "TEAMS_CONNECTOR"), - ("read_teams_messages", "TEAMS_CONNECTOR"), - ("send_teams_message", "TEAMS_CONNECTOR"), - ("list_luma_events", "LUMA_CONNECTOR"), - ("read_luma_event", "LUMA_CONNECTOR"), - ("create_luma_event", "LUMA_CONNECTOR"), -) - - -def iter_connector_gated_tools() -> tuple[tuple[str, str], ...]: - return _CONNECTOR_GATED diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py deleted file mode 100644 index b59e76083..000000000 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/permissions/rule.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Minimal permission rule type (mirrors OpenCode semantics used by PermissionMiddleware).""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -RuleAction = Literal["allow", "deny", "ask"] - - -@dataclass(frozen=True) -class Rule: - permission: str - pattern: str - action: RuleAction From eefdd1d7f00953b0a4f759884c7c936663fb3198 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 2 May 2026 00:43:46 +0200 Subject: [PATCH 079/131] Add connector routing entries and per-subagent connector token map. --- .../multi_agent_with_deepagents/constants.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py index 775027764..972677502 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py @@ -1,4 +1,4 @@ -"""Map connector type strings to the agent route key used for tools and MCP slices.""" +"""Connector-type to subagent name; subagent name to availability tokens for build_subagents.""" from __future__ import annotations @@ -15,4 +15,29 @@ CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = { "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"}), } From 06cef04e57ca5ea6b125c9ccac686327650c6710 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 2 May 2026 00:43:52 +0200 Subject: [PATCH 080/131] Gate builds via exclude list and expose registry helpers from subagents package. --- .../subagents/__init__.py | 19 ++++++ .../subagents/registry.py | 64 +++++++++++++++---- 2 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/__init__.py new file mode 100644 index 000000000..ca9e4aa3e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/__init__.py @@ -0,0 +1,19 @@ +"""Registry-backed subagent builders and helpers.""" + +from __future__ import annotations + +from .registry import ( + SUBAGENT_BUILDERS_BY_NAME, + SubagentBuilder, + build_subagents, + get_subagents_to_exclude, + main_prompt_registry_subagent_lines, +) + +__all__ = [ + "SUBAGENT_BUILDERS_BY_NAME", + "SubagentBuilder", + "build_subagents", + "get_subagents_to_exclude", + "main_prompt_registry_subagent_lines", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py index c2a648f45..85e23de84 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py @@ -62,6 +62,12 @@ from app.agents.multi_agent_with_deepagents.subagents.connectors.slack.agent imp from app.agents.multi_agent_with_deepagents.subagents.connectors.teams.agent import ( build_subagent as build_teams_subagent, ) +from app.agents.multi_agent_with_deepagents.constants import ( + SUBAGENT_TO_REQUIRED_CONNECTOR_MAP, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, ) @@ -99,11 +105,44 @@ SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = { "teams": build_teams_subagent, } -__all__ = [ - "SUBAGENT_BUILDERS_BY_NAME", - "SubagentBuilder", - "build_subagents", -] +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 build_subagents( @@ -112,19 +151,16 @@ def build_subagents( model: BaseChatModel | None = None, extra_middleware: Sequence[Any] | None = None, mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, - only_names: frozenset[str] | None = None, + exclude: list[str] | None = None, ) -> list[SubAgent]: - """Build registry route specs. - - ``memory`` and ``research`` are never included (main agent holds those tools). - When ``only_names`` is set, only matching routes among the remainder are built. - """ + """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) for name in sorted(SUBAGENT_BUILDERS_BY_NAME): - if name in ("memory", "research"): - continue - if only_names is not None and name not in only_names: + if name in excluded: continue builder = SUBAGENT_BUILDERS_BY_NAME[name] specs.append( From ffed829cf86aaaa0c67a2584fbce1a11d0493a62 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 2 May 2026 00:43:56 +0200 Subject: [PATCH 081/131] Skip registry subagents missing connectors when assembling the stack. --- .../main_agent/graph/middleware/deepagent_stack.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py index be7f76fc9..70d5e47b6 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py @@ -23,8 +23,10 @@ from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool from ...context_prune.prune_tool_names import safe_exclude_tools -from ...permissions.connector_deny_rules import synthesize_connector_deny_rules -from app.agents.multi_agent_with_deepagents.subagents.registry import build_subagents +from app.agents.multi_agent_with_deepagents.subagents import ( + build_subagents, + get_subagents_to_exclude, +) from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, ) @@ -130,6 +132,7 @@ def build_main_agent_deepagent_middleware( model=llm, extra_middleware=subagent_extra_middleware, mcp_tools_by_agent=mcp_tools_by_agent or {}, + exclude=get_subagents_to_exclude(available_connectors), ) logging.info( "Registry subagents: %s", @@ -263,12 +266,6 @@ def build_main_agent_deepagent_middleware( origin="desktop_safety", ) ) - if permission_enabled: - synthesized = synthesize_connector_deny_rules( - available_connectors=available_connectors, - enabled_tool_names={t.name for t in tools}, - ) - rulesets.append(Ruleset(rules=synthesized, origin="connector_synthesized")) permission_mw = PermissionMiddleware(rulesets=rulesets) action_log_mw: ActionLogMiddleware | None = None From 4fd3c4fb27c39ca6854b67d08c59ec9aa38ee21e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 2 May 2026 00:44:02 +0200 Subject: [PATCH 082/131] Inject dynamic registry subagents into the main prompt and simplify connector discovery. --- .../main_agent/runtime/factory.py | 20 +++++++++----- .../system_prompt/builder/compose.py | 8 +++--- .../builder/sections/registry_subagents.py | 27 +++++++++++++++++++ .../markdown/main_agent_tool_routing.md | 2 ++ 4 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/registry_subagents.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py index 9c9abd664..72e09edab 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py @@ -24,12 +24,13 @@ 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.multi_agent_with_deepagents.subagents import ( + get_subagents_to_exclude, + main_prompt_registry_subagent_lines, +) from ..system_prompt import build_main_agent_system_prompt 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, - get_connector_gated_tools, -) +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 @@ -73,8 +74,7 @@ async def create_surfsense_deep_agent( connector_types = await connector_service.get_available_connectors( search_space_id ) - if connector_types: - available_connectors = _map_connectors_to_searchable_types(connector_types) + available_connectors = _map_connectors_to_searchable_types(connector_types) available_document_types = await connector_service.get_available_document_types( search_space_id @@ -119,7 +119,6 @@ async def create_surfsense_deep_agent( ) modified_disabled_tools = list(disabled_tools) if disabled_tools else [] - modified_disabled_tools.extend(get_connector_gated_tools(available_connectors)) if "search_knowledge_base" not in modified_disabled_tools: modified_disabled_tools.append("search_knowledge_base") @@ -160,6 +159,11 @@ async def create_surfsense_deep_agent( 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, @@ -170,6 +174,7 @@ async def create_surfsense_deep_agent( 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( @@ -178,6 +183,7 @@ async def create_surfsense_deep_agent( 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 diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py index 31b0adb01..5f09b9cac 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py @@ -1,8 +1,7 @@ """Assemble the **main-agent** deep-agent system string only. -Sections (order matters): core instructions → provider flavour → **citations policy** -→ SurfSense tool docs. Citations come before ```` so citation on/off rules -apply before any tool text that mentions attribution. +Sections (order matters): core instructions → provider → citations → dynamic +```` → SurfSense ````. """ from __future__ import annotations @@ -13,6 +12,7 @@ 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 @@ -27,6 +27,7 @@ def build_main_agent_system_prompt( 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 @@ -43,6 +44,7 @@ def build_main_agent_system_prompt( 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, diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/registry_subagents.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/registry_subagents.py new file mode 100644 index 000000000..90f4cc2d6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/registry_subagents.py @@ -0,0 +1,27 @@ +"""Dynamic ```` block: **task** specialists actually built for this workspace.""" + +from __future__ import annotations + + +def build_registry_subagents_section( + registry_subagent_lines: list[tuple[str, str]] | None, +) -> str: + if registry_subagent_lines is None: + return "" + if not registry_subagent_lines: + return ( + "\n\n" + "No registry specialists are listed for **task** in this workspace.\n" + "\n" + ) + bullets = "\n".join( + f"- **{name}** — {desc}" for name, desc in registry_subagent_lines + ) + return ( + "\n\n" + "These specialists are registered for **task** (routes without a matching connector are omitted).\n" + f"{bullets}\n" + "The runtime may also offer a general-purpose **task** helper with your tools in a separate context.\n" + "Pick the specialist by **name**. Put full instructions in the task prompt; they do not see this thread.\n" + "\n" + ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md index 21b6d3254..d31e24ce9 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md @@ -2,6 +2,8 @@ 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 From 9975e085aa17b624bc3a41e4e11e2b148c04a01a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 2 May 2026 16:10:30 +0530 Subject: [PATCH 083/131] refactor(memory): streamline memory extraction by utilizing extract_text_content utility --- .../app/agents/new_chat/memory_extraction.py | 13 +-- .../agents/new_chat/tools/update_memory.py | 17 ++-- surfsense_backend/app/routes/memory_routes.py | 7 +- .../app/routes/search_spaces_routes.py | 7 +- .../new_chat/test_memory_response_content.py | 87 +++++++++++++++++++ 5 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py diff --git a/surfsense_backend/app/agents/new_chat/memory_extraction.py b/surfsense_backend/app/agents/new_chat/memory_extraction.py index 221c4c75a..e31774a7c 100644 --- a/surfsense_backend/app/agents/new_chat/memory_extraction.py +++ b/surfsense_backend/app/agents/new_chat/memory_extraction.py @@ -16,6 +16,7 @@ from sqlalchemy import select from app.agents.new_chat.tools.update_memory import _save_memory from app.db import SearchSpace, User, shielded_async_session +from app.utils.content_utils import extract_text_content logger = logging.getLogger(__name__) @@ -144,11 +145,7 @@ async def extract_and_save_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-extraction"]}, ) - text = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + text = extract_text_content(response.content).strip() if text == "NO_UPDATE" or not text: logger.debug("Memory extraction: no update needed (user %s)", uid) @@ -207,11 +204,7 @@ async def extract_and_save_team_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "team-memory-extraction"]}, ) - text = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + text = extract_text_content(response.content).strip() if text == "NO_UPDATE" or not text: logger.debug( diff --git a/surfsense_backend/app/agents/new_chat/tools/update_memory.py b/surfsense_backend/app/agents/new_chat/tools/update_memory.py index 4128ac0dc..ceaddb80f 100644 --- a/surfsense_backend/app/agents/new_chat/tools/update_memory.py +++ b/surfsense_backend/app/agents/new_chat/tools/update_memory.py @@ -27,6 +27,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import SearchSpace, User +from app.utils.content_utils import extract_text_content logger = logging.getLogger(__name__) @@ -188,11 +189,7 @@ async def _forced_rewrite(content: str, llm: Any) -> str | None: [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal"]}, ) - text = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ) + text = extract_text_content(response.content) return text.strip() except Exception: logger.exception("Forced rewrite LLM call failed") @@ -235,6 +232,16 @@ async def _save_memory( label : str Human label for log messages (e.g. "user memory", "team memory"). """ + if not isinstance(updated_memory, str): + logger.warning( + "Refusing non-string memory payload (type=%s)", + type(updated_memory).__name__, + ) + return { + "status": "error", + "message": "Internal error: memory payload must be a string.", + } + content = updated_memory # --- forced rewrite if over the hard limit --- diff --git a/surfsense_backend/app/routes/memory_routes.py b/surfsense_backend/app/routes/memory_routes.py index f5df45cf1..e57ca4055 100644 --- a/surfsense_backend/app/routes/memory_routes.py +++ b/surfsense_backend/app/routes/memory_routes.py @@ -16,6 +16,7 @@ from app.agents.new_chat.llm_config import ( from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, _save_memory from app.db import User, get_async_session from app.users import current_active_user +from app.utils.content_utils import extract_text_content logger = logging.getLogger(__name__) @@ -123,11 +124,7 @@ async def edit_user_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-edit"]}, ) - updated = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + updated = extract_text_content(response.content).strip() except Exception as e: logger.exception("Memory edit LLM call failed: %s", e) raise HTTPException(status_code=500, detail="Memory edit failed.") from e diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index 72715ea5b..e44455ad3 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -35,6 +35,7 @@ from app.schemas import ( SearchSpaceWithStats, ) from app.users import current_active_user +from app.utils.content_utils import extract_text_content from app.utils.rbac import check_permission, check_search_space_access logger = logging.getLogger(__name__) @@ -356,11 +357,7 @@ async def edit_team_memory( [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal", "memory-edit"]}, ) - updated = ( - response.content - if isinstance(response.content, str) - else str(response.content) - ).strip() + updated = extract_text_content(response.content).strip() except Exception as e: logger.exception("Team memory edit LLM call failed: %s", e) raise HTTPException(status_code=500, detail="Team memory edit failed.") from e diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py b/surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py new file mode 100644 index 000000000..535e4e940 --- /dev/null +++ b/surfsense_backend/tests/unit/agents/new_chat/test_memory_response_content.py @@ -0,0 +1,87 @@ +"""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 From e38e20b48407da9205ca7b7e26a62f8b8959b126 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 4 May 2026 12:18:09 +0530 Subject: [PATCH 084/131] fix: handle empty response in forced rewrite function - Updated the `_forced_rewrite` function to strip whitespace from the extracted text and added a warning log if the response is empty, preventing potential issues with empty rewrites. --- .../app/agents/new_chat/tools/update_memory.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 42148967c..062668aac 100644 --- a/surfsense_backend/app/agents/new_chat/tools/update_memory.py +++ b/surfsense_backend/app/agents/new_chat/tools/update_memory.py @@ -189,8 +189,11 @@ async def _forced_rewrite(content: str, llm: Any) -> str | None: [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal"]}, ) - text = extract_text_content(response.content) - return text.strip() + text = extract_text_content(response.content).strip() + if not text: + logger.warning("Forced rewrite returned empty text; aborting rewrite") + return None + return text except Exception: logger.exception("Forced rewrite LLM call failed") return None From acd2fdda8aaac4090fa66408269ad4e27963972c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 18:42:39 +0200 Subject: [PATCH 085/131] Add SurfSenseCheckpointedSubAgentMiddleware to bridge HITL into deepagents subagents. --- .../__init__.py | 26 ++ .../config.py | 35 +++ .../constants.py | 18 ++ .../middleware.py | 103 ++++++++ .../propagation.py | 74 ++++++ .../resume.py | 71 ++++++ .../task_tool.py | 224 ++++++++++++++++++ 7 files changed, 551 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py new file mode 100644 index 000000000..d03b571ca --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py @@ -0,0 +1,26 @@ +"""SubAgent ``task`` tool wiring required for HITL inside subagents. + +Replaces upstream ``SubAgentMiddleware`` to: + +- share the parent's checkpointer with each subagent, +- forward ``runtime.config`` (thread_id, recursion_limit, …) into nested invokes, +- bridge ``Command(resume=...)`` from the parent into the subagent via the + ``config["configurable"]["surfsense_resume_value"]`` side-channel, +- target the resume at the captured interrupt id so a follow-up + ``HumanInTheLoopMiddleware.after_model`` does not consume the same payload, +- re-raise any new subagent interrupt at the parent so the SSE stream surfaces it. + +Module layout +------------- + +- ``constants`` — shared keys / limits. +- ``config`` — RunnableConfig + side-channel resume read. +- ``resume`` — pending-interrupt detection, fan-out, ``Command(resume=...)`` builder. +- ``propagation`` — re-raise pending subagent interrupts at the parent. +- ``task_tool`` — the ``task`` tool factory (sync + async). +- ``middleware`` — :class:`SurfSenseCheckpointedSubAgentMiddleware` itself. +""" + +from .middleware import SurfSenseCheckpointedSubAgentMiddleware + +__all__ = ["SurfSenseCheckpointedSubAgentMiddleware"] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py new file mode 100644 index 000000000..0312a2da5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py @@ -0,0 +1,35 @@ +"""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 extract_surfsense_resume(runtime: ToolRuntime) -> Any: + """Resume payload stashed by ``stream_resume_chat``; ``None`` on a first-time call.""" + cfg = runtime.config or {} + configurable = cfg.get("configurable") if isinstance(cfg, dict) else None + if not isinstance(configurable, dict): + return None + return configurable.get("surfsense_resume_value") diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py new file mode 100644 index 000000000..6c4519f3a --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py @@ -0,0 +1,18 @@ +"""Constants shared by the checkpointed subagent middleware.""" + +from __future__ import annotations + +# Mirror of deepagents.middleware.subagents._EXCLUDED_STATE_KEYS. +EXCLUDED_STATE_KEYS = frozenset( + { + "messages", + "todos", + "structured_response", + "skills_metadata", + "memory_contents", + } +) + +# Match the parent graph's budget; the LangGraph default of 25 trips on +# multi-step subagent runs. +DEFAULT_SUBAGENT_RECURSION_LIMIT = 10_000 diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py new file mode 100644 index 000000000..da8a62cdc --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py @@ -0,0 +1,103 @@ +"""SubAgent middleware that compiles each subagent against the parent checkpointer.""" + +from __future__ import annotations + +from typing import Any, cast + +from deepagents.backends.protocol import BackendFactory, BackendProtocol +from deepagents.middleware.subagents import ( + TASK_SYSTEM_PROMPT, + CompiledSubAgent, + SubAgent, + SubAgentMiddleware, +) +from langchain.agents import create_agent +from langchain.agents.middleware import HumanInTheLoopMiddleware +from langchain.chat_models import init_chat_model +from langgraph.types import Checkpointer + +from .task_tool import build_task_tool_with_parent_config + + +class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware): + """``SubAgentMiddleware`` variant that compiles each subagent against the parent checkpointer.""" + + def __init__( + self, + *, + checkpointer: Checkpointer, + backend: BackendProtocol | BackendFactory, + subagents: list[SubAgent | CompiledSubAgent], + system_prompt: str | None = TASK_SYSTEM_PROMPT, + task_description: str | None = None, + ) -> None: + self._surf_checkpointer = checkpointer + super(SubAgentMiddleware, self).__init__() + if not subagents: + raise ValueError( + "At least one subagent must be specified when using the new API" + ) + self._backend = backend + self._subagents = subagents + subagent_specs = self._surf_compile_subagent_graphs() + task_tool = build_task_tool_with_parent_config(subagent_specs, task_description) + if system_prompt and subagent_specs: + agents_desc = "\n".join( + f"- {s['name']}: {s['description']}" for s in subagent_specs + ) + self.system_prompt = ( + system_prompt + "\n\nAvailable subagent types:\n" + agents_desc + ) + else: + self.system_prompt = system_prompt + self.tools = [task_tool] + + def _surf_compile_subagent_graphs(self) -> list[dict[str, Any]]: + """Mirror of ``SubAgentMiddleware._get_subagents`` that threads the parent checkpointer.""" + specs: list[dict[str, Any]] = [] + + for spec in self._subagents: + if "runnable" in spec: + compiled = cast(CompiledSubAgent, spec) + specs.append( + { + "name": compiled["name"], + "description": compiled["description"], + "runnable": compiled["runnable"], + } + ) + continue + + if "model" not in spec: + msg = f"SubAgent '{spec['name']}' must specify 'model'" + raise ValueError(msg) + if "tools" not in spec: + msg = f"SubAgent '{spec['name']}' must specify 'tools'" + raise ValueError(msg) + + model = spec["model"] + if isinstance(model, str): + model = init_chat_model(model) + + middleware: list[Any] = list(spec.get("middleware", [])) + + interrupt_on = spec.get("interrupt_on") + if interrupt_on: + middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on)) + + specs.append( + { + "name": spec["name"], + "description": spec["description"], + "runnable": create_agent( + model, + system_prompt=spec["system_prompt"], + tools=spec["tools"], + middleware=middleware, + name=spec["name"], + checkpointer=self._surf_checkpointer, + ), + } + ) + + return specs diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py new file mode 100644 index 000000000..55aae7201 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py @@ -0,0 +1,74 @@ +"""Re-raise still-pending subagent interrupts at the parent graph level. + +After ``subagent.[a]invoke(Command(resume=...))`` returns, the subagent may +still hold a pending interrupt (e.g. the LLM produced a follow-up tool call +that fired a fresh ``interrupt()``). The parent's pregel cannot see that +interrupt because it lives in a separate compiled graph; we re-raise it here +so the parent's SSE stream surfaces it as the next approval card. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from langchain_core.runnables import Runnable +from langgraph.types import interrupt as _lg_interrupt + +from .resume import get_first_pending_subagent_interrupt + +logger = logging.getLogger(__name__) + + +def maybe_propagate_subagent_interrupt( + subagent: Runnable, + sub_config: dict[str, Any], + subagent_type: str, +) -> None: + """Re-raise a still-pending subagent interrupt at the parent so the SSE stream surfaces it.""" + get_state_sync = getattr(subagent, "get_state", None) + if not callable(get_state_sync): + return + try: + snapshot = get_state_sync(sub_config) + except Exception: # pragma: no cover - defensive + logger.debug( + "Subagent get_state failed during re-interrupt check", + exc_info=True, + ) + return + _pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot) + if pending_value is None: + return + logger.info( + "Re-raising subagent %r interrupt to parent (multi-step HITL)", + subagent_type, + ) + _lg_interrupt(pending_value) + + +async def amaybe_propagate_subagent_interrupt( + subagent: Runnable, + sub_config: dict[str, Any], + subagent_type: str, +) -> None: + """Async counterpart of :func:`maybe_propagate_subagent_interrupt`.""" + aget_state = getattr(subagent, "aget_state", None) + if not callable(aget_state): + return + try: + snapshot = await aget_state(sub_config) + except Exception: # pragma: no cover - defensive + logger.debug( + "Subagent aget_state failed during re-interrupt check", + exc_info=True, + ) + return + _pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot) + if pending_value is None: + return + logger.info( + "Re-raising subagent %r interrupt to parent (multi-step HITL)", + subagent_type, + ) + _lg_interrupt(pending_value) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py new file mode 100644 index 000000000..c9b8b01e6 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py @@ -0,0 +1,71 @@ +"""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: + """Pad a single-decision resume to N entries so an ``action_requests=N`` bundle accepts it.""" + 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)`` in the snapshot, else ``(None, None)``. + + The ``id`` lets the caller target ``Command(resume={id: value})`` so the + payload is not broadcast to a later fresh interrupt in the same run. + """ + 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_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py new file mode 100644 index 000000000..15145b1b8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py @@ -0,0 +1,224 @@ +"""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 extract_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, + hitlrequest_action_count, + get_first_pending_subagent_interrupt, +) + +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.", # noqa: E501 + ], + subagent_type: Annotated[ + str, + "The type of subagent to use. Must be one of the available agent types listed in the tool description.", # noqa: E501 + ], + 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: # pragma: no cover - defensive + logger.debug( + "Subagent get_state failed; falling back to fresh invoke", + exc_info=True, + ) + + if pending_value is not None: + resume_value = extract_surfsense_resume(runtime) + if resume_value is not None: + expected = hitlrequest_action_count(pending_value) + resume_value = fan_out_decisions_to_match(resume_value, expected) + logger.info( + "Forwarding surfsense_resume_value into subagent %r " + "(action_requests=%d, targeted_id=%s)", + subagent_type, + expected, + pending_id is not None, + ) + result = subagent.invoke( + build_resume_command(resume_value, pending_id), + config=sub_config, + ) + else: + logger.warning( + "Subagent %r has pending interrupt but no surfsense_resume_value " + "on config — replaying with fresh state (interrupt will re-fire).", + subagent_type, + ) + result = subagent.invoke(subagent_state, 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.", # noqa: E501 + ], + subagent_type: Annotated[ + str, + "The type of subagent to use. Must be one of the available agent types listed in the tool description.", # noqa: E501 + ], + 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: # pragma: no cover - defensive + logger.debug( + "Subagent aget_state failed; falling back to fresh ainvoke", + exc_info=True, + ) + + if pending_value is not None: + resume_value = extract_surfsense_resume(runtime) + if resume_value is not None: + expected = hitlrequest_action_count(pending_value) + resume_value = fan_out_decisions_to_match(resume_value, expected) + logger.info( + "Forwarding surfsense_resume_value into subagent %r " + "(action_requests=%d, targeted_id=%s)", + subagent_type, + expected, + pending_id is not None, + ) + result = await subagent.ainvoke( + build_resume_command(resume_value, pending_id), + config=sub_config, + ) + else: + logger.warning( + "Subagent %r has pending interrupt but no surfsense_resume_value " + "on config — replaying with fresh state (interrupt will re-fire).", + subagent_type, + ) + result = await subagent.ainvoke(subagent_state, 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, + ) From ba2138c1644e34d23cf4959c183774e5cc8bf6d4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 18:42:46 +0200 Subject: [PATCH 086/131] Wire SurfSenseCheckpointedSubAgentMiddleware into the multi-agent stack. --- .../main_agent/graph/compile_graph_sync.py | 1 + .../graph/middleware/deepagent_stack.py | 80 +++++++++++++------ 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py index 43d45dcfd..309d37a84 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py @@ -59,6 +59,7 @@ def build_compiled_agent_graph_sync( max_input_tokens=max_input_tokens, flags=flags, subagent_dependencies=subagent_dependencies, + checkpointer=checkpointer, mcp_tools_by_agent=mcp_tools_by_agent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py index 70d5e47b6..31d4dbd40 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py @@ -6,7 +6,7 @@ import logging from collections.abc import Sequence from typing import Any -from deepagents import SubAgent, SubAgentMiddleware +from deepagents import SubAgent from deepagents.backends import StateBackend from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware from deepagents.middleware.skills import SkillsMiddleware @@ -21,6 +21,7 @@ from langchain.agents.middleware import ( 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 ...context_prune.prune_tool_names import safe_exclude_tools from app.agents.multi_agent_with_deepagents.subagents import ( @@ -65,6 +66,8 @@ from app.agents.new_chat.plugin_loader import ( from app.agents.new_chat.tools.registry import BUILTIN_TOOLS from app.db import ChatVisibility +from .checkpointed_subagent_middleware import SurfSenseCheckpointedSubAgentMiddleware + def build_main_agent_deepagent_middleware( *, @@ -83,6 +86,7 @@ def build_main_agent_deepagent_middleware( max_input_tokens: int | None, flags: AgentFeatureFlags, subagent_dependencies: dict[str, Any], + checkpointer: Checkpointer, mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, ) -> list[Any]: """Build ordered middleware for ``create_agent`` (Nones already stripped).""" @@ -108,12 +112,51 @@ def build_main_agent_deepagent_middleware( 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} + 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 registry_subagents: list[SubAgent] = [] try: @@ -243,30 +286,11 @@ def build_main_agent_deepagent_middleware( else None ) - permission_mw: PermissionMiddleware | None = None - is_desktop_fs = filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER - permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack - if permission_enabled or is_desktop_fs: - rulesets: list[Ruleset] = [ - Ruleset( - rules=[Rule(permission="*", pattern="*", action="allow")], - origin="surfsense_defaults", - ), - ] - if is_desktop_fs: - rulesets.append( - Ruleset( - rules=[ - Rule(permission="rm", pattern="*", action="ask"), - Rule(permission="rmdir", pattern="*", action="ask"), - Rule(permission="move_file", pattern="*", action="ask"), - Rule(permission="edit_file", pattern="*", action="ask"), - Rule(permission="write_file", pattern="*", action="ask"), - ], - origin="desktop_safety", - ) - ) - permission_mw = PermissionMiddleware(rulesets=rulesets) + permission_mw: PermissionMiddleware | None = ( + PermissionMiddleware(rulesets=permission_rulesets) + if permission_rulesets + else None + ) action_log_mw: ActionLogMiddleware | None = None if ( @@ -404,7 +428,11 @@ def build_main_agent_deepagent_middleware( if filesystem_mode == FilesystemMode.CLOUD else None, skills_mw, - SubAgentMiddleware(backend=StateBackend, subagents=subagent_specs), + SurfSenseCheckpointedSubAgentMiddleware( + checkpointer=checkpointer, + backend=StateBackend, + subagents=subagent_specs, + ), selector_mw, model_call_limit_mw, tool_call_limit_mw, From 4ac3f0b304a30d2cfb1f3b3327e3dd0fc328b77a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 18:42:58 +0200 Subject: [PATCH 087/131] Forward HITL decisions from the streaming layer to subagents via the config side-channel. --- .../agents/new_chat/middleware/busy_mutex.py | 23 +++++++ .../app/routes/new_chat_routes.py | 1 + surfsense_backend/app/schemas/new_chat.py | 3 + .../app/tasks/chat/stream_new_chat.py | 68 +++++++++++++------ 4 files changed, 75 insertions(+), 20 deletions(-) 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 c57d85004..4b5ad546d 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py +++ b/surfsense_backend/app/agents/new_chat/middleware/busy_mutex.py @@ -85,6 +85,23 @@ class _ThreadLockManager: if event is not None: event.clear() + 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 @@ -107,6 +124,11 @@ def reset_cancel(thread_id: str) -> None: manager.reset(thread_id) +def release_lock(thread_id: str) -> bool: + """Force-release the per-thread busy lock; safe to call when not held.""" + return manager.release(thread_id) + + class BusyMutexMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]): """Block concurrent prompts on the same thread. @@ -231,6 +253,7 @@ __all__ = [ "BusyMutexMiddleware", "get_cancel_event", "manager", + "release_lock", "request_cancel", "reset_cancel", ] diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 26c72bd45..c95553fce 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -2046,6 +2046,7 @@ async def resume_chat( thread_visibility=thread.visibility, filesystem_selection=filesystem_selection, request_id=getattr(http_request.state, "request_id", "unknown"), + disabled_tools=request.disabled_tools, ), media_type="text/event-stream", headers={ diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index c7284e901..cfb4b8b37 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -330,6 +330,9 @@ class ResumeDecision(BaseModel): class ResumeRequest(BaseModel): search_space_id: int decisions: list[ResumeDecision] + # Mirrors ``NewChatRequest.disabled_tools`` so the resumed run sees the + # same tool surface as the originating turn. + disabled_tools: list[str] | None = None filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" client_platform: Literal["web", "desktop"] = "web" local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 45612bf56..e22aae8c4 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -28,7 +28,9 @@ from sqlalchemy import func from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from app.agents.multi_agent_chat.integration import create_multi_agent_chat +from app.agents.multi_agent_with_deepagents 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.feature_flags import get_flags @@ -44,6 +46,7 @@ from app.agents.new_chat.memory_extraction import ( extract_and_save_memory, extract_and_save_team_memory, ) +from app.agents.new_chat.middleware.busy_mutex import release_lock as _release_busy_lock from app.agents.new_chat.middleware.kb_persistence import ( commit_staged_filesystem_state, ) @@ -2087,27 +2090,28 @@ 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 and not disabled_tools) - if _app_config.MULTI_AGENT_CHAT_ENABLED and disabled_tools: - logger.info( - "MULTI_AGENT_CHAT_ENABLED is on, but falling back to new_chat because disabled_tools are requested." - ) + use_multi_agent = bool(_app_config.MULTI_AGENT_CHAT_ENABLED) _t0 = time.perf_counter() if use_multi_agent: - agent = await create_multi_agent_chat( + # TODO: Propagate ``disabled_tools`` into registry subagents. Today only the main + # agent honors UI disables; ``task`` delegates still get full specialist tool sets. + # Deliverables (and similar) are user-disableable but implemented on subagents, so + # disabling them in the UI does not fully apply until subagents filter too. + agent = await create_registry_deep_agent( llm=llm, - db_session=session, search_space_id=search_space_id, - user_id=str(user_id), - checkpointer=checkpointer, - thread_id=str(chat_id), - firecrawl_api_key=firecrawl_api_key, + db_session=session, connector_service=connector_service, + checkpointer=checkpointer, + user_id=user_id, + thread_id=chat_id, + agent_config=agent_config, + firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, filesystem_selection=filesystem_selection, mentioned_document_ids=mentioned_document_ids, - citations_enabled=agent_config.citations_enabled, + disabled_tools=disabled_tools, ) else: agent = await create_surfsense_deep_agent( @@ -2691,6 +2695,15 @@ async def stream_new_chat( chat_id, stream_result.sandbox_files ) + # Release the busy lock here too: ``aafter_agent`` does not fire if the + # graph paused on ``interrupt()`` or the stream bailed out early. + with contextlib.suppress(Exception): + if _release_busy_lock(str(chat_id)): + _perf_log.info( + "[stream_new_chat] released stale busy lock (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 @@ -2717,6 +2730,7 @@ async def stream_resume_chat( thread_visibility: ChatVisibility | None = None, filesystem_selection: FilesystemSelection | None = None, request_id: str | None = None, + disabled_tools: list[str] | None = None, ) -> AsyncGenerator[str, None]: streaming_service = VercelStreamingService() stream_result = StreamResult() @@ -2842,18 +2856,19 @@ async def stream_resume_chat( _t0 = time.perf_counter() if _app_config.MULTI_AGENT_CHAT_ENABLED: - agent = await create_multi_agent_chat( + agent = await create_registry_deep_agent( llm=llm, - db_session=session, search_space_id=search_space_id, - user_id=str(user_id), - checkpointer=checkpointer, - thread_id=str(chat_id), - firecrawl_api_key=firecrawl_api_key, + db_session=session, connector_service=connector_service, + checkpointer=checkpointer, + user_id=user_id, + thread_id=chat_id, + agent_config=agent_config, + firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, filesystem_selection=filesystem_selection, - citations_enabled=agent_config.citations_enabled, + disabled_tools=disabled_tools, ) else: agent = await create_surfsense_deep_agent( @@ -2868,6 +2883,7 @@ async def stream_resume_chat( firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, filesystem_selection=filesystem_selection, + disabled_tools=disabled_tools, ) _perf_log.info( "[stream_resume] Agent created in %.3fs", time.perf_counter() - _t0 @@ -2890,6 +2906,9 @@ async def stream_resume_chat( "thread_id": str(chat_id), "request_id": request_id or "unknown", "turn_id": stream_result.turn_id, + # Side-channel consumed by ``SurfSenseCheckpointedSubAgentMiddleware`` + # to forward the resume into a subagent's pending ``interrupt()``. + "surfsense_resume_value": {"decisions": decisions}, }, # See ``stream_new_chat`` above for rationale: effectively # uncapped to mirror the agent default and OpenCode's @@ -3065,6 +3084,15 @@ async def stream_resume_chat( with contextlib.suppress(Exception): await session.close() + # Release the busy lock left held by the originally-interrupted turn, + # and any re-interrupt or early bailout from this resume. + with contextlib.suppress(Exception): + if _release_busy_lock(str(chat_id)): + _perf_log.info( + "[stream_resume] released stale busy lock (chat_id=%s)", + chat_id, + ) + agent = llm = connector_service = None stream_result = None session = None From 277bd50f37d477ab40f74e142efdf22ee132f8f4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 19:25:27 +0200 Subject: [PATCH 088/131] Harden HITL for multi-step tasks: bypass internal MCP gate, full-args dedup, and decision-envelope normalization. --- .../new_chat/middleware/dedup_tool_calls.py | 14 ++ .../agents/new_chat/middleware/permission.py | 82 +++++++++- .../app/agents/new_chat/tools/mcp_tool.py | 112 ++++++++----- .../agents/new_chat/test_dedup_tool_calls.py | 73 +++++++++ .../new_chat/test_permission_middleware.py | 153 +++++++++++++++++- .../new-chat/[[...chat_id]]/page.tsx | 73 +++++++-- 6 files changed, 442 insertions(+), 65 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py b/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py index c55347284..a6d2ce310 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py +++ b/surfsense_backend/app/agents/new_chat/middleware/dedup_tool_calls.py @@ -21,6 +21,7 @@ A tool with no resolver from either path simply opts out of dedup. from __future__ import annotations +import json import logging from collections.abc import Callable from typing import Any @@ -57,6 +58,19 @@ def wrap_dedup_key_by_arg_name(arg_name: str) -> DedupResolver: return _resolver +def dedup_key_full_args(args: dict[str, Any]) -> str: + """Resolver that collapses calls only when **every** argument is identical. + + Safe default for tools where no single field uniquely identifies a call + (e.g. MCP tools whose first required field is a shared workspace id). + """ + + try: + return json.dumps(args, sort_keys=True, default=str) + except (TypeError, ValueError): + return repr(sorted(args.items())) if isinstance(args, dict) else repr(args) + + # Backwards-compatible alias for code that imported the original # private name. New callers should use :func:`wrap_dedup_key_by_arg_name`. _wrap_string_key = wrap_dedup_key_by_arg_name diff --git a/surfsense_backend/app/agents/new_chat/middleware/permission.py b/surfsense_backend/app/agents/new_chat/middleware/permission.py index 37719e96a..5ea7f1740 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/permission.py +++ b/surfsense_backend/app/agents/new_chat/middleware/permission.py @@ -19,8 +19,9 @@ Operation: the results: ``deny`` > ``ask`` > ``allow``. 3. On ``deny``: replaces the call with a synthetic ``ToolMessage`` containing a :class:`StreamingError`. -4. On ``ask``: raises a SurfSense-style ``interrupt(...)``. The reply - shape is ``{"decision_type": "once|always|reject", "feedback"?: str}``. +4. On ``ask``: raises a SurfSense-style ``interrupt(...)``. Both the legacy + SurfSense shape and LangChain HITL ``{"decisions": [{"type": ...}]}`` + replies are accepted via :func:`_normalize_permission_decision`. - ``once``: proceed. - ``always``: also persist allow rules for ``request.always`` patterns. - ``reject`` w/o feedback: raise :class:`RejectedError`. @@ -81,6 +82,75 @@ def _default_pattern_resolver(name: str) -> PatternResolver: return _resolve +# Translation from the LangChain HITL envelope (what ``stream_resume_chat`` +# sends) to SurfSense's legacy ``decision_type`` shape. ``edit`` keeps the +# original tool args — tools needing argument edits should use +# ``request_approval`` from ``app/agents/new_chat/tools/hitl.py``. +_LC_TYPE_TO_PERMISSION_DECISION: dict[str, str] = { + "approve": "once", + "reject": "reject", + "edit": "once", +} + + +def _normalize_permission_decision(decision: Any) -> dict[str, Any]: + """Coerce any accepted reply shape into ``{"decision_type": ..., "feedback"?}``. + + Falls back to ``reject`` (with a warning) on unrecognized payloads so the + middleware fails closed. + """ + if isinstance(decision, str): + return {"decision_type": decision} + if not isinstance(decision, dict): + logger.warning( + "Unrecognized permission resume value (%s); treating as reject", + type(decision).__name__, + ) + return {"decision_type": "reject"} + + if decision.get("decision_type"): + return decision + + payload: dict[str, Any] = decision + decisions = decision.get("decisions") + if isinstance(decisions, list) and decisions: + first = decisions[0] + if isinstance(first, dict): + payload = first + + raw_type = payload.get("type") or payload.get("decision_type") + if not raw_type: + logger.warning( + "Permission resume missing decision type (keys=%s); treating as reject", + list(payload.keys()), + ) + return {"decision_type": "reject"} + + raw_type = str(raw_type).lower() + mapped = _LC_TYPE_TO_PERMISSION_DECISION.get(raw_type) + if mapped is None: + # Tolerate legacy values arriving without ``decision_type`` wrapping. + if raw_type in {"once", "always", "reject"}: + mapped = raw_type + else: + logger.warning( + "Unknown permission decision type %r; treating as reject", raw_type + ) + mapped = "reject" + + if raw_type == "edit": + logger.warning( + "Permission middleware received an 'edit' decision; original args " + "kept (edits not merged here)." + ) + + out: dict[str, Any] = {"decision_type": mapped} + feedback = payload.get("feedback") or payload.get("message") + if isinstance(feedback, str) and feedback.strip(): + out["feedback"] = feedback + return out + + class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] """Allow/deny/ask layer over the agent's tool calls. @@ -214,12 +284,7 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] ot.interrupt_span(interrupt_type="permission_ask"), ): decision = interrupt(payload) - if isinstance(decision, dict): - return decision - # Tolerate a plain string reply ("once", "always", "reject") - if isinstance(decision, str): - return {"decision_type": decision} - return {"decision_type": "reject"} + return _normalize_permission_decision(decision) def _persist_always(self, tool_name: str, patterns: list[str]) -> None: """Promote ``always`` reply into runtime allow rules. @@ -355,4 +420,5 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg] __all__ = [ "PatternResolver", "PermissionMiddleware", + "_normalize_permission_decision", ] diff --git a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py index 5b96ab374..92a808a5e 100644 --- a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py +++ b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py @@ -33,6 +33,7 @@ from sqlalchemy import cast, select from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession +from app.agents.new_chat.middleware.dedup_tool_calls import dedup_key_full_args from app.agents.new_chat.tools.hitl import request_approval from app.agents.new_chat.tools.mcp_client import MCPClient from app.db import SearchSourceConnector @@ -45,7 +46,10 @@ _MCP_CACHE_MAX_SIZE = 50 _MCP_DISCOVERY_TIMEOUT_SECONDS = 30 _TOOL_CALL_MAX_RETRIES = 3 _TOOL_CALL_RETRY_DELAY = 1.5 # seconds, doubles per attempt -_mcp_tools_cache: dict[int, tuple[float, list[StructuredTool]]] = {} +# Keyed by ``(search_space_id, bypass_internal_hitl)`` so single-agent and +# multi-agent paths cannot share tool closures with different HITL wiring. +_MCPCacheKey = tuple[int, bool] +_mcp_tools_cache: dict[_MCPCacheKey, tuple[float, list[StructuredTool]]] = {} def _evict_expired_mcp_cache() -> None: @@ -137,12 +141,13 @@ async def _create_mcp_tool_from_definition_stdio( connector_name: str = "", connector_id: int | None = None, trusted_tools: list[str] | None = None, + bypass_internal_hitl: bool = False, ) -> StructuredTool: """Create a LangChain tool from an MCP tool definition (stdio transport). - All MCP tools are unconditionally wrapped with HITL approval. - ``request_approval()`` is called OUTSIDE the try/except so that - ``GraphInterrupt`` propagates cleanly to LangGraph. + Set ``bypass_internal_hitl=True`` when an outer ``HumanInTheLoopMiddleware`` + already gates the tool, otherwise the body's ``request_approval()`` is the + sole HITL gate (single-agent path). """ tool_name = tool_def.get("name", "unnamed_tool") raw_description = tool_def.get("description", "No description provided") @@ -161,24 +166,29 @@ async def _create_mcp_tool_from_definition_stdio( """Execute the MCP tool call via the client with retry support.""" logger.debug("MCP tool '%s' called", tool_name) - # HITL — OUTSIDE try/except so GraphInterrupt propagates to LangGraph - hitl_result = request_approval( - action_type="mcp_tool_call", - tool_name=tool_name, - params=kwargs, - context={ - "mcp_server": connector_name, - "tool_description": raw_description, - "mcp_transport": "stdio", - "mcp_connector_id": connector_id, - }, - trusted_tools=trusted_tools, - ) - if hitl_result.rejected: - return "Tool call rejected by user." - call_kwargs = _unpack_synthetic_input_data( - {k: v for k, v in hitl_result.params.items() if v is not None} - ) + if bypass_internal_hitl: + call_kwargs = _unpack_synthetic_input_data( + {k: v for k, v in kwargs.items() if v is not None} + ) + else: + # Outside try/except so ``GraphInterrupt`` propagates to LangGraph. + hitl_result = request_approval( + action_type="mcp_tool_call", + tool_name=tool_name, + params=kwargs, + context={ + "mcp_server": connector_name, + "tool_description": raw_description, + "mcp_transport": "stdio", + "mcp_connector_id": connector_id, + }, + trusted_tools=trusted_tools, + ) + if hitl_result.rejected: + return "Tool call rejected by user." + call_kwargs = _unpack_synthetic_input_data( + {k: v for k, v in hitl_result.params.items() if v is not None} + ) last_error: Exception | None = None for attempt in range(_TOOL_CALL_MAX_RETRIES): @@ -221,7 +231,9 @@ async def _create_mcp_tool_from_definition_stdio( "mcp_connector_name": connector_name or None, "mcp_is_generic": True, "hitl": True, - "hitl_dedup_key": next(iter(input_schema.get("required", [])), None), + # Full-args hash: shared identifiers (cloudId, workspaceId, …) + # would otherwise collapse legitimate batches. + "dedup_key": dedup_key_full_args, }, ) @@ -240,11 +252,14 @@ async def _create_mcp_tool_from_definition_http( readonly_tools: frozenset[str] | None = None, tool_name_prefix: str | None = None, is_generic_mcp: bool = False, + bypass_internal_hitl: bool = False, ) -> StructuredTool: """Create a LangChain tool from an MCP tool definition (HTTP transport). Write tools are wrapped with HITL approval; read-only tools (listed in - ``readonly_tools``) execute immediately without user confirmation. + ``readonly_tools``) execute immediately without user confirmation. Set + ``bypass_internal_hitl=True`` when an outer ``HumanInTheLoopMiddleware`` + already gates the tool. When ``tool_name_prefix`` is set (multi-account disambiguation), the tool exposed to the LLM gets a prefixed name (e.g. ``linear_25_list_issues``) @@ -302,7 +317,7 @@ async def _create_mcp_tool_from_definition_http( """Execute the MCP tool call via HTTP transport.""" logger.debug("MCP HTTP tool '%s' called", exposed_name) - if is_readonly: + if is_readonly or bypass_internal_hitl: call_kwargs = _unpack_synthetic_input_data( {k: v for k, v in kwargs.items() if v is not None} ) @@ -385,7 +400,9 @@ async def _create_mcp_tool_from_definition_http( "mcp_connector_name": connector_name or None, "mcp_is_generic": is_generic_mcp, "hitl": not is_readonly, - "hitl_dedup_key": next(iter(input_schema.get("required", [])), None), + # Full-args hash: shared identifiers (cloudId, workspaceId, …) + # would otherwise collapse legitimate batches. + "dedup_key": dedup_key_full_args, "mcp_original_tool_name": original_tool_name, "mcp_connector_id": connector_id, }, @@ -400,6 +417,8 @@ async def _load_stdio_mcp_tools( connector_name: str, server_config: dict[str, Any], trusted_tools: list[str] | None = None, + *, + bypass_internal_hitl: bool = False, ) -> list[StructuredTool]: """Load tools from a stdio-based MCP server.""" tools: list[StructuredTool] = [] @@ -451,6 +470,7 @@ async def _load_stdio_mcp_tools( connector_name=connector_name, connector_id=connector_id, trusted_tools=trusted_tools, + bypass_internal_hitl=bypass_internal_hitl, ) tools.append(tool) except Exception as e: @@ -473,6 +493,8 @@ async def _load_http_mcp_tools( readonly_tools: frozenset[str] | None = None, tool_name_prefix: str | None = None, is_generic_mcp: bool = False, + *, + bypass_internal_hitl: bool = False, ) -> list[StructuredTool]: """Load tools from an HTTP-based MCP server. @@ -598,6 +620,7 @@ async def _load_http_mcp_tools( readonly_tools=readonly_tools, tool_name_prefix=tool_name_prefix, is_generic_mcp=is_generic_mcp, + bypass_internal_hitl=bypass_internal_hitl, ) tools.append(tool) except Exception as e: @@ -905,14 +928,10 @@ async def _mark_connector_auth_expired(connector_id: int) -> None: def invalidate_mcp_tools_cache(search_space_id: int | None = None) -> None: - """Invalidate cached MCP tools. - - Args: - search_space_id: If provided, only invalidate for this search space. - If None, invalidate all cached MCP tools. - """ + """Invalidate cached MCP tools (both ``bypass_internal_hitl`` variants together).""" if search_space_id is not None: - _mcp_tools_cache.pop(search_space_id, None) + for key in [k for k in _mcp_tools_cache if k[0] == search_space_id]: + _mcp_tools_cache.pop(key, None) else: _mcp_tools_cache.clear() @@ -920,27 +939,29 @@ def invalidate_mcp_tools_cache(search_space_id: int | None = None) -> None: async def load_mcp_tools( session: AsyncSession, search_space_id: int, + *, + bypass_internal_hitl: bool = False, ) -> list[StructuredTool]: - """Load all MCP tools from user's active MCP server connectors. + """Load all MCP tools from the user's active MCP server connectors. - This discovers tools dynamically from MCP servers using the protocol. - Supports both stdio (local process) and HTTP (remote server) transports. - - Results are cached per search space for up to 5 minutes to avoid - re-spawning MCP server processes on every chat message. + Results are cached per ``(search_space_id, bypass_internal_hitl)`` for up + to 5 minutes; bypass is keyed because each variant builds a different tool + closure (with vs. without the in-wrapper ``request_approval`` gate). """ _evict_expired_mcp_cache() now = time.monotonic() - cached = _mcp_tools_cache.get(search_space_id) + cache_key: _MCPCacheKey = (search_space_id, bypass_internal_hitl) + cached = _mcp_tools_cache.get(cache_key) if cached is not None: cached_at, cached_tools = cached if now - cached_at < _MCP_CACHE_TTL_SECONDS: logger.info( - "Using cached MCP tools for search space %s (%d tools, age=%.0fs)", + "Using cached MCP tools for search space %s (%d tools, age=%.0fs, bypass_hitl=%s)", search_space_id, len(cached_tools), now - cached_at, + bypass_internal_hitl, ) return list(cached_tools) @@ -1064,6 +1085,7 @@ async def load_mcp_tools( readonly_tools=task["readonly_tools"], tool_name_prefix=task["tool_name_prefix"], is_generic_mcp=task.get("is_generic_mcp", False), + bypass_internal_hitl=bypass_internal_hitl, ), timeout=_MCP_DISCOVERY_TIMEOUT_SECONDS, ) @@ -1074,6 +1096,7 @@ async def load_mcp_tools( task["connector_name"], task["server_config"], trusted_tools=task["trusted_tools"], + bypass_internal_hitl=bypass_internal_hitl, ), timeout=_MCP_DISCOVERY_TIMEOUT_SECONDS, ) @@ -1095,14 +1118,17 @@ async def load_mcp_tools( results = await asyncio.gather(*[_discover_one(t) for t in discovery_tasks]) tools: list[StructuredTool] = [tool for sublist in results for tool in sublist] - _mcp_tools_cache[search_space_id] = (now, tools) + _mcp_tools_cache[cache_key] = (now, tools) if len(_mcp_tools_cache) > _MCP_CACHE_MAX_SIZE: oldest_key = min(_mcp_tools_cache, key=lambda k: _mcp_tools_cache[k][0]) del _mcp_tools_cache[oldest_key] logger.info( - "Loaded %d MCP tools for search space %d", len(tools), search_space_id + "Loaded %d MCP tools for search space %d (bypass_hitl=%s)", + len(tools), + search_space_id, + bypass_internal_hitl, ) return tools diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py b/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py index e04f50815..61d9b499f 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_dedup_tool_calls.py @@ -130,6 +130,79 @@ def test_registry_propagates_dedup_key_to_tool_metadata() -> None: assert sample == "plan" +def test_full_args_dedup_keeps_distinct_calls_sharing_a_field() -> None: + """Regression: MCP tools (e.g. ``createJiraIssue``) used to dedup on + the schema's first required field, which is often the workspace / + cloudId — so 3 distinct issues in the same workspace collapsed to 1. + + With :func:`dedup_key_full_args` only fully identical arg dicts dedup. + """ + from app.agents.new_chat.middleware.dedup_tool_calls import dedup_key_full_args + + tool = _make_tool("createJiraIssue", dedup_key=dedup_key_full_args) + mw = DedupHITLToolCallsMiddleware(agent_tools=[tool]) + state = { + "messages": [ + _msg( + { + "name": "createJiraIssue", + "args": { + "cloudId": "ws.atlassian.net", + "projectKey": "PROJ", + "summary": "Fix login bug", + }, + "id": "1", + }, + { + "name": "createJiraIssue", + "args": { + "cloudId": "ws.atlassian.net", + "projectKey": "PROJ", + "summary": "Add dark mode", + }, + "id": "2", + }, + { + "name": "createJiraIssue", + "args": { + "cloudId": "ws.atlassian.net", + "projectKey": "PROJ", + "summary": "Improve perf", + }, + "id": "3", + }, + ) + ] + } + out = mw.after_model(state, _Runtime()) + assert out is None # nothing dropped — all three differ in summary + + +def test_full_args_dedup_drops_only_exact_duplicates() -> None: + from app.agents.new_chat.middleware.dedup_tool_calls import dedup_key_full_args + + tool = _make_tool("createJiraIssue", dedup_key=dedup_key_full_args) + mw = DedupHITLToolCallsMiddleware(agent_tools=[tool]) + args = {"cloudId": "ws.atlassian.net", "summary": "Fix bug"} + state = { + "messages": [ + _msg( + {"name": "createJiraIssue", "args": args, "id": "1"}, + {"name": "createJiraIssue", "args": dict(args), "id": "2"}, + { + "name": "createJiraIssue", + "args": {**args, "summary": "Different"}, + "id": "3", + }, + ) + ] + } + out = mw.after_model(state, _Runtime()) + assert out is not None + new_calls = out["messages"][0].tool_calls + assert {c["id"] for c in new_calls} == {"1", "3"} + + def test_unknown_tool_passes_through() -> None: mw = DedupHITLToolCallsMiddleware(agent_tools=None) state = { diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py b/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py index a997c8d61..eda5be150 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py @@ -6,7 +6,10 @@ import pytest from langchain_core.messages import AIMessage, ToolMessage from app.agents.new_chat.errors import CorrectedError, RejectedError -from app.agents.new_chat.middleware.permission import PermissionMiddleware +from app.agents.new_chat.middleware.permission import ( + PermissionMiddleware, + _normalize_permission_decision, +) from app.agents.new_chat.permissions import Rule, Ruleset pytestmark = pytest.mark.unit @@ -112,3 +115,151 @@ 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_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 e5ac61cd9..21fc4cf1a 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 @@ -146,6 +146,31 @@ function markInterruptsCompleted(contentParts: Array<{ type: string; result?: un } } +/** + * Most recent pending tool-call card with this name, so a new HITL interrupt + * does not overwrite an already-approved card with the same tool name. + */ +function findHitlTargetToolCallId( + toolCallIndices: Map, + contentParts: Array<{ + type: string; + toolName?: string; + result?: unknown; + }>, + toolName: string +): string | null { + const entries = Array.from(toolCallIndices.entries()); + for (let i = entries.length - 1; i >= 0; i--) { + const [tcId, idx] = entries[i]; + const part = contentParts[idx]; + if (!part || part.type !== "tool-call" || part.toolName !== toolName) continue; + const result = part.result as Record | undefined | null; + if (result == null) return tcId; + if (result.__interrupt__ === true && !result.__decided__) return tcId; + } + return null; +} + /** * Zod schema for mentioned document info (for type-safe parsing) */ @@ -949,12 +974,13 @@ export default function NewChatPage() { args: Record; }>; for (const action of actionRequests) { - const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => { - const part = contentParts[idx]; - return part?.type === "tool-call" && part.toolName === action.name; - }); - if (existingIdx) { - updateToolCall(contentPartsState, existingIdx[0], { + const targetTcId = findHitlTargetToolCallId( + toolCallIndices, + contentParts, + action.name + ); + if (targetTcId) { + updateToolCall(contentPartsState, targetTcId, { result: { __interrupt__: true, ...interruptData }, }); } else { @@ -1265,6 +1291,7 @@ export default function NewChatPage() { body: JSON.stringify({ search_space_id: searchSpaceId, decisions, + disabled_tools: disabledTools.length > 0 ? disabledTools : undefined, filesystem_mode: selection.filesystem_mode, client_platform: selection.client_platform, local_filesystem_mounts: selection.local_filesystem_mounts, @@ -1388,12 +1415,13 @@ export default function NewChatPage() { args: Record; }>; for (const action of actionRequests) { - const existingIdx = Array.from(toolCallIndices.entries()).find(([, idx]) => { - const part = contentParts[idx]; - return part?.type === "tool-call" && part.toolName === action.name; - }); - if (existingIdx) { - updateToolCall(contentPartsState, existingIdx[0], { + const targetTcId = findHitlTargetToolCallId( + toolCallIndices, + contentParts, + action.name + ); + if (targetTcId) { + updateToolCall(contentPartsState, targetTcId, { result: { __interrupt__: true, ...interruptData, @@ -1514,6 +1542,25 @@ export default function NewChatPage() { const decision = detail.decisions[0]; const decisionType = decision?.type as "approve" | "reject" | "edit"; + // Fan a single click out to N decisions when the backend bundled + // N tool calls into one HITLRequest (one Approve/Reject covers + // the whole batch until per-card decisions land). + const interruptData = pendingInterrupt.interruptData as + | { action_requests?: unknown[] } + | undefined; + const expectedCount = Array.isArray(interruptData?.action_requests) + ? interruptData.action_requests.length + : detail.decisions.length; + const submittedDecisions = + detail.decisions.length >= expectedCount || expectedCount <= 1 + ? detail.decisions + : [ + ...detail.decisions, + ...Array.from({ length: expectedCount - detail.decisions.length }, () => ({ + ...detail.decisions[detail.decisions.length - 1], + })), + ]; + setMessages((prev) => prev.map((m) => { if (m.id !== pendingInterrupt.assistantMsgId) return m; @@ -1554,7 +1601,7 @@ export default function NewChatPage() { return { ...m, content: newContent as unknown as ThreadMessageLike["content"] }; }) ); - handleResume(detail.decisions); + handleResume(submittedDecisions); } }; window.addEventListener("hitl-decision", handler); From 7735becd02e611d895fd796fe26f779ca72421c1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 19:25:50 +0200 Subject: [PATCH 089/131] Skip middleware gate for native body-gated tools to restore approval-card context. --- .../subagents/builtins/deliverables/agent.py | 3 ++- .../subagents/builtins/memory/agent.py | 3 ++- .../subagents/builtins/research/agent.py | 3 ++- .../subagents/connectors/airtable/agent.py | 3 ++- .../subagents/connectors/calendar/agent.py | 3 ++- .../subagents/connectors/clickup/agent.py | 3 ++- .../subagents/connectors/confluence/agent.py | 3 ++- .../subagents/connectors/discord/agent.py | 3 ++- .../subagents/connectors/dropbox/agent.py | 3 ++- .../subagents/connectors/gmail/agent.py | 3 ++- .../connectors/google_drive/agent.py | 3 ++- .../subagents/connectors/jira/agent.py | 3 ++- .../subagents/connectors/linear/agent.py | 3 ++- .../subagents/connectors/luma/agent.py | 3 ++- .../subagents/connectors/notion/agent.py | 3 ++- .../subagents/connectors/onedrive/agent.py | 3 ++- .../subagents/connectors/slack/agent.py | 3 ++- .../subagents/connectors/teams/agent.py | 3 ++- .../subagents/mcp_tools/index.py | 20 +++++++++------ .../subagents/shared/permissions.py | 25 +++++++++++++++++-- 20 files changed, 72 insertions(+), 27 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py index e7eeec4db..775624a6f 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py index 2d231e383..f9ee96938 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py index c04330607..8211686e4 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py index 215e995df..583df0078 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py index 89117fd6e..378f2095a 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py index fd404cdb0..a4a193332 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py index 3b27d39e8..11c4fc0e8 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py index 793de429f..9e1eb964a 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py index dc26c181b..60a01c20a 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py index de4971e1c..6910030c4 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py index 091f431f3..e8601ab54 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py index 8e606a129..a21662eb5 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py index f95d07010..82fdb245f 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py index 7b53d4edd..81a95270b 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py index 7d15e5cc0..9ff3105b4 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py index 698a5ce5f..edf90c1ea 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py index 31de5e2f2..aa8198827 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py index 9d09bcf65..b8fc5d1ba 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py @@ -14,6 +14,7 @@ from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, + middleware_gated_interrupt_on, ) from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( pack_subagent, @@ -38,7 +39,7 @@ def build_subagent( for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) if row.get("tool") is not None ] - interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + 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." diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py index 087110974..1e635cae5 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py @@ -21,7 +21,7 @@ from app.agents.multi_agent_with_deepagents.subagents.mcp_tools.permissions impo from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( ToolPermissionItem, ToolsPermissions, - tool_permission_row, + mcp_tool_permission_row, ) from app.agents.new_chat.tools.mcp_tool import load_mcp_tools from app.db import SearchSourceConnector @@ -125,15 +125,15 @@ def _split_tools_by_permissions( for t in tools: meta: dict[str, Any] = getattr(t, "metadata", None) or {} if meta.get("hitl") is False: - allow.append(tool_permission_row(t)) + allow.append(mcp_tool_permission_row(t)) continue key = _get_mcp_tool_name(t) if key in allow_names: - allow.append(tool_permission_row(t)) + allow.append(mcp_tool_permission_row(t)) elif key in ask_names: - ask.append(tool_permission_row(t)) + ask.append(mcp_tool_permission_row(t)) else: - ask.append(tool_permission_row(t)) + ask.append(mcp_tool_permission_row(t)) return {"allow": allow, "ask": ask} @@ -143,8 +143,14 @@ 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.""" - flat = await load_mcp_tools(session, search_space_id) + """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 { diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py index 20a9433e7..649478485 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py @@ -2,16 +2,21 @@ from __future__ import annotations -from typing import NotRequired, TypedDict +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.""" + """``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): @@ -26,6 +31,11 @@ def tool_permission_row(tool: BaseTool) -> ToolPermissionItem: 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, @@ -37,3 +47,14 @@ def merge_tools_permissions( "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" + } From 65f1f8f73ce1cb5ec553a4a519b21036df285602 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 20:48:55 +0200 Subject: [PATCH 090/131] Harden multi-agent for production: resume cleanup, busy-mutex race, deny propagation, disabled-tools. --- .../main_agent/graph/compile_graph_sync.py | 2 + .../config.py | 11 +++- .../task_tool.py | 6 +- .../graph/middleware/deepagent_stack.py | 40 +++++++++++++ .../main_agent/runtime/factory.py | 1 + .../subagents/registry.py | 40 ++++++++++--- .../app/tasks/chat/stream_new_chat.py | 57 +++++++++++++------ 7 files changed, 128 insertions(+), 29 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py index 309d37a84..89d950c54 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py @@ -41,6 +41,7 @@ def build_compiled_agent_graph_sync( 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( @@ -61,6 +62,7 @@ def build_compiled_agent_graph_sync( subagent_dependencies=subagent_dependencies, checkpointer=checkpointer, mcp_tools_by_agent=mcp_tools_by_agent, + disabled_tools=disabled_tools, ) agent = create_agent( diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py index 0312a2da5..0d4a3e4e2 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py @@ -26,10 +26,15 @@ def subagent_invoke_config(runtime: ToolRuntime) -> dict[str, Any]: return merged -def extract_surfsense_resume(runtime: ToolRuntime) -> Any: - """Resume payload stashed by ``stream_resume_chat``; ``None`` on a first-time call.""" +def consume_surfsense_resume(runtime: ToolRuntime) -> Any: + """Pop the resume payload so only the first matching subagent applies it. + + Sibling/nested ``task`` calls in the same parent run share the same + ``configurable`` dict by reference; leaving the value would replay decisions + onto unrelated subagent interrupts. + """ cfg = runtime.config or {} configurable = cfg.get("configurable") if isinstance(cfg, dict) else None if not isinstance(configurable, dict): return None - return configurable.get("surfsense_resume_value") + return configurable.pop("surfsense_resume_value", None) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py index 15145b1b8..57e01d791 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py @@ -18,7 +18,7 @@ from langchain_core.runnables import Runnable from langchain_core.tools import StructuredTool from langgraph.types import Command -from .config import extract_surfsense_resume, subagent_invoke_config +from .config import consume_surfsense_resume, subagent_invoke_config from .constants import EXCLUDED_STATE_KEYS from .propagation import ( amaybe_propagate_subagent_interrupt, @@ -123,7 +123,7 @@ def build_task_tool_with_parent_config( ) if pending_value is not None: - resume_value = extract_surfsense_resume(runtime) + resume_value = consume_surfsense_resume(runtime) if resume_value is not None: expected = hitlrequest_action_count(pending_value) resume_value = fan_out_decisions_to_match(resume_value, expected) @@ -189,7 +189,7 @@ def build_task_tool_with_parent_config( ) if pending_value is not None: - resume_value = extract_surfsense_resume(runtime) + resume_value = consume_surfsense_resume(runtime) if resume_value is not None: expected = hitlrequest_action_count(pending_value) resume_value = fan_out_decisions_to_match(resume_value, expected) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py index 31d4dbd40..8dcac512c 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py @@ -88,6 +88,7 @@ def build_main_agent_deepagent_middleware( 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( @@ -158,6 +159,42 @@ def build_main_agent_deepagent_middleware( if gp_interrupt_on: general_purpose_spec["interrupt_on"] = gp_interrupt_on + # ``deny`` rules must apply on every tool call, including those emitted + # from ``task`` runs that never reach the parent's ``PermissionMiddleware``. + # Stripping ``allow``/``ask`` keeps the bucket-based ask gates (per-tool + # ``interrupt_on`` for ``mcp`` rows + ``request_approval`` in native tool + # bodies) as the single ask path — no double-prompt — and ensures the + # ``runtime_ruleset`` mutation in ``_persist_always`` is unreachable, so a + # shared instance across subagents stays read-only. + 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: + # Match new_chat ordering: deny check runs on already-repaired tool + # calls. Insert just before ``PatchToolCallsMiddleware`` (and fall back + # to 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] = [ @@ -170,12 +207,15 @@ def build_main_agent_deepagent_middleware( 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", diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py index 72e09edab..13d570832 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py @@ -212,6 +212,7 @@ async def create_surfsense_deep_agent( 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", diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py index 85e23de84..6e2859b0f 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py @@ -145,6 +145,30 @@ def get_subagents_to_exclude( 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"]``. + + Single funnel for both native (loaded by the route's ``load_tools``) and MCP + (passed via ``extra_tools_bucket``) — by post-processing the packed spec we + avoid touching every per-route ``build_subagent``. + """ + 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], @@ -152,6 +176,7 @@ def build_subagents( 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 {} @@ -159,16 +184,17 @@ def build_subagents( 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] - specs.append( - builder( - dependencies=dependencies, - model=model, - extra_middleware=extra_middleware, - extra_tools_bucket=mcp.get(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/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index e22aae8c4..286b13312 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -46,6 +46,7 @@ from app.agents.new_chat.memory_extraction import ( extract_and_save_memory, extract_and_save_team_memory, ) +from app.agents.new_chat.errors import BusyError from app.agents.new_chat.middleware.busy_mutex import release_lock as _release_busy_lock from app.agents.new_chat.middleware.kb_persistence import ( commit_staged_filesystem_state, @@ -1977,6 +1978,11 @@ async def stream_new_chat( _premium_reserved = 0 _premium_request_id: str | None = None + # ``BusyMutexMiddleware.abefore_agent`` raises ``BusyError`` *before* + # acquiring the lock, so a concurrent caller must not release the + # in-flight caller's lock from its own ``finally`` block. + _busy_error_raised = False + session = async_session_maker() try: # Mark AI as responding to this user for live collaboration @@ -2094,10 +2100,6 @@ async def stream_new_chat( _t0 = time.perf_counter() if use_multi_agent: - # TODO: Propagate ``disabled_tools`` into registry subagents. Today only the main - # agent honors UI disables; ``task`` delegates still get full specialist tool sets. - # Deliverables (and similar) are user-disableable but implemented on subagents, so - # disabling them in the UI does not fully apply until subagents filter too. agent = await create_registry_deep_agent( llm=llm, search_space_id=search_space_id, @@ -2620,6 +2622,13 @@ async def stream_new_chat( yield streaming_service.format_finish() yield streaming_service.format_done() + except BusyError as e: + _busy_error_raised = True + yield streaming_service.format_error(str(e)) + yield streaming_service.format_finish_step() + yield streaming_service.format_finish() + yield streaming_service.format_done() + except Exception as e: # Handle any errors import traceback @@ -2697,12 +2706,15 @@ async def stream_new_chat( # Release the busy lock here too: ``aafter_agent`` does not fire if the # graph paused on ``interrupt()`` or the stream bailed out early. - with contextlib.suppress(Exception): - if _release_busy_lock(str(chat_id)): - _perf_log.info( - "[stream_new_chat] released stale busy lock (chat_id=%s)", - chat_id, - ) + # Skip on ``BusyError``: this caller never acquired the lock, so a + # release here would steal the in-flight caller's lock. + if not _busy_error_raised: + with contextlib.suppress(Exception): + if _release_busy_lock(str(chat_id)): + _perf_log.info( + "[stream_new_chat] released stale busy lock (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. @@ -2754,6 +2766,10 @@ async def stream_resume_chat( accumulator = start_turn() + # See ``stream_new_chat``: skip the finally release when ``BusyError`` + # short-circuited before this caller acquired the lock. + _busy_error_raised = False + session = async_session_maker() try: if user_id: @@ -3036,6 +3052,13 @@ async def stream_resume_chat( yield streaming_service.format_finish() yield streaming_service.format_done() + except BusyError as e: + _busy_error_raised = True + yield streaming_service.format_error(str(e)) + yield streaming_service.format_finish_step() + yield streaming_service.format_finish() + yield streaming_service.format_done() + except Exception as e: import traceback @@ -3086,12 +3109,14 @@ async def stream_resume_chat( # Release the busy lock left held by the originally-interrupted turn, # and any re-interrupt or early bailout from this resume. - with contextlib.suppress(Exception): - if _release_busy_lock(str(chat_id)): - _perf_log.info( - "[stream_resume] released stale busy lock (chat_id=%s)", - chat_id, - ) + # Skip on ``BusyError``: this caller never acquired the lock. + if not _busy_error_raised: + with contextlib.suppress(Exception): + if _release_busy_lock(str(chat_id)): + _perf_log.info( + "[stream_resume] released stale busy lock (chat_id=%s)", + chat_id, + ) agent = llm = connector_service = None stream_result = None From 216a678f1a743061801ed6d39068cc3dabe90669 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 21:32:42 +0200 Subject: [PATCH 091/131] Address LLM review findings; trim comments. --- .../config.py | 16 +++++---- .../resume.py | 6 ++-- .../task_tool.py | 24 ++++++++++++-- .../graph/middleware/deepagent_stack.py | 33 +++++++++++++------ .../subagents/registry.py | 7 +--- .../app/tasks/chat/stream_new_chat.py | 19 ++++------- 6 files changed, 65 insertions(+), 40 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py index 0d4a3e4e2..16211686c 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py @@ -27,14 +27,18 @@ def subagent_invoke_config(runtime: ToolRuntime) -> dict[str, Any]: def consume_surfsense_resume(runtime: ToolRuntime) -> Any: - """Pop the resume payload so only the first matching subagent applies it. - - Sibling/nested ``task`` calls in the same parent run share the same - ``configurable`` dict by reference; leaving the value would replay decisions - onto unrelated subagent interrupts. - """ + """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_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py index c9b8b01e6..d09eec6af 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py @@ -37,10 +37,10 @@ def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any: def get_first_pending_subagent_interrupt(state: Any) -> tuple[str | None, Any]: - """First pending ``(interrupt_id, value)`` in the snapshot, else ``(None, None)``. + """First pending ``(interrupt_id, value)``; ``(None, None)`` if no interrupt. - The ``id`` lets the caller target ``Command(resume={id: value})`` so the - payload is not broadcast to a later fresh interrupt in the same run. + 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 diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py index 57e01d791..e7458dde9 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py @@ -18,7 +18,11 @@ from langchain_core.runnables import Runnable from langchain_core.tools import StructuredTool from langgraph.types import Command -from .config import consume_surfsense_resume, subagent_invoke_config +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, @@ -116,7 +120,15 @@ def build_task_tool_with_parent_config( try: snapshot = get_state(sub_config) pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot) - except Exception: # pragma: no cover - defensive + 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, @@ -182,7 +194,13 @@ def build_task_tool_with_parent_config( try: snapshot = await aget_state(sub_config) pending_id, pending_value = get_first_pending_subagent_interrupt(snapshot) - except Exception: # pragma: no cover - defensive + 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, diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py index 8dcac512c..57fa3b34a 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py @@ -143,6 +143,25 @@ def build_main_agent_deepagent_middleware( # 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 @@ -159,13 +178,8 @@ def build_main_agent_deepagent_middleware( if gp_interrupt_on: general_purpose_spec["interrupt_on"] = gp_interrupt_on - # ``deny`` rules must apply on every tool call, including those emitted - # from ``task`` runs that never reach the parent's ``PermissionMiddleware``. - # Stripping ``allow``/``ask`` keeps the bucket-based ask gates (per-tool - # ``interrupt_on`` for ``mcp`` rows + ``request_approval`` in native tool - # bodies) as the single ask path — no double-prompt — and ensures the - # ``runtime_ruleset`` mutation in ``_persist_always`` is unreachable, so a - # shared instance across subagents stays read-only. + # 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"], @@ -182,9 +196,8 @@ def build_main_agent_deepagent_middleware( ) if subagent_deny_permission_mw is not None: - # Match new_chat ordering: deny check runs on already-repaired tool - # calls. Insert just before ``PatchToolCallsMiddleware`` (and fall back - # to append if the slot moves). + # Run deny check on already-repaired tool calls; insert before + # PatchToolCallsMiddleware (append if the slot moves). _patch_idx = next( ( i diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py index 6e2859b0f..dde98018e 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py @@ -149,12 +149,7 @@ def _filter_disabled_tools_in_place( spec: SubAgent, disabled_names: frozenset[str], ) -> None: - """Drop UI-disabled tools from ``spec["tools"]`` and ``spec["interrupt_on"]``. - - Single funnel for both native (loaded by the route's ``load_tools``) and MCP - (passed via ``extra_tools_bucket``) — by post-processing the packed spec we - avoid touching every per-route ``build_subagent``. - """ + """Drop UI-disabled tools from ``spec["tools"]`` and ``spec["interrupt_on"]``.""" if not disabled_names: return tools = spec.get("tools") # type: ignore[typeddict-item] diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 286b13312..03a039054 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1978,9 +1978,8 @@ async def stream_new_chat( _premium_reserved = 0 _premium_request_id: str | None = None - # ``BusyMutexMiddleware.abefore_agent`` raises ``BusyError`` *before* - # acquiring the lock, so a concurrent caller must not release the - # in-flight caller's lock from its own ``finally`` block. + # ``BusyError`` fires before the lock is acquired; the ``finally`` must + # not release the in-flight caller's lock. _busy_error_raised = False session = async_session_maker() @@ -2704,10 +2703,8 @@ async def stream_new_chat( chat_id, stream_result.sandbox_files ) - # Release the busy lock here too: ``aafter_agent`` does not fire if the - # graph paused on ``interrupt()`` or the stream bailed out early. - # Skip on ``BusyError``: this caller never acquired the lock, so a - # release here would steal the in-flight caller's lock. + # ``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): if _release_busy_lock(str(chat_id)): @@ -2766,8 +2763,7 @@ async def stream_resume_chat( accumulator = start_turn() - # See ``stream_new_chat``: skip the finally release when ``BusyError`` - # short-circuited before this caller acquired the lock. + # Skip the finally release on ``BusyError`` (caller never acquired the lock). _busy_error_raised = False session = async_session_maker() @@ -3107,9 +3103,8 @@ async def stream_resume_chat( with contextlib.suppress(Exception): await session.close() - # Release the busy lock left held by the originally-interrupted turn, - # and any re-interrupt or early bailout from this resume. - # Skip on ``BusyError``: this caller never acquired the lock. + # 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): if _release_busy_lock(str(chat_id)): From d675d4df3f2c8d678cfdd9b4605f55b9f73de5b2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 21:45:57 +0200 Subject: [PATCH 092/131] Remove unwired multi_agent_chat package. --- .../app/agents/multi_agent_chat/__init__.py | 132 ------- .../agents/multi_agent_chat/core/__init__.py | 25 -- .../multi_agent_chat/core/agents/__init__.py | 5 - .../core/agents/domain_graph.py | 27 -- .../core/bindings/__init__.py | 5 - .../multi_agent_chat/core/bindings/binding.py | 18 - .../core/delegation/__init__.py | 5 - .../core/delegation/child_task.py | 22 -- .../core/invocation/__init__.py | 5 - .../core/invocation/output.py | 17 - .../multi_agent_chat/core/mcp_partition.py | 135 ------- .../multi_agent_chat/core/prompts/__init__.py | 5 - .../multi_agent_chat/core/prompts/load.py | 19 - .../core/registry/__init__.py | 15 - .../core/registry/categories.py | 84 ---- .../core/registry/dependencies.py | 61 --- .../multi_agent_chat/core/registry/subset.py | 22 -- .../multi_agent_chat/expert_agent/__init__.py | 5 - .../expert_agent/builtins/__init__.py | 1 - .../builtins/deliverables/__init__.py | 11 - .../builtins/deliverables/agent.py | 21 - .../builtins/deliverables/domain_prompt.md | 55 --- .../builtins/deliverables/slice_tools.py | 14 - .../expert_agent/builtins/memory/__init__.py | 9 - .../expert_agent/builtins/memory/agent.py | 45 --- .../builtins/memory/domain_prompt.md | 56 --- .../builtins/memory/slice_tools.py | 14 - .../builtins/research/__init__.py | 9 - .../expert_agent/builtins/research/agent.py | 21 - .../builtins/research/domain_prompt.md | 53 --- .../builtins/research/slice_tools.py | 14 - .../expert_agent/connectors/__init__.py | 1 - .../connectors/calendar/__init__.py | 11 - .../expert_agent/connectors/calendar/agent.py | 21 - .../connectors/calendar/domain_prompt.md | 62 --- .../connectors/calendar/slice_tools.py | 14 - .../connectors/confluence/__init__.py | 11 - .../connectors/confluence/agent.py | 21 - .../connectors/confluence/domain_prompt.md | 55 --- .../connectors/confluence/slice_tools.py | 14 - .../connectors/discord/__init__.py | 9 - .../expert_agent/connectors/discord/agent.py | 21 - .../connectors/discord/domain_prompt.md | 56 --- .../connectors/discord/slice_tools.py | 14 - .../connectors/dropbox/__init__.py | 11 - .../expert_agent/connectors/dropbox/agent.py | 21 - .../connectors/dropbox/domain_prompt.md | 52 --- .../connectors/dropbox/slice_tools.py | 14 - .../expert_agent/connectors/gmail/__init__.py | 9 - .../expert_agent/connectors/gmail/agent.py | 21 - .../connectors/gmail/domain_prompt.md | 82 ---- .../connectors/gmail/slice_tools.py | 14 - .../connectors/google_drive/__init__.py | 13 - .../connectors/google_drive/agent.py | 21 - .../connectors/google_drive/domain_prompt.md | 54 --- .../connectors/google_drive/slice_tools.py | 14 - .../expert_agent/connectors/luma/__init__.py | 9 - .../expert_agent/connectors/luma/agent.py | 21 - .../connectors/luma/domain_prompt.md | 55 --- .../connectors/luma/slice_tools.py | 14 - .../connectors/notion/__init__.py | 11 - .../expert_agent/connectors/notion/agent.py | 21 - .../connectors/notion/domain_prompt.md | 56 --- .../connectors/notion/slice_tools.py | 14 - .../connectors/onedrive/__init__.py | 11 - .../expert_agent/connectors/onedrive/agent.py | 21 - .../connectors/onedrive/domain_prompt.md | 52 --- .../connectors/onedrive/slice_tools.py | 14 - .../expert_agent/connectors/teams/__init__.py | 9 - .../expert_agent/connectors/teams/agent.py | 21 - .../connectors/teams/domain_prompt.md | 55 --- .../connectors/teams/slice_tools.py | 14 - .../expert_agent/mcp_bridge/__init__.py | 5 - .../expert_agent/mcp_bridge/agent.py | 25 -- .../mcp_bridge/airtable_domain.md | 46 --- .../expert_agent/mcp_bridge/clickup_domain.md | 45 --- .../mcp_bridge/generic_mcp_domain.md | 46 --- .../expert_agent/mcp_bridge/jira_domain.md | 46 --- .../expert_agent/mcp_bridge/linear_domain.md | 45 --- .../expert_agent/mcp_bridge/slack_domain.md | 45 --- .../multi_agent_chat/integration/__init__.py | 7 - .../integration/create_multi_agent_chat.py | 256 ------------ .../multi_agent_chat/middleware/__init__.py | 11 - .../middleware/supervisor_stack.py | 363 ------------------ .../multi_agent_chat/routing/__init__.py | 13 - .../routing/domain_routing_spec.py | 22 -- .../routing/from_domain_agents.py | 123 ------ .../routing/route_connector_gate.py | 57 --- .../routing/supervisor_routing.py | 327 ---------------- .../multi_agent_chat/supervisor/__init__.py | 5 - .../multi_agent_chat/supervisor/graph.py | 51 --- .../supervisor/prompt_assembly.py | 128 ------ .../supervisor/supervisor_prompt.md | 67 ---- 93 files changed, 3697 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md 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 bdd54b4e0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/__init__.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Multi-agent chat (LangChain Subagents pattern). - -**Layout (SRP)** - -- :mod:`expert_agent.builtins` — general categories from the tool registry (research, memory, deliverables — not tied to one vendor). -- :mod:`expert_agent.connectors` — external integrations (one subgraph per product where split). -- :mod:`core` — prompts, compiled subgraph helper, delegation, registry subsets, tool-factory kwargs (:mod:`core.bindings`). -- :mod:`routing` — supervisor-facing ``@tool`` routers → domain invoke. -- :mod:`supervisor` — orchestrator graph + ``supervisor_prompt.md``. -- :mod:`integration` — async ``create_multi_agent_chat`` composer (partitions MCP tools into experts). - -Documentation: -https://docs.langchain.com/oss/python/langchain/multi-agent -https://docs.langchain.com/oss/python/langchain/multi-agent/subagents - -Display name: ``multi-agent-chat`` — Python package: ``multi_agent_chat``. -""" - -from app.agents.multi_agent_chat.expert_agent.builtins.deliverables import ( - build_deliverables_tools, - build_deliverables_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.builtins.memory import ( - build_memory_tools, - build_memory_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.builtins.research import ( - build_research_tools, - build_research_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.calendar import ( - build_calendar_domain_agent, - build_calendar_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.confluence import ( - build_confluence_tools, - build_confluence_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.discord import ( - build_discord_tools, - build_discord_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.dropbox import ( - build_dropbox_tools, - build_dropbox_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.gmail import ( - build_gmail_tools, - build_gmail_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.google_drive import ( - build_google_drive_tools, - build_google_drive_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.luma import ( - build_luma_tools, - build_luma_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.notion import ( - build_notion_tools, - build_notion_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.onedrive import ( - build_onedrive_tools, - build_onedrive_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.teams import ( - build_teams_tools, - build_teams_domain_agent, -) -from app.agents.multi_agent_chat.core import ( - REGISTRY_ROUTING_CATEGORY_KEYS, - TOOL_NAMES_BY_CATEGORY, - build_domain_agent, - build_registry_dependencies, - build_registry_tools_for_category, - compose_child_task, - connector_binding, - extract_last_assistant_text, - read_prompt_md, -) -from app.agents.multi_agent_chat.integration import create_multi_agent_chat -from app.agents.multi_agent_chat.routing import ( - DomainRoutingSpec, - build_supervisor_routing_tools, - routing_tools_from_specs, -) -from app.agents.multi_agent_chat.supervisor import build_supervisor_agent - -__all__ = [ - "REGISTRY_ROUTING_CATEGORY_KEYS", - "TOOL_NAMES_BY_CATEGORY", - "DomainRoutingSpec", - "build_calendar_domain_agent", - "build_confluence_tools", - "build_confluence_domain_agent", - "build_deliverables_tools", - "build_deliverables_domain_agent", - "build_discord_tools", - "build_discord_domain_agent", - "build_domain_agent", - "build_dropbox_tools", - "build_dropbox_domain_agent", - "build_gmail_tools", - "build_gmail_domain_agent", - "build_calendar_tools", - "build_google_drive_tools", - "build_google_drive_domain_agent", - "build_luma_tools", - "build_luma_domain_agent", - "build_memory_tools", - "build_memory_domain_agent", - "build_notion_tools", - "build_notion_domain_agent", - "build_onedrive_tools", - "build_onedrive_domain_agent", - "build_registry_dependencies", - "build_registry_tools_for_category", - "build_research_tools", - "build_research_domain_agent", - "build_supervisor_agent", - "build_supervisor_routing_tools", - "build_teams_tools", - "build_teams_domain_agent", - "connector_binding", - "compose_child_task", - "create_multi_agent_chat", - "extract_last_assistant_text", - "read_prompt_md", - "routing_tools_from_specs", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/__init__.py deleted file mode 100644 index 0299138fe..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Cross-cutting building blocks (prompts, agents, delegation, registry) — not domain logic.""" - -from app.agents.multi_agent_chat.core.agents import build_domain_agent -from app.agents.multi_agent_chat.core.bindings import connector_binding -from app.agents.multi_agent_chat.core.delegation import compose_child_task -from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text -from app.agents.multi_agent_chat.core.prompts import read_prompt_md -from app.agents.multi_agent_chat.core.registry import ( - REGISTRY_ROUTING_CATEGORY_KEYS, - TOOL_NAMES_BY_CATEGORY, - build_registry_dependencies, - build_registry_tools_for_category, -) - -__all__ = [ - "REGISTRY_ROUTING_CATEGORY_KEYS", - "TOOL_NAMES_BY_CATEGORY", - "build_domain_agent", - "build_registry_dependencies", - "build_registry_tools_for_category", - "compose_child_task", - "connector_binding", - "extract_last_assistant_text", - "read_prompt_md", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py deleted file mode 100644 index 7586c72b0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/agents/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compiled subgraph factories shared by domain slices.""" - -from app.agents.multi_agent_chat.core.agents.domain_graph import build_domain_agent - -__all__ = ["build_domain_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py b/surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py deleted file mode 100644 index 51b745553..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/agents/domain_graph.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Compile a domain LangGraph agent from a co-located prompt + tool list.""" - -from __future__ import annotations - -from collections.abc import Sequence - -from langchain.agents import create_agent -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.prompts import read_prompt_md - - -def build_domain_agent( - llm: BaseChatModel, - tools: Sequence[BaseTool], - *, - prompt_package: str, - prompt_stem: str = "domain_prompt", -): - """``create_agent`` + ``{prompt_stem}.md`` loaded from ``prompt_package``.""" - system_prompt = read_prompt_md(prompt_package, prompt_stem) - return create_agent( - llm, - system_prompt=system_prompt, - tools=list(tools), - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py deleted file mode 100644 index c15375e47..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Search-space / DB kwargs shared by main-chat tool factories (distinct from ``expert_agent.connectors`` integrations).""" - -from app.agents.multi_agent_chat.core.bindings.binding import connector_binding - -__all__ = ["connector_binding"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py b/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py deleted file mode 100644 index da82e3b3c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/bindings/binding.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Shared kwargs dict for main-chat tool factories (DB session + search space + user).""" - -from __future__ import annotations - -from sqlalchemy.ext.asyncio import AsyncSession - - -def connector_binding( - *, - db_session: AsyncSession, - search_space_id: int, - user_id: str, -) -> dict[str, AsyncSession | int | str]: - return { - "db_session": db_session, - "search_space_id": search_space_id, - "user_id": user_id, - } diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py deleted file mode 100644 index cc27ec6f5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Supervisor → domain message shaping.""" - -from app.agents.multi_agent_chat.core.delegation.child_task import compose_child_task - -__all__ = ["compose_child_task"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py b/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py deleted file mode 100644 index ac8a5b25a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/delegation/child_task.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Fold orchestrator-selected context into the single user message sent to a domain agent.""" - -from __future__ import annotations - - -def compose_child_task(task: str, *, curated_context: str | None = None) -> str: - """Build the domain-agent user message: optional curated KB/context + task. - - When ``curated_context`` is set (from supervisor/KB wiring), it is prepended so the - child sees only what orchestration chose — not the full parent transcript. - """ - task = task.strip() - if not curated_context or not curated_context.strip(): - return f"\n{task}\n" - return ( - "\n" - f"{curated_context.strip()}\n" - "\n\n" - "\n" - f"{task}\n" - "" - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py deleted file mode 100644 index 60d0ff9fa..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/invocation/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Parsing LangGraph invoke results.""" - -from app.agents.multi_agent_chat.core.invocation.output import extract_last_assistant_text - -__all__ = ["extract_last_assistant_text"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py b/surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py deleted file mode 100644 index 2bbab6e57..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/invocation/output.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Extract displayable text from a LangGraph agent ``invoke`` / ``ainvoke`` result.""" - -from __future__ import annotations - -from typing import Any - - -def extract_last_assistant_text(result: dict[str, Any]) -> str: - """Return the last message's string content, or ``\"\"`` if missing.""" - messages = result.get("messages") or [] - if not messages: - return "" - last = messages[-1] - content = getattr(last, "content", None) - if isinstance(content, str): - return content - return str(last) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py b/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py deleted file mode 100644 index a1ee6fdb6..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/mcp_partition.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Partition MCP tools onto multi-agent expert routes (read-only; does not change the MCP loader). - -Uses the same connector discovery shape as ``load_mcp_tools`` (copied query below). Tools come from -``app.agents.new_chat.tools.mcp_tool.load_mcp_tools``; routing uses metadata already set there: - -- HTTP tools: ``metadata["mcp_connector_id"]`` → DB connector row → expert route. -- stdio tools: no connector id on the tool; ``metadata["mcp_connector_name"]`` → connector name map - (duplicate names: last row wins — rare). -""" - -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.db import SearchSourceConnector - -logger = logging.getLogger(__name__) - -# SurfSense ``SearchSourceConnectorType`` string → supervisor routing key (must match -# ``DomainRoutingSpec.tool_name`` values used in ``supervisor_routing``). -_CONNECTOR_TYPE_TO_EXPERT_ROUTE: 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", - # generic_mcp route intentionally disabled for now. - # "MCP_CONNECTOR": "generic_mcp", -} - -# Ordering when appending MCP-only routes (no native registry slice for these types). -MCP_ONLY_ROUTE_KEYS_IN_ORDER: tuple[str, ...] = ( - "linear", - "slack", - "jira", - "clickup", - "airtable", - # generic_mcp intentionally disabled for now. - # "generic_mcp", -) - - -async def fetch_mcp_connector_metadata_maps( - session: AsyncSession, - search_space_id: int, -) -> tuple[dict[int, str], dict[str, str]]: - """Read-only copy of connector discovery used alongside ``load_mcp_tools``. - - Same filter as :func:`app.agents.new_chat.tools.mcp_tool.load_mcp_tools` (connectors with ``server_config``). - """ - 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 - - -def partition_mcp_tools_by_expert_route( - tools: Sequence[BaseTool], - connector_id_to_type: dict[int, str], - connector_name_to_type: dict[str, str], -) -> dict[str, list[BaseTool]]: - """Bucket MCP tools by expert route key. Supervisor never receives raw MCP tools. - - Same inclusion rule as :func:`app.agents.new_chat.tools.registry.build_tools_async`: all tools returned by - ``load_mcp_tools`` are partitioned — connector availability for **registry** builtins is handled via - ``get_connector_gated_tools`` / routing gates; MCP tools are not pre-filtered by inventory here. - """ - 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 - - route = _CONNECTOR_TYPE_TO_EXPERT_ROUTE.get(connector_type) - if route is None: - logger.warning( - "MCP tool %r has unmapped connector type %s — skipped", - getattr(tool, "name", None), - connector_type, - ) - continue - - buckets[route].append(tool) - - return dict(buckets) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py deleted file mode 100644 index 92dd9b854..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Markdown prompt loading for domain and supervisor packages.""" - -from app.agents.multi_agent_chat.core.prompts.load import read_prompt_md - -__all__ = ["read_prompt_md"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py b/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py deleted file mode 100644 index 355a26a4f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/prompts/load.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Load ``*.md`` prompt files from co-located packages (domain slices ship ``domain_prompt.md``).""" - -from __future__ import annotations - -from importlib import resources - - -def read_prompt_md(package: str, stem: str) -> str: - """Read ``{stem}.md`` from the given import package (e.g. ``…expert_agent.connectors.notion``).""" - try: - ref = resources.files(package).joinpath(f"{stem}.md") - if not ref.is_file(): - return "" - text = ref.read_text(encoding="utf-8") - except (FileNotFoundError, ModuleNotFoundError, OSError, TypeError): - return "" - if text.endswith("\n"): - text = text[:-1] - return text diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py deleted file mode 100644 index cfd8a5d62..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Main chat tool registry grouping + dependency bundles for domain slices.""" - -from app.agents.multi_agent_chat.core.registry.categories import ( - REGISTRY_ROUTING_CATEGORY_KEYS, - TOOL_NAMES_BY_CATEGORY, -) -from app.agents.multi_agent_chat.core.registry.dependencies import build_registry_dependencies -from app.agents.multi_agent_chat.core.registry.subset import build_registry_tools_for_category - -__all__ = [ - "REGISTRY_ROUTING_CATEGORY_KEYS", - "TOOL_NAMES_BY_CATEGORY", - "build_registry_dependencies", - "build_registry_tools_for_category", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py deleted file mode 100644 index 13d8cd12f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/categories.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Registry tool names grouped by multi-agent routing category. - -Each string must match ``ToolDefinition.name`` in -``app.agents.new_chat.tools.registry.BUILTIN_TOOLS`` — these are **not** guessed or MCP-only: -:class:`~app.agents.multi_agent_chat.core.registry.subset.build_registry_tools_for_category` -uses synchronous :func:`~app.agents.new_chat.tools.registry.build_tools`, which only instantiates -``BUILTIN_TOOLS``. MCP tools are loaded separately and merged in ``supervisor_routing``. - -Connectors that exist for search/indexing but have **no** entry in ``BUILTIN_TOOLS`` correctly have -no row here (no chat tools to delegate).""" - -from __future__ import annotations - -# Keys match supervisor routing tool names; values match ``BUILTIN_TOOLS`` names exactly. -TOOL_NAMES_BY_CATEGORY: dict[str, list[str]] = { - "gmail": [ - "search_gmail", - "read_gmail_email", - "create_gmail_draft", - "send_gmail_email", - "trash_gmail_email", - "update_gmail_draft", - ], - "calendar": [ - "search_calendar_events", - "create_calendar_event", - "update_calendar_event", - "delete_calendar_event", - ], - "research": [ - "web_search", - "scrape_webpage", - "search_surfsense_docs", - ], - "deliverables": [ - "generate_podcast", - "generate_video_presentation", - "generate_report", - "generate_resume", - "generate_image", - ], - "memory": [ - "update_memory", - ], - "discord": [ - "list_discord_channels", - "read_discord_messages", - "send_discord_message", - ], - "teams": [ - "list_teams_channels", - "read_teams_messages", - "send_teams_message", - ], - "notion": [ - "create_notion_page", - "update_notion_page", - "delete_notion_page", - ], - "confluence": [ - "create_confluence_page", - "update_confluence_page", - "delete_confluence_page", - ], - "google_drive": [ - "create_google_drive_file", - "delete_google_drive_file", - ], - "dropbox": [ - "create_dropbox_file", - "delete_dropbox_file", - ], - "onedrive": [ - "create_onedrive_file", - "delete_onedrive_file", - ], - "luma": [ - "list_luma_events", - "read_luma_event", - "create_luma_event", - ], -} - -REGISTRY_ROUTING_CATEGORY_KEYS: tuple[str, ...] = tuple(TOOL_NAMES_BY_CATEGORY.keys()) diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py deleted file mode 100644 index 24fa6b19c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/dependencies.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Dependency dict for :func:`app.agents.new_chat.tools.registry.build_tools` on expert subgraphs.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.language_models import BaseChatModel -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db import ChatVisibility - - -def coerce_thread_id_for_registry(thread_id: str | int | None) -> int | None: - """Normalize chat thread id for registry tools that FK to ``new_chat_threads.id``. - - ``create_surfsense_deep_agent`` passes an ``int``; multi-agent wiring may pass - ``str(chat_id)`` for LangGraph/checkpointer consistency. AsyncPG requires ``int`` - for integer columns. - """ - if thread_id is None: - return None - if isinstance(thread_id, int): - return thread_id - s = str(thread_id).strip() - if not s: - return None - if s.isdigit(): - return int(s) - return None - - -def build_registry_dependencies( - *, - db_session: AsyncSession, - search_space_id: int, - user_id: str, - thread_id: str | int | None, - llm: BaseChatModel | None = None, - firecrawl_api_key: str | None = None, - connector_service: Any | None = None, - available_connectors: list[str] | None = None, - available_document_types: list[str] | None = None, - thread_visibility: ChatVisibility = ChatVisibility.PRIVATE, -) -> dict[str, Any]: - """Union of kwargs commonly required by registry factories across category slices. - - Individual categories enable a subset of tools; each tool still validates its own - ``ToolDefinition.requires`` against this dict. - """ - return { - "db_session": db_session, - "search_space_id": search_space_id, - "user_id": user_id, - "thread_id": coerce_thread_id_for_registry(thread_id), - "llm": llm, - "firecrawl_api_key": firecrawl_api_key, - "connector_service": connector_service, - "available_connectors": available_connectors, - "available_document_types": available_document_types, - "thread_visibility": thread_visibility, - } diff --git a/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py b/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py deleted file mode 100644 index 95db1b64c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/core/registry/subset.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Build registry tool subsets (``app.agents.new_chat.tools.registry``) for multi-agent domain slices.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.new_chat.tools.registry import build_tools -from app.agents.multi_agent_chat.core.registry.categories import TOOL_NAMES_BY_CATEGORY - - -def build_registry_tools_for_category( - dependencies: dict[str, Any], - category: str, -) -> list[BaseTool]: - """Instantiate only the tools registered for ``category`` (see ``TOOL_NAMES_BY_CATEGORY``).""" - names = TOOL_NAMES_BY_CATEGORY.get(category) - if not names: - msg = f"Unknown registry category: {category!r}" - raise ValueError(msg) - return build_tools(dependencies, enabled_tools=names) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py deleted file mode 100644 index 4ca5c00de..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Expert subgraphs (specialists the supervisor delegates to). - -- :mod:`expert_agent.builtins` — cross-cutting registry categories (e.g. research, memory, deliverables). -- :mod:`expert_agent.connectors` — vendor/product integrations (email, chat, documents, … — one slice per route). -""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py deleted file mode 100644 index 84bd2948d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Built-ins: broad capability categories from the registry (not single-vendor integrations).""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py deleted file mode 100644 index 8a225b50b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Deliverables vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.builtins.deliverables.agent import build_deliverables_domain_agent -from app.agents.multi_agent_chat.expert_agent.builtins.deliverables.slice_tools import ( - build_deliverables_tools, -) - -__all__ = [ - "build_deliverables_tools", - "build_deliverables_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py deleted file mode 100644 index 729dc9410..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Deliverables domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.builtins.deliverables as deliverables_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_deliverables_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled deliverables domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=deliverables_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_prompt.md deleted file mode 100644 index c44f131bb..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/domain_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/expert_agent/builtins/deliverables/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py deleted file mode 100644 index 42241bda5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/deliverables/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed deliverables tools (reports, media exports, resume, images).""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_deliverables_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``deliverables`` category.""" - return build_registry_tools_for_category(dependencies, "deliverables") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py deleted file mode 100644 index 0499bfdf4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Memory vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.builtins.memory.agent import build_memory_domain_agent -from app.agents.multi_agent_chat.expert_agent.builtins.memory.slice_tools import build_memory_tools - -__all__ = [ - "build_memory_tools", - "build_memory_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py deleted file mode 100644 index 6a0c115c2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/agent.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Memory domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.builtins.memory as memory_pkg -from langchain.agents import create_agent -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.prompts import read_prompt_md -from app.db import ChatVisibility - -_PRIVATE_VISIBILITY_POLICY = ( - "This thread is private. Store user-specific long-lived preferences, facts, and instructions." -) -_TEAM_VISIBILITY_POLICY = ( - "This thread is shared with the search space. Store only team-appropriate shared preferences," - " facts, and instructions that are safe for all members to inherit." -) - - -def _render_memory_prompt(thread_visibility: ChatVisibility | None) -> str: - template = read_prompt_md(memory_pkg.__name__, "domain_prompt") - policy = ( - _TEAM_VISIBILITY_POLICY - if thread_visibility == ChatVisibility.SEARCH_SPACE - else _PRIVATE_VISIBILITY_POLICY - ) - return template.replace("{{MEMORY_VISIBILITY_POLICY}}", policy) - - -def build_memory_domain_agent( - llm: BaseChatModel, - tools: Sequence[BaseTool], - *, - thread_visibility: ChatVisibility | None = None, -): - """Compiled memory domain-agent graph.""" - return create_agent( - llm, - system_prompt=_render_memory_prompt(thread_visibility), - tools=list(tools), - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_prompt.md deleted file mode 100644 index 32becf233..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/domain_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/expert_agent/builtins/memory/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py deleted file mode 100644 index 7f4d2d29a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/memory/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed memory tools (long-term user or team memory).""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_memory_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``memory`` category.""" - return build_registry_tools_for_category(dependencies, "memory") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py deleted file mode 100644 index ada6c9853..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Research vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.builtins.research.agent import build_research_domain_agent -from app.agents.multi_agent_chat.expert_agent.builtins.research.slice_tools import build_research_tools - -__all__ = [ - "build_research_tools", - "build_research_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py deleted file mode 100644 index a7dc635c9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Research domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.builtins.research as research_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_research_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled research domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=research_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_prompt.md deleted file mode 100644 index cf558db62..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/domain_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/expert_agent/builtins/research/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py deleted file mode 100644 index 85a2a9dd9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/builtins/research/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed research tools (web, scrape, SurfSense docs help).""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_research_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``research`` category.""" - return build_registry_tools_for_category(dependencies, "research") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py deleted file mode 100644 index f752e4dd9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""External integrations: third-party products (explicit factories or registry-backed connector tools).""" diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py deleted file mode 100644 index 65b880dd0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Google Calendar vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.calendar.agent import build_calendar_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.calendar.slice_tools import ( - build_calendar_tools, -) - -__all__ = [ - "build_calendar_domain_agent", - "build_calendar_tools", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py deleted file mode 100644 index 64a82c6ba..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Google Calendar domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.calendar as calendar_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_calendar_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Google Calendar domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=calendar_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_prompt.md deleted file mode 100644 index a7ef846d5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/domain_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/expert_agent/connectors/calendar/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py deleted file mode 100644 index e2f2b404a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/calendar/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Google Calendar tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_calendar_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``calendar`` category.""" - return build_registry_tools_for_category(dependencies, "calendar") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py deleted file mode 100644 index a3aa01959..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Confluence connector slice.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.confluence.agent import build_confluence_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.confluence.slice_tools import ( - build_confluence_tools, -) - -__all__ = [ - "build_confluence_tools", - "build_confluence_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py deleted file mode 100644 index 2746d31f0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Confluence domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.confluence as confluence_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_confluence_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Confluence domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=confluence_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_prompt.md deleted file mode 100644 index 4d3b7462c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/domain_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/expert_agent/connectors/confluence/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py deleted file mode 100644 index 3f4f2d45c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/confluence/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Confluence tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_confluence_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``confluence`` category.""" - return build_registry_tools_for_category(dependencies, "confluence") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py deleted file mode 100644 index a7b864f16..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Discord vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.discord.agent import build_discord_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.discord.slice_tools import build_discord_tools - -__all__ = [ - "build_discord_tools", - "build_discord_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py deleted file mode 100644 index dfcd4ec45..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Discord domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.discord as discord_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_discord_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Discord domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=discord_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_prompt.md deleted file mode 100644 index 40e9eb314..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/domain_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/expert_agent/connectors/discord/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py deleted file mode 100644 index 79eea4f3f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/discord/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Discord tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_discord_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``discord`` category.""" - return build_registry_tools_for_category(dependencies, "discord") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py deleted file mode 100644 index 61c58aaa6..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Dropbox connector slice.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.dropbox.agent import build_dropbox_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.dropbox.slice_tools import ( - build_dropbox_tools, -) - -__all__ = [ - "build_dropbox_tools", - "build_dropbox_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py deleted file mode 100644 index 6913f4e6f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Dropbox domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.dropbox as dropbox_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_dropbox_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Dropbox domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=dropbox_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_prompt.md deleted file mode 100644 index 4b19be794..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/domain_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/expert_agent/connectors/dropbox/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py deleted file mode 100644 index ff28a5b71..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/dropbox/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Dropbox tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_dropbox_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``dropbox`` category.""" - return build_registry_tools_for_category(dependencies, "dropbox") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py deleted file mode 100644 index f7f899b4b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Gmail vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.gmail.agent import build_gmail_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.gmail.slice_tools import build_gmail_tools - -__all__ = [ - "build_gmail_tools", - "build_gmail_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py deleted file mode 100644 index 76d9c8cef..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Gmail domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.gmail as gmail_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_gmail_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Gmail domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=gmail_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_prompt.md deleted file mode 100644 index 961100261..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/domain_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/expert_agent/connectors/gmail/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py deleted file mode 100644 index 87876804e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/gmail/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Gmail tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_gmail_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``gmail`` category.""" - return build_registry_tools_for_category(dependencies, "gmail") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py deleted file mode 100644 index b1cf3680d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Google Drive connector slice.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.google_drive.agent import ( - build_google_drive_domain_agent, -) -from app.agents.multi_agent_chat.expert_agent.connectors.google_drive.slice_tools import ( - build_google_drive_tools, -) - -__all__ = [ - "build_google_drive_tools", - "build_google_drive_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py deleted file mode 100644 index 674c17188..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Google Drive domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.google_drive as google_drive_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_google_drive_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Google Drive domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=google_drive_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_prompt.md deleted file mode 100644 index 09dc0caa2..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/domain_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/expert_agent/connectors/google_drive/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py deleted file mode 100644 index ee6defe4b..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/google_drive/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Google Drive tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_google_drive_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``google_drive`` category.""" - return build_registry_tools_for_category(dependencies, "google_drive") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py deleted file mode 100644 index 6c070ebdd..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Luma vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.luma.agent import build_luma_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.luma.slice_tools import build_luma_tools - -__all__ = [ - "build_luma_tools", - "build_luma_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py deleted file mode 100644 index d0d3c11d9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Luma domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.luma as luma_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_luma_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Luma domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=luma_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_prompt.md deleted file mode 100644 index a2b4b7391..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/domain_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/expert_agent/connectors/luma/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py deleted file mode 100644 index bf4efde00..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/luma/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Luma tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_luma_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``luma`` category.""" - return build_registry_tools_for_category(dependencies, "luma") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py deleted file mode 100644 index 2e17a4749..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Notion connector slice.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.notion.agent import build_notion_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.notion.slice_tools import ( - build_notion_tools, -) - -__all__ = [ - "build_notion_tools", - "build_notion_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py deleted file mode 100644 index 3dc971f41..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Notion domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.notion as notion_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_notion_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Notion domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=notion_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_prompt.md deleted file mode 100644 index a40e9f4d0..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/domain_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/expert_agent/connectors/notion/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py deleted file mode 100644 index 4fecd13a4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/notion/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Notion tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_notion_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``notion`` category.""" - return build_registry_tools_for_category(dependencies, "notion") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py deleted file mode 100644 index d350176de..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Microsoft OneDrive connector slice.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.onedrive.agent import build_onedrive_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.onedrive.slice_tools import ( - build_onedrive_tools, -) - -__all__ = [ - "build_onedrive_tools", - "build_onedrive_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py deleted file mode 100644 index d97083232..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Microsoft OneDrive domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.onedrive as onedrive_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_onedrive_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled OneDrive domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=onedrive_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_prompt.md deleted file mode 100644 index a2f3617ba..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/domain_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/expert_agent/connectors/onedrive/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py deleted file mode 100644 index 572cc6e36..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/onedrive/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Microsoft OneDrive tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_onedrive_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``onedrive`` category.""" - return build_registry_tools_for_category(dependencies, "onedrive") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py deleted file mode 100644 index b9ab5a862..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Microsoft Teams vertical slice: registry tools, domain agent, ``domain_prompt.md``.""" - -from app.agents.multi_agent_chat.expert_agent.connectors.teams.agent import build_teams_domain_agent -from app.agents.multi_agent_chat.expert_agent.connectors.teams.slice_tools import build_teams_tools - -__all__ = [ - "build_teams_tools", - "build_teams_domain_agent", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py deleted file mode 100644 index d8c55e462..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/agent.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Microsoft Teams domain agent graph.""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.connectors.teams as teams_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_teams_domain_agent(llm: BaseChatModel, tools: Sequence[BaseTool]): - """Compiled Microsoft Teams domain-agent graph.""" - return build_domain_agent( - llm, - tools, - prompt_package=teams_pkg.__name__, - prompt_stem="domain_prompt", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_prompt.md deleted file mode 100644 index 8c0eebdd1..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/domain_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/expert_agent/connectors/teams/slice_tools.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py deleted file mode 100644 index e66ed3295..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/connectors/teams/slice_tools.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registry-backed Microsoft Teams tools.""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.registry import build_registry_tools_for_category - - -def build_teams_tools(dependencies: dict[str, Any]) -> list[BaseTool]: - """Registry-backed tools for the ``teams`` category.""" - return build_registry_tools_for_category(dependencies, "teams") diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py deleted file mode 100644 index 2b03b4235..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Prompt-backed subgraphs for MCP OAuth integrations without a native tool registry slice.""" - -from app.agents.multi_agent_chat.expert_agent.mcp_bridge.agent import build_mcp_route_domain_agent - -__all__ = ["build_mcp_route_domain_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py deleted file mode 100644 index be495488e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/agent.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Domain agents for MCP-only OAuth integrations (no native registry slice).""" - -from __future__ import annotations - -from collections.abc import Sequence - -import app.agents.multi_agent_chat.expert_agent.mcp_bridge as mcp_bridge_pkg -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.agents import build_domain_agent - - -def build_mcp_route_domain_agent( - llm: BaseChatModel, - route_key: str, - tools: Sequence[BaseTool], -): - """One subgraph per MCP-only route (``linear``, ``slack``, …); prompt stem ``{route_key}_domain``.""" - return build_domain_agent( - llm, - tools, - prompt_package=mcp_bridge_pkg.__name__, - prompt_stem=f"{route_key}_domain", - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.md deleted file mode 100644 index 0f15f137f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/airtable_domain.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/expert_agent/mcp_bridge/clickup_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.md deleted file mode 100644 index 84014246d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/clickup_domain.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/expert_agent/mcp_bridge/generic_mcp_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md deleted file mode 100644 index d2d5a2f1f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/generic_mcp_domain.md +++ /dev/null @@ -1,46 +0,0 @@ -You are the generic MCP operations sub-agent for user-defined servers. -You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. - - -Execute tasks strictly through runtime-exposed MCP tools while respecting tool contracts. - - - -- Runtime-provided MCP tools exposed by the connected custom server. - - - -- Follow each tool description and argument contract exactly. -- Never assume a capability exists unless a tool explicitly provides it. -- If required inputs are missing, return `status=blocked` with `missing_fields`. -- Never claim success without tool output confirmation. - - - -- Do not claim capabilities that are not present in runtime-exposed tools. - - - -- Never perform destructive operations without explicit delegated instruction and successful tool confirmation. - - - -- On tool failure, return `status=error` with concise recovery `next_step`. -- On missing required inputs, return `status=blocked` with `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/expert_agent/mcp_bridge/jira_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.md deleted file mode 100644 index 4f4ae8a66..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/jira_domain.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/expert_agent/mcp_bridge/linear_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.md deleted file mode 100644 index ce91cc49f..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/linear_domain.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/expert_agent/mcp_bridge/slack_domain.md b/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.md deleted file mode 100644 index 009a3205c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/expert_agent/mcp_bridge/slack_domain.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/integration/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py deleted file mode 100644 index f73a554ef..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Full-stack wiring (DB-scoped) on top of :mod:`routing` and :mod:`supervisor`.""" - -from app.agents.multi_agent_chat.integration.create_multi_agent_chat import ( - create_multi_agent_chat, -) - -__all__ = ["create_multi_agent_chat"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py b/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py deleted file mode 100644 index 36c731735..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/integration/create_multi_agent_chat.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Build the multi-agent supervisor graph: MCP partition, registry, routing tools, optional SurfSense middleware.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -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.core.mcp_partition import ( - fetch_mcp_connector_metadata_maps, - partition_mcp_tools_by_expert_route, -) -from app.agents.multi_agent_chat.core.registry.dependencies import ( - build_registry_dependencies, - coerce_thread_id_for_registry, -) -from app.agents.multi_agent_chat.middleware.supervisor_stack import ( - build_supervisor_middleware_stack, -) -from app.agents.multi_agent_chat.routing.supervisor_routing import ( - build_supervisor_routing_tools, -) -from app.agents.multi_agent_chat.supervisor import build_supervisor_agent -from app.agents.new_chat.chat_deepagent import _map_connectors_to_searchable_types -from app.agents.new_chat.context import SurfSenseContextSchema -from app.agents.new_chat.feature_flags import get_flags -from app.agents.new_chat.filesystem_backends import build_backend_resolver -from app.agents.new_chat.filesystem_selection import FilesystemSelection -from app.agents.new_chat.tools.mcp_tool import load_mcp_tools -from app.db import ChatVisibility - -logger = logging.getLogger(__name__) - - -async def _discover_connectors_and_doc_types( - *, - connector_service: Any | None, - search_space_id: int, - available_connectors: list[str] | None, - available_document_types: list[str] | None, -) -> tuple[list[str] | None, list[str] | None]: - """Fill connector / document-type lists from ``connector_service`` when callers omit them.""" - connectors = available_connectors - doc_types = available_document_types - if connector_service is None: - return connectors, doc_types - try: - if connectors is None: - raw = await connector_service.get_available_connectors(search_space_id) - if raw: - connectors = _map_connectors_to_searchable_types(raw) - if doc_types is None: - doc_types = await connector_service.get_available_document_types(search_space_id) - except Exception as exc: - logger.warning("Failed to discover available connectors/document types: %s", exc) - return connectors, doc_types - - -async def _mcp_tools_by_expert_route( - *, - db_session: AsyncSession, - search_space_id: int, -) -> dict[str, list[BaseTool]] | None: - mcp_flat = await load_mcp_tools(db_session, search_space_id) - id_map, name_map = await fetch_mcp_connector_metadata_maps(db_session, search_space_id) - return partition_mcp_tools_by_expert_route(mcp_flat, id_map, name_map) - - -def _make_supervisor_routing_tools( - llm: BaseChatModel, - *, - db_session: AsyncSession, - search_space_id: int, - user_id: str, - thread_id: str | int | None, - firecrawl_api_key: str | None, - connector_service: Any | None, - available_connectors: list[str] | None, - available_document_types: list[str] | None, - thread_visibility: ChatVisibility, - mcp_tools_by_route: dict[str, list[BaseTool]] | None, -) -> list[BaseTool]: - registry_dependencies = build_registry_dependencies( - db_session=db_session, - search_space_id=search_space_id, - user_id=user_id, - thread_id=thread_id, - llm=llm, - firecrawl_api_key=firecrawl_api_key, - connector_service=connector_service, - available_connectors=available_connectors, - available_document_types=available_document_types, - thread_visibility=thread_visibility, - ) - return build_supervisor_routing_tools( - llm, - registry_dependencies=registry_dependencies, - include_deliverables=coerce_thread_id_for_registry(thread_id) is not None, - mcp_tools_by_route=mcp_tools_by_route, - available_connectors=available_connectors, - thread_visibility=thread_visibility, - ) - - -def _compile_supervisor_agent_sync( - *, - llm: BaseChatModel, - routing_tools: list[BaseTool], - checkpointer: Checkpointer | None, - backend_resolver: Any, - filesystem_mode: Any, - search_space_id: int, - user_id: str, - thread_id: str | int | None, - thread_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, - citations_enabled: bool, -) -> Any: - """CPU-heavy: middleware stack + ``create_agent`` (intended for ``asyncio.to_thread``).""" - middleware = build_supervisor_middleware_stack( - llm=llm, - tools=routing_tools, - backend_resolver=backend_resolver, - filesystem_mode=filesystem_mode, - search_space_id=search_space_id, - user_id=user_id, - thread_id=thread_id, - visibility=thread_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=get_flags(), - ) - return build_supervisor_agent( - llm, - tools=routing_tools, - checkpointer=checkpointer, - thread_visibility=thread_visibility, - middleware=middleware, - context_schema=SurfSenseContextSchema, - citations_enabled=citations_enabled, - ) - - -async def create_multi_agent_chat( - llm: BaseChatModel, - *, - db_session: AsyncSession, - search_space_id: int, - user_id: str, - checkpointer: Checkpointer | None = None, - thread_id: str | int | None = None, - firecrawl_api_key: str | None = None, - connector_service: Any | None = None, - available_connectors: list[str] | None = None, - available_document_types: list[str] | None = None, - thread_visibility: ChatVisibility = ChatVisibility.PRIVATE, - include_mcp_tools: bool = True, - filesystem_selection: FilesystemSelection | None = None, - anon_session_id: str | None = None, - mentioned_document_ids: list[int] | None = None, - max_input_tokens: int | None = None, - surfsense_stack: bool = True, - citations_enabled: bool | None = None, -): - """Build the full multi-agent chat graph (supervisor + expert subgraphs via routing tools). - - **Builtins** (:mod:`expert_agent.builtins`): registry-grouped **categories** (research, memory, deliverables). - **Connectors** (:mod:`expert_agent.connectors`): **vendor integrations** — one subgraph per route in - ``TOOL_NAMES_BY_CATEGORY`` (e.g. calendar, confluence, discord, dropbox, gmail, google_drive, luma, notion, onedrive, teams). - - MCP tools (via ``load_mcp_tools``) are partitioned inside this package and attached only - to the matching expert subgraphs — not to the supervisor tool list as raw MCP calls. Inclusion matches - ``app.agents.new_chat.tools.registry.build_tools_async``: all tools returned by ``load_mcp_tools`` are merged - after partitioning (no extra inventory filter on MCP). Connector routing uses ``available_connectors``: - pass explicitly, or provide ``connector_service`` so lists are resolved like - ``create_surfsense_deep_agent`` (``get_available_connectors`` → searchable types). - - Deliverables (thread-scoped reports, podcasts, etc.) are registered only when ``thread_id`` is set. - - When ``surfsense_stack`` is true (default), the supervisor uses the same SurfSense middleware shell as - the main single-agent chat (KB priority/tree, filesystem, compaction, permissions, etc.) except - ``SubAgentMiddleware`` / ``task``, since experts are separate graphs behind routing tools. Graph - compilation runs in ``asyncio.to_thread`` so heavy CPU work does not block the event loop. - - ``citations_enabled``: when ``None``, defaults to ``True`` (same default as ``AgentConfig`` / main chat). - """ - citations = True if citations_enabled is None else citations_enabled - connectors, doc_types = await _discover_connectors_and_doc_types( - connector_service=connector_service, - search_space_id=search_space_id, - available_connectors=available_connectors, - available_document_types=available_document_types, - ) - - mcp_by_route: dict[str, list[BaseTool]] | None = None - if include_mcp_tools: - mcp_by_route = await _mcp_tools_by_expert_route( - db_session=db_session, search_space_id=search_space_id - ) - - routing_tools = _make_supervisor_routing_tools( - llm, - db_session=db_session, - search_space_id=search_space_id, - user_id=user_id, - thread_id=thread_id, - firecrawl_api_key=firecrawl_api_key, - connector_service=connector_service, - available_connectors=connectors, - available_document_types=doc_types, - thread_visibility=thread_visibility, - mcp_tools_by_route=mcp_by_route, - ) - - fs_sel = filesystem_selection or FilesystemSelection() - backend_resolver = build_backend_resolver(fs_sel, search_space_id=search_space_id) - - if not surfsense_stack: - return build_supervisor_agent( - llm, - tools=routing_tools, - checkpointer=checkpointer, - thread_visibility=thread_visibility, - citations_enabled=citations, - ) - - return await asyncio.to_thread( - _compile_supervisor_agent_sync, - llm=llm, - routing_tools=routing_tools, - checkpointer=checkpointer, - backend_resolver=backend_resolver, - filesystem_mode=fs_sel.mode, - search_space_id=search_space_id, - user_id=user_id, - thread_id=thread_id, - thread_visibility=thread_visibility, - anon_session_id=anon_session_id, - available_connectors=connectors, - available_document_types=doc_types, - mentioned_document_ids=mentioned_document_ids, - max_input_tokens=max_input_tokens, - citations_enabled=citations, - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py deleted file mode 100644 index 058cf705a..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""SurfSense supervisor middleware (parity with the main single-agent chat, minus subagents).""" - -from app.agents.multi_agent_chat.middleware.supervisor_stack import ( - build_supervisor_middleware_stack, - parse_thread_id_for_action_log, -) - -__all__ = [ - "build_supervisor_middleware_stack", - "parse_thread_id_for_action_log", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py deleted file mode 100644 index 0cd390949..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/supervisor_stack.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Supervisor middleware stack matching the main single-agent chat (no ``SubAgentMiddleware`` / ``task``).""" - -from __future__ import annotations - -import logging -from collections.abc import Sequence -from typing import Any - -from deepagents.backends import StateBackend -from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware -from deepagents.middleware.skills import SkillsMiddleware -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 app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags -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, - RetryAfterMiddleware, - SpillingContextEditingMiddleware, - SpillToBackendEdit, - SurfSenseFilesystemMiddleware, - ToolCallNameRepairMiddleware, - build_skills_backend_factory, - create_surfsense_compaction_middleware, - default_skills_sources, -) -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 - -logger = logging.getLogger(__name__) - -# Routing tools with heavy outputs — never prune via context editing when bound. -_SUPERVISOR_PRUNE_PROTECTED: frozenset[str] = frozenset( - { - "deliverables", - "invalid", - # Align with single-agent surfacing of costly connector reads if names overlap later. - "read_email", - "search_emails", - "generate_report", - "generate_resume", - "generate_podcast", - "generate_video_presentation", - "generate_image", - } -) - - -def _safe_exclude_tools_supervisor(tools: Sequence[BaseTool]) -> tuple[str, ...]: - enabled = {t.name for t in tools} - return tuple(n for n in _SUPERVISOR_PRUNE_PROTECTED if n in enabled) - - -def parse_thread_id_for_action_log(thread_id: int | str | None) -> int | None: - """Numeric DB thread ids only — UUID strings skip action logging (no FK row).""" - if thread_id is None: - return None - if isinstance(thread_id, int): - return thread_id - s = str(thread_id).strip() - if s.isdigit(): - return int(s) - return None - - -def build_supervisor_middleware_stack( - *, - llm: BaseChatModel, - tools: Sequence[BaseTool], - backend_resolver: Any, - filesystem_mode: FilesystemMode, - search_space_id: int, - user_id: str | None, - thread_id: int | str | 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 | None = None, -) -> list[Any]: - """Build middleware list for the multi-agent supervisor (parity with ``_build_compiled_agent_blocking`` minus subagents).""" - flags = flags or get_flags() - - memory_middleware = MemoryInjectionMiddleware( - user_id=user_id, - search_space_id=search_space_id, - thread_visibility=visibility, - ) - - summarization_mw = create_surfsense_compaction_middleware(llm, StateBackend) - _ = flags.enable_compaction_v2 - - 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_supervisor(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_supervisor(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: - logger.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", - # No ``task`` — multi-agent uses routing tools instead of SubAgentMiddleware. - } - 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 - ) - - thread_id_action_log = parse_thread_id_for_action_log(thread_id) - action_log_mw: ActionLogMiddleware | None = None - if ( - flags.enable_action_log - and not flags.disable_new_agent_stack - and thread_id_action_log is not None - ): - try: - tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS} - action_log_mw = ActionLogMiddleware( - thread_id=thread_id_action_log, - search_space_id=search_space_id, - user_id=user_id, - tool_definitions=tool_defs_by_name, - ) - except Exception: # pragma: no cover - defensive - logger.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 - logger.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 - logger.warning("SkillsMiddleware init failed; skipping: %s", exc) - skills_mw = None - - names = {t.name for t in tools} - 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=[ - n - for n in ( - "research", - "memory", - "update_memory", - "get_connected_accounts", - "scrape_webpage", - ) - if n in names - ], - ) - except Exception: - logger.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, - ) - if filesystem_mode == FilesystemMode.CLOUD - else None, - skills_mw, - selector_mw, - model_call_limit_mw, - tool_call_limit_mw, - context_edit_mw, - summarization_mw, - noop_mw, - retry_mw, - fallback_mw, - repair_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/routing/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py deleted file mode 100644 index c369aeea5..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Supervisor routing: domain-agent wrappers and composed routing tool lists.""" - -from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec -from app.agents.multi_agent_chat.routing.from_domain_agents import routing_tools_from_specs -from app.agents.multi_agent_chat.routing.supervisor_routing import build_supervisor_routing_tools -from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text - -__all__ = [ - "DomainRoutingSpec", - "build_supervisor_routing_tools", - "extract_last_assistant_text", - "routing_tools_from_specs", -] diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py b/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py deleted file mode 100644 index f61d5b151..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/domain_routing_spec.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Declarative description of one supervisor routing tool → domain agent.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - - -@dataclass(frozen=True) -class DomainRoutingSpec: - """One supervisor-facing routing ``@tool`` bound to a compiled domain graph. - - ``curated_context`` is optional for **any** route: when set, the routing tool prepends its return - value into the child task via :func:`~app.agents.multi_agent_chat.core.delegation.compose_child_task`. - :func:`build_supervisor_routing_tools` does not pass it (all routes treated the same); use when building specs manually. - """ - - tool_name: str - description: str - domain_agent: Any - curated_context: Callable[[str], str | None] | None = None diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py b/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py deleted file mode 100644 index a2c1513f4..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/from_domain_agents.py +++ /dev/null @@ -1,123 +0,0 @@ -"""LangChain ``@tool`` wrappers that invoke compiled domain-agent graphs (supervisor-facing only).""" - -from __future__ import annotations - -from collections.abc import Sequence -import json -from typing import Any - -from langchain_core.tools import BaseTool, tool - -from app.agents.multi_agent_chat.core.delegation import compose_child_task -from app.agents.multi_agent_chat.core.invocation import extract_last_assistant_text -from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec - -_ALLOWED_STATUSES = {"success", "partial", "blocked", "error"} -_REQUIRED_KEYS = { - "status", - "action_summary", - "evidence", - "next_step", - "missing_fields", - "assumptions", -} - - -def _fallback_payload(spec: DomainRoutingSpec, reason: str, raw_text: str) -> dict[str, Any]: - preview = raw_text[:800] - return { - "status": "error", - "action_summary": "Domain agent output failed JSON contract validation.", - "evidence": { - "route_tool": spec.tool_name, - "validation_error": reason, - "raw_output_preview": preview, - }, - "next_step": ( - "Re-delegate with a strict reminder to return exactly one JSON object " - "matching the output_contract." - ), - "missing_fields": None, - "assumptions": None, - } - - -def _validate_contract_payload(payload: dict[str, Any]) -> str | None: - missing = sorted(_REQUIRED_KEYS - set(payload)) - if missing: - return f"missing required keys: {', '.join(missing)}" - - status = payload.get("status") - if status not in _ALLOWED_STATUSES: - return "invalid status value" - - action_summary = payload.get("action_summary") - if not isinstance(action_summary, str) or not action_summary.strip(): - return "action_summary must be a non-empty string" - - evidence = payload.get("evidence") - if not isinstance(evidence, dict): - return "evidence must be an object" - - next_step = payload.get("next_step") - if status == "success": - if next_step is not None: - return "next_step must be null when status=success" - if payload.get("missing_fields") is not None: - return "missing_fields must be null when status=success" - else: - if not isinstance(next_step, str) or not next_step.strip(): - return "next_step must be a non-empty string for non-success statuses" - - missing_fields = payload.get("missing_fields") - if missing_fields is not None: - if not isinstance(missing_fields, list) or any( - not isinstance(item, str) or not item.strip() for item in missing_fields - ): - return "missing_fields must be null or a list of non-empty strings" - - assumptions = payload.get("assumptions") - if assumptions is not None: - if not isinstance(assumptions, list) or any( - not isinstance(item, str) or not item.strip() for item in assumptions - ): - return "assumptions must be null or a list of non-empty strings" - - return None - - -def _normalize_domain_output(spec: DomainRoutingSpec, raw_text: str) -> str: - try: - parsed = json.loads(raw_text) - except json.JSONDecodeError as exc: - fallback = _fallback_payload(spec, f"invalid JSON: {exc.msg}", raw_text) - return json.dumps(fallback) - - if not isinstance(parsed, dict): - fallback = _fallback_payload(spec, "top-level JSON must be an object", raw_text) - return json.dumps(fallback) - - validation_error = _validate_contract_payload(parsed) - if validation_error: - fallback = _fallback_payload(spec, validation_error, raw_text) - return json.dumps(fallback) - - return json.dumps(parsed) - - -def _routing_tool_for_spec(spec: DomainRoutingSpec) -> BaseTool: - @tool(spec.tool_name, description=spec.description) - async def _route(task: str) -> str: - curated = spec.curated_context(task) if spec.curated_context else None - content = compose_child_task(task, curated_context=curated) - result = await spec.domain_agent.ainvoke( - {"messages": [{"role": "user", "content": content}]}, - ) - return _normalize_domain_output(spec, extract_last_assistant_text(result)) - - return _route - - -def routing_tools_from_specs(specs: Sequence[DomainRoutingSpec]) -> list[BaseTool]: - """Build one supervisor-facing routing ``@tool`` per :class:`DomainRoutingSpec`.""" - return [_routing_tool_for_spec(spec) for spec in specs] diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py b/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py deleted file mode 100644 index 84e2359e7..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/route_connector_gate.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Gate supervisor routing tools by connected searchable connector types (aligned with main chat KB). - -When ``available_connectors`` is ``None``, all routes are emitted (caller did not pass an inventory). - -When provided, a connector route is emitted only if at least one required searchable type is present. -MCP tools are filtered upstream in :func:`~app.agents.multi_agent_chat.core.mcp_partition.partition_mcp_tools_by_expert_route` -so merges only include tools for connected accounts. -""" - -from __future__ import annotations - -# Route tool_name → searchable connector / doc-type strings (same family as -# ``chat_deepagent._CONNECTOR_TYPE_TO_SEARCHABLE`` values in ``available_connectors``). -_ROUTE_REQUIRES_ANY: dict[str, frozenset[str]] = { - "calendar": frozenset( - {"GOOGLE_CALENDAR_CONNECTOR", "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"} - ), - "confluence": frozenset({"CONFLUENCE_CONNECTOR"}), - "discord": frozenset({"DISCORD_CONNECTOR"}), - "dropbox": frozenset({"DROPBOX_FILE"}), - "gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR", "COMPOSIO_GMAIL_CONNECTOR"}), - "google_drive": frozenset( - {"GOOGLE_DRIVE_FILE", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"} - ), - "luma": frozenset({"LUMA_CONNECTOR"}), - "notion": frozenset({"NOTION_CONNECTOR"}), - "onedrive": frozenset({"ONEDRIVE_FILE"}), - "teams": frozenset({"TEAMS_CONNECTOR"}), - # MCP-only supervisor routes (see ``core.mcp_partition.MCP_ONLY_ROUTE_KEYS_IN_ORDER``). - "linear": frozenset({"LINEAR_CONNECTOR"}), - "slack": frozenset({"SLACK_CONNECTOR"}), - "jira": frozenset({"JIRA_CONNECTOR"}), - "clickup": frozenset({"CLICKUP_CONNECTOR"}), - "airtable": frozenset({"AIRTABLE_CONNECTOR"}), - # generic_mcp route intentionally disabled for now. - # "generic_mcp": frozenset({"MCP_CONNECTOR"}), -} - - -def include_connector_route( - route_key: str, - available_connectors: list[str] | None, -) -> bool: - """Return whether to register this connector route on the supervisor. - - If ``available_connectors`` is omitted, preserve legacy behaviour (emit the route). - - Otherwise require at least one matching entry in ``available_connectors`` for connector-backed routes. - Builtin routes (research, memory, …) have no entry in ``_ROUTE_REQUIRES_ANY`` and are always included. - """ - if available_connectors is None: - return True - required = _ROUTE_REQUIRES_ANY.get(route_key) - if required is None: - return True - avail = set(available_connectors) - return bool(required & avail) diff --git a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py b/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py deleted file mode 100644 index ab1f5cafc..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/routing/supervisor_routing.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Compose domain agents + tool lists into supervisor routing tools (one ``@tool`` per category).""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.language_models import BaseChatModel -from langchain_core.tools import BaseTool - -from app.agents.multi_agent_chat.core.mcp_partition import MCP_ONLY_ROUTE_KEYS_IN_ORDER -from app.agents.multi_agent_chat.expert_agent.builtins.deliverables import ( - build_deliverables_domain_agent, - build_deliverables_tools, -) -from app.agents.multi_agent_chat.expert_agent.builtins.memory import ( - build_memory_domain_agent, - build_memory_tools, -) -from app.agents.multi_agent_chat.expert_agent.builtins.research import ( - build_research_domain_agent, - build_research_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.calendar import ( - build_calendar_domain_agent, - build_calendar_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.confluence import ( - build_confluence_domain_agent, - build_confluence_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.discord import ( - build_discord_domain_agent, - build_discord_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.dropbox import ( - build_dropbox_domain_agent, - build_dropbox_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.gmail import ( - build_gmail_domain_agent, - build_gmail_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.google_drive import ( - build_google_drive_domain_agent, - build_google_drive_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.luma import ( - build_luma_domain_agent, - build_luma_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.notion import ( - build_notion_domain_agent, - build_notion_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.onedrive import ( - build_onedrive_domain_agent, - build_onedrive_tools, -) -from app.agents.multi_agent_chat.expert_agent.connectors.teams import ( - build_teams_domain_agent, - build_teams_tools, -) -from app.agents.multi_agent_chat.expert_agent.mcp_bridge import ( - build_mcp_route_domain_agent, -) -from app.agents.multi_agent_chat.routing.domain_routing_spec import DomainRoutingSpec -from app.agents.multi_agent_chat.routing.from_domain_agents import ( - routing_tools_from_specs, -) -from app.agents.multi_agent_chat.routing.route_connector_gate import ( - include_connector_route, -) -from app.db import ChatVisibility - -_MCP_ONLY_ROUTE_DESCRIPTIONS: dict[str, str] = { - "linear": ( - "Use for Linear issue/project work: find/create issues, update status/assignees, review project progress, and inspect cycles." - ), - "slack": ( - "Use for Slack channel communication: read channel/thread history, summarize conversations, and post replies." - ), - "jira": ( - "Use for Jira issue/project workflows: search issues, inspect fields, update tickets, and move work through workflow states." - ), - "clickup": ( - "Use for ClickUp task management: find tasks/lists, update task fields, and track execution progress." - ), - "airtable": ( - "Use for Airtable structured data operations: locate bases/tables and create/read/update records." - ), - # generic_mcp intentionally disabled for now. - # "generic_mcp": ( - # "Use as a fallback for custom connected app tasks not covered by a named specialist. " - # "Do not use if another specialist clearly matches." - # ), -} - - -def _memory_route_description(thread_visibility: ChatVisibility | None) -> str: - if thread_visibility == ChatVisibility.SEARCH_SPACE: - return "Use for storing durable team memory: shared team preferences, conventions, and long-lived team facts." - return "Use for storing durable user memory: personal preferences, instructions, and long-lived user facts." - - -def build_supervisor_routing_tools( - llm: BaseChatModel, - *, - registry_dependencies: dict[str, Any] | None = None, - include_deliverables: bool = True, - mcp_tools_by_route: dict[str, list[BaseTool]] | None = None, - available_connectors: list[str] | None = None, - thread_visibility: ChatVisibility | None = None, -) -> list[BaseTool]: - """Build supervisor routing tools: builtins first, then connector experts (same pattern for all). - - Requires ``registry_dependencies`` to produce any routing tools; otherwise returns an empty list. - - Pass ``registry_dependencies`` from - :func:`app.agents.multi_agent_chat.core.registry.build_registry_dependencies` - for builtins (**research**, **memory**, **deliverables** when ``include_deliverables``) and every - registry-backed connector route. - - ``mcp_tools_by_route`` maps route keys to MCP tools merged into the matching expert subgraph. - - When ``available_connectors`` is set (searchable connector strings, same shape as the main chat agent), - a connector-backed route is registered only if its required searchable connector type is available. - """ - if registry_dependencies is None: - return routing_tools_from_specs([]) - - mcp = mcp_tools_by_route or {} - specs: list[DomainRoutingSpec] = [] - - research_tools = build_research_tools(registry_dependencies) - research_agent = build_research_domain_agent(llm, research_tools) - specs.append( - DomainRoutingSpec( - tool_name="research", - description=( - "Use for external research: find sources on the web, extract evidence, and answer documentation questions." - ), - domain_agent=research_agent, - ), - ) - - memory_tools = build_memory_tools(registry_dependencies) - memory_agent = build_memory_domain_agent( - llm, - memory_tools, - thread_visibility=thread_visibility, - ) - specs.append( - DomainRoutingSpec( - tool_name="memory", - description=_memory_route_description(thread_visibility), - domain_agent=memory_agent, - ), - ) - - if include_deliverables: - deliverables_tools = build_deliverables_tools(registry_dependencies) - deliverables_agent = build_deliverables_domain_agent(llm, deliverables_tools) - specs.append( - DomainRoutingSpec( - tool_name="deliverables", - description=( - "Use for deliverables and shareable artifacts: generated reports, podcasts, " - "video presentations, resumes, and images—not for routine lookups or single small edits elsewhere." - ), - domain_agent=deliverables_agent, - ), - ) - - # Connector experts (registry-backed + optional MCP merge): alphabetical by route key. - if include_connector_route("calendar", available_connectors): - calendar_agent = build_calendar_domain_agent( - llm, - build_calendar_tools(registry_dependencies) + mcp.get("calendar", []), - ) - specs.append( - DomainRoutingSpec( - tool_name="calendar", - description=( - "Use for calendar planning and scheduling: check availability, read event details, create events, and update events." - ), - domain_agent=calendar_agent, - ), - ) - - if include_connector_route("confluence", available_connectors): - confluence_tools = build_confluence_tools(registry_dependencies) - confluence_agent = build_confluence_domain_agent(llm, confluence_tools) - specs.append( - DomainRoutingSpec( - tool_name="confluence", - description=( - "Use for Confluence knowledge pages: search/read existing pages, create new pages, and update page content." - ), - domain_agent=confluence_agent, - ), - ) - - if include_connector_route("discord", available_connectors): - discord_tools = build_discord_tools(registry_dependencies) - discord_agent = build_discord_domain_agent(llm, discord_tools + mcp.get("discord", [])) - specs.append( - DomainRoutingSpec( - tool_name="discord", - description=( - "Use for Discord communication: read channel/thread messages, gather context, and send replies." - ), - domain_agent=discord_agent, - ), - ) - - if include_connector_route("dropbox", available_connectors): - dropbox_tools = build_dropbox_tools(registry_dependencies) - dropbox_agent = build_dropbox_domain_agent(llm, dropbox_tools) - specs.append( - DomainRoutingSpec( - tool_name="dropbox", - description=( - "Use for Dropbox file storage tasks: browse folders, read files, and manage Dropbox file content." - ), - domain_agent=dropbox_agent, - ), - ) - - if include_connector_route("gmail", available_connectors): - gmail_agent = build_gmail_domain_agent( - llm, - build_gmail_tools(registry_dependencies) + mcp.get("gmail", []), - ) - specs.append( - DomainRoutingSpec( - tool_name="gmail", - description=( - "Use for Gmail inbox actions: search/read emails, draft or update replies, send messages, and trash emails." - ), - domain_agent=gmail_agent, - ), - ) - - if include_connector_route("google_drive", available_connectors): - google_drive_tools = build_google_drive_tools(registry_dependencies) - google_drive_agent = build_google_drive_domain_agent(llm, google_drive_tools) - specs.append( - DomainRoutingSpec( - tool_name="google_drive", - description=( - "Use for Google Drive document/file tasks: locate files, inspect content, and manage Drive files or folders." - ), - domain_agent=google_drive_agent, - ), - ) - - if include_connector_route("luma", available_connectors): - luma_tools = build_luma_tools(registry_dependencies) - luma_agent = build_luma_domain_agent(llm, luma_tools + mcp.get("luma", [])) - specs.append( - DomainRoutingSpec( - tool_name="luma", - description=( - "Use for Luma event operations: list events, inspect event details, and create new events." - ), - domain_agent=luma_agent, - ), - ) - - if include_connector_route("notion", available_connectors): - notion_tools = build_notion_tools(registry_dependencies) - notion_agent = build_notion_domain_agent(llm, notion_tools) - specs.append( - DomainRoutingSpec( - tool_name="notion", - description=( - "Use for Notion workspace pages: create pages, update page content, and delete pages." - ), - domain_agent=notion_agent, - ), - ) - - if include_connector_route("onedrive", available_connectors): - onedrive_tools = build_onedrive_tools(registry_dependencies) - onedrive_agent = build_onedrive_domain_agent(llm, onedrive_tools) - specs.append( - DomainRoutingSpec( - tool_name="onedrive", - description=( - "Use for OneDrive file storage tasks: browse folders, read files, and manage OneDrive file content." - ), - domain_agent=onedrive_agent, - ), - ) - - if include_connector_route("teams", available_connectors): - teams_tools = build_teams_tools(registry_dependencies) - teams_agent = build_teams_domain_agent(llm, teams_tools + mcp.get("teams", [])) - specs.append( - DomainRoutingSpec( - tool_name="teams", - description=( - "Use for Microsoft Teams communication: read channel/thread messages, gather context, and post replies." - ), - domain_agent=teams_agent, - ), - ) - - for route_key in MCP_ONLY_ROUTE_KEYS_IN_ORDER: - only_mcp = mcp.get(route_key) or [] - if not only_mcp: - continue - if not include_connector_route(route_key, available_connectors): - continue - desc = _MCP_ONLY_ROUTE_DESCRIPTIONS.get( - route_key, - f"Use for {route_key} tasks related to that system's core work objects and workflows.", - ) - specs.append( - DomainRoutingSpec( - tool_name=route_key, - description=desc, - domain_agent=build_mcp_route_domain_agent(llm, route_key, only_mcp), - ), - ) - - return routing_tools_from_specs(specs) diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py deleted file mode 100644 index d96ee3e39..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Supervisor agent graph only; supply routing ``tools`` from ``build_supervisor_routing_tools``.""" - -from app.agents.multi_agent_chat.supervisor.graph import build_supervisor_agent - -__all__ = ["build_supervisor_agent"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py deleted file mode 100644 index 7823a0380..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/graph.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Compile the supervisor agent graph (LangChain ``create_agent`` + caller routing tools).""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import Any - -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.supervisor.prompt_assembly import ( - build_supervisor_system_prompt, -) - - -def build_supervisor_agent( - llm: BaseChatModel, - *, - tools: Sequence[BaseTool], - checkpointer: Checkpointer | None = None, - thread_visibility: Any | None = None, - middleware: Sequence[Any] | None = None, - context_schema: Any | None = None, - citations_enabled: bool = True, -): - """Compile the supervisor **agent** (graph). ``tools`` = output of ``build_supervisor_routing_tools``.""" - system_prompt = build_supervisor_system_prompt( - tools, - thread_visibility=thread_visibility, - citations_enabled=citations_enabled, - ) - kwargs: dict[str, Any] = { - "system_prompt": system_prompt, - "tools": list(tools), - "checkpointer": checkpointer, - } - if middleware is not None: - kwargs["middleware"] = list(middleware) - if context_schema is not None: - kwargs["context_schema"] = context_schema - agent = create_agent(llm, **kwargs) - if middleware is not None or context_schema is not None: - return agent.with_config( - { - "recursion_limit": 10_000, - "metadata": {"ls_integration": "multi_agent_supervisor"}, - } - ) - return agent diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py b/surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py deleted file mode 100644 index ac7140c7d..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/prompt_assembly.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Supervisor system prompt: template load, shared agent-identity injection, specialist list.""" - -from __future__ import annotations - -from collections.abc import Sequence -from datetime import UTC, datetime -from typing import Any - -from langchain_core.tools import BaseTool - -import app.agents.multi_agent_chat.supervisor as supervisor_pkg -from app.agents.multi_agent_chat.core.prompts import read_prompt_md -from app.agents.new_chat.prompts.composer import _build_citation_block, _read_fragment -from app.db import ChatVisibility - -_MEMORY_SPECIALIST_PHRASE = "invoke the **memory** specialist" - -_BUILTIN_SPECIALISTS: frozenset[str] = frozenset({"research", "memory", "deliverables"}) -_SPECIALIST_CAPABILITIES: dict[str, str] = { - "research": "external research: web lookup, source gathering, and SurfSense documentation help.", - "memory": "save durable long-lived memory items.", - "deliverables": "deliverables and shareable artifacts: reports, podcasts, video presentations, resumes, and images.", - "gmail": "email inbox actions: search/read emails, draft updates, send messages, and trash emails.", - "calendar": "scheduling actions: check availability, inspect events, create events, and update events.", - "google_drive": "Drive file/document actions: locate files, inspect content, and manage files/folders.", - "notion": "Notion page actions: create pages, update content, and delete pages.", - "confluence": "Confluence page actions: find/read pages and create/update pages.", - "dropbox": "Dropbox file storage actions: browse folders, read files, and manage file content.", - "onedrive": "OneDrive file storage actions: browse folders, read files, and manage file content.", - "discord": "Discord communication actions: read channels/threads and post replies.", - "teams": "Microsoft Teams communication actions: read channels/threads and post replies.", - "luma": "Luma event actions: list events, inspect event details, and create events.", - "linear": "Linear workflow actions: search/update issues and inspect projects/cycles.", - "jira": "Jira workflow actions: search/update issues and manage workflow transitions.", - "clickup": "ClickUp workflow actions: find/update tasks and lists.", - "airtable": "Airtable data actions: locate bases/tables and create/read/update records.", - "slack": "Slack communication actions: read channel/thread history and post replies.", - # generic_mcp specialist intentionally disabled for now. - # "generic_mcp": "handle tasks through user-defined custom app integration tools not covered above.", -} -_SPECIALIST_ORDER: tuple[str, ...] = tuple(_SPECIALIST_CAPABILITIES.keys()) - - -def _normalize_chat_visibility(thread_visibility: Any | None) -> ChatVisibility: - if thread_visibility is None: - return ChatVisibility.PRIVATE - if thread_visibility == ChatVisibility.SEARCH_SPACE: - return ChatVisibility.SEARCH_SPACE - raw = getattr(thread_visibility, "value", thread_visibility) - if str(raw).upper() == "SEARCH_SPACE": - return ChatVisibility.SEARCH_SPACE - return ChatVisibility.PRIVATE - - -def _identity_fragment_key(thread_visibility: Any | None) -> str: - """``private`` / ``team`` suffix for ``agent_*`` and ``memory_protocol_*`` fragments.""" - return ( - "team" - if _normalize_chat_visibility(thread_visibility) == ChatVisibility.SEARCH_SPACE - else "private" - ) - - -def _compose_identity_memory_citations( - *, - thread_visibility: Any | None, - citations_enabled: bool, -) -> str: - """Main-chat identity, memory protocol, and citation fragments (supervisor slice only).""" - key = _identity_fragment_key(thread_visibility) - today = datetime.now(UTC).date().isoformat() - - intro = _read_fragment(f"base/agent_{key}.md") - if intro: - intro = intro.format(resolved_today=today) - - memory = _read_fragment(f"base/memory_protocol_{key}.md").replace( - "call update_memory", - _MEMORY_SPECIALIST_PHRASE, - ) - tail = ( - f"\n{memory}\n\n\n" - + _build_citation_block(citations_enabled) - ) - return "\n\n".join(part for part in (intro.strip(), tail.strip()) if part) - - -def _memory_specialist_capability(thread_visibility: Any | None) -> str: - vis = str(getattr(thread_visibility, "value", thread_visibility)).upper() - if vis == "SEARCH_SPACE": - return "team memory actions: save shared team preferences, conventions, and long-lived team facts." - return "user memory actions: save personal preferences, instructions, and long-lived user facts." - - -def _specialists_markdown( - tools: Sequence[BaseTool], - *, - thread_visibility: Any | None, -) -> str: - available_names = { - tool.name for tool in tools if isinstance(getattr(tool, "name", None), str) - } - capabilities = dict(_SPECIALIST_CAPABILITIES) - capabilities["memory"] = _memory_specialist_capability(thread_visibility) - lines: list[str] = [] - for name in _SPECIALIST_ORDER: - if name in _BUILTIN_SPECIALISTS or name in available_names: - lines.append(f"- {name}: {capabilities[name]}") - return "\n".join(lines) - - -def build_supervisor_system_prompt( - tools: Sequence[BaseTool], - *, - thread_visibility: Any | None, - citations_enabled: bool, -) -> str: - """Load ``supervisor_prompt.md`` and fill placeholders.""" - template = read_prompt_md(supervisor_pkg.__name__, "supervisor_prompt") - specialists = _specialists_markdown(tools, thread_visibility=thread_visibility) - injected = _compose_identity_memory_citations( - thread_visibility=thread_visibility, - citations_enabled=citations_enabled, - ) - return template.replace("{{AVAILABLE_SPECIALISTS_LIST}}", specialists).replace( - "{{SUPERVISOR_BASE_INJECTION}}", - injected, - ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md deleted file mode 100644 index 632c888c9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/supervisor/supervisor_prompt.md +++ /dev/null @@ -1,67 +0,0 @@ -{{SUPERVISOR_BASE_INJECTION}} - - -In this **multi-agent** session you also **coordinate specialists** (listed below): call a specialist only when their domain matches the need; give each call a compact, outcome-focused task; merge structured results into one clear user-facing reply. When you can satisfy the turn with your own tools and reasoning, do so without delegating. - - - -Use only the specialists listed below. -{{AVAILABLE_SPECIALISTS_LIST}} - - - -1) Delegate when the request clearly belongs to a specialist's capabilities. -2) Answer directly when no expert tool is needed. -3) For multi-domain work, decompose into sequential expert calls (or parallel only when independent). -4) Do not call a specialist "just in case". Every delegation must have a clear purpose. -5) Specialists are best for **one clear step at a time**—for example “find this,” “show that record,” “make this one change.” Do **not** hand them an entire “analyze everything and write me a trends report” brief in one go. -6) When the user wants **big-picture synthesis**—patterns across lots of items, comparisons across time, or an executive-style overview—**you** split the work: several **small** asks to whoever actually holds that information (each with a clear cap: how many items, how far back, which fields), then **you** combine the answers into one clear reply. If they need a **deliverable**—a real **artifact** others can read, hear, or watch (report, slide-style video, podcast, resume, image)—delegate to the **deliverables** specialist. Do not ask other specialists to replace that: their job is smaller steps (lookups and targeted changes), not producing the final artifact. -7) Each specialist answers in a **single short structured reply** (no extra chatter after it). Ask them only for what that reply can reasonably hold. If the user needs a long narrative or full report, **you** combine steps—or use the **deliverables** specialist—not one overloaded ask. -8) Prefer **a few clear, small asks** over one huge vague ask that invites guessing, cut-off answers, or broken replies. - - - -When delegating to a specialist, pass a compact but complete task that includes: -- the **outcome** they should produce, in **your own words** as clear instructions (do **not** paste or forward the user’s message verbatim), -- concrete limits (dates, names, “last N items,” which details matter), -- how you will judge success, -- any identifiers or links the user already gave. - -When asking for lists or searches, always say **how many** items at most and **which details** you need back. - -Never pass implementation chatter. Pass only actionable instructions. -Each delegation should sound like **one clear action** (or two that belong together), not a full project brief—unless you are intentionally speaking to **research** or to **deliverables** for a **deliverable artifact** (report, slide-style video, podcast, resume, image). - - - -Every specialist returns **one structured reply** in a fixed layout. Treat it like a small form, not prose. It includes: -- **outcome**: succeeded, partly done, blocked, or failed -- **short summary** of what they did -- **proof**: what they actually saw or changed (when relevant) -- **what to do next** if they are not done -- **what you must ask the user** if something was missing -- **what they assumed** if they had to fill a gap - -How to use it: -1) **Succeeded**: only treat it as done if the **proof** backs it up. -2) **Partly done**: use what they proved, then follow their **what to do next**. -3) **Blocked**: do not blindly retry; ask the user only what they said was missing (or pick from options they listed). -4) **Failed**: do not pretend it worked; either retry with a clearer small ask or explain honestly and follow their suggested recovery. -5) If the reply is missing, garbled, or contradicts itself, treat it as failed, do not invent facts, and recover with a safer smaller ask or a question to the user. - - - -Ask a concise clarifying question only when a missing detail blocks execution. -If one reasonable default is safe and obvious, use it and state the assumption. - - - -After expert calls, produce one coherent final answer: -- what was done, -- key results/artifacts, -- unresolved items and the next best step. -- include assumptions only when they affected outcomes. -- when multiple experts are used, merge outputs into one user-facing narrative (do not paste their raw structured reply verbatim). - -Never claim an action succeeded unless their reply includes proof that matches what you claim. - From 972650909c99d7ea3bd37a91e4b7de26a8d38f3a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 21:48:43 +0200 Subject: [PATCH 093/131] Rename package: multi_agent_chat --- .../__init__.py | 0 .../constants.py | 0 .../main_agent/__init__.py | 0 .../main_agent/context_prune/__init__.py | 0 .../context_prune/prune_tool_names.py | 0 .../main_agent/graph/__init__.py | 0 .../main_agent/graph/compile_graph_sync.py | 2 +- .../main_agent/graph/middleware/__init__.py | 0 .../__init__.py | 0 .../config.py | 0 .../constants.py | 0 .../middleware.py | 0 .../propagation.py | 0 .../resume.py | 0 .../task_tool.py | 0 .../graph/middleware/deepagent_stack.py | 4 +- .../main_agent/runtime/__init__.py | 0 .../main_agent/runtime/factory.py | 4 +- .../main_agent/system_prompt/__init__.py | 0 .../system_prompt/builder/__init__.py | 0 .../system_prompt/builder/compose.py | 0 .../system_prompt/builder/load_md.py | 2 +- .../system_prompt/builder/provider_hints.py | 0 .../builder/sections/__init__.py | 0 .../builder/sections/citations.py | 0 .../builder/sections/provider.py | 0 .../builder/sections/registry_subagents.py | 0 .../builder/sections/system_instruction.py | 0 .../system_prompt/builder/sections/tools.py | 0 .../builder/tool_instruction_block.py | 0 .../system_prompt/markdown/__init__.py | 0 .../system_prompt/markdown/agent_private.md | 0 .../system_prompt/markdown/agent_team.md | 0 .../system_prompt/markdown/citations_off.md | 0 .../system_prompt/markdown/citations_on.md | 0 .../markdown/examples/__init__.py | 0 .../markdown/examples/scrape_webpage.md | 0 .../examples/search_surfsense_docs.md | 0 .../examples/update_memory_private.md | 0 .../markdown/examples/update_memory_team.md | 0 .../markdown/examples/web_search.md | 0 .../markdown/kb_only_policy_private.md | 0 .../markdown/kb_only_policy_team.md | 0 .../markdown/main_agent_tool_routing.md | 0 .../markdown/memory_protocol_private.md | 0 .../markdown/memory_protocol_team.md | 0 .../markdown/parameter_resolution.md | 0 .../markdown/providers/__init__.py | 0 .../markdown/providers/anthropic.md | 0 .../markdown/providers/deepseek.md | 0 .../markdown/providers/default.md | 0 .../markdown/providers/google.md | 0 .../system_prompt/markdown/providers/grok.md | 0 .../system_prompt/markdown/providers/kimi.md | 0 .../markdown/providers/openai_classic.md | 0 .../markdown/providers/openai_codex.md | 0 .../markdown/providers/openai_reasoning.md | 0 .../system_prompt/markdown/tools/__init__.py | 0 .../system_prompt/markdown/tools/_preamble.md | 0 .../markdown/tools/scrape_webpage.md | 0 .../markdown/tools/search_surfsense_docs.md | 0 .../markdown/tools/update_memory_private.md | 0 .../markdown/tools/update_memory_team.md | 0 .../markdown/tools/web_search.md | 0 .../main_agent/tools/__init__.py | 0 .../main_agent/tools/index.py | 0 .../subagents/__init__.py | 0 .../subagents/builtins/__init__.py | 0 .../builtins/deliverables/__init__.py | 0 .../subagents/builtins/deliverables/agent.py | 6 +-- .../builtins/deliverables/description.md | 0 .../builtins/deliverables/system_prompt.md | 0 .../builtins/deliverables/tools/__init__.py | 0 .../deliverables/tools/generate_image.py | 0 .../builtins/deliverables/tools/index.py | 2 +- .../builtins/deliverables/tools/podcast.py | 0 .../builtins/deliverables/tools/report.py | 0 .../builtins/deliverables/tools/resume.py | 0 .../deliverables/tools/video_presentation.py | 0 .../subagents/builtins/memory/__init__.py | 0 .../subagents/builtins/memory/agent.py | 6 +-- .../subagents/builtins/memory/description.md | 0 .../builtins/memory/system_prompt.md | 0 .../builtins/memory/tools/__init__.py | 0 .../subagents/builtins/memory/tools/index.py | 2 +- .../builtins/memory/tools/update_memory.py | 0 .../subagents/builtins/research/__init__.py | 0 .../subagents/builtins/research/agent.py | 6 +-- .../builtins/research/description.md | 0 .../builtins/research/system_prompt.md | 0 .../builtins/research/tools/__init__.py | 0 .../builtins/research/tools/index.py | 2 +- .../builtins/research/tools/scrape_webpage.py | 0 .../research/tools/search_surfsense_docs.py | 0 .../builtins/research/tools/web_search.py | 0 .../subagents/connectors/__init__.py | 0 .../subagents/connectors/airtable/__init__.py | 0 .../subagents/connectors/airtable/agent.py | 6 +-- .../connectors/airtable/description.md | 0 .../connectors/airtable/system_prompt.md | 0 .../connectors/airtable/tools/__init__.py | 0 .../connectors/airtable}/tools/index.py | 2 +- .../subagents/connectors/calendar/__init__.py | 0 .../subagents/connectors/calendar/agent.py | 6 +-- .../connectors/calendar/description.md | 0 .../connectors/calendar/system_prompt.md | 0 .../connectors/calendar/tools/__init__.py | 0 .../connectors/calendar/tools/create_event.py | 0 .../connectors/calendar/tools/delete_event.py | 0 .../connectors/calendar/tools/index.py | 2 +- .../calendar/tools/search_events.py | 0 .../connectors/calendar/tools/update_event.py | 0 .../subagents/connectors/clickup/__init__.py | 0 .../subagents/connectors/clickup/agent.py | 6 +-- .../connectors/clickup/description.md | 0 .../connectors/clickup/system_prompt.md | 0 .../connectors/clickup/tools/__init__.py | 0 .../connectors/clickup}/tools/index.py | 2 +- .../connectors/confluence/__init__.py | 0 .../subagents/connectors/confluence/agent.py | 6 +-- .../connectors/confluence/description.md | 0 .../connectors/confluence/system_prompt.md | 0 .../connectors/confluence/tools/__init__.py | 0 .../confluence/tools/create_page.py | 0 .../confluence/tools/delete_page.py | 0 .../connectors/confluence/tools/index.py | 2 +- .../confluence/tools/update_page.py | 0 .../subagents/connectors/discord/__init__.py | 0 .../subagents/connectors/discord/agent.py | 6 +-- .../connectors/discord/description.md | 0 .../connectors/discord/system_prompt.md | 0 .../connectors/discord/tools/__init__.py | 0 .../connectors/discord/tools/_auth.py | 0 .../connectors/discord/tools/index.py | 2 +- .../connectors/discord/tools/list_channels.py | 0 .../connectors/discord/tools/read_messages.py | 0 .../connectors/discord/tools/send_message.py | 0 .../subagents/connectors/dropbox/__init__.py | 0 .../subagents/connectors/dropbox/agent.py | 6 +-- .../connectors/dropbox/description.md | 0 .../connectors/dropbox/system_prompt.md | 0 .../connectors/dropbox/tools/__init__.py | 0 .../connectors/dropbox/tools/create_file.py | 0 .../connectors/dropbox/tools/index.py | 2 +- .../connectors/dropbox/tools/trash_file.py | 0 .../subagents/connectors/gmail/__init__.py | 0 .../subagents/connectors/gmail/agent.py | 6 +-- .../subagents/connectors/gmail/description.md | 0 .../connectors/gmail/system_prompt.md | 0 .../connectors/gmail/tools/__init__.py | 0 .../connectors/gmail/tools/create_draft.py | 0 .../subagents/connectors/gmail/tools/index.py | 2 +- .../connectors/gmail/tools/read_email.py | 0 .../connectors/gmail/tools/search_emails.py | 0 .../connectors/gmail/tools/send_email.py | 0 .../connectors/gmail/tools/trash_email.py | 0 .../connectors/gmail/tools/update_draft.py | 0 .../connectors/google_drive/__init__.py | 0 .../connectors/google_drive/agent.py | 6 +-- .../connectors/google_drive/description.md | 0 .../connectors/google_drive/system_prompt.md | 0 .../connectors/google_drive/tools/__init__.py | 0 .../google_drive/tools/create_file.py | 0 .../connectors/google_drive/tools/index.py | 2 +- .../google_drive/tools/trash_file.py | 0 .../subagents/connectors/jira/__init__.py | 0 .../subagents/connectors/jira/agent.py | 6 +-- .../subagents/connectors/jira/description.md | 0 .../connectors/jira/system_prompt.md | 0 .../connectors/jira/tools/__init__.py | 0 .../connectors/jira/tools/create_issue.py | 0 .../connectors/jira/tools/delete_issue.py | 0 .../subagents/connectors/jira/tools/index.py | 2 +- .../connectors/jira/tools/update_issue.py | 0 .../subagents/connectors/linear/__init__.py | 0 .../subagents/connectors/linear/agent.py | 6 +-- .../connectors/linear/description.md | 0 .../connectors/linear/system_prompt.md | 0 .../connectors/linear/tools/__init__.py | 0 .../connectors/linear/tools/create_issue.py | 0 .../connectors/linear/tools/delete_issue.py | 0 .../connectors/linear/tools/index.py | 2 +- .../connectors/linear/tools/update_issue.py | 0 .../subagents/connectors/luma/__init__.py | 0 .../subagents/connectors/luma/agent.py | 6 +-- .../subagents/connectors/luma/description.md | 0 .../connectors/luma/system_prompt.md | 0 .../connectors/luma/tools/__init__.py | 0 .../subagents/connectors/luma/tools/_auth.py | 0 .../connectors/luma/tools/create_event.py | 0 .../subagents/connectors/luma/tools/index.py | 2 +- .../connectors/luma/tools/list_events.py | 0 .../connectors/luma/tools/read_event.py | 0 .../subagents/connectors/notion/__init__.py | 0 .../subagents/connectors/notion/agent.py | 6 +-- .../connectors/notion/description.md | 0 .../connectors/notion/system_prompt.md | 0 .../connectors/notion/tools/__init__.py | 0 .../connectors/notion/tools/create_page.py | 0 .../connectors/notion/tools/delete_page.py | 0 .../connectors/notion/tools/index.py | 2 +- .../connectors/notion/tools/update_page.py | 0 .../subagents/connectors/onedrive/__init__.py | 0 .../subagents/connectors/onedrive/agent.py | 6 +-- .../connectors/onedrive/description.md | 0 .../connectors/onedrive/system_prompt.md | 0 .../connectors/onedrive/tools/__init__.py | 0 .../connectors/onedrive/tools/create_file.py | 0 .../connectors/onedrive/tools/index.py | 2 +- .../connectors/onedrive/tools/trash_file.py | 0 .../subagents/connectors/slack/__init__.py | 0 .../subagents/connectors/slack/agent.py | 6 +-- .../subagents/connectors/slack/description.md | 0 .../connectors/slack/system_prompt.md | 0 .../connectors/slack/tools/__init__.py | 0 .../connectors/slack}/tools/index.py | 2 +- .../subagents/connectors/teams/__init__.py | 0 .../subagents/connectors/teams/agent.py | 6 +-- .../subagents/connectors/teams/description.md | 0 .../connectors/teams/system_prompt.md | 0 .../connectors/teams/tools/__init__.py | 0 .../subagents/connectors/teams/tools/_auth.py | 0 .../subagents/connectors/teams/tools/index.py | 2 +- .../connectors/teams/tools/list_channels.py | 0 .../connectors/teams/tools/read_messages.py | 0 .../connectors/teams/tools/send_message.py | 0 .../subagents/mcp_tools/__init__.py | 2 +- .../subagents/mcp_tools/index.py | 6 +-- .../mcp_tools/permissions/__init__.py | 2 +- .../mcp_tools/permissions/airtable.py | 2 +- .../mcp_tools/permissions/clickup.py | 2 +- .../subagents/mcp_tools/permissions/index.py | 2 +- .../subagents/mcp_tools/permissions/jira.py | 2 +- .../subagents/mcp_tools/permissions/linear.py | 2 +- .../subagents/mcp_tools/permissions/slack.py | 2 +- .../subagents/registry.py | 42 +++++++++---------- .../subagents/shared/__init__.py | 6 +-- .../subagents/shared/md_file_reader.py | 0 .../subagents/shared/permissions.py | 0 .../subagents/shared/subagent_builder.py | 0 .../app/tasks/chat/stream_new_chat.py | 2 +- 241 files changed, 114 insertions(+), 114 deletions(-) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/constants.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/context_prune/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/context_prune/prune_tool_names.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/compile_graph_sync.py (97%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/graph/middleware/deepagent_stack.py (99%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/runtime/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/runtime/factory.py (98%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/compose.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/load_md.py (84%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/provider_hints.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/sections/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/sections/citations.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/sections/provider.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/sections/registry_subagents.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/sections/system_instruction.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/sections/tools.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/builder/tool_instruction_block.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/agent_private.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/agent_team.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/citations_off.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/citations_on.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/examples/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/examples/scrape_webpage.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/examples/update_memory_private.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/examples/update_memory_team.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/examples/web_search.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/kb_only_policy_private.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/kb_only_policy_team.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/main_agent_tool_routing.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/memory_protocol_private.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/memory_protocol_team.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/parameter_resolution.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/anthropic.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/deepseek.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/default.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/google.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/grok.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/kimi.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/openai_classic.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/openai_codex.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/providers/openai_reasoning.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/tools/_preamble.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/tools/scrape_webpage.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/tools/update_memory_private.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/tools/update_memory_team.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/system_prompt/markdown/tools/web_search.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/main_agent/tools/index.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/tools/generate_image.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/tools/index.py (96%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/tools/podcast.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/tools/report.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/tools/resume.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/deliverables/tools/video_presentation.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/memory/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/memory/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/memory/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/memory/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/memory/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/memory/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/memory/tools/update_memory.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/tools/scrape_webpage.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/tools/search_surfsense_docs.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/builtins/research/tools/web_search.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/airtable/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/airtable/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/airtable/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/airtable/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/airtable/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents/subagents/connectors/clickup => multi_agent_chat/subagents/connectors/airtable}/tools/index.py (76%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/tools/create_event.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/tools/delete_event.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/tools/index.py (94%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/tools/search_events.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/calendar/tools/update_event.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/clickup/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/clickup/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/clickup/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/clickup/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/clickup/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents/subagents/connectors/slack => multi_agent_chat/subagents/connectors/clickup}/tools/index.py (76%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/tools/create_page.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/tools/delete_page.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/tools/index.py (93%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/confluence/tools/update_page.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/tools/_auth.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/tools/list_channels.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/tools/read_messages.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/discord/tools/send_message.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/tools/create_file.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/tools/index.py (90%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/dropbox/tools/trash_file.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/create_draft.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/index.py (94%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/read_email.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/search_emails.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/send_email.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/trash_email.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/gmail/tools/update_draft.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/tools/create_file.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/tools/index.py (90%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/google_drive/tools/trash_file.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/tools/create_issue.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/tools/delete_issue.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/jira/tools/update_issue.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/tools/create_issue.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/tools/delete_issue.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/linear/tools/update_issue.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/tools/_auth.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/tools/create_event.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/tools/list_events.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/luma/tools/read_event.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/tools/create_page.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/tools/delete_page.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/notion/tools/update_page.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/tools/create_file.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/tools/index.py (90%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/onedrive/tools/trash_file.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/slack/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/slack/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/slack/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/slack/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/slack/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents/subagents/connectors/airtable => multi_agent_chat/subagents/connectors/slack}/tools/index.py (76%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/agent.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/description.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/system_prompt.md (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/tools/__init__.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/tools/_auth.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/tools/index.py (92%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/tools/list_channels.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/tools/read_messages.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/connectors/teams/tools/send_message.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/__init__.py (84%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/index.py (95%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/permissions/__init__.py (88%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/permissions/airtable.py (79%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/permissions/clickup.py (77%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/permissions/index.py (71%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/permissions/jira.py (85%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/permissions/linear.py (88%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/mcp_tools/permissions/slack.py (79%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/registry.py (75%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/shared/__init__.py (60%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/shared/md_file_reader.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/shared/permissions.py (100%) rename surfsense_backend/app/agents/{multi_agent_with_deepagents => multi_agent_chat}/subagents/shared/subagent_builder.py (100%) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py b/surfsense_backend/app/agents/multi_agent_chat/constants.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/constants.py rename to surfsense_backend/app/agents/multi_agent_chat/constants.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/prune_tool_names.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/context_prune/prune_tool_names.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/context_prune/prune_tool_names.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py similarity index 97% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py index 89d950c54..d3acba175 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/compile_graph_sync.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/compile_graph_sync.py @@ -12,7 +12,7 @@ from langchain_core.tools import BaseTool from langgraph.types import Checkpointer from .middleware import build_main_agent_deepagent_middleware -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) from app.agents.new_chat.context import SurfSenseContextSchema diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py similarity index 99% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py index 57fa3b34a..984556b1e 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/graph/middleware/deepagent_stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py @@ -24,11 +24,11 @@ from langchain_core.tools import BaseTool from langgraph.types import Checkpointer from ...context_prune.prune_tool_names import safe_exclude_tools -from app.agents.multi_agent_with_deepagents.subagents import ( +from app.agents.multi_agent_chat.subagents import ( build_subagents, get_subagents_to_exclude, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) from app.agents.new_chat.feature_flags import AgentFeatureFlags diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py similarity index 98% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py index 13d570832..6dd3eb721 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/runtime/factory.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py @@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..graph.compile_graph_sync import build_compiled_agent_graph_sync from ..tools import MAIN_AGENT_SURFSENSE_TOOL_NAMES, MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED -from app.agents.multi_agent_with_deepagents.subagents.mcp_tools.index import ( +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 @@ -24,7 +24,7 @@ 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.multi_agent_with_deepagents.subagents import ( +from app.agents.multi_agent_chat.subagents import ( get_subagents_to_exclude, main_prompt_registry_subagent_lines, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/compose.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/compose.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/load_md.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py similarity index 84% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/load_md.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py index 3aeb89e9d..f29e7f9ef 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/load_md.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/load_md.py @@ -4,7 +4,7 @@ from __future__ import annotations from importlib import resources -_PROMPTS_PACKAGE = "app.agents.multi_agent_with_deepagents.main_agent.system_prompt.markdown" +_PROMPTS_PACKAGE = "app.agents.multi_agent_chat.main_agent.system_prompt.markdown" def read_prompt_md(filename: str) -> str: diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/provider_hints.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/provider_hints.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/provider_hints.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/citations.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/citations.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/citations.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/provider.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/provider.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/provider.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/registry_subagents.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/registry_subagents.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/system_instruction.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/system_instruction.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/tools.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/sections/tools.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/sections/tools.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/builder/tool_instruction_block.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/builder/tool_instruction_block.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_private.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_private.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_private.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_private.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_team.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_team.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/agent_team.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/agent_team.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_off.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_off.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_off.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_off.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_on.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_on.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/citations_on.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/citations_on.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/scrape_webpage.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/scrape_webpage.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/search_surfsense_docs.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_private.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_private.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/update_memory_team.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/update_memory_team.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/examples/web_search.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/examples/web_search.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_private.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_private.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/kb_only_policy_team.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/kb_only_policy_team.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/main_agent_tool_routing.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/main_agent_tool_routing.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_private.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_private.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/memory_protocol_team.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/memory_protocol_team.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/parameter_resolution.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/parameter_resolution.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/parameter_resolution.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/parameter_resolution.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/anthropic.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/anthropic.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/anthropic.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/anthropic.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/deepseek.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/deepseek.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/deepseek.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/deepseek.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/default.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/default.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/default.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/default.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/google.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/google.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/google.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/google.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/grok.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/grok.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/grok.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/grok.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/kimi.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/kimi.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/kimi.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/kimi.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_classic.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_classic.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_codex.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_codex.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/providers/openai_reasoning.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/providers/openai_reasoning.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/_preamble.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/_preamble.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/_preamble.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/_preamble.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/scrape_webpage.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/scrape_webpage.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/search_surfsense_docs.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_private.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_private.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/update_memory_team.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/update_memory_team.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/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 similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/system_prompt/markdown/tools/web_search.md rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/markdown/tools/web_search.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/main_agent/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/index.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py index 775624a6f..0f7070645 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/generate_image.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py similarity index 96% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py index d640837b5..c2ebc2029 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/report.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/resume.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/video_presentation.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py index f9ee96938..0afe207ce 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py index 71d66d15f..4ff02856f 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) from app.db import ChatVisibility diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/memory/tools/update_memory.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/update_memory.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py index 8211686e4..1b7998153 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py index a616ac2dc..350dab563 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/scrape_webpage.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/scrape_webpage.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/scrape_webpage.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/search_surfsense_docs.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/search_surfsense_docs.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/web_search.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/research/tools/web_search.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/web_search.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py index 583df0078..7b78f4565 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py similarity index 76% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py index 639cea3a9..9bbfdccb9 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py index 378f2095a..42ccba213 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/create_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/create_event.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/create_event.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/delete_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/delete_event.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/delete_event.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py similarity index 94% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py index 99d769ddc..57d8e277d 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/search_events.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/search_events.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/search_events.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/update_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/calendar/tools/update_event.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/update_event.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py index a4a193332..057351c77 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/clickup/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py similarity index 76% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py index 639cea3a9..9bbfdccb9 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py index 11c4fc0e8..3b021ee70 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/create_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/create_page.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/create_page.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/delete_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/delete_page.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/delete_page.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py similarity index 93% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py index eb76eb17d..561ea44ab 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/update_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/confluence/tools/update_page.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/update_page.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py index 9e1eb964a..feacecd78 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/_auth.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/_auth.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/_auth.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py index 66d13e7a6..04db7cda6 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/list_channels.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/list_channels.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/list_channels.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/read_messages.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/read_messages.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/read_messages.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/send_message.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/send_message.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/discord/tools/send_message.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/send_message.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py index 60a01c20a..9ff9bc1f3 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/create_file.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/create_file.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py similarity index 90% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py index ba2c31f9a..a25755a8d 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/dropbox/tools/trash_file.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/trash_file.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py index 6910030c4..5edf37b85 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/create_draft.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/create_draft.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/create_draft.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py similarity index 94% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py index d382aaf7d..c355536e8 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/read_email.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/read_email.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/read_email.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/search_emails.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/search_emails.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/search_emails.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/send_email.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/send_email.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/send_email.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/trash_email.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/trash_email.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/trash_email.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/update_draft.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/gmail/tools/update_draft.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/update_draft.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py index e8601ab54..4b4269e2b 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/create_file.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/create_file.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py similarity index 90% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py index 074cba74c..85f414d14 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/google_drive/tools/trash_file.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/trash_file.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py index a21662eb5..b381c6bcf 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/create_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/create_issue.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/create_issue.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/create_issue.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/delete_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/delete_issue.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/delete_issue.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/delete_issue.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py index c08909fcf..9d32c320a 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/update_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/update_issue.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/jira/tools/update_issue.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/update_issue.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py index 82fdb245f..4c3d1d3a5 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/create_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/create_issue.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/create_issue.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/delete_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/delete_issue.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/delete_issue.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py index ef668ffb1..dc147055e 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/update_issue.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/update_issue.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/linear/tools/update_issue.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/update_issue.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py index 81a95270b..343874c33 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/_auth.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/_auth.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/_auth.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/create_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/create_event.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/create_event.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/create_event.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py index 2be92a227..053a905a3 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/list_events.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/list_events.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/list_events.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/list_events.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/read_event.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/read_event.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/luma/tools/read_event.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/read_event.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py index 9ff3105b4..8c8a80ab5 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/create_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/create_page.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/create_page.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/create_page.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/delete_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/delete_page.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/delete_page.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py index dd1db9031..0323781e5 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/update_page.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/update_page.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/notion/tools/update_page.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/update_page.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py index edf90c1ea..551388d34 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/create_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/create_file.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/create_file.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py similarity index 90% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py index 90be31309..5f40ba704 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/trash_file.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/onedrive/tools/trash_file.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/trash_file.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py index aa8198827..b72f82dab 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/slack/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py similarity index 76% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py index 639cea3a9..9bbfdccb9 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/airtable/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py index b8fc5d1ba..aa6f34935 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py @@ -8,15 +8,15 @@ from typing import Any from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, merge_tools_permissions, middleware_gated_interrupt_on, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/description.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/description.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/description.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/description.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/system_prompt.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/system_prompt.md similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/system_prompt.md rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/system_prompt.md diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/_auth.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/_auth.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/_auth.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/_auth.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py similarity index 92% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py index d9058908d..4bc481307 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/list_channels.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/list_channels.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/list_channels.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/read_messages.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/read_messages.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/read_messages.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/send_message.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/send_message.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/connectors/teams/tools/send_message.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/send_message.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/__init__.py similarity index 84% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/__init__.py index 33e19c827..c8714cd04 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.mcp_tools.permissions import ( +from app.agents.multi_agent_chat.subagents.mcp_tools.permissions import ( TOOLS_PERMISSIONS_BY_AGENT, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/index.py similarity index 95% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/index.py index 1e635cae5..68c6ce995 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/index.py @@ -12,13 +12,13 @@ from sqlalchemy import cast, select from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession -from app.agents.multi_agent_with_deepagents.constants import ( +from app.agents.multi_agent_chat.constants import ( CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS, ) -from app.agents.multi_agent_with_deepagents.subagents.mcp_tools.permissions import ( +from app.agents.multi_agent_chat.subagents.mcp_tools.permissions import ( TOOLS_PERMISSIONS_BY_AGENT, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolPermissionItem, ToolsPermissions, mcp_tool_permission_row, diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/__init__.py similarity index 88% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/__init__.py index 2d0341fb7..f24dedcf2 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/airtable.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/airtable.py similarity index 79% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/airtable.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/airtable.py index ec252a6ae..d2d426ef2 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/airtable.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/airtable.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/clickup.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/clickup.py similarity index 77% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/clickup.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/clickup.py index 2eb00eec9..9ddec5fe8 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/clickup.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/clickup.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/index.py similarity index 71% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/index.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/index.py index e8340abe5..10781c9d9 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/index.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolPermissionItem, ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/jira.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/jira.py similarity index 85% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/jira.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/jira.py index 93b57823e..5a67c9dc1 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/jira.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/jira.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/linear.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/linear.py similarity index 88% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/linear.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/linear.py index 25c37494a..18fd827dc 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/linear.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/linear.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/slack.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/slack.py similarity index 79% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/slack.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/slack.py index bd5454523..f9c9d3635 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/mcp_tools/permissions/slack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/mcp_tools/permissions/slack.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py similarity index 75% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py index dde98018e..51906858a 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/registry.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py @@ -8,67 +8,67 @@ from typing import Any, Protocol from deepagents import SubAgent from langchain_core.language_models import BaseChatModel -from app.agents.multi_agent_with_deepagents.subagents.builtins.deliverables.agent import ( +from app.agents.multi_agent_chat.subagents.builtins.deliverables.agent import ( build_subagent as build_deliverables_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.builtins.memory.agent import ( +from app.agents.multi_agent_chat.subagents.builtins.memory.agent import ( build_subagent as build_memory_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.builtins.research.agent import ( +from app.agents.multi_agent_chat.subagents.builtins.research.agent import ( build_subagent as build_research_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.airtable.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.airtable.agent import ( build_subagent as build_airtable_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.calendar.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.calendar.agent import ( build_subagent as build_calendar_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.clickup.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.clickup.agent import ( build_subagent as build_clickup_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.confluence.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.confluence.agent import ( build_subagent as build_confluence_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.discord.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.discord.agent import ( build_subagent as build_discord_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.dropbox.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.dropbox.agent import ( build_subagent as build_dropbox_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.gmail.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.gmail.agent import ( build_subagent as build_gmail_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.google_drive.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.google_drive.agent import ( build_subagent as build_google_drive_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.jira.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.jira.agent import ( build_subagent as build_jira_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.linear.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.linear.agent import ( build_subagent as build_linear_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.luma.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.luma.agent import ( build_subagent as build_luma_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.notion.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.notion.agent import ( build_subagent as build_notion_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.onedrive.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.onedrive.agent import ( build_subagent as build_onedrive_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.slack.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.slack.agent import ( build_subagent as build_slack_subagent, ) -from app.agents.multi_agent_with_deepagents.subagents.connectors.teams.agent import ( +from app.agents.multi_agent_chat.subagents.connectors.teams.agent import ( build_subagent as build_teams_subagent, ) -from app.agents.multi_agent_with_deepagents.constants import ( +from app.agents.multi_agent_chat.constants import ( SUBAGENT_TO_REQUIRED_CONNECTOR_MAP, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/__init__.py similarity index 60% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/shared/__init__.py index 0dbccf126..12443da88 100644 --- a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/__init__.py @@ -2,16 +2,16 @@ from __future__ import annotations -from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( +from app.agents.multi_agent_chat.subagents.shared.md_file_reader import ( read_md_file, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( +from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolPermissionItem, ToolsPermissions, merge_tools_permissions, tool_permission_row, ) -from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/md_file_reader.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/md_file_reader.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/md_file_reader.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/shared/md_file_reader.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/permissions.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/permissions.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/shared/permissions.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/subagent_builder.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/shared/subagent_builder.py rename to surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 03a039054..7d359b7ab 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -28,7 +28,7 @@ from sqlalchemy import func from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from app.agents.multi_agent_with_deepagents import ( +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 0af2c28a8d6ea46285099b2bfdcf7cee37c559a5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 4 May 2026 23:58:53 +0200 Subject: [PATCH 094/131] Stabilize HITL bundle UX and resume. --- .../resume.py | 7 +- .../task_tool.py | 14 - .../markdown/main_agent_tool_routing.md | 11 + .../app/tasks/chat/stream_new_chat.py | 1 - .../new-chat/[[...chat_id]]/page.tsx | 330 +++++++++++------- .../assistant-ui/assistant-message.tsx | 89 ++--- .../components/hitl-bundle-pager/index.ts | 2 + .../hitl-bundle-pager/pager-chrome.tsx | 61 ++++ .../hitl-bundle-pager/with-bundle-step.tsx | 37 ++ surfsense_web/lib/hitl/bundle-context.tsx | 153 ++++++++ surfsense_web/lib/hitl/index.ts | 8 + surfsense_web/lib/hitl/use-hitl-decision.ts | 24 +- 12 files changed, 553 insertions(+), 184 deletions(-) create mode 100644 surfsense_web/components/hitl-bundle-pager/index.ts create mode 100644 surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx create mode 100644 surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx create mode 100644 surfsense_web/lib/hitl/bundle-context.tsx 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 index d09eec6af..0bb477b6b 100644 --- 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 @@ -22,7 +22,12 @@ def hitlrequest_action_count(pending_value: Any) -> int: def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any: - """Pad a single-decision resume to N entries so an ``action_requests=N`` bundle accepts it.""" + """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): 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 index e7458dde9..2458f72c4 100644 --- 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 @@ -139,13 +139,6 @@ def build_task_tool_with_parent_config( if resume_value is not None: expected = hitlrequest_action_count(pending_value) resume_value = fan_out_decisions_to_match(resume_value, expected) - logger.info( - "Forwarding surfsense_resume_value into subagent %r " - "(action_requests=%d, targeted_id=%s)", - subagent_type, - expected, - pending_id is not None, - ) result = subagent.invoke( build_resume_command(resume_value, pending_id), config=sub_config, @@ -211,13 +204,6 @@ def build_task_tool_with_parent_config( if resume_value is not None: expected = hitlrequest_action_count(pending_value) resume_value = fan_out_decisions_to_match(resume_value, expected) - logger.info( - "Forwarding surfsense_resume_value into subagent %r " - "(action_requests=%d, targeted_id=%s)", - subagent_type, - expected, - pending_id is not None, - ) result = await subagent.ainvoke( build_resume_command(resume_value, pending_id), config=sub_config, 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 index d31e24ce9..e91075c35 100644 --- 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 @@ -13,4 +13,15 @@ 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/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 7d359b7ab..8288fb75a 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -2758,7 +2758,6 @@ async def stream_resume_chat( fs_mode, fs_platform, ) - from app.services.token_tracking_service import start_turn accumulator = start_turn() 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 21fc4cf1a..533aefa89 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 @@ -97,6 +97,7 @@ import { type NewChatUserImagePayload, } from "@/lib/chat/user-turn-api-parts"; import { NotFoundError } from "@/lib/error"; +import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl"; import { trackChatCreated, trackChatError, @@ -147,28 +148,59 @@ function markInterruptsCompleted(contentParts: Array<{ type: string; result?: un } /** - * Most recent pending tool-call card with this name, so a new HITL interrupt - * does not overwrite an already-approved card with the same tool name. + * 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 findHitlTargetToolCallId( +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; }>, - toolName: string -): string | null { - const entries = Array.from(toolCallIndices.entries()); - for (let i = entries.length - 1; i >= 0; i--) { - const [tcId, idx] = entries[i]; - const part = contentParts[idx]; - if (!part || part.type !== "tool-call" || part.toolName !== toolName) continue; - const result = part.result as Record | undefined | null; - if (result == null) return tcId; - if (result.__interrupt__ === true && !result.__decided__) return tcId; + 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 null; + return paired; } /** @@ -249,6 +281,7 @@ export default function NewChatPage() { threadId: number; assistantMsgId: string; interruptData: Record; + bundleToolCallIds: string[]; } | null>(null); const toolsWithUI = TOOLS_WITH_UI_ALL; @@ -973,23 +1006,26 @@ export default function NewChatPage() { name: string; args: Record; }>; - for (const action of actionRequests) { - const targetTcId = findHitlTargetToolCallId( - toolCallIndices, - contentParts, - action.name - ); - if (targetTcId) { - updateToolCall(contentPartsState, targetTcId, { - result: { __interrupt__: true, ...interruptData }, - }); - } else { - const tcId = `interrupt-${action.name}`; - addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true); - updateToolCall(contentPartsState, tcId, { - result: { __interrupt__: true, ...interruptData }, - }); + const paired = pairBundleToolCallIds(toolCallIndices, 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(toolCallIndices, action.name, i); + addToolCall( + contentPartsState, + toolsWithUI, + targetTcId, + action.name, + action.args, + true + ); } + updateToolCall(contentPartsState, targetTcId, { + result: { __interrupt__: true, ...interruptData }, + }); + bundleToolCallIds.push(targetTcId); } setMessages((prev) => prev.map((m) => @@ -1003,6 +1039,7 @@ export default function NewChatPage() { threadId: currentThreadId, assistantMsgId, interruptData, + bundleToolCallIds, }); } break; @@ -1414,29 +1451,26 @@ export default function NewChatPage() { name: string; args: Record; }>; - for (const action of actionRequests) { - const targetTcId = findHitlTargetToolCallId( - toolCallIndices, - contentParts, - action.name - ); - if (targetTcId) { - updateToolCall(contentPartsState, targetTcId, { - result: { - __interrupt__: true, - ...interruptData, - }, - }); - } else { - const tcId = `interrupt-${action.name}`; - addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true); - updateToolCall(contentPartsState, tcId, { - result: { - __interrupt__: true, - ...interruptData, - }, - }); + const paired = pairBundleToolCallIds(toolCallIndices, 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(toolCallIndices, action.name, i); + addToolCall( + contentPartsState, + toolsWithUI, + targetTcId, + action.name, + action.args, + true + ); } + updateToolCall(contentPartsState, targetTcId, { + result: { __interrupt__: true, ...interruptData }, + }); + bundleToolCallIds.push(targetTcId); } setMessages((prev) => prev.map((m) => @@ -1449,6 +1483,7 @@ export default function NewChatPage() { threadId: resumeThreadId, assistantMsgId, interruptData, + bundleToolCallIds, }); break; } @@ -1538,76 +1573,116 @@ export default function NewChatPage() { edited_action?: { name: string; args: Record }; }>; }; - if (detail?.decisions && pendingInterrupt) { - const decision = detail.decisions[0]; - const decisionType = decision?.type as "approve" | "reject" | "edit"; + if (!detail?.decisions || !pendingInterrupt) return; + const incoming = detail.decisions; + if (incoming.length === 0) return; + const tcIds = pendingInterrupt.bundleToolCallIds; + const N = tcIds.length; - // Fan a single click out to N decisions when the backend bundled - // N tool calls into one HITLRequest (one Approve/Reject covers - // the whole batch until per-card decisions land). - const interruptData = pendingInterrupt.interruptData as - | { action_requests?: unknown[] } - | undefined; - const expectedCount = Array.isArray(interruptData?.action_requests) - ? interruptData.action_requests.length - : detail.decisions.length; - const submittedDecisions = - detail.decisions.length >= expectedCount || expectedCount <= 1 - ? detail.decisions - : [ - ...detail.decisions, - ...Array.from({ length: expectedCount - detail.decisions.length }, () => ({ - ...detail.decisions[detail.decisions.length - 1], - })), - ]; - - 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"] }; - }) - ); - handleResume(submittedDecisions); + // Build a per-card decision map. Bundle path: one decision per + // action_request in order. Legacy single-click on a multi-card + // interrupt: replay the last decision across the bundle. + const byTcId = new Map(); + if (incoming.length === N) { + for (let i = 0; i < N; i++) byTcId.set(tcIds[i], incoming[i]); + } else { + const fallback = incoming[incoming.length - 1]; + for (const tcId of tcIds) byTcId.set(tcId, fallback); } + 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, @@ -2151,6 +2226,12 @@ export default function NewChatPage() { [handleRegenerate, messages, agentActionItems] ); + const handleBundleSubmit = useCallback((orderedDecisions) => { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } }) + ); + }, []); + const handleEditDialogChoice = useCallback( async (choice: EditMessageDialogChoice) => { const pending = editDialogState; @@ -2220,14 +2301,19 @@ 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 bfe0434b4..048837c89 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -40,6 +40,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; +import { withBundleStep } from "@/components/hitl-bundle-pager"; import type { SerializableCitation } from "@/components/tool-ui/citation"; import { openSafeNavigationHref, @@ -484,6 +485,51 @@ const MessageInfoDropdown: FC = () => { ); }; +// Wrap each tool-ui card with ``withBundleStep`` so multi-card HITL bundles +// page through them and stage decisions instead of firing one resume per card. +const TOOLS_BY_NAME = { + generate_report: withBundleStep(GenerateReportToolUI), + generate_resume: withBundleStep(GenerateResumeToolUI), + generate_podcast: withBundleStep(GeneratePodcastToolUI), + generate_video_presentation: withBundleStep(GenerateVideoPresentationToolUI), + display_image: withBundleStep(GenerateImageToolUI), + generate_image: withBundleStep(GenerateImageToolUI), + update_memory: withBundleStep(UpdateMemoryToolUI), + execute: withBundleStep(SandboxExecuteToolUI), + execute_code: withBundleStep(SandboxExecuteToolUI), + create_notion_page: withBundleStep(CreateNotionPageToolUI), + update_notion_page: withBundleStep(UpdateNotionPageToolUI), + delete_notion_page: withBundleStep(DeleteNotionPageToolUI), + create_linear_issue: withBundleStep(CreateLinearIssueToolUI), + update_linear_issue: withBundleStep(UpdateLinearIssueToolUI), + delete_linear_issue: withBundleStep(DeleteLinearIssueToolUI), + create_google_drive_file: withBundleStep(CreateGoogleDriveFileToolUI), + delete_google_drive_file: withBundleStep(DeleteGoogleDriveFileToolUI), + create_onedrive_file: withBundleStep(CreateOneDriveFileToolUI), + delete_onedrive_file: withBundleStep(DeleteOneDriveFileToolUI), + create_dropbox_file: withBundleStep(CreateDropboxFileToolUI), + delete_dropbox_file: withBundleStep(DeleteDropboxFileToolUI), + create_calendar_event: withBundleStep(CreateCalendarEventToolUI), + update_calendar_event: withBundleStep(UpdateCalendarEventToolUI), + delete_calendar_event: withBundleStep(DeleteCalendarEventToolUI), + create_gmail_draft: withBundleStep(CreateGmailDraftToolUI), + update_gmail_draft: withBundleStep(UpdateGmailDraftToolUI), + send_gmail_email: withBundleStep(SendGmailEmailToolUI), + trash_gmail_email: withBundleStep(TrashGmailEmailToolUI), + create_jira_issue: withBundleStep(CreateJiraIssueToolUI), + update_jira_issue: withBundleStep(UpdateJiraIssueToolUI), + delete_jira_issue: withBundleStep(DeleteJiraIssueToolUI), + create_confluence_page: withBundleStep(CreateConfluencePageToolUI), + update_confluence_page: withBundleStep(UpdateConfluencePageToolUI), + delete_confluence_page: withBundleStep(DeleteConfluencePageToolUI), + web_search: () => null, + link_preview: () => null, + multi_link_preview: () => null, + scrape_webpage: () => null, +} as const; + +const TOOLS_FALLBACK = withBundleStep(ToolFallback); + const AssistantMessageInner: FC = () => { const isMobile = !useMediaQuery("(min-width: 768px)"); @@ -495,47 +541,8 @@ const AssistantMessageInner: FC = () => { Text: MarkdownText, Reasoning: ReasoningMessagePart, tools: { - by_name: { - generate_report: GenerateReportToolUI, - generate_resume: GenerateResumeToolUI, - generate_podcast: GeneratePodcastToolUI, - generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: GenerateImageToolUI, - generate_image: GenerateImageToolUI, - update_memory: UpdateMemoryToolUI, - execute: SandboxExecuteToolUI, - execute_code: SandboxExecuteToolUI, - create_notion_page: CreateNotionPageToolUI, - update_notion_page: UpdateNotionPageToolUI, - delete_notion_page: DeleteNotionPageToolUI, - create_linear_issue: CreateLinearIssueToolUI, - update_linear_issue: UpdateLinearIssueToolUI, - delete_linear_issue: DeleteLinearIssueToolUI, - create_google_drive_file: CreateGoogleDriveFileToolUI, - delete_google_drive_file: DeleteGoogleDriveFileToolUI, - create_onedrive_file: CreateOneDriveFileToolUI, - delete_onedrive_file: DeleteOneDriveFileToolUI, - create_dropbox_file: CreateDropboxFileToolUI, - delete_dropbox_file: DeleteDropboxFileToolUI, - create_calendar_event: CreateCalendarEventToolUI, - update_calendar_event: UpdateCalendarEventToolUI, - delete_calendar_event: DeleteCalendarEventToolUI, - create_gmail_draft: CreateGmailDraftToolUI, - update_gmail_draft: UpdateGmailDraftToolUI, - send_gmail_email: SendGmailEmailToolUI, - trash_gmail_email: TrashGmailEmailToolUI, - create_jira_issue: CreateJiraIssueToolUI, - update_jira_issue: UpdateJiraIssueToolUI, - delete_jira_issue: DeleteJiraIssueToolUI, - create_confluence_page: CreateConfluencePageToolUI, - update_confluence_page: UpdateConfluencePageToolUI, - delete_confluence_page: DeleteConfluencePageToolUI, - web_search: () => null, - link_preview: () => null, - multi_link_preview: () => null, - scrape_webpage: () => null, - }, - Fallback: ToolFallback, + by_name: TOOLS_BY_NAME, + Fallback: TOOLS_FALLBACK, }, }} /> diff --git a/surfsense_web/components/hitl-bundle-pager/index.ts b/surfsense_web/components/hitl-bundle-pager/index.ts new file mode 100644 index 000000000..ce434d224 --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/index.ts @@ -0,0 +1,2 @@ +export { PagerChrome } from "./pager-chrome"; +export { withBundleStep } from "./with-bundle-step"; diff --git a/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx b/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx new file mode 100644 index 000000000..77d75fb6d --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useHitlBundle } from "@/lib/hitl"; + +/** + * Prev/next nav and Submit for the current step of an active HITL bundle. + * Submission is gated on every action_request having a staged decision. + */ +export function PagerChrome() { + const bundle = useHitlBundle(); + if (!bundle) return null; + + const total = bundle.toolCallIds.length; + const step = bundle.currentStep; + const allStaged = bundle.stagedCount === total; + + return ( +
+ + + {step + 1} / {total} + + · + + {bundle.stagedCount} of {total} decided + + +
+ +
+
+ ); +} diff --git a/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx b/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx new file mode 100644 index 000000000..64ac801fb --- /dev/null +++ b/surfsense_web/components/hitl-bundle-pager/with-bundle-step.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import type { ComponentType } from "react"; +import { ToolCallIdProvider, useHitlBundle } from "@/lib/hitl"; +import { PagerChrome } from "./pager-chrome"; + +/** + * Wrap a tool-ui card so that, when a multi-card HITL bundle is active: + * - cards belonging to the bundle but not the current step render ``null``; + * - the current-step card renders normally and is followed by ``PagerChrome``. + * + * Cards stay completely unchanged — the wrapper provides the + * ``ToolCallIdContext`` that ``useHitlDecision`` reads to stage decisions + * against the right ``toolCallId`` instead of firing the global event. + */ +export function withBundleStep

>( + Component: ComponentType

+): ComponentType

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

; +} diff --git a/surfsense_web/lib/hitl/bundle-context.tsx b/surfsense_web/lib/hitl/bundle-context.tsx new file mode 100644 index 000000000..3f52ee4d0 --- /dev/null +++ b/surfsense_web/lib/hitl/bundle-context.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; +import type { HitlDecision } from "./types"; + +export type BundleSubmit = (orderedDecisions: HitlDecision[]) => void; + +export interface HitlBundleAPI { + toolCallIds: readonly string[]; + currentStep: number; + stagedCount: number; + isInBundle: (toolCallId: string) => boolean; + isCurrentStep: (toolCallId: string) => boolean; + getStaged: (toolCallId: string) => HitlDecision | undefined; + stage: (toolCallId: string, decision: HitlDecision) => void; + goToStep: (i: number) => void; + next: () => void; + prev: () => void; + submit: () => void; +} + +const HitlBundleContext = createContext(null); +const ToolCallIdContext = createContext(null); + +export function useHitlBundle(): HitlBundleAPI | null { + return useContext(HitlBundleContext); +} + +export function useToolCallIdContext(): string | null { + return useContext(ToolCallIdContext); +} + +export function ToolCallIdProvider({ + toolCallId, + children, +}: { + toolCallId: string; + children: ReactNode; +}) { + return {children}; +} + +interface HitlBundleProviderProps { + toolCallIds: readonly string[] | null; + onSubmit: BundleSubmit; + children: ReactNode; +} + +/** + * Activates only when ``toolCallIds`` has 2+ entries; single-card interrupts + * keep their direct ``window`` dispatch path so N=1 UX is unchanged. + */ +export function HitlBundleProvider({ toolCallIds, onSubmit, children }: HitlBundleProviderProps) { + const active = toolCallIds !== null && toolCallIds.length >= 2; + const ids = useMemo(() => (active ? [...toolCallIds] : []), [active, toolCallIds]); + const bundleKey = ids.join("|"); + + // Derived-state-from-props: reset staging + step when the bundle changes. + const [prevBundleKey, setPrevBundleKey] = useState(bundleKey); + const [staged, setStaged] = useState>(() => new Map()); + const [currentStep, setCurrentStep] = useState(0); + if (bundleKey !== prevBundleKey) { + setPrevBundleKey(bundleKey); + setStaged(new Map()); + setCurrentStep(0); + } + + const isInBundle = useCallback((tcId: string) => ids.includes(tcId), [ids]); + const isCurrentStep = useCallback( + (tcId: string) => active === true && ids[currentStep] === tcId, + [active, ids, currentStep] + ); + const getStaged = useCallback((tcId: string) => staged.get(tcId), [staged]); + const stage = useCallback( + (tcId: string, decision: HitlDecision) => { + if (!active || !ids.includes(tcId)) return; + setStaged((prev) => { + const next = new Map(prev); + next.set(tcId, decision); + return next; + }); + // Mirror the staged decision onto the card immediately so prev/next + // nav doesn't re-show approve/reject buttons for already-decided cards. + // Submit's ``hitl-decision`` event re-applies these (no-op) and runs + // the actual resume. + window.dispatchEvent( + new CustomEvent("hitl-stage", { detail: { toolCallId: tcId, decision } }) + ); + const idx = ids.indexOf(tcId); + if (idx >= 0 && idx < ids.length - 1) { + setCurrentStep(idx + 1); + } + }, + [active, ids] + ); + const goToStep = useCallback( + (i: number) => { + if (i < 0 || i >= ids.length) return; + setCurrentStep(i); + }, + [ids.length] + ); + const next = useCallback(() => { + setCurrentStep((s) => Math.min(s + 1, Math.max(0, ids.length - 1))); + }, [ids.length]); + const prev = useCallback(() => { + setCurrentStep((s) => Math.max(s - 1, 0)); + }, []); + + const submit = useCallback(() => { + if (!active) return; + if (staged.size !== ids.length) return; + const ordered: HitlDecision[] = []; + for (const tcId of ids) { + const d = staged.get(tcId); + if (!d) return; + ordered.push(d); + } + onSubmit(ordered); + }, [active, ids, staged, onSubmit]); + + const value = useMemo(() => { + if (!active) return null; + return { + toolCallIds: ids, + currentStep, + stagedCount: staged.size, + isInBundle, + isCurrentStep, + getStaged, + stage, + goToStep, + next, + prev, + submit, + }; + }, [ + active, + ids, + currentStep, + staged, + isInBundle, + isCurrentStep, + getStaged, + stage, + goToStep, + next, + prev, + submit, + ]); + + return {children}; +} diff --git a/surfsense_web/lib/hitl/index.ts b/surfsense_web/lib/hitl/index.ts index decf5980d..4bb15e8b5 100644 --- a/surfsense_web/lib/hitl/index.ts +++ b/surfsense_web/lib/hitl/index.ts @@ -1,3 +1,11 @@ +export { + type BundleSubmit, + type HitlBundleAPI, + HitlBundleProvider, + ToolCallIdProvider, + useHitlBundle, + useToolCallIdContext, +} from "./bundle-context"; export type { HitlDecision, InterruptActionRequest, diff --git a/surfsense_web/lib/hitl/use-hitl-decision.ts b/surfsense_web/lib/hitl/use-hitl-decision.ts index 439f35f21..203c04b16 100644 --- a/surfsense_web/lib/hitl/use-hitl-decision.ts +++ b/surfsense_web/lib/hitl/use-hitl-decision.ts @@ -1,17 +1,31 @@ /** * Shared hook for dispatching HITL decisions. * - * All tool-ui components that handle approve/reject/edit should use this - * instead of manually constructing `CustomEvent("hitl-decision", ...)`. + * Tool-ui cards always call ``dispatch([decision])``. When a multi-card bundle + * is active (``HitlBundleProvider``), the dispatch is intercepted and staged + * against this card's ``toolCallId`` so the orchestrator can submit one + * ordered N-decision payload. With no bundle active (N=1 path), it falls back + * to the legacy ``window`` event the host listens for in ``page.tsx``. */ import { useCallback } from "react"; +import { useHitlBundle, useToolCallIdContext } from "./bundle-context"; import type { HitlDecision } from "./types"; export function useHitlDecision() { - const dispatch = useCallback((decisions: HitlDecision[]) => { - window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); - }, []); + const bundle = useHitlBundle(); + const toolCallId = useToolCallIdContext(); + + const dispatch = useCallback( + (decisions: HitlDecision[]) => { + if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) { + bundle.stage(toolCallId, decisions[0]); + return; + } + window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } })); + }, + [bundle, toolCallId] + ); return { dispatch }; } From 6a9433c8fff0a8d15fc74f4a819aa0e3f469cc2e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 00:22:10 +0200 Subject: [PATCH 095/131] Apply per-card decisions in HITL bundle resume. --- .../task_tool.py | 49 ++++++------- .../new-chat/[[...chat_id]]/page.tsx | 71 ++++++++++--------- surfsense_web/lib/hitl/use-hitl-decision.ts | 10 +++ 3 files changed, 70 insertions(+), 60 deletions(-) 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 index 2458f72c4..cb387278b 100644 --- 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 @@ -136,20 +136,20 @@ def build_task_tool_with_parent_config( if pending_value is not None: resume_value = consume_surfsense_resume(runtime) - if resume_value is not None: - 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, + 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." ) - else: - logger.warning( - "Subagent %r has pending interrupt but no surfsense_resume_value " - "on config — replaying with fresh state (interrupt will re-fire).", - subagent_type, - ) - result = subagent.invoke(subagent_state, config=sub_config) + 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) @@ -201,20 +201,17 @@ def build_task_tool_with_parent_config( if pending_value is not None: resume_value = consume_surfsense_resume(runtime) - if resume_value is not None: - 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, + 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." ) - else: - logger.warning( - "Subagent %r has pending interrupt but no surfsense_resume_value " - "on config — replaying with fresh state (interrupt will re-fire).", - subagent_type, - ) - result = await subagent.ainvoke(subagent_state, config=sub_config) + 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) 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 533aefa89..bb8f62703 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 @@ -1283,36 +1283,36 @@ export default function NewChatPage() { } } - // Merge edited args if present to fix race condition - if (decisions.length > 0 && decisions[0].type === "edit" && decisions[0].edited_action) { - const editedAction = decisions[0].edited_action; + // Apply each decision to its own card by toolCallId so mixed + // bundles (approve/edit/reject) and multi-edit bundles do not + // collapse onto ``decisions[0]``. Cards outside the bundle are + // untouched. Mirrors the host ``hitl-decision`` handler. + const decisionByTcId = new Map(); + const tcIds = pendingInterrupt.bundleToolCallIds; + if (decisions.length === tcIds.length) { + for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]); + } + if (decisionByTcId.size > 0) { for (const part of contentParts) { - if (part.type === "tool-call" && part.toolName === editedAction.name) { - const mergedArgs = { ...part.args, ...editedAction.args }; + 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 caller- - // supplied argsText over JSON.stringify(args). + // edited inputs (assistant-ui prefers it over + // JSON.stringify(args)). part.argsText = JSON.stringify(mergedArgs, null, 2); - break; - } - } - } - - const decisionType = decisions[0]?.type as "approve" | "reject" | undefined; - if (decisionType) { - 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, - }; } + part.result = { + ...(part.result as Record), + __decided__: decided, + }; } } @@ -1579,16 +1579,19 @@ export default function NewChatPage() { const tcIds = pendingInterrupt.bundleToolCallIds; const N = tcIds.length; - // Build a per-card decision map. Bundle path: one decision per - // action_request in order. Legacy single-click on a multi-card - // interrupt: replay the last decision across the bundle. - const byTcId = new Map(); - if (incoming.length === N) { - for (let i = 0; i < N; i++) byTcId.set(tcIds[i], incoming[i]); - } else { - const fallback = incoming[incoming.length - 1]; - for (const tcId of tcIds) byTcId.set(tcId, fallback); + // 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.` + ); + return; } + + const byTcId = new Map(); + for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]); const submittedDecisions = tcIds.map((id) => byTcId.get(id)!); setMessages((prev) => diff --git a/surfsense_web/lib/hitl/use-hitl-decision.ts b/surfsense_web/lib/hitl/use-hitl-decision.ts index 203c04b16..e2aaf8514 100644 --- a/surfsense_web/lib/hitl/use-hitl-decision.ts +++ b/surfsense_web/lib/hitl/use-hitl-decision.ts @@ -19,6 +19,16 @@ export function useHitlDecision() { 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; } From 9e35cdaec77bb687ec51a4b12fe7489d33257392 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 00:38:34 +0200 Subject: [PATCH 096/131] Fail loud on registry subagent build error. --- .../main_agent/graph/middleware/deepagent_stack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 984556b1e..1eae3a519 100644 --- 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 @@ -234,9 +234,9 @@ def build_main_agent_deepagent_middleware( "Registry subagents: %s", [s["name"] for s in registry_subagents], ) - except Exception as exc: - logging.warning("Registry subagent build failed: %s", exc) - registry_subagents = [] + except Exception: + logging.exception("Registry subagent build failed") + raise subagent_specs: list[SubAgent] = [general_purpose_spec, *registry_subagents] From 6c4ede5f9a9580618e24a85200809c34006043ce Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 02:00:34 +0200 Subject: [PATCH 097/131] fix(test): pin enable_kb_planner_runnable=false for KB-search planner tests --- .../tests/unit/middleware/test_knowledge_search.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/surfsense_backend/tests/unit/middleware/test_knowledge_search.py b/surfsense_backend/tests/unit/middleware/test_knowledge_search.py index 2933a0504..3529a946b 100644 --- a/surfsense_backend/tests/unit/middleware/test_knowledge_search.py +++ b/surfsense_backend/tests/unit/middleware/test_knowledge_search.py @@ -202,6 +202,15 @@ class FakeBudgetLLM: class TestKnowledgeBaseSearchMiddlewarePlanner: + @pytest.fixture(autouse=True) + def _disable_planner_runnable(self, monkeypatch): + # ``FakeLLM`` is a duck-typed mock; ``create_agent`` (used when the + # planner Runnable path is enabled) calls ``.bind()`` on the LLM, + # which the mock does not implement. Pin the flag off so the + # planner falls through to the legacy ``self.llm.ainvoke`` path + # these tests assert against (``llm.calls[0]["config"]``). + monkeypatch.setenv("SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE", "false") + def test_render_recent_conversation_prefers_latest_messages_under_budget(self): messages = [ HumanMessage(content="old user context " * 40), From 2f3a33c9d518bb3ca8c1ac3535fc633375886078 Mon Sep 17 00:00:00 2001 From: guangyang1206 Date: Tue, 5 May 2026 12:48:04 +0800 Subject: [PATCH 098/131] feat(chunker): add table-aware chunk_text_hybrid to prevent mid-row table splits Document_chunker currently splits Markdown tables mid-row when the table is larger than a single chunk window, producing garbled rows that are useless for RAG retrieval (issue #1334). Changes: - document_chunker.py: add chunk_text_hybrid() that detects Markdown table blocks with a regex, emits each table as an indivisible single chunk, and feeds the surrounding prose through the normal chunk_text() chunker. - indexing_pipeline_service.py: route normal (non-code) documents through chunk_text_hybrid instead of chunk_text so tables are protected by default. Fixes #1334 --- .../app/indexing_pipeline/document_chunker.py | 50 +++++++++++++++++++ .../indexing_pipeline_service.py | 20 +++++--- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/surfsense_backend/app/indexing_pipeline/document_chunker.py b/surfsense_backend/app/indexing_pipeline/document_chunker.py index 4f3c698ef..6ae81b7a8 100644 --- a/surfsense_backend/app/indexing_pipeline/document_chunker.py +++ b/surfsense_backend/app/indexing_pipeline/document_chunker.py @@ -1,5 +1,15 @@ +import re + from app.config import config +# Regex that matches a Markdown table block (header + separator + one or more rows) +# A table block starts with a | at the beginning of a line and ends when a +# non-table line (or end of string) is encountered. +_TABLE_BLOCK_RE = re.compile( + r"(?:(?:^|\n)(?=[ \t]*\|)(?:[ \t]*\|[^\n]*\n)+)", + re.MULTILINE, +) + def chunk_text(text: str, use_code_chunker: bool = False) -> list[str]: """Chunk a text string using the configured chunker and return the chunk texts.""" @@ -7,3 +17,43 @@ def chunk_text(text: str, use_code_chunker: bool = False) -> list[str]: config.code_chunker_instance if use_code_chunker else config.chunker_instance ) return [c.text for c in chunker.chunk(text)] + + +def chunk_text_hybrid(text: str) -> list[str]: + """Table-aware chunker that prevents Markdown tables from being split mid-row. + + Algorithm: + 1. Scan the document for Markdown table blocks. + 2. Each table block is emitted as a single, unmodified chunk so that its + header, separator row, and data rows always stay together. + 3. The non-table prose segments between (and around) tables are passed through + the normal ``chunk_text`` chunker and their sub-chunks are interleaved in + document order. + + This ensures that table data is never sliced in the middle by the token-based + chunker, which would otherwise produce garbled rows that are useless for RAG. + + Fixes #1334. + """ + chunks: list[str] = [] + cursor = 0 + + for match in _TABLE_BLOCK_RE.finditer(text): + # Prose before this table + prose = text[cursor : match.start()].strip() + if prose: + chunks.extend(chunk_text(prose)) + + # The table itself is kept as one indivisible chunk + table_block = match.group(0).strip() + if table_block: + chunks.append(table_block) + + cursor = match.end() + + # Remaining prose after the last table (or entire text if no tables) + trailing = text[cursor:].strip() + if trailing: + chunks.extend(chunk_text(trailing)) + + return chunks diff --git a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py index e6b2458f3..2339647ea 100644 --- a/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py +++ b/surfsense_backend/app/indexing_pipeline/indexing_pipeline_service.py @@ -19,7 +19,7 @@ from app.db import ( DocumentType, ) from app.indexing_pipeline.connector_document import ConnectorDocument -from app.indexing_pipeline.document_chunker import chunk_text +from app.indexing_pipeline.document_chunker import chunk_text, chunk_text_hybrid from app.indexing_pipeline.document_embedder import embed_texts from app.indexing_pipeline.document_hashing import ( compute_content_hash, @@ -387,11 +387,19 @@ class IndexingPipelineService: ) t_step = time.perf_counter() - chunk_texts = await asyncio.to_thread( - chunk_text, - connector_doc.source_markdown, - use_code_chunker=connector_doc.should_use_code_chunker, - ) + if connector_doc.should_use_code_chunker: + chunk_texts = await asyncio.to_thread( + chunk_text, + connector_doc.source_markdown, + use_code_chunker=True, + ) + else: + # Use the table-aware hybrid chunker so Markdown tables are not + # split mid-row (see issue #1334). + chunk_texts = await asyncio.to_thread( + chunk_text_hybrid, + connector_doc.source_markdown, + ) texts_to_embed = [content, *chunk_texts] embeddings = await asyncio.to_thread(embed_texts, texts_to_embed) From 102f77ab7f22e69d83b66a7201b4218b495199de Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 17:04:19 +0200 Subject: [PATCH 099/131] test(hitl): regression net for surfsense_resume_value pop-on-read --- .../unit/agents/multi_agent_chat/__init__.py | 0 .../multi_agent_chat/middleware/__init__.py | 0 .../__init__.py | 0 .../test_resume_helpers.py | 84 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/__init__.py create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/__init__.py create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/__init__.py create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py new file mode 100644 index 000000000..2060051a2 --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py @@ -0,0 +1,84 @@ +"""Pure-function tests for the HITL resume side-channel helpers. + +Tests the invariant that backs the bridge: a queued resume value must be +read exactly once per turn. A second read returns ``None`` so the +parent ``task`` tool falls through to its fail-loud guard rather than +replaying the same resume payload (which would re-fire the interrupt). +""" + +from __future__ import annotations + +from langchain.tools import ToolRuntime + +from app.agents.multi_agent_chat.main_agent.graph.middleware.checkpointed_subagent_middleware.config import ( + consume_surfsense_resume, + has_surfsense_resume, +) + + +def _runtime_with_config(config: dict) -> ToolRuntime: + """Real ToolRuntime; only ``.config`` is exercised by the helpers.""" + return ToolRuntime( + state=None, + context=None, + config=config, + stream_writer=None, + tool_call_id="tcid-test", + store=None, + ) + + +class TestConsumeSurfsenseResume: + def test_pops_value_on_first_call(self): + runtime = _runtime_with_config( + {"configurable": {"surfsense_resume_value": {"decisions": ["approve"]}}} + ) + + assert consume_surfsense_resume(runtime) == {"decisions": ["approve"]} + + def test_second_call_returns_none(self): + # Regression guard: a second read must not replay the queued + # resume. If it did, the subagent would re-invoke with the + # same Command and the user-facing interrupt would fire twice. + configurable: dict = {"surfsense_resume_value": {"decisions": ["approve"]}} + runtime = _runtime_with_config({"configurable": configurable}) + + consume_surfsense_resume(runtime) + + assert consume_surfsense_resume(runtime) is None + assert "surfsense_resume_value" not in configurable + + def test_returns_none_when_no_payload_queued(self): + runtime = _runtime_with_config({"configurable": {}}) + + assert consume_surfsense_resume(runtime) is None + + def test_returns_none_when_configurable_missing(self): + runtime = _runtime_with_config({}) + + assert consume_surfsense_resume(runtime) is None + + +class TestHasSurfsenseResume: + def test_true_when_payload_queued(self): + runtime = _runtime_with_config( + {"configurable": {"surfsense_resume_value": "approve"}} + ) + + assert has_surfsense_resume(runtime) is True + + def test_does_not_consume_payload(self): + # The fail-loud guard in ``task_tool`` calls ``has_surfsense_resume`` + # *before* deciding to consume; the check itself must leave the + # payload queued for the matching ``consume_surfsense_resume`` call. + configurable = {"surfsense_resume_value": "approve"} + runtime = _runtime_with_config({"configurable": configurable}) + + has_surfsense_resume(runtime) + + assert configurable == {"surfsense_resume_value": "approve"} + + def test_false_when_payload_absent(self): + runtime = _runtime_with_config({"configurable": {}}) + + assert has_surfsense_resume(runtime) is False From 681895d28df0aa100159a981b38188e7dcdb4dd3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 17:13:13 +0200 Subject: [PATCH 100/131] test(hitl): regression net for end-to-end resume bridge dispatch --- .../test_hitl_bridge.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py new file mode 100644 index 000000000..8e9f0a96c --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py @@ -0,0 +1,132 @@ +"""End-to-end resume-bridge tests against a real LangGraph subagent. + +Builds a minimal Pregel subagent that calls ``interrupt(...)`` and drives the +``task`` tool directly with a hand-crafted ``ToolRuntime``. Exercises the only +runtime contract we own: parent stashes a decision in +``config["configurable"]["surfsense_resume_value"]`` -> bridge forwards it as +``Command(resume={interrupt_id: value})`` -> subagent completes -> return value +reflects the decision. + +We pause the subagent **outside** the parent task tool (calling +``subagent.ainvoke`` directly) to skip the ``_lg_interrupt`` re-raise path, +which requires a parent runnable context. The bridge logic under test is the +*resume* dispatch, not the propagation; propagation is exercised separately in +its own module's tests. +""" + +from __future__ import annotations + +import pytest +from langchain.tools import ToolRuntime +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph +from langgraph.types import Command, interrupt +from typing_extensions import TypedDict + +from app.agents.multi_agent_chat.main_agent.graph.middleware.checkpointed_subagent_middleware.task_tool import ( + build_task_tool_with_parent_config, +) + + +class _SubagentState(TypedDict, total=False): + messages: list + decision_text: str + + +def _build_single_interrupt_subagent(): + """Subagent that interrupts once, then echoes the resume decision into state.""" + + def approve_node(state): + from langchain_core.messages import AIMessage + + decision = interrupt( + { + "action_requests": [ + { + "name": "do_thing", + "args": {"x": 1}, + "description": "test action", + } + ], + "review_configs": [{}], + } + ) + # Capture the resume payload verbatim so the test can assert the + # bridge forwarded it intact (no reshape, no scalar broadcast). + return { + "messages": [AIMessage(content="done")], + "decision_text": repr(decision), + } + + graph = StateGraph(_SubagentState) + graph.add_node("approve", approve_node) + graph.add_edge(START, "approve") + graph.add_edge("approve", END) + return graph.compile(checkpointer=InMemorySaver()) + + +def _make_runtime(config: dict) -> ToolRuntime: + return ToolRuntime( + state={"messages": [HumanMessage(content="seed")]}, + context=None, + config=config, + stream_writer=None, + tool_call_id="parent-tcid-1", + store=None, + ) + + +@pytest.mark.asyncio +async def test_resume_bridge_dispatches_decision_into_pending_subagent(): + """Side-channel decision -> targeted Command(resume) -> subagent completes.""" + subagent = _build_single_interrupt_subagent() + task_tool = build_task_tool_with_parent_config( + [ + { + "name": "approver", + "description": "approves things", + "runnable": subagent, + } + ] + ) + + # 1. Pause the subagent directly so we can test only the resume path. + parent_config: dict = { + "configurable": {"thread_id": "shared-thread"}, + "recursion_limit": 100, + } + await subagent.ainvoke({"messages": [HumanMessage(content="seed")]}, parent_config) + snap = await subagent.aget_state(parent_config) + assert snap.tasks and snap.tasks[0].interrupts, ( + "fixture broken: subagent should be paused on its interrupt" + ) + + # 2. Stash the user's decision on the side-channel — this is what + # ``stream_resume_chat`` does in production. + parent_config["configurable"]["surfsense_resume_value"] = { + "decisions": ["APPROVED"] + } + runtime = _make_runtime(parent_config) + + # 3. Drive the bridge. Subagent has no remaining interrupt after resume, + # so propagation will not call ``_lg_interrupt`` (no parent ctx needed). + result = await task_tool.coroutine( + description="please approve", + subagent_type="approver", + runtime=runtime, + ) + + assert isinstance(result, Command) + update = result.update + # Bridge forwards the side-channel payload **verbatim** to the + # subagent's ``interrupt()``. A scalar broadcast or accidental + # unwrap would change this shape and we want to catch that. + assert update["decision_text"] == repr({"decisions": ["APPROVED"]}) + + # 4. Side-channel was consumed; a stale replay would re-prompt the user. + assert "surfsense_resume_value" not in parent_config["configurable"] + + # 5. Subagent moved past the interrupt (no pending tasks remain). + final = await subagent.aget_state(parent_config) + assert not final.tasks or all(not t.interrupts for t in final.tasks) From 0c556356032d536dc57d83057595dfd669b90bb3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 17:15:28 +0200 Subject: [PATCH 101/131] test(hitl): regression net for fail-loud guard on missing resume value --- .../test_hitl_bridge.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py index 8e9f0a96c..2ba95dfef 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py @@ -130,3 +130,41 @@ async def test_resume_bridge_dispatches_decision_into_pending_subagent(): # 5. Subagent moved past the interrupt (no pending tasks remain). final = await subagent.aget_state(parent_config) assert not final.tasks or all(not t.interrupts for t in final.tasks) + + +@pytest.mark.asyncio +async def test_pending_interrupt_without_resume_value_raises_runtime_error(): + """Bridge must fail loud if a paused subagent has no decision queued. + + The fail-open alternative (silently re-invoking) would re-fire the + same interrupt to the user. The error surfaces a real broken bridge + instead of confusing duplicate approval cards. + """ + subagent = _build_single_interrupt_subagent() + task_tool = build_task_tool_with_parent_config( + [ + { + "name": "approver", + "description": "approves things", + "runnable": subagent, + } + ] + ) + + parent_config: dict = { + "configurable": {"thread_id": "guard-thread"}, + "recursion_limit": 100, + } + await subagent.ainvoke({"messages": [HumanMessage(content="seed")]}, parent_config) + snap = await subagent.aget_state(parent_config) + assert snap.tasks and snap.tasks[0].interrupts, "fixture broken" + + # No surfsense_resume_value injected — bridge must refuse to proceed. + runtime = _make_runtime(parent_config) + + with pytest.raises(RuntimeError, match="resume bridge is broken"): + await task_tool.coroutine( + description="please approve", + subagent_type="approver", + runtime=runtime, + ) From fa6f3015a996640fb74fd7b483171d1f747c4302 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 17:19:39 +0200 Subject: [PATCH 102/131] test(hitl): regression net for bundle decision shape preservation --- .../test_hitl_bridge.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py index 2ba95dfef..188224074 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py @@ -16,6 +16,8 @@ its own module's tests. from __future__ import annotations +import ast + import pytest from langchain.tools import ToolRuntime from langchain_core.messages import HumanMessage @@ -168,3 +170,84 @@ async def test_pending_interrupt_without_resume_value_raises_runtime_error(): subagent_type="approver", runtime=runtime, ) + + +def _build_bundle_subagent(): + """Subagent that raises a 3-action HITL bundle on its only node.""" + + def bundle_node(state): + from langchain_core.messages import AIMessage + + decision = interrupt( + { + "action_requests": [ + {"name": "create_a", "args": {}, "description": ""}, + {"name": "create_b", "args": {}, "description": ""}, + {"name": "create_c", "args": {}, "description": ""}, + ], + "review_configs": [{}, {}, {}], + } + ) + return { + "messages": [AIMessage(content="bundle-done")], + "decision_text": repr(decision), + } + + graph = StateGraph(_SubagentState) + graph.add_node("bundle", bundle_node) + graph.add_edge(START, "bundle") + graph.add_edge("bundle", END) + return graph.compile(checkpointer=InMemorySaver()) + + +@pytest.mark.asyncio +async def test_bundle_three_mixed_decisions_arrive_in_order(): + """Approve / edit / reject for a 3-action bundle land at ordinals 0/1/2. + + Catches reshape regressions: truncation, decision collapse, order + scrambling, and the legacy single-decision broadcast that would + fan-out one verdict to every action. + """ + subagent = _build_bundle_subagent() + task_tool = build_task_tool_with_parent_config( + [ + { + "name": "bundler", + "description": "creates a bundle", + "runnable": subagent, + } + ] + ) + + parent_config: dict = { + "configurable": {"thread_id": "bundle-thread"}, + "recursion_limit": 100, + } + await subagent.ainvoke({"messages": [HumanMessage(content="seed")]}, parent_config) + + decisions_payload = { + "decisions": [ + {"type": "approve", "args": {}}, + {"type": "edit", "args": {"args": {"name": "edited-b"}}}, + {"type": "reject", "args": {"message": "no thanks"}}, + ] + } + parent_config["configurable"]["surfsense_resume_value"] = decisions_payload + runtime = _make_runtime(parent_config) + + result = await task_tool.coroutine( + description="run bundle", + subagent_type="bundler", + runtime=runtime, + ) + + assert isinstance(result, Command) + decision_text = result.update["decision_text"] + received = ast.literal_eval(decision_text) + assert received == decisions_payload, "bundle decisions must arrive verbatim" + # Cross-checks for the regressions this test exists to catch. + assert len(received["decisions"]) == 3 + assert received["decisions"][0]["type"] == "approve" + assert received["decisions"][1]["type"] == "edit" + assert received["decisions"][1]["args"] == {"args": {"name": "edited-b"}} + assert received["decisions"][2]["type"] == "reject" From f695298d30f6e6d50bb701e3e613fb207c650d30 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 17:39:27 +0200 Subject: [PATCH 103/131] feat(multi-agent): wire model fallback and retry into subagent middleware --- .../graph/middleware/deepagent_stack.py | 39 ++++--- .../multi_agent_chat/subagents/__init__.py | 0 .../subagents/shared/__init__.py | 0 .../subagents/shared/test_subagent_builder.py | 105 ++++++++++++++++++ 4 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/__init__.py create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/__init__.py create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py 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 index 74e47cfab..8b7e3d0b0 100644 --- 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 @@ -208,6 +208,26 @@ def build_main_agent_deepagent_middleware( ) gp_middleware.insert(_patch_idx, subagent_deny_permission_mw) + # Defined here (instead of further down with the other ``wrap_model_call`` + # middlewares) so subagents share the same instances as the parent — + # otherwise a connector subagent would die on the first provider hiccup + # while the parent stays resilient. + 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 + registry_subagents: list[SubAgent] = [] try: subagent_extra_middleware: list[Any] = [ @@ -222,6 +242,10 @@ def build_main_agent_deepagent_middleware( ] if subagent_deny_permission_mw is not None: subagent_extra_middleware.append(subagent_deny_permission_mw) + if retry_mw is not None: + subagent_extra_middleware.append(retry_mw) + if fallback_mw is not None: + subagent_extra_middleware.append(fallback_mw) registry_subagents = build_subagents( dependencies=subagent_dependencies, model=llm, @@ -268,21 +292,6 @@ def build_main_agent_deepagent_middleware( 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, diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/__init__.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py new file mode 100644 index 000000000..82f66891a --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py @@ -0,0 +1,105 @@ +"""Resilience contract for subagents built via ``pack_subagent``. + +Subagents (jira, linear, notion, ...) run on the same LLM as the parent. When +the provider rate-limits or returns an empty stream, a single hiccup must not +abort the user's HITL flow — the connector subagent has to keep moving. This +relies on ``ModelFallbackMiddleware`` being usable as a subagent +``extra_middleware`` so the production builder can wire it in. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import pytest +from langchain.agents import create_agent +from langchain.agents.middleware import ModelFallbackMiddleware +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, +) +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( + pack_subagent, +) + + +class _AlwaysFailingChatModel(BaseChatModel): + """Mimics a provider hard-failing on every call (rate limit / empty stream). + + ``ModelFallbackMiddleware`` triggers on any ``Exception``, so the exact + error type doesn't matter for the contract under test. + """ + + @property + def _llm_type(self) -> str: + return "always-failing-test-model" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + msg = "primary llm exploded" + raise RuntimeError(msg) + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + msg = "primary llm exploded" + raise RuntimeError(msg) + + def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGeneration]: + msg = "primary llm exploded" + raise RuntimeError(msg) + + async def _astream( + self, *args: Any, **kwargs: Any + ) -> AsyncIterator[ChatGeneration]: + msg = "primary llm exploded" + raise RuntimeError(msg) + yield # pragma: no cover - unreachable, satisfies async generator typing + + +@pytest.mark.asyncio +async def test_subagent_recovers_when_primary_llm_fails(): + """Primary blows up → fallback in extra_middleware finishes the turn.""" + primary = _AlwaysFailingChatModel() + fallback = FakeMessagesListChatModel( + responses=[AIMessage(content="recovered via fallback")] + ) + + spec = pack_subagent( + name="resilience_test", + description="test subagent", + system_prompt="be helpful", + tools=[], + model=primary, + extra_middleware=[ModelFallbackMiddleware(fallback)], + ) + + agent = create_agent( + model=spec["model"], + tools=spec["tools"], + middleware=spec["middleware"], + system_prompt=spec["system_prompt"], + ) + + result = await agent.ainvoke({"messages": [HumanMessage(content="hi")]}) + + final = result["messages"][-1] + assert isinstance(final, AIMessage) + assert final.content == "recovered via fallback" From 1745d7dccf250fe489c1f9ff422491ca11661d4d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 18:04:47 +0200 Subject: [PATCH 104/131] feat(middleware): scope model fallback to provider/network errors only --- .../graph/middleware/deepagent_stack.py | 10 +- .../app/agents/new_chat/chat_deepagent.py | 10 +- .../middleware/scoped_model_fallback.py | 106 +++++++++++++ .../subagents/shared/test_subagent_builder.py | 18 +-- .../agents/new_chat/middleware/__init__.py | 0 .../middleware/test_scoped_model_fallback.py | 148 ++++++++++++++++++ 6 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py create mode 100644 surfsense_backend/tests/unit/agents/new_chat/middleware/__init__.py create mode 100644 surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py 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 index 8b7e3d0b0..e490b6b47 100644 --- 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 @@ -14,7 +14,6 @@ from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT from langchain.agents.middleware import ( LLMToolSelectorMiddleware, ModelCallLimitMiddleware, - ModelFallbackMiddleware, TodoListMiddleware, ToolCallLimitMiddleware, ) @@ -56,6 +55,9 @@ from app.agents.new_chat.middleware import ( create_surfsense_compaction_middleware, default_skills_sources, ) +from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, +) from app.agents.new_chat.permissions import Rule, Ruleset from app.agents.new_chat.plugin_loader import ( PluginContext, @@ -217,15 +219,15 @@ def build_main_agent_deepagent_middleware( if flags.enable_retry_after and not flags.disable_new_agent_stack else None ) - fallback_mw: ModelFallbackMiddleware | None = None + fallback_mw: ScopedModelFallbackMiddleware | None = None if flags.enable_model_fallback and not flags.disable_new_agent_stack: try: - fallback_mw = ModelFallbackMiddleware( + fallback_mw = ScopedModelFallbackMiddleware( "openai:gpt-4o-mini", "anthropic:claude-3-5-haiku-20241022", ) except Exception: - logging.warning("ModelFallbackMiddleware init failed; skipping.") + logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") fallback_mw = None registry_subagents: list[SubAgent] = [] diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 1f4024d9d..605c31416 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -31,7 +31,6 @@ from langchain.agents import create_agent from langchain.agents.middleware import ( LLMToolSelectorMiddleware, ModelCallLimitMiddleware, - ModelFallbackMiddleware, TodoListMiddleware, ToolCallLimitMiddleware, ) @@ -77,6 +76,9 @@ from app.agents.new_chat.middleware import ( create_surfsense_compaction_middleware, default_skills_sources, ) +from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, +) from app.agents.new_chat.permissions import Rule, Ruleset from app.agents.new_chat.plugin_loader import ( PluginContext, @@ -792,15 +794,15 @@ def _build_compiled_agent_blocking( # Fallback chain — primary is the agent's own model; we add cheap # alternatives. Off by default; only the first call site that # configures the chain via env should enable it. - fallback_mw: ModelFallbackMiddleware | None = None + fallback_mw: ScopedModelFallbackMiddleware | None = None if flags.enable_model_fallback and not flags.disable_new_agent_stack: try: - fallback_mw = ModelFallbackMiddleware( + fallback_mw = ScopedModelFallbackMiddleware( "openai:gpt-4o-mini", "anthropic:claude-3-5-haiku-20241022", ) except Exception: - logging.warning("ModelFallbackMiddleware init failed; skipping.") + logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") fallback_mw = None model_call_limit_mw = ( ModelCallLimitMiddleware( diff --git a/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py new file mode 100644 index 000000000..de367fda9 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py @@ -0,0 +1,106 @@ +"""Fallback only on provider/network errors; let programming bugs raise. + +Upstream :class:`langchain.agents.middleware.ModelFallbackMiddleware` catches +every ``Exception``. With a non-provider bug (``KeyError``, ``TypeError``, +``AttributeError`` from middleware/state), every fallback model in the chain +hits the same bug — burning latency and tokens before the real cause finally +surfaces. Scoping the catch to provider-style exception types lets bugs fail +fast with clean tracebacks. + +Class-name matching (instead of ``isinstance`` against imported provider +types) keeps the dependency surface flat: openai, anthropic, google, +mistral, etc. all ship their own ``RateLimitError`` and we don't want to +import them all. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from langchain.agents.middleware import ModelFallbackMiddleware + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from langchain.agents.middleware.types import ModelRequest, ModelResponse + from langchain_core.messages import AIMessage + + +_FALLBACK_ELIGIBLE_NAMES: frozenset[str] = frozenset( + { + # Rate / quota + "RateLimitError", + # Server-side + "APIStatusError", + "InternalServerError", + "ServiceUnavailableError", + "BadGatewayError", + "GatewayTimeoutError", + # Network + "APIConnectionError", + "APITimeoutError", + "ConnectError", + "ConnectTimeout", + "ReadTimeout", + "RemoteProtocolError", + "TimeoutError", + "TimeoutException", + } +) + + +def _is_fallback_eligible(exc: BaseException) -> bool: + """Eligible if the exception or any base in its MRO matches by class name.""" + return any(cls.__name__ in _FALLBACK_ELIGIBLE_NAMES for cls in type(exc).__mro__) + + +class ScopedModelFallbackMiddleware(ModelFallbackMiddleware): + """``ModelFallbackMiddleware`` that re-raises non-provider exceptions.""" + + def wrap_model_call( # type: ignore[override] + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], ModelResponse[Any]], + ) -> ModelResponse[Any] | AIMessage: + last_exception: Exception + try: + return handler(request) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + + for fallback_model in self.models: + try: + return handler(request.override(model=fallback_model)) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + continue + + raise last_exception + + async def awrap_model_call( # type: ignore[override] + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]], + ) -> ModelResponse[Any] | AIMessage: + last_exception: Exception + try: + return await handler(request) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + + for fallback_model in self.models: + try: + return await handler(request.override(model=fallback_model)) + except Exception as e: + if not _is_fallback_eligible(e): + raise + last_exception = e + continue + + raise last_exception diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py index 82f66891a..859833f1c 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py @@ -31,12 +31,12 @@ from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( ) -class _AlwaysFailingChatModel(BaseChatModel): - """Mimics a provider hard-failing on every call (rate limit / empty stream). +class RateLimitError(Exception): + """Provider-style 429; matches the scoped-fallback eligibility allowlist by name.""" - ``ModelFallbackMiddleware`` triggers on any ``Exception``, so the exact - error type doesn't matter for the contract under test. - """ + +class _AlwaysFailingChatModel(BaseChatModel): + """Mimics a provider hard-failing on every call (rate limit / empty stream).""" @property def _llm_type(self) -> str: @@ -50,7 +50,7 @@ class _AlwaysFailingChatModel(BaseChatModel): **kwargs: Any, ) -> ChatResult: msg = "primary llm exploded" - raise RuntimeError(msg) + raise RateLimitError(msg) async def _agenerate( self, @@ -60,17 +60,17 @@ class _AlwaysFailingChatModel(BaseChatModel): **kwargs: Any, ) -> ChatResult: msg = "primary llm exploded" - raise RuntimeError(msg) + raise RateLimitError(msg) def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGeneration]: msg = "primary llm exploded" - raise RuntimeError(msg) + raise RateLimitError(msg) async def _astream( self, *args: Any, **kwargs: Any ) -> AsyncIterator[ChatGeneration]: msg = "primary llm exploded" - raise RuntimeError(msg) + raise RateLimitError(msg) yield # pragma: no cover - unreachable, satisfies async generator typing diff --git a/surfsense_backend/tests/unit/agents/new_chat/middleware/__init__.py b/surfsense_backend/tests/unit/agents/new_chat/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py new file mode 100644 index 000000000..af464d1dc --- /dev/null +++ b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py @@ -0,0 +1,148 @@ +"""Exception-scope contract for ``ScopedModelFallbackMiddleware``. + +Upstream ``ModelFallbackMiddleware`` catches every ``Exception`` and walks +the fallback chain. That means a programming bug (``KeyError`` from a +botched tool config, ``TypeError`` from middleware, ...) burns 1+N model +round-trips and ~Nx tokens before its real cause surfaces. The scoped +variant only falls back on provider/network exception types so bugs fail +fast, with clean tracebacks. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import pytest +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult + + +class _RaisingChatModel(BaseChatModel): + """LLM that raises a configurable exception on every invocation.""" + + exc_to_raise: Any + + @property + def _llm_type(self) -> str: + return "raising-test-model" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + raise self.exc_to_raise + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + raise self.exc_to_raise + + def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGeneration]: + raise self.exc_to_raise + + async def _astream( + self, *args: Any, **kwargs: Any + ) -> AsyncIterator[ChatGeneration]: + raise self.exc_to_raise + yield # pragma: no cover - unreachable + + +class _RecordingChatModel(BaseChatModel): + """Returns a fixed message and counts how often it was called.""" + + response_text: str = "fallback-ok" + call_count: int = 0 + + @property + def _llm_type(self) -> str: + return "recording-test-model" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + self.call_count += 1 + return ChatResult( + generations=[ + ChatGeneration(message=AIMessage(content=self.response_text)) + ] + ) + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + return self._generate(messages, stop, None, **kwargs) + + +# Locally defined provider-style error: importing openai/anthropic/etc. +# would couple the test to provider SDKs the contract intentionally avoids. +class RateLimitError(Exception): + """Mimics ``openai.RateLimitError`` for name-based eligibility.""" + + +def _build_agent(primary: BaseChatModel, fallback: BaseChatModel): + """Compile a no-tools agent with the scoped fallback wired in.""" + from langchain.agents import create_agent + + from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, + ) + + return create_agent( + model=primary, + tools=[], + middleware=[ScopedModelFallbackMiddleware(fallback)], + system_prompt="be helpful", + ) + + +@pytest.mark.asyncio +async def test_provider_errors_trigger_fallback(): + """Class names matching the provider allowlist drive the fallback chain.""" + primary = _RaisingChatModel(exc_to_raise=RateLimitError("429 from provider")) + fallback = _RecordingChatModel(response_text="recovered") + + agent = _build_agent(primary, fallback) + result = await agent.ainvoke({"messages": [("user", "hi")]}) + + final = result["messages"][-1] + assert isinstance(final, AIMessage) + assert final.content == "recovered" + assert fallback.call_count == 1 + + +@pytest.mark.asyncio +async def test_programming_errors_propagate_without_invoking_fallback(): + """``KeyError`` from agent-side bugs must surface immediately, no fallback retry.""" + primary = _RaisingChatModel(exc_to_raise=KeyError("missing_state_field")) + fallback = _RecordingChatModel(response_text="should-never-arrive") + + agent = _build_agent(primary, fallback) + + with pytest.raises(KeyError, match="missing_state_field"): + await agent.ainvoke({"messages": [("user", "hi")]}) + + assert fallback.call_count == 0, ( + "fallback was invoked for a programming error; " + "scoping rule is broken" + ) From b394dc71c717ad42d73fea4aa874ee0dace866aa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 18:12:38 +0200 Subject: [PATCH 105/131] feat(multi-agent): extend model fallback to general-purpose subagent --- .../graph/middleware/deepagent_stack.py | 16 ++++++++++++++++ .../new_chat/middleware/scoped_model_fallback.py | 1 + 2 files changed, 17 insertions(+) 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 index e490b6b47..af7fceffa 100644 --- 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 @@ -230,6 +230,22 @@ def build_main_agent_deepagent_middleware( logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") fallback_mw = None + # Mirror the parent's ordering: retry / fallback wrap caching, which wraps + # the model. ``gp_middleware`` is held by reference inside + # ``general_purpose_spec`` so this insertion propagates into the spec. + _gp_resilience: list[Any] = [m for m in (retry_mw, fallback_mw) if m is not None] + if _gp_resilience: + _cache_idx = next( + ( + i + for i, m in enumerate(gp_middleware) + if isinstance(m, AnthropicPromptCachingMiddleware) + ), + len(gp_middleware), + ) + for offset, mw in enumerate(_gp_resilience): + gp_middleware.insert(_cache_idx + offset, mw) + registry_subagents: list[SubAgent] = [] try: subagent_extra_middleware: list[Any] = [ diff --git a/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py index de367fda9..bde8edeba 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py +++ b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py @@ -45,6 +45,7 @@ _FALLBACK_ELIGIBLE_NAMES: frozenset[str] = frozenset( "RemoteProtocolError", "TimeoutError", "TimeoutException", + # Can be extended to other exceptions in the future } ) From 309c69553123483a2f1a23b6c6cb544f38699203 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 18:16:00 +0200 Subject: [PATCH 106/131] feat(multi-agent): cap subagent model and tool call counts --- .../graph/middleware/deepagent_stack.py | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) 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 index af7fceffa..b76b54c27 100644 --- 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 @@ -230,10 +230,34 @@ def build_main_agent_deepagent_middleware( logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") fallback_mw = None - # Mirror the parent's ordering: retry / fallback wrap caching, which wraps - # the model. ``gp_middleware`` is held by reference inside + # Cost / loop ceiling shared with subagents. ``state_schema`` of these + # middlewares is per-agent; counts are not summed across parent + sub — + # the cap acts as a safety net per agent, not a global budget. + 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 + ) + + # Mirror the parent's ordering: retry / fallback / limits wrap caching, + # which wraps the model. ``gp_middleware`` is held by reference inside # ``general_purpose_spec`` so this insertion propagates into the spec. - _gp_resilience: list[Any] = [m for m in (retry_mw, fallback_mw) if m is not None] + _gp_resilience: list[Any] = [ + m + for m in (retry_mw, fallback_mw, model_call_limit_mw, tool_call_limit_mw) + if m is not None + ] if _gp_resilience: _cache_idx = next( ( @@ -260,10 +284,14 @@ def build_main_agent_deepagent_middleware( ] if subagent_deny_permission_mw is not None: subagent_extra_middleware.append(subagent_deny_permission_mw) - if retry_mw is not None: - subagent_extra_middleware.append(retry_mw) - if fallback_mw is not None: - subagent_extra_middleware.append(fallback_mw) + for _resilience_mw in ( + retry_mw, + fallback_mw, + model_call_limit_mw, + tool_call_limit_mw, + ): + if _resilience_mw is not None: + subagent_extra_middleware.append(_resilience_mw) registry_subagents = build_subagents( dependencies=subagent_dependencies, model=llm, @@ -310,23 +338,6 @@ def build_main_agent_deepagent_middleware( backend_resolver=backend_resolver, ) - 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 From 9a4ee5d16bedff12f76810c6b432d76c1a9f615d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 18:27:46 +0200 Subject: [PATCH 107/131] chore: trim narrative comments and docstrings --- .../graph/middleware/deepagent_stack.py | 15 ++--- .../middleware/scoped_model_fallback.py | 24 ++------ .../test_hitl_bridge.py | 57 ++----------------- .../test_resume_helpers.py | 15 +---- .../subagents/shared/test_subagent_builder.py | 14 +---- .../middleware/test_scoped_model_fallback.py | 28 ++------- 6 files changed, 24 insertions(+), 129 deletions(-) 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 index b76b54c27..1d6a8763e 100644 --- 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 @@ -210,10 +210,8 @@ def build_main_agent_deepagent_middleware( ) gp_middleware.insert(_patch_idx, subagent_deny_permission_mw) - # Defined here (instead of further down with the other ``wrap_model_call`` - # middlewares) so subagents share the same instances as the parent — - # otherwise a connector subagent would die on the first provider hiccup - # while the parent stays resilient. + # Defined early so the same instances reach both gp_middleware and + # subagent_extra_middleware below. retry_mw = ( RetryAfterMiddleware(max_retries=3) if flags.enable_retry_after and not flags.disable_new_agent_stack @@ -230,9 +228,7 @@ def build_main_agent_deepagent_middleware( logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") fallback_mw = None - # Cost / loop ceiling shared with subagents. ``state_schema`` of these - # middlewares is per-agent; counts are not summed across parent + sub — - # the cap acts as a safety net per agent, not a global budget. + # Per-agent caps; counts are not summed across parent + subagents. model_call_limit_mw = ( ModelCallLimitMiddleware( thread_limit=120, @@ -250,9 +246,8 @@ def build_main_agent_deepagent_middleware( else None ) - # Mirror the parent's ordering: retry / fallback / limits wrap caching, - # which wraps the model. ``gp_middleware`` is held by reference inside - # ``general_purpose_spec`` so this insertion propagates into the spec. + # gp_middleware is held by reference inside general_purpose_spec, so + # mutating it here propagates into the spec. _gp_resilience: list[Any] = [ m for m in (retry_mw, fallback_mw, model_call_limit_mw, tool_call_limit_mw) diff --git a/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py index bde8edeba..99eb2d74a 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py +++ b/surfsense_backend/app/agents/new_chat/middleware/scoped_model_fallback.py @@ -1,17 +1,4 @@ -"""Fallback only on provider/network errors; let programming bugs raise. - -Upstream :class:`langchain.agents.middleware.ModelFallbackMiddleware` catches -every ``Exception``. With a non-provider bug (``KeyError``, ``TypeError``, -``AttributeError`` from middleware/state), every fallback model in the chain -hits the same bug — burning latency and tokens before the real cause finally -surfaces. Scoping the catch to provider-style exception types lets bugs fail -fast with clean tracebacks. - -Class-name matching (instead of ``isinstance`` against imported provider -types) keeps the dependency surface flat: openai, anthropic, google, -mistral, etc. all ship their own ``RateLimitError`` and we don't want to -import them all. -""" +"""Fallback only on provider/network errors; let programming bugs raise.""" from __future__ import annotations @@ -26,17 +13,16 @@ if TYPE_CHECKING: from langchain_core.messages import AIMessage +# Matched by class name across the MRO so we don't have to import every +# provider SDK (openai/anthropic/google/...). Extend as new providers ship. _FALLBACK_ELIGIBLE_NAMES: frozenset[str] = frozenset( { - # Rate / quota "RateLimitError", - # Server-side "APIStatusError", "InternalServerError", "ServiceUnavailableError", "BadGatewayError", "GatewayTimeoutError", - # Network "APIConnectionError", "APITimeoutError", "ConnectError", @@ -45,18 +31,16 @@ _FALLBACK_ELIGIBLE_NAMES: frozenset[str] = frozenset( "RemoteProtocolError", "TimeoutError", "TimeoutException", - # Can be extended to other exceptions in the future } ) def _is_fallback_eligible(exc: BaseException) -> bool: - """Eligible if the exception or any base in its MRO matches by class name.""" return any(cls.__name__ in _FALLBACK_ELIGIBLE_NAMES for cls in type(exc).__mro__) class ScopedModelFallbackMiddleware(ModelFallbackMiddleware): - """``ModelFallbackMiddleware`` that re-raises non-provider exceptions.""" + """Re-raise non-provider exceptions instead of walking the fallback chain.""" def wrap_model_call( # type: ignore[override] self, diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py index 188224074..ab6a644b5 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py @@ -1,18 +1,4 @@ -"""End-to-end resume-bridge tests against a real LangGraph subagent. - -Builds a minimal Pregel subagent that calls ``interrupt(...)`` and drives the -``task`` tool directly with a hand-crafted ``ToolRuntime``. Exercises the only -runtime contract we own: parent stashes a decision in -``config["configurable"]["surfsense_resume_value"]`` -> bridge forwards it as -``Command(resume={interrupt_id: value})`` -> subagent completes -> return value -reflects the decision. - -We pause the subagent **outside** the parent task tool (calling -``subagent.ainvoke`` directly) to skip the ``_lg_interrupt`` re-raise path, -which requires a parent runnable context. The bridge logic under test is the -*resume* dispatch, not the propagation; propagation is exercised separately in -its own module's tests. -""" +"""End-to-end resume-bridge tests against a real LangGraph subagent.""" from __future__ import annotations @@ -37,8 +23,6 @@ class _SubagentState(TypedDict, total=False): def _build_single_interrupt_subagent(): - """Subagent that interrupts once, then echoes the resume decision into state.""" - def approve_node(state): from langchain_core.messages import AIMessage @@ -54,8 +38,6 @@ def _build_single_interrupt_subagent(): "review_configs": [{}], } ) - # Capture the resume payload verbatim so the test can assert the - # bridge forwarded it intact (no reshape, no scalar broadcast). return { "messages": [AIMessage(content="done")], "decision_text": repr(decision), @@ -81,7 +63,7 @@ def _make_runtime(config: dict) -> ToolRuntime: @pytest.mark.asyncio async def test_resume_bridge_dispatches_decision_into_pending_subagent(): - """Side-channel decision -> targeted Command(resume) -> subagent completes.""" + """Side-channel decision must reach the subagent's pending interrupt verbatim.""" subagent = _build_single_interrupt_subagent() task_tool = build_task_tool_with_parent_config( [ @@ -93,7 +75,6 @@ async def test_resume_bridge_dispatches_decision_into_pending_subagent(): ] ) - # 1. Pause the subagent directly so we can test only the resume path. parent_config: dict = { "configurable": {"thread_id": "shared-thread"}, "recursion_limit": 100, @@ -104,15 +85,11 @@ async def test_resume_bridge_dispatches_decision_into_pending_subagent(): "fixture broken: subagent should be paused on its interrupt" ) - # 2. Stash the user's decision on the side-channel — this is what - # ``stream_resume_chat`` does in production. parent_config["configurable"]["surfsense_resume_value"] = { "decisions": ["APPROVED"] } runtime = _make_runtime(parent_config) - # 3. Drive the bridge. Subagent has no remaining interrupt after resume, - # so propagation will not call ``_lg_interrupt`` (no parent ctx needed). result = await task_tool.coroutine( description="please approve", subagent_type="approver", @@ -121,27 +98,16 @@ async def test_resume_bridge_dispatches_decision_into_pending_subagent(): assert isinstance(result, Command) update = result.update - # Bridge forwards the side-channel payload **verbatim** to the - # subagent's ``interrupt()``. A scalar broadcast or accidental - # unwrap would change this shape and we want to catch that. assert update["decision_text"] == repr({"decisions": ["APPROVED"]}) - - # 4. Side-channel was consumed; a stale replay would re-prompt the user. assert "surfsense_resume_value" not in parent_config["configurable"] - # 5. Subagent moved past the interrupt (no pending tasks remain). final = await subagent.aget_state(parent_config) assert not final.tasks or all(not t.interrupts for t in final.tasks) @pytest.mark.asyncio async def test_pending_interrupt_without_resume_value_raises_runtime_error(): - """Bridge must fail loud if a paused subagent has no decision queued. - - The fail-open alternative (silently re-invoking) would re-fire the - same interrupt to the user. The error surfaces a real broken bridge - instead of confusing duplicate approval cards. - """ + """Bridge must fail loud rather than silently replay the user's interrupt.""" subagent = _build_single_interrupt_subagent() task_tool = build_task_tool_with_parent_config( [ @@ -161,7 +127,6 @@ async def test_pending_interrupt_without_resume_value_raises_runtime_error(): snap = await subagent.aget_state(parent_config) assert snap.tasks and snap.tasks[0].interrupts, "fixture broken" - # No surfsense_resume_value injected — bridge must refuse to proceed. runtime = _make_runtime(parent_config) with pytest.raises(RuntimeError, match="resume bridge is broken"): @@ -173,8 +138,6 @@ async def test_pending_interrupt_without_resume_value_raises_runtime_error(): def _build_bundle_subagent(): - """Subagent that raises a 3-action HITL bundle on its only node.""" - def bundle_node(state): from langchain_core.messages import AIMessage @@ -202,12 +165,7 @@ def _build_bundle_subagent(): @pytest.mark.asyncio async def test_bundle_three_mixed_decisions_arrive_in_order(): - """Approve / edit / reject for a 3-action bundle land at ordinals 0/1/2. - - Catches reshape regressions: truncation, decision collapse, order - scrambling, and the legacy single-decision broadcast that would - fan-out one verdict to every action. - """ + """Approve / edit / reject for a 3-action bundle must land at ordinals 0/1/2.""" subagent = _build_bundle_subagent() task_tool = build_task_tool_with_parent_config( [ @@ -242,11 +200,8 @@ async def test_bundle_three_mixed_decisions_arrive_in_order(): ) assert isinstance(result, Command) - decision_text = result.update["decision_text"] - received = ast.literal_eval(decision_text) - assert received == decisions_payload, "bundle decisions must arrive verbatim" - # Cross-checks for the regressions this test exists to catch. - assert len(received["decisions"]) == 3 + received = ast.literal_eval(result.update["decision_text"]) + assert received == decisions_payload assert received["decisions"][0]["type"] == "approve" assert received["decisions"][1]["type"] == "edit" assert received["decisions"][1]["args"] == {"args": {"name": "edited-b"}} diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py index 2060051a2..e73fb2823 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py @@ -1,10 +1,4 @@ -"""Pure-function tests for the HITL resume side-channel helpers. - -Tests the invariant that backs the bridge: a queued resume value must be -read exactly once per turn. A second read returns ``None`` so the -parent ``task`` tool falls through to its fail-loud guard rather than -replaying the same resume payload (which would re-fire the interrupt). -""" +"""Resume side-channel must be read exactly once per turn.""" from __future__ import annotations @@ -17,7 +11,6 @@ from app.agents.multi_agent_chat.main_agent.graph.middleware.checkpointed_subage def _runtime_with_config(config: dict) -> ToolRuntime: - """Real ToolRuntime; only ``.config`` is exercised by the helpers.""" return ToolRuntime( state=None, context=None, @@ -37,9 +30,6 @@ class TestConsumeSurfsenseResume: assert consume_surfsense_resume(runtime) == {"decisions": ["approve"]} def test_second_call_returns_none(self): - # Regression guard: a second read must not replay the queued - # resume. If it did, the subagent would re-invoke with the - # same Command and the user-facing interrupt would fire twice. configurable: dict = {"surfsense_resume_value": {"decisions": ["approve"]}} runtime = _runtime_with_config({"configurable": configurable}) @@ -68,9 +58,6 @@ class TestHasSurfsenseResume: assert has_surfsense_resume(runtime) is True def test_does_not_consume_payload(self): - # The fail-loud guard in ``task_tool`` calls ``has_surfsense_resume`` - # *before* deciding to consume; the check itself must leave the - # payload queued for the matching ``consume_surfsense_resume`` call. configurable = {"surfsense_resume_value": "approve"} runtime = _runtime_with_config({"configurable": configurable}) diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py index 859833f1c..5cd62ed36 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py @@ -1,11 +1,4 @@ -"""Resilience contract for subagents built via ``pack_subagent``. - -Subagents (jira, linear, notion, ...) run on the same LLM as the parent. When -the provider rate-limits or returns an empty stream, a single hiccup must not -abort the user's HITL flow — the connector subagent has to keep moving. This -relies on ``ModelFallbackMiddleware`` being usable as a subagent -``extra_middleware`` so the production builder can wire it in. -""" +"""Subagent resilience contract: ``extra_middleware`` reaches the agent chain.""" from __future__ import annotations @@ -32,11 +25,10 @@ from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( class RateLimitError(Exception): - """Provider-style 429; matches the scoped-fallback eligibility allowlist by name.""" + """Name matches the scoped-fallback eligibility allowlist.""" class _AlwaysFailingChatModel(BaseChatModel): - """Mimics a provider hard-failing on every call (rate limit / empty stream).""" @property def _llm_type(self) -> str: @@ -76,7 +68,7 @@ class _AlwaysFailingChatModel(BaseChatModel): @pytest.mark.asyncio async def test_subagent_recovers_when_primary_llm_fails(): - """Primary blows up → fallback in extra_middleware finishes the turn.""" + """Fallback in ``extra_middleware`` must finish the turn when primary raises.""" primary = _AlwaysFailingChatModel() fallback = FakeMessagesListChatModel( responses=[AIMessage(content="recovered via fallback")] diff --git a/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py index af464d1dc..69f6fe6b7 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py +++ b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py @@ -1,12 +1,4 @@ -"""Exception-scope contract for ``ScopedModelFallbackMiddleware``. - -Upstream ``ModelFallbackMiddleware`` catches every ``Exception`` and walks -the fallback chain. That means a programming bug (``KeyError`` from a -botched tool config, ``TypeError`` from middleware, ...) burns 1+N model -round-trips and ~Nx tokens before its real cause surfaces. The scoped -variant only falls back on provider/network exception types so bugs fail -fast, with clean tracebacks. -""" +"""``ScopedModelFallbackMiddleware`` triggers fallback only on provider errors.""" from __future__ import annotations @@ -24,8 +16,6 @@ from langchain_core.outputs import ChatGeneration, ChatResult class _RaisingChatModel(BaseChatModel): - """LLM that raises a configurable exception on every invocation.""" - exc_to_raise: Any @property @@ -61,8 +51,6 @@ class _RaisingChatModel(BaseChatModel): class _RecordingChatModel(BaseChatModel): - """Returns a fixed message and counts how often it was called.""" - response_text: str = "fallback-ok" call_count: int = 0 @@ -94,14 +82,11 @@ class _RecordingChatModel(BaseChatModel): return self._generate(messages, stop, None, **kwargs) -# Locally defined provider-style error: importing openai/anthropic/etc. -# would couple the test to provider SDKs the contract intentionally avoids. class RateLimitError(Exception): - """Mimics ``openai.RateLimitError`` for name-based eligibility.""" + """Name matches the scoped-fallback eligibility allowlist.""" def _build_agent(primary: BaseChatModel, fallback: BaseChatModel): - """Compile a no-tools agent with the scoped fallback wired in.""" from langchain.agents import create_agent from app.agents.new_chat.middleware.scoped_model_fallback import ( @@ -118,7 +103,7 @@ def _build_agent(primary: BaseChatModel, fallback: BaseChatModel): @pytest.mark.asyncio async def test_provider_errors_trigger_fallback(): - """Class names matching the provider allowlist drive the fallback chain.""" + """Eligible exception names must drive the fallback chain.""" primary = _RaisingChatModel(exc_to_raise=RateLimitError("429 from provider")) fallback = _RecordingChatModel(response_text="recovered") @@ -133,7 +118,7 @@ async def test_provider_errors_trigger_fallback(): @pytest.mark.asyncio async def test_programming_errors_propagate_without_invoking_fallback(): - """``KeyError`` from agent-side bugs must surface immediately, no fallback retry.""" + """Non-eligible exceptions must propagate; fallback must not be invoked.""" primary = _RaisingChatModel(exc_to_raise=KeyError("missing_state_field")) fallback = _RecordingChatModel(response_text="should-never-arrive") @@ -142,7 +127,4 @@ async def test_programming_errors_propagate_without_invoking_fallback(): with pytest.raises(KeyError, match="missing_state_field"): await agent.ainvoke({"messages": [("user", "hi")]}) - assert fallback.call_count == 0, ( - "fallback was invoked for a programming error; " - "scoping rule is broken" - ) + assert fallback.call_count == 0 From a6df944247219a1e2ad6f1d3f0ee06dd6c17ae44 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:49:47 +0200 Subject: [PATCH 108/131] refactor(multi-agent): introduce shared flags helper and permissions package --- .../middleware/shared/__init__.py | 0 .../middleware/shared/flags.py | 10 ++ .../middleware/shared/permissions/__init__.py | 12 ++ .../middleware/shared/permissions/context.py | 109 ++++++++++++++++++ .../shared/permissions/middleware.py | 10 ++ 5 files changed, 141 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py new file mode 100644 index 000000000..69994ae00 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/flags.py @@ -0,0 +1,10 @@ +"""Single source of truth for the feature-flag predicate.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + + +def enabled(flags: AgentFeatureFlags, attr: str) -> bool: + """``flags.`` is on AND the new-agent-stack kill switch is off.""" + return getattr(flags, attr) and not flags.disable_new_agent_stack diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py new file mode 100644 index 000000000..4f2228170 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py @@ -0,0 +1,12 @@ +"""Permission rulesets fanned out to parent / general-purpose / subagent stacks.""" + +from __future__ import annotations + +from .context import PermissionContext, build_permission_context +from .middleware import build_full_permission_mw + +__all__ = [ + "PermissionContext", + "build_full_permission_mw", + "build_permission_context", +] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py new file mode 100644 index 000000000..f14d52714 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py @@ -0,0 +1,109 @@ +"""Derive shared permission context once; fan out to all three stack layers. + +The context carries: +- ``rulesets``: full ask/deny/allow rules for the main-agent permission middleware. +- ``general_purpose_interrupt_on``: ``ask`` rules mirrored as deepagents + ``interrupt_on`` so HITL still triggers from inside ``task`` runs (subagents + bypass the main-agent permission middleware). +- ``subagent_deny_mw``: a deny-only ``PermissionMiddleware`` instance shared + across the general-purpose and registry subagent stacks. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from langchain_core.tools import BaseTool + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import PermissionMiddleware +from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.new_chat.tools.registry import BUILTIN_TOOLS + +from ..flags import enabled + + +@dataclass(frozen=True) +class PermissionContext: + rulesets: list[Ruleset] + general_purpose_interrupt_on: dict[str, bool] + subagent_deny_mw: PermissionMiddleware | None + + +def build_permission_context( + *, + flags: AgentFeatureFlags, + filesystem_mode: FilesystemMode, + tools: Sequence[BaseTool], + available_connectors: list[str] | None, +) -> PermissionContext: + is_desktop_fs = filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER + permission_enabled = enabled(flags, "enable_permission") + + rulesets: list[Ruleset] = [] + if permission_enabled or is_desktop_fs: + rulesets.append( + Ruleset( + rules=[Rule(permission="*", pattern="*", action="allow")], + origin="surfsense_defaults", + ) + ) + if is_desktop_fs: + rulesets.append( + Ruleset( + rules=[ + Rule(permission="rm", pattern="*", action="ask"), + Rule(permission="rmdir", pattern="*", action="ask"), + Rule(permission="move_file", pattern="*", action="ask"), + Rule(permission="edit_file", pattern="*", action="ask"), + Rule(permission="write_file", pattern="*", action="ask"), + ], + origin="desktop_safety", + ) + ) + + tool_names_in_use = {t.name for t in tools} + + if permission_enabled: + available_set = set(available_connectors or []) + synthesized: list[Rule] = [] + for tool_def in BUILTIN_TOOLS: + if tool_def.name not in tool_names_in_use: + continue + rc = tool_def.required_connector + if rc and rc not in available_set: + synthesized.append( + Rule(permission=tool_def.name, pattern="*", action="deny") + ) + if synthesized: + rulesets.append( + Ruleset(rules=synthesized, origin="connector_synthesized") + ) + + general_purpose_interrupt_on: dict[str, bool] = { + rule.permission: True + for rs in rulesets + for rule in rs.rules + if rule.action == "ask" and rule.permission in tool_names_in_use + } + + deny_rulesets = [ + Ruleset( + rules=[r for r in rs.rules if r.action == "deny"], + origin=rs.origin, + ) + for rs in rulesets + ] + deny_rulesets = [rs for rs in deny_rulesets if rs.rules] + + subagent_deny_mw: PermissionMiddleware | None = ( + PermissionMiddleware(rulesets=deny_rulesets) if deny_rulesets else None + ) + + return PermissionContext( + rulesets=rulesets, + general_purpose_interrupt_on=general_purpose_interrupt_on, + subagent_deny_mw=subagent_deny_mw, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware.py new file mode 100644 index 000000000..704a26fb3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware.py @@ -0,0 +1,10 @@ +"""Main-agent permission middleware (full ask/deny/allow rules).""" + +from __future__ import annotations + +from app.agents.new_chat.middleware import PermissionMiddleware +from app.agents.new_chat.permissions import Ruleset + + +def build_full_permission_mw(rulesets: list[Ruleset]) -> PermissionMiddleware | None: + return PermissionMiddleware(rulesets=rulesets) if rulesets else None From 91701bb49afe93578c99f25cb5b019b2b261ceb8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:51:02 +0200 Subject: [PATCH 109/131] refactor(multi-agent): split shared resilience bundle into per-concept files --- .../middleware/shared/resilience/__init__.py | 7 +++ .../middleware/shared/resilience/bundle.py | 51 +++++++++++++++++++ .../middleware/shared/resilience/fallback.py | 27 ++++++++++ .../shared/resilience/model_call_limit.py | 21 ++++++++ .../middleware/shared/resilience/retry.py | 16 ++++++ .../shared/resilience/tool_call_limit.py | 21 ++++++++ 6 files changed, 143 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py new file mode 100644 index 000000000..92596b771 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/__init__.py @@ -0,0 +1,7 @@ +"""Resilience middleware shared as the same instances across parent / general-purpose / registry.""" + +from __future__ import annotations + +from .bundle import ResilienceBundle, build_resilience_bundle + +__all__ = ["ResilienceBundle", "build_resilience_bundle"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py new file mode 100644 index 000000000..45f76a6f3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/bundle.py @@ -0,0 +1,51 @@ +"""Construct each resilience middleware once; same instances flow into every consumer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from langchain.agents.middleware import ( + ModelCallLimitMiddleware, + ToolCallLimitMiddleware, +) + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import RetryAfterMiddleware +from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, +) + +from .fallback import build_fallback_mw +from .model_call_limit import build_model_call_limit_mw +from .retry import build_retry_mw +from .tool_call_limit import build_tool_call_limit_mw + + +@dataclass(frozen=True) +class ResilienceBundle: + retry: RetryAfterMiddleware | None + fallback: ScopedModelFallbackMiddleware | None + model_call_limit: ModelCallLimitMiddleware | None + tool_call_limit: ToolCallLimitMiddleware | None + + def as_list(self) -> list[Any]: + return [ + m + for m in ( + self.retry, + self.fallback, + self.model_call_limit, + self.tool_call_limit, + ) + if m is not None + ] + + +def build_resilience_bundle(flags: AgentFeatureFlags) -> ResilienceBundle: + return ResilienceBundle( + retry=build_retry_mw(flags), + fallback=build_fallback_mw(flags), + model_call_limit=build_model_call_limit_mw(flags), + tool_call_limit=build_tool_call_limit_mw(flags), + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py new file mode 100644 index 000000000..ea68a764e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/fallback.py @@ -0,0 +1,27 @@ +"""Switch to a fallback model on provider/network errors only.""" + +from __future__ import annotations + +import logging + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware.scoped_model_fallback import ( + ScopedModelFallbackMiddleware, +) + +from ..flags import enabled + + +def build_fallback_mw( + flags: AgentFeatureFlags, +) -> ScopedModelFallbackMiddleware | None: + if not enabled(flags, "enable_model_fallback"): + return None + try: + return ScopedModelFallbackMiddleware( + "openai:gpt-4o-mini", + "anthropic:claude-3-5-haiku-20241022", + ) + except Exception: + logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py new file mode 100644 index 000000000..85707a385 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/model_call_limit.py @@ -0,0 +1,21 @@ +"""Cap model calls per thread / per run to prevent runaway cost.""" + +from __future__ import annotations + +from langchain.agents.middleware import ModelCallLimitMiddleware + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + +from ..flags import enabled + + +def build_model_call_limit_mw( + flags: AgentFeatureFlags, +) -> ModelCallLimitMiddleware | None: + if not enabled(flags, "enable_model_call_limit"): + return None + return ModelCallLimitMiddleware( + thread_limit=120, + run_limit=80, + exit_behavior="end", + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py new file mode 100644 index 000000000..c98fc4083 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/retry.py @@ -0,0 +1,16 @@ +"""Retry on transient model errors (e.g. Retry-After-bearing 429s).""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import RetryAfterMiddleware + +from ..flags import enabled + + +def build_retry_mw(flags: AgentFeatureFlags) -> RetryAfterMiddleware | None: + return ( + RetryAfterMiddleware(max_retries=3) + if enabled(flags, "enable_retry_after") + else None + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py new file mode 100644 index 000000000..dcde81f37 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/resilience/tool_call_limit.py @@ -0,0 +1,21 @@ +"""Cap tool calls per thread / per run to bound infinite-loop blast radius.""" + +from __future__ import annotations + +from langchain.agents.middleware import ToolCallLimitMiddleware + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + +from ..flags import enabled + + +def build_tool_call_limit_mw( + flags: AgentFeatureFlags, +) -> ToolCallLimitMiddleware | None: + if not enabled(flags, "enable_tool_call_limit"): + return None + return ToolCallLimitMiddleware( + thread_limit=300, + run_limit=80, + exit_behavior="continue", + ) From 67036448f9ae42ae788780e2893e13eb86e2f842 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:51:17 +0200 Subject: [PATCH 110/131] refactor(multi-agent): add shared middleware factory per concept --- .../middleware/shared/anthropic_cache.py | 9 +++++++ .../middleware/shared/compaction.py | 14 +++++++++++ .../middleware/shared/file_intent.py | 11 ++++++++ .../middleware/shared/filesystem.py | 25 +++++++++++++++++++ .../middleware/shared/memory.py | 19 ++++++++++++++ .../middleware/shared/patch_tool_calls.py | 9 +++++++ .../middleware/shared/todos.py | 9 +++++++ 7 files changed, 96 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/file_intent.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py new file mode 100644 index 000000000..f99fb9c7f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/anthropic_cache.py @@ -0,0 +1,9 @@ +"""Anthropic prompt caching annotations on system/tool/message blocks.""" + +from __future__ import annotations + +from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware + + +def build_anthropic_cache_mw() -> AnthropicPromptCachingMiddleware: + return AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore") diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py new file mode 100644 index 000000000..b59e7d2c4 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/compaction.py @@ -0,0 +1,14 @@ +"""Context-window summarization with SurfSense protected sections.""" + +from __future__ import annotations + +from typing import Any + +from deepagents.backends import StateBackend +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.middleware import create_surfsense_compaction_middleware + + +def build_compaction_mw(llm: BaseChatModel) -> Any: + return create_surfsense_compaction_middleware(llm, StateBackend) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/file_intent.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/file_intent.py new file mode 100644 index 000000000..5ff65aa12 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/file_intent.py @@ -0,0 +1,11 @@ +"""File-intent classifier that gates strict write contracts.""" + +from __future__ import annotations + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.middleware import FileIntentMiddleware + + +def build_file_intent_mw(llm: BaseChatModel) -> FileIntentMiddleware: + return FileIntentMiddleware(llm=llm) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem.py new file mode 100644 index 000000000..9481f5167 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/filesystem.py @@ -0,0 +1,25 @@ +"""SurfSense filesystem tools/middleware.""" + +from __future__ import annotations + +from typing import Any + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import SurfSenseFilesystemMiddleware + + +def build_filesystem_mw( + *, + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, +) -> SurfSenseFilesystemMiddleware: + return SurfSenseFilesystemMiddleware( + backend=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + created_by_id=user_id, + thread_id=thread_id, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py new file mode 100644 index 000000000..9316b3e21 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/memory.py @@ -0,0 +1,19 @@ +"""User/team memory injection prepended to the conversation.""" + +from __future__ import annotations + +from app.agents.new_chat.middleware import MemoryInjectionMiddleware +from app.db import ChatVisibility + + +def build_memory_mw( + *, + user_id: str | None, + search_space_id: int, + visibility: ChatVisibility, +) -> MemoryInjectionMiddleware: + return MemoryInjectionMiddleware( + user_id=user_id, + search_space_id=search_space_id, + thread_visibility=visibility, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py new file mode 100644 index 000000000..50036dbbe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/patch_tool_calls.py @@ -0,0 +1,9 @@ +"""Repair dangling tool-call sequences before each agent turn.""" + +from __future__ import annotations + +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware + + +def build_patch_tool_calls_mw() -> PatchToolCallsMiddleware: + return PatchToolCallsMiddleware() diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py new file mode 100644 index 000000000..ea9173a1d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/todos.py @@ -0,0 +1,9 @@ +"""Todo-list middleware (each consumer needs its own instance).""" + +from __future__ import annotations + +from langchain.agents.middleware import TodoListMiddleware + + +def build_todos_mw() -> TodoListMiddleware: + return TodoListMiddleware() From 6a4dacda72bce83c6ce072e07ed928bdfe851240 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:53:49 +0200 Subject: [PATCH 111/131] refactor(multi-agent): add main-agent observability and lifecycle middleware factories --- .../middleware/main_agent/__init__.py | 0 .../middleware/main_agent/action_log.py | 36 ++++++++++++++ .../middleware/main_agent/anonymous_doc.py | 16 ++++++ .../middleware/main_agent/busy_mutex.py | 12 +++++ .../middleware/main_agent/otel.py | 12 +++++ .../middleware/main_agent/plugins.py | 49 +++++++++++++++++++ 6 files changed, 125 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py new file mode 100644 index 000000000..c9f893d97 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/action_log.py @@ -0,0 +1,36 @@ +"""Audit row per tool call (reversibility metadata).""" + +from __future__ import annotations + +import logging + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import ActionLogMiddleware +from app.agents.new_chat.tools.registry import BUILTIN_TOOLS + +from ..shared.flags import enabled + + +def build_action_log_mw( + *, + flags: AgentFeatureFlags, + thread_id: int | None, + search_space_id: int, + user_id: str | None, +) -> ActionLogMiddleware | None: + if not enabled(flags, "enable_action_log") or thread_id is None: + return None + try: + tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS} + return ActionLogMiddleware( + thread_id=thread_id, + search_space_id=search_space_id, + user_id=user_id, + tool_definitions=tool_defs_by_name, + ) + except Exception: # pragma: no cover - defensive + logging.warning( + "ActionLogMiddleware init failed; running without it.", + exc_info=True, + ) + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py new file mode 100644 index 000000000..afd54a2d3 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/anonymous_doc.py @@ -0,0 +1,16 @@ +"""Anonymous document hydration from Redis (cloud only).""" + +from __future__ import annotations + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import AnonymousDocumentMiddleware + + +def build_anonymous_doc_mw( + *, + filesystem_mode: FilesystemMode, + anon_session_id: str | None, +) -> AnonymousDocumentMiddleware | None: + if filesystem_mode != FilesystemMode.CLOUD: + return None + return AnonymousDocumentMiddleware(anon_session_id=anon_session_id) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py new file mode 100644 index 000000000..0ea53bf16 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/busy_mutex.py @@ -0,0 +1,12 @@ +"""Per-thread cooperative lock around the whole turn.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import BusyMutexMiddleware + +from ..shared.flags import enabled + + +def build_busy_mutex_mw(flags: AgentFeatureFlags) -> BusyMutexMiddleware | None: + return BusyMutexMiddleware() if enabled(flags, "enable_busy_mutex") else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py new file mode 100644 index 000000000..bd7516e65 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/otel.py @@ -0,0 +1,12 @@ +"""OTel spans on model and tool calls.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import OtelSpanMiddleware + +from ..shared.flags import enabled + + +def build_otel_mw(flags: AgentFeatureFlags) -> OtelSpanMiddleware | None: + return OtelSpanMiddleware() if enabled(flags, "enable_otel") else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py new file mode 100644 index 000000000..4418e3806 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/plugins.py @@ -0,0 +1,49 @@ +"""Tail-of-stack plugin slot driven by env allowlist.""" + +from __future__ import annotations + +import logging +from typing import Any + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.plugin_loader import ( + PluginContext, + load_allowed_plugin_names_from_env, + load_plugin_middlewares, +) +from app.db import ChatVisibility + +from ..shared.flags import enabled + + +def build_plugin_middlewares( + *, + flags: AgentFeatureFlags, + search_space_id: int, + user_id: str | None, + visibility: ChatVisibility, + llm: BaseChatModel, +) -> list[Any]: + if not enabled(flags, "enable_plugin_loader"): + return [] + try: + allowed_names = load_allowed_plugin_names_from_env() + if not allowed_names: + return [] + return load_plugin_middlewares( + PluginContext.build( + search_space_id=search_space_id, + user_id=user_id, + thread_visibility=visibility, + llm=llm, + ), + allowed_plugin_names=allowed_names, + ) + except Exception: # pragma: no cover - defensive + logging.warning( + "Plugin loader failed; continuing without plugins.", + exc_info=True, + ) + return [] From 390dc9307fac6733df8848b043cac7d7fabd1365 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:53:59 +0200 Subject: [PATCH 112/131] refactor(multi-agent): add main-agent knowledge middleware factories --- .../middleware/main_agent/kb_persistence.py | 23 ++++++++++++++++ .../main_agent/knowledge_priority.py | 27 +++++++++++++++++++ .../middleware/main_agent/knowledge_tree.py | 23 ++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py new file mode 100644 index 000000000..4b27581e7 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/kb_persistence.py @@ -0,0 +1,23 @@ +"""Commit staged cloud filesystem mutations to Postgres at end of turn.""" + +from __future__ import annotations + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import KnowledgeBasePersistenceMiddleware + + +def build_kb_persistence_mw( + *, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, +) -> KnowledgeBasePersistenceMiddleware | None: + if filesystem_mode != FilesystemMode.CLOUD: + return None + return KnowledgeBasePersistenceMiddleware( + search_space_id=search_space_id, + created_by_id=user_id, + filesystem_mode=filesystem_mode, + thread_id=thread_id, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py new file mode 100644 index 000000000..395d2a7af --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_priority.py @@ -0,0 +1,27 @@ +"""KB priority planner: injection.""" + +from __future__ import annotations + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import KnowledgePriorityMiddleware + + +def build_knowledge_priority_mw( + *, + llm: BaseChatModel, + search_space_id: int, + filesystem_mode: FilesystemMode, + available_connectors: list[str] | None, + available_document_types: list[str] | None, + mentioned_document_ids: list[int] | None, +) -> KnowledgePriorityMiddleware: + return KnowledgePriorityMiddleware( + llm=llm, + search_space_id=search_space_id, + filesystem_mode=filesystem_mode, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py new file mode 100644 index 000000000..404082401 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/knowledge_tree.py @@ -0,0 +1,23 @@ +""" injection (cloud only).""" + +from __future__ import annotations + +from langchain_core.language_models import BaseChatModel + +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import KnowledgeTreeMiddleware + + +def build_knowledge_tree_mw( + *, + filesystem_mode: FilesystemMode, + search_space_id: int, + llm: BaseChatModel, +) -> KnowledgeTreeMiddleware | None: + if filesystem_mode != FilesystemMode.CLOUD: + return None + return KnowledgeTreeMiddleware( + search_space_id=search_space_id, + filesystem_mode=filesystem_mode, + llm=llm, + ) From b0ee44b2f15adfdf379c2b7f1967733a66a8e090 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:54:13 +0200 Subject: [PATCH 113/131] refactor(multi-agent): add main-agent safety and llm-shaping middleware factories --- .../middleware/main_agent/context_editing.py | 50 +++++++++++++++++++ .../middleware/main_agent/dedup_hitl.py | 13 +++++ .../middleware/main_agent/doom_loop.py | 12 +++++ .../middleware/main_agent/noop_injection.py | 12 +++++ .../middleware/main_agent/repair.py | 50 +++++++++++++++++++ .../middleware/main_agent/selector.py | 39 +++++++++++++++ .../middleware/main_agent/skills.py | 39 +++++++++++++++ 7 files changed, 215 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/selector.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py new file mode 100644 index 000000000..e8f99933e --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/context_editing.py @@ -0,0 +1,50 @@ +"""Spill + clear-tool-uses passes to keep payloads under budget.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.main_agent.context_prune.prune_tool_names import ( + safe_exclude_tools, +) +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import ( + ClearToolUsesEdit, + SpillingContextEditingMiddleware, + SpillToBackendEdit, +) + +from ..shared.flags import enabled + + +def build_context_editing_mw( + *, + flags: AgentFeatureFlags, + max_input_tokens: int | None, + tools: Sequence[BaseTool], + backend_resolver: Any, +) -> SpillingContextEditingMiddleware | None: + if not enabled(flags, "enable_context_editing") or not max_input_tokens: + return None + spill_edit = SpillToBackendEdit( + trigger=int(max_input_tokens * 0.55), + clear_at_least=int(max_input_tokens * 0.15), + keep=5, + exclude_tools=safe_exclude_tools(tools), + clear_tool_inputs=True, + ) + clear_edit = ClearToolUsesEdit( + trigger=int(max_input_tokens * 0.55), + clear_at_least=int(max_input_tokens * 0.15), + keep=5, + exclude_tools=safe_exclude_tools(tools), + clear_tool_inputs=True, + placeholder="[cleared - older tool output trimmed for context]", + ) + return SpillingContextEditingMiddleware( + edits=[spill_edit, clear_edit], + backend_resolver=backend_resolver, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py new file mode 100644 index 000000000..66cae300b --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/dedup_hitl.py @@ -0,0 +1,13 @@ +"""Drop duplicate HITL tool calls before execution.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.tools import BaseTool + +from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware + + +def build_dedup_hitl_mw(tools: Sequence[BaseTool]) -> DedupHITLToolCallsMiddleware: + return DedupHITLToolCallsMiddleware(agent_tools=list(tools)) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py new file mode 100644 index 000000000..a0b294092 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py @@ -0,0 +1,12 @@ +"""Stop N identical tool calls in a row via interrupt.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import DoomLoopMiddleware + +from ..shared.flags import enabled + + +def build_doom_loop_mw(flags: AgentFeatureFlags) -> DoomLoopMiddleware | None: + return DoomLoopMiddleware(threshold=3) if enabled(flags, "enable_doom_loop") else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py new file mode 100644 index 000000000..6e6467ad0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/noop_injection.py @@ -0,0 +1,12 @@ +"""Provider-compat: append a `_noop` tool when tools=[] but history has tool calls.""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import NoopInjectionMiddleware + +from ..shared.flags import enabled + + +def build_noop_injection_mw(flags: AgentFeatureFlags) -> NoopInjectionMiddleware | None: + return NoopInjectionMiddleware() if enabled(flags, "enable_compaction_v2") else None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py new file mode 100644 index 000000000..378b61be1 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/repair.py @@ -0,0 +1,50 @@ +"""Repair miscased / unknown tool names to the registered set or invalid_tool.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.tools import BaseTool + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.middleware import ToolCallNameRepairMiddleware + +from ..shared.flags import enabled + +# deepagents-built-in tool names the repair pass treats as known. +_DEEPAGENT_BUILTIN_TOOL_NAMES: frozenset[str] = frozenset( + { + "write_todos", + "ls", + "read_file", + "write_file", + "edit_file", + "glob", + "grep", + "execute", + "task", + "mkdir", + "cd", + "pwd", + "move_file", + "rm", + "rmdir", + "list_tree", + "execute_code", + } +) + + +def build_repair_mw( + *, + flags: AgentFeatureFlags, + tools: Sequence[BaseTool], +) -> ToolCallNameRepairMiddleware | None: + if not enabled(flags, "enable_tool_call_repair"): + return None + registered_names: set[str] = {t.name for t in tools} + registered_names |= _DEEPAGENT_BUILTIN_TOOL_NAMES + return ToolCallNameRepairMiddleware( + registered_tool_names=registered_names, + fuzzy_match_threshold=None, + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/selector.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/selector.py new file mode 100644 index 000000000..8e7a32be8 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/selector.py @@ -0,0 +1,39 @@ +"""LLM-based tool subset selection (only when >30 tools).""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence + +from langchain.agents.middleware import LLMToolSelectorMiddleware +from langchain_core.tools import BaseTool + +from app.agents.new_chat.feature_flags import AgentFeatureFlags + +from ..shared.flags import enabled + + +def build_selector_mw( + *, + flags: AgentFeatureFlags, + tools: Sequence[BaseTool], +) -> LLMToolSelectorMiddleware | None: + if not enabled(flags, "enable_llm_tool_selector") or len(tools) <= 30: + return None + try: + return LLMToolSelectorMiddleware( + model="openai:gpt-4o-mini", + max_tools=12, + always_include=[ + name + for name in ( + "update_memory", + "get_connected_accounts", + "scrape_webpage", + ) + if name in {t.name for t in tools} + ], + ) + except Exception: + logging.warning("LLMToolSelectorMiddleware init failed; skipping.") + return None diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py new file mode 100644 index 000000000..63a57c5a0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/skills.py @@ -0,0 +1,39 @@ +"""Skill discovery + injection.""" + +from __future__ import annotations + +import logging + +from deepagents.middleware.skills import SkillsMiddleware + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import ( + build_skills_backend_factory, + default_skills_sources, +) + +from ..shared.flags import enabled + + +def build_skills_mw( + *, + flags: AgentFeatureFlags, + filesystem_mode: FilesystemMode, + search_space_id: int, +) -> SkillsMiddleware | None: + if not enabled(flags, "enable_skills"): + return None + try: + skills_factory = build_skills_backend_factory( + search_space_id=search_space_id + if filesystem_mode == FilesystemMode.CLOUD + else None, + ) + return SkillsMiddleware( + backend=skills_factory, + sources=default_skills_sources(), + ) + except Exception as exc: # pragma: no cover - defensive + logging.warning("SkillsMiddleware init failed; skipping: %s", exc) + return None From 611fef8666990b4994d8f990b8b796a36027c6fd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:54:27 +0200 Subject: [PATCH 114/131] refactor(multi-agent): add subagent extras builder and drop filesystem from registry subagents --- .../middleware/subagent/__init__.py | 0 .../middleware/subagent/extras.py | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/extras.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/extras.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/extras.py new file mode 100644 index 000000000..46dca8a81 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/extras.py @@ -0,0 +1,28 @@ +"""Extra middleware threaded into every registry subagent's stack. + +Registry subagents are scoped to one domain (deliverables, research, memory, +connectors, MCP) and never read or write the SurfSense filesystem — that +capability belongs to the main agent and is delegated to the general-purpose +subagent as an escape hatch. Keeping FS off the registry stacks avoids +polluting their tool surface with FS tools they never act on. +""" + +from __future__ import annotations + +from typing import Any + +from ..shared.permissions import PermissionContext +from ..shared.resilience import ResilienceBundle +from ..shared.todos import build_todos_mw + + +def build_subagent_extras( + *, + permissions: PermissionContext, + resilience: ResilienceBundle, +) -> list[Any]: + extras: list[Any] = [build_todos_mw()] + if permissions.subagent_deny_mw is not None: + extras.append(permissions.subagent_deny_mw) + extras.extend(resilience.as_list()) + return extras From 7690e8b278e525cae61291ac9bec6cfa773b06db Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:54:45 +0200 Subject: [PATCH 115/131] refactor(multi-agent): relocate general-purpose subagent next to other builtins --- .../builtins/general_purpose/__init__.py | 0 .../builtins/general_purpose/agent.py | 105 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/agent.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/agent.py new file mode 100644 index 000000000..1c3c44f12 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/general_purpose/agent.py @@ -0,0 +1,105 @@ +"""General-purpose subagent for the multi-agent main agent.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, cast + +from deepagents import SubAgent +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware +from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT +from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +from app.agents.multi_agent_chat.middleware.shared.anthropic_cache import ( + build_anthropic_cache_mw, +) +from app.agents.multi_agent_chat.middleware.shared.compaction import ( + build_compaction_mw, +) +from app.agents.multi_agent_chat.middleware.shared.file_intent import ( + build_file_intent_mw, +) +from app.agents.multi_agent_chat.middleware.shared.filesystem import ( + build_filesystem_mw, +) +from app.agents.multi_agent_chat.middleware.shared.patch_tool_calls import ( + build_patch_tool_calls_mw, +) +from app.agents.multi_agent_chat.middleware.shared.permissions import ( + PermissionContext, +) +from app.agents.multi_agent_chat.middleware.shared.resilience import ( + ResilienceBundle, +) +from app.agents.multi_agent_chat.middleware.shared.todos import build_todos_mw +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware import MemoryInjectionMiddleware + +NAME = "general-purpose" + + +def build_subagent( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + permissions: PermissionContext, + resilience: ResilienceBundle, + memory_mw: MemoryInjectionMiddleware, +) -> SubAgent: + """Deny + resilience inserts encapsulated here so the orchestrator never mutates the list.""" + middleware: list[Any] = [ + build_todos_mw(), + memory_mw, + build_file_intent_mw(llm), + build_filesystem_mw( + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + ), + build_compaction_mw(llm), + build_patch_tool_calls_mw(), + build_anthropic_cache_mw(), + ] + + if permissions.subagent_deny_mw is not None: + patch_idx = next( + ( + i + for i, m in enumerate(middleware) + if isinstance(m, PatchToolCallsMiddleware) + ), + len(middleware), + ) + middleware.insert(patch_idx, permissions.subagent_deny_mw) + + resilience_mws = resilience.as_list() + if resilience_mws: + cache_idx = next( + ( + i + for i, m in enumerate(middleware) + if isinstance(m, AnthropicPromptCachingMiddleware) + ), + len(middleware), + ) + for offset, mw in enumerate(resilience_mws): + middleware.insert(cache_idx + offset, mw) + + spec: dict[str, Any] = { + **GENERAL_PURPOSE_SUBAGENT, + "model": llm, + "tools": tools, + "middleware": middleware, + } + if permissions.general_purpose_interrupt_on: + spec["interrupt_on"] = permissions.general_purpose_interrupt_on + return cast(SubAgent, spec) From 5abae09435eaae8885978d12f48898eed83751dc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:55:03 +0200 Subject: [PATCH 116/131] refactor(multi-agent): add slimmed orchestrator at middleware/stack.py --- .../multi_agent_chat/middleware/__init__.py | 7 + .../multi_agent_chat/middleware/stack.py | 212 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py new file mode 100644 index 000000000..e6eed9fbe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/__init__.py @@ -0,0 +1,7 @@ +"""Multi-agent middleware stack assembly.""" + +from __future__ import annotations + +from .stack import build_main_agent_deepagent_middleware + +__all__ = ["build_main_agent_deepagent_middleware"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py new file mode 100644 index 000000000..563332986 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py @@ -0,0 +1,212 @@ +"""Main-agent middleware list assembly: one line per slot.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from deepagents.backends import StateBackend +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.types import Checkpointer + +from app.agents.multi_agent_chat.subagents import ( + build_subagents, + get_subagents_to_exclude, +) +from app.agents.multi_agent_chat.subagents.builtins.general_purpose.agent import ( + build_subagent as build_general_purpose_subagent, +) +from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.db import ChatVisibility + +from .main_agent.action_log import build_action_log_mw +from .main_agent.anonymous_doc import build_anonymous_doc_mw +from .main_agent.busy_mutex import build_busy_mutex_mw +from .main_agent.checkpointed_subagent_middleware import ( + SurfSenseCheckpointedSubAgentMiddleware, +) +from .main_agent.context_editing import build_context_editing_mw +from .main_agent.dedup_hitl import build_dedup_hitl_mw +from .main_agent.doom_loop import build_doom_loop_mw +from .main_agent.kb_persistence import build_kb_persistence_mw +from .main_agent.knowledge_priority import build_knowledge_priority_mw +from .main_agent.knowledge_tree import build_knowledge_tree_mw +from .main_agent.noop_injection import build_noop_injection_mw +from .main_agent.otel import build_otel_mw +from .main_agent.plugins import build_plugin_middlewares +from .main_agent.repair import build_repair_mw +from .main_agent.selector import build_selector_mw +from .main_agent.skills import build_skills_mw +from .shared.anthropic_cache import build_anthropic_cache_mw +from .shared.compaction import build_compaction_mw +from .shared.file_intent import build_file_intent_mw +from .shared.filesystem import build_filesystem_mw +from .shared.memory import build_memory_mw +from .shared.patch_tool_calls import build_patch_tool_calls_mw +from .shared.permissions import ( + build_full_permission_mw, + build_permission_context, +) +from .shared.resilience import build_resilience_bundle +from .shared.todos import build_todos_mw +from .subagent.extras import build_subagent_extras + + +def build_main_agent_deepagent_middleware( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + visibility: ChatVisibility, + anon_session_id: str | None, + available_connectors: list[str] | None, + available_document_types: list[str] | None, + mentioned_document_ids: list[int] | None, + max_input_tokens: int | None, + flags: AgentFeatureFlags, + subagent_dependencies: dict[str, Any], + checkpointer: Checkpointer, + mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None, + disabled_tools: list[str] | None = None, +) -> list[Any]: + """Ordered middleware for ``create_agent`` (None entries already stripped).""" + permissions = build_permission_context( + flags=flags, + filesystem_mode=filesystem_mode, + tools=tools, + available_connectors=available_connectors, + ) + resilience = build_resilience_bundle(flags) + + # Single instance threaded into both the main-agent stack and the general-purpose subagent. + memory_mw = build_memory_mw( + user_id=user_id, + search_space_id=search_space_id, + visibility=visibility, + ) + + general_purpose_subagent = build_general_purpose_subagent( + llm=llm, + tools=tools, + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + permissions=permissions, + resilience=resilience, + memory_mw=memory_mw, + ) + + subagents_registry: list[SubAgent] = [] + try: + subagent_extras = build_subagent_extras( + permissions=permissions, + resilience=resilience, + ) + subagents_registry = build_subagents( + dependencies=subagent_dependencies, + model=llm, + extra_middleware=subagent_extras, + mcp_tools_by_agent=mcp_tools_by_agent or {}, + exclude=get_subagents_to_exclude(available_connectors), + disabled_tools=disabled_tools, + ) + logging.info( + "Subagents registry: %s", + [s["name"] for s in subagents_registry], + ) + except Exception: + logging.exception("Subagents registry build failed") + raise + + subagents: list[SubAgent] = [general_purpose_subagent, *subagents_registry] + + stack: list[Any] = [ + build_busy_mutex_mw(flags), + build_otel_mw(flags), + build_todos_mw(), + memory_mw, + build_anonymous_doc_mw( + filesystem_mode=filesystem_mode, anon_session_id=anon_session_id + ), + build_knowledge_tree_mw( + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + llm=llm, + ), + build_knowledge_priority_mw( + llm=llm, + search_space_id=search_space_id, + filesystem_mode=filesystem_mode, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + ), + build_file_intent_mw(llm), + build_filesystem_mw( + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + ), + build_kb_persistence_mw( + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + ), + build_skills_mw( + flags=flags, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + ), + SurfSenseCheckpointedSubAgentMiddleware( + checkpointer=checkpointer, + backend=StateBackend, + subagents=subagents, + ), + build_selector_mw(flags=flags, tools=tools), + resilience.model_call_limit, + resilience.tool_call_limit, + build_context_editing_mw( + flags=flags, + max_input_tokens=max_input_tokens, + tools=tools, + backend_resolver=backend_resolver, + ), + build_compaction_mw(llm), + build_noop_injection_mw(flags), + resilience.retry, + resilience.fallback, + build_repair_mw(flags=flags, tools=tools), + build_full_permission_mw(permissions.rulesets), + build_doom_loop_mw(flags), + build_action_log_mw( + flags=flags, + thread_id=thread_id, + search_space_id=search_space_id, + user_id=user_id, + ), + build_patch_tool_calls_mw(), + build_dedup_hitl_mw(tools), + *build_plugin_middlewares( + flags=flags, + search_space_id=search_space_id, + user_id=user_id, + visibility=visibility, + llm=llm, + ), + build_anthropic_cache_mw(), + ] + return [m for m in stack if m is not None] From 73272ce348e5da1dd92ae4c301a5dff8eb215fef Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 20:55:38 +0200 Subject: [PATCH 117/131] refactor(multi-agent): switch compile graph to new orchestrator and drop deepagent_stack --- .../main_agent/graph/compile_graph_sync.py | 5 +- .../main_agent/graph/middleware/__init__.py | 7 - .../graph/middleware/deepagent_stack.py | 539 ------------------ .../__init__.py | 0 .../config.py | 0 .../constants.py | 0 .../middleware.py | 0 .../propagation.py | 0 .../resume.py | 0 .../task_tool.py | 0 .../test_hitl_bridge.py | 2 +- .../test_resume_helpers.py | 2 +- 12 files changed, 5 insertions(+), 550 deletions(-) delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py rename surfsense_backend/app/agents/multi_agent_chat/{main_agent/graph/middleware => middleware/main_agent}/checkpointed_subagent_middleware/__init__.py (100%) rename surfsense_backend/app/agents/multi_agent_chat/{main_agent/graph/middleware => middleware/main_agent}/checkpointed_subagent_middleware/config.py (100%) rename surfsense_backend/app/agents/multi_agent_chat/{main_agent/graph/middleware => middleware/main_agent}/checkpointed_subagent_middleware/constants.py (100%) rename surfsense_backend/app/agents/multi_agent_chat/{main_agent/graph/middleware => middleware/main_agent}/checkpointed_subagent_middleware/middleware.py (100%) rename surfsense_backend/app/agents/multi_agent_chat/{main_agent/graph/middleware => middleware/main_agent}/checkpointed_subagent_middleware/propagation.py (100%) rename surfsense_backend/app/agents/multi_agent_chat/{main_agent/graph/middleware => middleware/main_agent}/checkpointed_subagent_middleware/resume.py (100%) rename surfsense_backend/app/agents/multi_agent_chat/{main_agent/graph/middleware => middleware/main_agent}/checkpointed_subagent_middleware/task_tool.py (100%) 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 index 7afa30a31..4ed94bf7b 100644 --- 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 @@ -11,6 +11,9 @@ from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool from langgraph.types import Checkpointer +from app.agents.multi_agent_chat.middleware import ( + build_main_agent_deepagent_middleware, +) from app.agents.multi_agent_chat.subagents.shared.permissions import ( ToolsPermissions, ) @@ -19,8 +22,6 @@ 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( *, 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/deepagent_stack.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py deleted file mode 100644 index 1d6a8763e..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/deepagent_stack.py +++ /dev/null @@ -1,539 +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, - 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.middleware.scoped_model_fallback import ( - ScopedModelFallbackMiddleware, -) -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) - - # Defined early so the same instances reach both gp_middleware and - # subagent_extra_middleware below. - retry_mw = ( - RetryAfterMiddleware(max_retries=3) - if flags.enable_retry_after and not flags.disable_new_agent_stack - else None - ) - fallback_mw: ScopedModelFallbackMiddleware | None = None - if flags.enable_model_fallback and not flags.disable_new_agent_stack: - try: - fallback_mw = ScopedModelFallbackMiddleware( - "openai:gpt-4o-mini", - "anthropic:claude-3-5-haiku-20241022", - ) - except Exception: - logging.warning("ScopedModelFallbackMiddleware init failed; skipping.") - fallback_mw = None - - # Per-agent caps; counts are not summed across parent + subagents. - 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 - ) - - # gp_middleware is held by reference inside general_purpose_spec, so - # mutating it here propagates into the spec. - _gp_resilience: list[Any] = [ - m - for m in (retry_mw, fallback_mw, model_call_limit_mw, tool_call_limit_mw) - if m is not None - ] - if _gp_resilience: - _cache_idx = next( - ( - i - for i, m in enumerate(gp_middleware) - if isinstance(m, AnthropicPromptCachingMiddleware) - ), - len(gp_middleware), - ) - for offset, mw in enumerate(_gp_resilience): - gp_middleware.insert(_cache_idx + offset, 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) - for _resilience_mw in ( - retry_mw, - fallback_mw, - model_call_limit_mw, - tool_call_limit_mw, - ): - if _resilience_mw is not None: - subagent_extra_middleware.append(_resilience_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, - ) - - 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/graph/middleware/checkpointed_subagent_middleware/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/__init__.py 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/middleware/main_agent/checkpointed_subagent_middleware/config.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/config.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/config.py 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/middleware/main_agent/checkpointed_subagent_middleware/constants.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/constants.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/constants.py 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/middleware/main_agent/checkpointed_subagent_middleware/middleware.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/middleware.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py 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/middleware/main_agent/checkpointed_subagent_middleware/propagation.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/propagation.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/propagation.py 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/middleware/main_agent/checkpointed_subagent_middleware/resume.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/resume.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/resume.py 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/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/main_agent/graph/middleware/checkpointed_subagent_middleware/task_tool.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py index ab6a644b5..dbc2c9c00 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_hitl_bridge.py @@ -12,7 +12,7 @@ from langgraph.graph import END, START, StateGraph from langgraph.types import Command, interrupt from typing_extensions import TypedDict -from app.agents.multi_agent_chat.main_agent.graph.middleware.checkpointed_subagent_middleware.task_tool import ( +from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.task_tool import ( build_task_tool_with_parent_config, ) diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py index e73fb2823..347b32dbd 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_resume_helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations from langchain.tools import ToolRuntime -from app.agents.multi_agent_chat.main_agent.graph.middleware.checkpointed_subagent_middleware.config import ( +from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.config import ( consume_surfsense_resume, has_surfsense_resume, ) From bba5fb1db830aa4a95e2011d237726f45d69e9b4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 22:22:57 +0200 Subject: [PATCH 118/131] fix(multi-agent): fail closed when connector discovery raises --- .../multi_agent_chat/main_agent/runtime/factory.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 index 6a6fd39b7..81123d450 100644 --- 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 @@ -85,7 +85,18 @@ async def create_surfsense_deep_agent( ) except Exception as e: - logging.warning("Failed to discover available connectors/document types: %s", e) + logging.warning( + "Connector/doc-type discovery failed; excluding connector subagents this turn: %s", + e, + ) + + # Fail closed: a None list short-circuits ``get_subagents_to_exclude`` to "exclude + # nothing", which would silently advertise every connector specialist on a flaky + # discovery call. Empty list excludes connector-gated subagents while keeping builtins. + if available_connectors is None: + available_connectors = [] + if available_document_types is None: + available_document_types = [] _perf_log.info( "[create_agent] Connector/doc-type discovery in %.3fs", time.perf_counter() - _t0, From 744ad92971d179af234b06fec0e57fde435e4cf2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 22:23:17 +0200 Subject: [PATCH 119/131] chore(multi-agent): demote subagent registry listing log to debug --- .../app/agents/multi_agent_chat/middleware/stack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py index 563332986..f894acc7e 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py @@ -121,7 +121,7 @@ def build_main_agent_deepagent_middleware( exclude=get_subagents_to_exclude(available_connectors), disabled_tools=disabled_tools, ) - logging.info( + logging.debug( "Subagents registry: %s", [s["name"] for s in subagents_registry], ) From c8ed70a26c5726339fe5b5451efdc6f520e1a1a8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 22:30:08 +0200 Subject: [PATCH 120/131] fix(multi-agent): layer per-thread prompt cache key onto LLM at agent build --- .../app/agents/multi_agent_chat/main_agent/runtime/factory.py | 4 ++++ 1 file changed, 4 insertions(+) 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 index 81123d450..86da53a1a 100644 --- 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 @@ -26,6 +26,7 @@ from app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags from app.agents.new_chat.filesystem_backends import build_backend_resolver from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection from app.agents.new_chat.llm_config import AgentConfig +from app.agents.new_chat.prompt_caching import apply_litellm_prompt_caching from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME, invalid_tool from app.agents.new_chat.tools.registry import build_tools_async from app.db import ChatVisibility @@ -62,6 +63,9 @@ async def create_surfsense_deep_agent( ): """Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.""" _t_agent_total = time.perf_counter() + + apply_litellm_prompt_caching(llm, agent_config=agent_config, thread_id=thread_id) + filesystem_selection = filesystem_selection or FilesystemSelection() backend_resolver = build_backend_resolver( filesystem_selection, From 07a84d1a41051f3a3c6068b8deb3c5cf674b7de0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 22:50:02 +0200 Subject: [PATCH 121/131] fix(multi-agent): cache compiled agent graph keyed on per-request inputs --- .../main_agent/runtime/agent_cache.py | 117 ++++++++++++++++++ .../main_agent/runtime/factory.py | 9 +- 2 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py new file mode 100644 index 000000000..42f984b79 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/agent_cache.py @@ -0,0 +1,117 @@ +"""Compiled agent graph caching for the multi-agent path.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from typing import Any + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.types import Checkpointer + +from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions +from app.agents.new_chat.agent_cache import ( + flags_signature, + get_cache, + stable_hash, + system_prompt_hash, + tools_signature, +) +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.db import ChatVisibility + +from ..graph.compile_graph_sync import build_compiled_agent_graph_sync + + +def mcp_signature(mcp_tools_by_agent: dict[str, ToolsPermissions]) -> str: + """Hash the per-agent MCP tool surface so a change rotates the cache key.""" + rows = [] + for agent_name in sorted(mcp_tools_by_agent.keys()): + perms = mcp_tools_by_agent[agent_name] + allow_names = sorted(item.get("name", "") for item in perms.get("allow", [])) + ask_names = sorted(item.get("name", "") for item in perms.get("ask", [])) + rows.append((agent_name, allow_names, ask_names)) + return stable_hash(rows) + + +async def build_agent_with_cache( + *, + llm: BaseChatModel, + tools: Sequence[BaseTool], + final_system_prompt: str, + backend_resolver: Any, + filesystem_mode: FilesystemMode, + search_space_id: int, + user_id: str | None, + thread_id: int | None, + visibility: ChatVisibility, + anon_session_id: str | None, + available_connectors: list[str], + available_document_types: list[str], + mentioned_document_ids: list[int] | None, + max_input_tokens: int | None, + flags: AgentFeatureFlags, + checkpointer: Checkpointer, + subagent_dependencies: dict[str, Any], + mcp_tools_by_agent: dict[str, ToolsPermissions], + disabled_tools: list[str] | None, + config_id: str | None, +) -> Any: + """Compile the multi-agent graph, serving from cache when key components are stable.""" + + async def _build() -> Any: + return await asyncio.to_thread( + build_compiled_agent_graph_sync, + llm=llm, + tools=tools, + final_system_prompt=final_system_prompt, + backend_resolver=backend_resolver, + filesystem_mode=filesystem_mode, + search_space_id=search_space_id, + user_id=user_id, + thread_id=thread_id, + visibility=visibility, + anon_session_id=anon_session_id, + available_connectors=available_connectors, + available_document_types=available_document_types, + mentioned_document_ids=mentioned_document_ids, + max_input_tokens=max_input_tokens, + flags=flags, + checkpointer=checkpointer, + subagent_dependencies=subagent_dependencies, + mcp_tools_by_agent=mcp_tools_by_agent, + disabled_tools=disabled_tools, + ) + + if not (flags.enable_agent_cache and not flags.disable_new_agent_stack): + return await _build() + + # Every per-request value any middleware closes over at __init__ must be in + # the key, otherwise a hit will leak state across threads. Bump the schema + # version when the component list changes shape. + cache_key = stable_hash( + "multi-agent-v1", + config_id, + thread_id, + user_id, + search_space_id, + visibility, + filesystem_mode, + anon_session_id, + tools_signature( + tools, + available_connectors=available_connectors, + available_document_types=available_document_types, + ), + mcp_signature(mcp_tools_by_agent), + flags_signature(flags), + system_prompt_hash(final_system_prompt), + max_input_tokens, + sorted(disabled_tools) if disabled_tools else None, + ) + return await get_cache().get_or_build(cache_key, builder=_build) + + +__all__ = ["build_agent_with_cache", "mcp_signature"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py index 86da53a1a..9e3c8eab4 100644 --- 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 @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging import time from collections.abc import Sequence @@ -33,12 +32,12 @@ 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, ) +from .agent_cache import build_agent_with_cache _perf_log = get_perf_logger() @@ -210,9 +209,10 @@ async def create_surfsense_deep_agent( final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT + config_id = agent_config.config_id if agent_config is not None else None + _t0 = time.perf_counter() - agent = await asyncio.to_thread( - build_compiled_agent_graph_sync, + agent = await build_agent_with_cache( llm=llm, tools=tools, final_system_prompt=final_system_prompt, @@ -232,6 +232,7 @@ async def create_surfsense_deep_agent( subagent_dependencies=dependencies, mcp_tools_by_agent=mcp_tools_by_agent, disabled_tools=disabled_tools, + config_id=config_id, ) _perf_log.info( "[create_agent] Middleware stack + graph compiled in %.3fs", From 997d86079046ed6f02bedb4c4a16adba5d1c4ed9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 22:52:03 +0200 Subject: [PATCH 122/131] fix(multi-agent): defensive message extraction on subagent task return --- .../checkpointed_subagent_middleware/task_tool.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py index d23dc33a9..5668f8ddb 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/task_tool.py @@ -69,9 +69,16 @@ def build_task_tool_with_parent_config( 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 "" - ) + messages = result["messages"] + if not messages: + msg = ( + "CompiledSubAgent returned an empty 'messages' list. " + "Subagents must produce at least one message so the parent has " + "output to forward back to the user." + ) + raise ValueError(msg) + last_text = getattr(messages[-1], "text", None) or "" + message_text = last_text.rstrip() return Command( update={ **state_update, From a3c3db2a18d270f73dbc19e6b502a6d0b01c2431 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 22:57:35 +0200 Subject: [PATCH 123/131] test(multi-agent): pin first-wins assumption on pending subagent interrupts --- .../test_pending_interrupt.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_pending_interrupt.py diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_pending_interrupt.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_pending_interrupt.py new file mode 100644 index 000000000..75242689d --- /dev/null +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_pending_interrupt.py @@ -0,0 +1,55 @@ +"""Pins the first-wins assumption of ``get_first_pending_subagent_interrupt``. + +The bridge currently relies on at-most-one pending interrupt per snapshot +(sequential tool nodes). If parallel tool calls are ever enabled, the bridge +needs an id-aware lookup; these tests will need to be revisited at that point. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from app.agents.multi_agent_chat.middleware.main_agent.checkpointed_subagent_middleware.resume import ( + get_first_pending_subagent_interrupt, +) + + +class TestGetFirstPendingSubagentInterrupt: + def test_returns_first_when_multiple_top_level_interrupts_pending(self): + first = SimpleNamespace(id="i-1", value={"decision": "approve"}) + second = SimpleNamespace(id="i-2", value={"decision": "reject"}) + state = SimpleNamespace(interrupts=(first, second), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == ( + "i-1", + {"decision": "approve"}, + ) + + def test_returns_first_when_multiple_subtask_interrupts_pending(self): + first = SimpleNamespace(id="i-A", value="approve") + second = SimpleNamespace(id="i-B", value="reject") + sub_task = SimpleNamespace(interrupts=(first, second)) + state = SimpleNamespace(interrupts=(), tasks=(sub_task,)) + + assert get_first_pending_subagent_interrupt(state) == ("i-A", "approve") + + def test_returns_none_when_no_interrupts(self): + state = SimpleNamespace(interrupts=(), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == (None, None) + + def test_returns_none_when_state_is_none(self): + assert get_first_pending_subagent_interrupt(None) == (None, None) + + def test_skips_interrupts_with_none_value(self): + empty = SimpleNamespace(id="i-empty", value=None) + real = SimpleNamespace(id="i-real", value="approve") + state = SimpleNamespace(interrupts=(empty, real), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == ("i-real", "approve") + + def test_normalizes_non_string_id_to_none(self): + interrupt = SimpleNamespace(id=12345, value="approve") + state = SimpleNamespace(interrupts=(interrupt,), tasks=()) + + assert get_first_pending_subagent_interrupt(state) == (None, "approve") From 657c31fdf47ecbd141ea1d64ab71b7e3e0727388 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 23:01:24 +0200 Subject: [PATCH 124/131] refactor(stream): rename multi-agent factory alias for clarity --- surfsense_backend/app/tasks/chat/stream_new_chat.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 3ba3912eb..2ee3f075a 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -29,7 +29,7 @@ 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, + create_surfsense_deep_agent as create_multi_agent_chat, ) from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.checkpointer import get_checkpointer @@ -2767,7 +2767,7 @@ async def stream_new_chat( _t0 = time.perf_counter() agent_factory = ( - create_registry_deep_agent + create_multi_agent_chat if use_multi_agent else create_surfsense_deep_agent ) @@ -4130,7 +4130,7 @@ async def stream_resume_chat( _t0 = time.perf_counter() agent_factory = ( - create_registry_deep_agent + create_multi_agent_chat if _app_config.MULTI_AGENT_CHAT_ENABLED else create_surfsense_deep_agent ) From 3cb2c3056ea2df059122250f7084ff7258930603 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 23:35:23 +0200 Subject: [PATCH 125/131] fix(stream): route every agent (re)build through one helper to prevent factory drift --- .../app/agents/multi_agent_chat/__init__.py | 4 +- .../multi_agent_chat/main_agent/__init__.py | 4 +- .../main_agent/runtime/__init__.py | 4 +- .../main_agent/runtime/factory.py | 2 +- .../app/tasks/chat/stream_new_chat.py | 65 +++++++++++++++---- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/__init__.py index f568dc6b2..6c7d79eb8 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/__init__.py @@ -2,6 +2,6 @@ from __future__ import annotations -from .main_agent import create_surfsense_deep_agent +from .main_agent import create_multi_agent_chat_deep_agent -__all__ = ["create_surfsense_deep_agent"] +__all__ = ["create_multi_agent_chat_deep_agent"] 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 index b9a18fe53..f74ca0cd0 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/__init__.py @@ -2,6 +2,6 @@ from __future__ import annotations -from .runtime import create_surfsense_deep_agent +from .runtime import create_multi_agent_chat_deep_agent -__all__ = ["create_surfsense_deep_agent"] +__all__ = ["create_multi_agent_chat_deep_agent"] 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 index 3d4ae977d..593e8da20 100644 --- 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 @@ -2,6 +2,6 @@ from __future__ import annotations -from .factory import create_surfsense_deep_agent +from .factory import create_multi_agent_chat_deep_agent -__all__ = ["create_surfsense_deep_agent"] +__all__ = ["create_multi_agent_chat_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 index 9e3c8eab4..630455694 100644 --- 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 @@ -42,7 +42,7 @@ from .agent_cache import build_agent_with_cache _perf_log = get_perf_logger() -async def create_surfsense_deep_agent( +async def create_multi_agent_chat_deep_agent( llm: BaseChatModel, search_space_id: int, db_session: AsyncSession, diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 2ee3f075a..1a2f38077 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -28,9 +28,7 @@ from langchain_core.messages import HumanMessage from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from app.agents.multi_agent_chat import ( - create_surfsense_deep_agent as create_multi_agent_chat, -) +from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.checkpointer import get_checkpointer from app.agents.new_chat.context import SurfSenseContextSchema @@ -577,6 +575,43 @@ async def _preflight_llm(llm: Any) -> None: ) +async def _build_main_agent_for_thread( + agent_factory: Any, + *, + llm: Any, + search_space_id: int, + db_session: Any, + connector_service: ConnectorService, + checkpointer: Any, + user_id: str | None, + thread_id: int | None, + agent_config: AgentConfig | None, + firecrawl_api_key: str | None, + thread_visibility: ChatVisibility | None, + filesystem_selection: FilesystemSelection | None, + disabled_tools: list[str] | None = None, + mentioned_document_ids: list[int] | None = None, +) -> Any: + """Single (re)build path so the agent factory cannot drift across + initial build, preflight repin, and mid-stream 429 recovery for one + ``thread_id``: a graph swap mid-turn would corrupt checkpointer state.""" + return await agent_factory( + llm=llm, + search_space_id=search_space_id, + db_session=db_session, + connector_service=connector_service, + checkpointer=checkpointer, + user_id=user_id, + thread_id=thread_id, + agent_config=agent_config, + firecrawl_api_key=firecrawl_api_key, + thread_visibility=thread_visibility, + filesystem_selection=filesystem_selection, + disabled_tools=disabled_tools, + mentioned_document_ids=mentioned_document_ids, + ) + + async def _settle_speculative_agent_build(task: asyncio.Task[Any]) -> None: """Wait for a discarded speculative agent build to release shared state. @@ -2767,7 +2802,7 @@ async def stream_new_chat( _t0 = time.perf_counter() agent_factory = ( - create_multi_agent_chat + create_multi_agent_chat_deep_agent if use_multi_agent else create_surfsense_deep_agent ) @@ -2776,7 +2811,8 @@ async def stream_new_chat( # 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( + _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -2787,9 +2823,9 @@ async def stream_new_chat( agent_config=agent_config, firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, + filesystem_selection=filesystem_selection, disabled_tools=disabled_tools, mentioned_document_ids=mentioned_document_ids, - filesystem_selection=filesystem_selection, ), name="agent_build:stream_new_chat", ) @@ -3466,7 +3502,8 @@ async def stream_new_chat( title_task = None _t0 = time.perf_counter() - agent = await create_surfsense_deep_agent( + agent = await _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -3477,9 +3514,9 @@ async def stream_new_chat( agent_config=agent_config, firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, + filesystem_selection=filesystem_selection, disabled_tools=disabled_tools, mentioned_document_ids=mentioned_document_ids, - filesystem_selection=filesystem_selection, ) _perf_log.info( "[stream_new_chat] Runtime rate-limit recovery repinned " @@ -4130,12 +4167,13 @@ async def stream_resume_chat( _t0 = time.perf_counter() agent_factory = ( - create_multi_agent_chat + create_multi_agent_chat_deep_agent if _app_config.MULTI_AGENT_CHAT_ENABLED else create_surfsense_deep_agent ) agent_build_task = asyncio.create_task( - agent_factory( + _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -4224,7 +4262,8 @@ async def stream_resume_chat( "fallback_config_id": llm_config_id, }, ) - agent = await agent_factory( + agent = await _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -4409,7 +4448,8 @@ async def stream_resume_chat( raise stream_exc _t0 = time.perf_counter() - agent = await create_surfsense_deep_agent( + agent = await _build_main_agent_for_thread( + agent_factory, llm=llm, search_space_id=search_space_id, db_session=session, @@ -4421,6 +4461,7 @@ async def stream_resume_chat( firecrawl_api_key=firecrawl_api_key, thread_visibility=visibility, filesystem_selection=filesystem_selection, + disabled_tools=disabled_tools, ) _perf_log.info( "[stream_resume] Runtime rate-limit recovery repinned " From a421e7d792ca7dcdd77c6c2ebdc4944042bb0646 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 5 May 2026 23:42:11 +0200 Subject: [PATCH 126/131] fix(multi-agent): degrade to builtins-only when MCP or subagent registry build fails --- .../multi_agent_chat/main_agent/runtime/factory.py | 11 ++++++++++- .../app/agents/multi_agent_chat/middleware/stack.py | 8 ++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) 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 index 630455694..d0354aca3 100644 --- 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 @@ -129,7 +129,16 @@ async def create_multi_agent_chat_deep_agent( } _t0 = time.perf_counter() - mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, search_space_id) + try: + mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, search_space_id) + except Exception as e: + # Degrade to builtins-only rather than aborting the turn: a transient + # DB or MCP-server hiccup should not deny the user a response. + logging.warning( + "MCP tool discovery failed; subagents will run without MCP tools this turn: %s", + e, + ) + mcp_tools_by_agent = {} _perf_log.info( "[create_agent] load_mcp_tools_by_connector in %.3fs (%d buckets)", time.perf_counter() - _t0, diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py index f894acc7e..6d8faa3f4 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py @@ -126,8 +126,12 @@ def build_main_agent_deepagent_middleware( [s["name"] for s in subagents_registry], ) except Exception: - logging.exception("Subagents registry build failed") - raise + # Degrade to general-purpose-only rather than aborting the turn: + # one bad subagent dep should not deny the user a response. + logging.exception( + "Subagents registry build failed; falling back to general-purpose only" + ) + subagents_registry = [] subagents: list[SubAgent] = [general_purpose_subagent, *subagents_registry] From 4e174f17f2b4dfd2446408cadd1150978594625b Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 5 May 2026 17:08:34 -0700 Subject: [PATCH 127/131] chore: linting --- .../agents/multi_agent_chat/main_agent/runtime/factory.py | 4 +++- .../multi_agent_chat/middleware/main_agent/doom_loop.py | 4 +++- .../middleware/shared/permissions/context.py | 4 +--- .../subagents/shared/test_subagent_builder.py | 1 - .../new_chat/middleware/test_scoped_model_fallback.py | 4 +--- .../unit/agents/new_chat/test_memory_response_content.py | 4 +++- .../unit/agents/new_chat/test_permission_middleware.py | 4 +--- .../dashboard/[search_space_id]/purchase-success/page.tsx | 6 +++--- 8 files changed, 15 insertions(+), 16 deletions(-) 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 index d0354aca3..cb6410acb 100644 --- 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 @@ -130,7 +130,9 @@ async def create_multi_agent_chat_deep_agent( _t0 = time.perf_counter() try: - mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, search_space_id) + mcp_tools_by_agent = await load_mcp_tools_by_connector( + db_session, search_space_id + ) except Exception as e: # Degrade to builtins-only rather than aborting the turn: a transient # DB or MCP-server hiccup should not deny the user a response. diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py index a0b294092..d67b8d518 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/doom_loop.py @@ -9,4 +9,6 @@ from ..shared.flags import enabled def build_doom_loop_mw(flags: AgentFeatureFlags) -> DoomLoopMiddleware | None: - return DoomLoopMiddleware(threshold=3) if enabled(flags, "enable_doom_loop") else None + return ( + DoomLoopMiddleware(threshold=3) if enabled(flags, "enable_doom_loop") else None + ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py index f14d52714..e121421a0 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/context.py @@ -78,9 +78,7 @@ def build_permission_context( Rule(permission=tool_def.name, pattern="*", action="deny") ) if synthesized: - rulesets.append( - Ruleset(rules=synthesized, origin="connector_synthesized") - ) + rulesets.append(Ruleset(rules=synthesized, origin="connector_synthesized")) general_purpose_interrupt_on: dict[str, bool] = { rule.permission: True diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py index 5cd62ed36..648e52115 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py @@ -29,7 +29,6 @@ class RateLimitError(Exception): class _AlwaysFailingChatModel(BaseChatModel): - @property def _llm_type(self) -> str: return "always-failing-test-model" diff --git a/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py index 69f6fe6b7..80b9862e7 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py +++ b/surfsense_backend/tests/unit/agents/new_chat/middleware/test_scoped_model_fallback.py @@ -67,9 +67,7 @@ class _RecordingChatModel(BaseChatModel): ) -> ChatResult: self.call_count += 1 return ChatResult( - generations=[ - ChatGeneration(message=AIMessage(content=self.response_text)) - ] + generations=[ChatGeneration(message=AIMessage(content=self.response_text))] ) async def _agenerate( 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 index 535e4e940..1f338ee3e 100644 --- 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 @@ -47,7 +47,9 @@ def test_extract_text_content_ignores_thinking_blocks_and_keeps_markdown_text() assert extract_text_content(content).strip() == markdown.strip() -def test_extract_text_content_returns_empty_when_only_thinking_blocks_are_present() -> None: +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."}, 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..47059ade6 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py @@ -141,9 +141,7 @@ class TestNormalizeDecision: 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"}] - } + decision = {"decisions": [{"type": "reject", "message": "wrong recipient"}]} out = _normalize_permission_decision(decision) assert out == {"decision_type": "reject", "feedback": "wrong recipient"} diff --git a/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx index b3d504ed5..8eaec3e5a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx @@ -119,8 +119,7 @@ export default function PurchaseSuccessPage() { "Stripe reported the checkout as failed or expired. Your card was not charged."} {state.kind === "error" && "Don't worry — if your card was charged, your purchase will still apply within a minute or two."} - {state.kind === "no_session" && - "Your purchase is being applied to your account."} + {state.kind === "no_session" && "Your purchase is being applied to your account."} @@ -134,7 +133,8 @@ export default function PurchaseSuccessPage() { )} {state.kind === "completed" && state.data.purchase_type === "premium_tokens" && (

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

)} {state.kind === "error" && ( From 5e87a7a251c5a614ab94f28c28bcea1457b67e47 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 5 May 2026 18:57:10 -0700 Subject: [PATCH 128/131] fix: composio tool calls in composio connectors --- .../connectors/calendar/tools/create_event.py | 149 +++--- .../connectors/calendar/tools/delete_event.py | 94 ++-- .../calendar/tools/search_events.py | 59 ++- .../connectors/calendar/tools/update_event.py | 188 ++++--- .../connectors/gmail/tools/create_draft.py | 133 +++-- .../connectors/gmail/tools/read_email.py | 51 +- .../connectors/gmail/tools/search_emails.py | 96 ++-- .../connectors/gmail/tools/send_email.py | 121 +++-- .../connectors/gmail/tools/trash_email.py | 90 ++-- .../connectors/gmail/tools/update_draft.py | 195 ++++--- .../google_drive/tools/create_file.py | 113 ++-- .../google_drive/tools/trash_file.py | 109 ++-- .../app/services/composio_service.py | 499 ++++++++++++++++++ 13 files changed, 1347 insertions(+), 550 deletions(-) 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 index 37bcf083e..a8183314a 100644 --- 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 @@ -168,20 +168,46 @@ def create_create_calendar_event_tool( f"Creating calendar event: summary='{final_summary}', connector={actual_connector_id}" ) + tz = context.get("timezone", "UTC") + if ( connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR ): - 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: + if not cca_id: return { "status": "error", "message": "Composio connected account ID not found for this connector.", } + + from app.services.composio_service import ComposioService + + ( + event_id, + html_link, + error, + ) = await ComposioService().create_calendar_event( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + summary=final_summary, + start_datetime=final_start_datetime, + end_datetime=final_end_datetime, + timezone=tz, + description=final_description, + location=final_location, + attendees=final_attendees, + ) + if error: + return {"status": "error", "message": error} + created = { + "id": event_id, + "summary": final_summary, + "htmlLink": html_link, + } + logger.info( + f"Calendar event created via Composio: id={event_id}, summary={final_summary}" + ) else: config_data = dict(connector.config) @@ -211,70 +237,69 @@ def create_create_calendar_event_tool( 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() - ), + service = await asyncio.get_event_loop().run_in_executor( + None, lambda: build("calendar", "v3", credentials=creds) ) - 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}" + 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() + ), ) - try: - from sqlalchemy.orm.attributes import flag_modified + except Exception as api_err: + from googleapiclient.errors import HttpError - _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: + if isinstance(api_err, HttpError) and api_err.resp.status == 403: logger.warning( - "Failed to persist auth_expired for connector %s", - actual_connector_id, - exc_info=True, + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" ) - return { - "status": "insufficient_permissions", - "connector_id": actual_connector_id, - "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.", - } - raise + try: + from sqlalchemy.orm.attributes import flag_modified - logger.info( - f"Calendar event created: id={created.get('id')}, summary={created.get('summary')}" - ) + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Calendar event created via Google API: id={created.get('id')}, summary={created.get('summary')}" + ) kb_message_suffix = "" try: 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 index 4d9d69b4b..3d160e669 100644 --- 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 @@ -163,16 +163,22 @@ def create_delete_calendar_event_tool( 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: + if not cca_id: return { "status": "error", "message": "Composio connected account ID not found for this connector.", } + + from app.services.composio_service import ComposioService + + error = await ComposioService().delete_calendar_event( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + event_id=final_event_id, + ) + if error: + return {"status": "error", "message": error} else: config_data = dict(connector.config) @@ -202,51 +208,51 @@ def create_delete_calendar_event_tool( 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() - ), + service = await asyncio.get_event_loop().run_in_executor( + None, lambda: build("calendar", "v3", credentials=creds) ) - 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: + await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + service.events() + .delete(calendarId="primary", eventId=final_event_id) + .execute() + ), ) - try: - from sqlalchemy.orm.attributes import flag_modified + except Exception as api_err: + from googleapiclient.errors import HttpError - _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: + if isinstance(api_err, HttpError) and api_err.resp.status == 403: logger.warning( - "Failed to persist auth_expired for connector %s", - actual_connector_id, - exc_info=True, + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" ) - return { - "status": "insufficient_permissions", - "connector_id": actual_connector_id, - "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.", - } - raise + 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}") 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 index dc6adb822..6772d5a1e 100644 --- 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 @@ -16,6 +16,14 @@ _CALENDAR_TYPES = [ ] +def _to_calendar_boundary(value: str, *, is_end: bool) -> str: + """Promote a bare YYYY-MM-DD to RFC3339 with a day-edge time, leave full datetimes alone.""" + if "T" in value: + return value + time = "23:59:59" if is_end else "00:00:00" + return f"{value}T{time}Z" + + def create_search_calendar_events_tool( db_session: AsyncSession | None = None, search_space_id: int | None = None, @@ -61,22 +69,47 @@ def create_search_calendar_events_tool( "message": "No Google Calendar connector found. Please connect Google Calendar in your workspace settings.", } - creds = _build_credentials(connector) + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this connector.", + } - from app.connectors.google_calendar_connector import GoogleCalendarConnector + from app.services.composio_service import ComposioService - cal = GoogleCalendarConnector( - credentials=creds, - session=db_session, - user_id=user_id, - connector_id=connector.id, - ) + events_raw, error = await ComposioService().get_calendar_events( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + time_min=_to_calendar_boundary(start_date, is_end=False), + time_max=_to_calendar_boundary(end_date, is_end=True), + max_results=max_results, + ) + if not events_raw and not error: + error = "No events found in the specified date range." + else: + creds = _build_credentials(connector) - events_raw, error = await cal.get_all_primary_calendar_events( - start_date=start_date, - end_date=end_date, - max_results=max_results, - ) + 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 ( 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 index 259f52bba..a74979484 100644 --- 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 @@ -192,20 +192,62 @@ def create_update_calendar_event_tool( f"Updating calendar event: event_id='{final_event_id}', connector={actual_connector_id}" ) + has_changes = any( + v is not None + for v in ( + final_new_summary, + final_new_start_datetime, + final_new_end_datetime, + final_new_description, + final_new_location, + final_new_attendees, + ) + ) + if not has_changes: + return { + "status": "error", + "message": "No changes specified. Please provide at least one field to update.", + } + if ( connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR ): - 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: + if not cca_id: return { "status": "error", "message": "Composio connected account ID not found for this connector.", } + + from app.services.composio_service import ComposioService + + tz_for_composio: str | None = None + if final_new_start_datetime is not None and not _is_date_only( + final_new_start_datetime + ): + tz_for_composio = ( + context.get("timezone") if isinstance(context, dict) else None + ) + + _, html_link, error = await ComposioService().update_calendar_event( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + event_id=final_event_id, + summary=final_new_summary, + start_time=final_new_start_datetime, + end_time=final_new_end_datetime, + timezone=tz_for_composio, + description=final_new_description, + location=final_new_location, + attendees=final_new_attendees, + ) + if error: + return {"status": "error", "message": error} + updated = {"htmlLink": html_link} + logger.info( + f"Calendar event updated via Composio: event_id={final_event_id}" + ) else: config_data = dict(connector.config) @@ -235,81 +277,79 @@ def create_update_calendar_event_tool( 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 + service = await asyncio.get_event_loop().run_in_executor( + None, lambda: build("calendar", "v3", credentials=creds) ) - 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}" + 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 ) - try: - from sqlalchemy.orm.attributes import flag_modified + 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() + ] - _res = await db_session.execute( - select(SearchSourceConnector).where( - SearchSourceConnector.id == actual_connector_id + try: + updated = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + service.events() + .patch( + calendarId="primary", + eventId=final_event_id, + body=update_body, ) - ) - _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 + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError - logger.info(f"Calendar event updated: event_id={final_event_id}") + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Calendar account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Calendar event updated via Google API: event_id={final_event_id}" + ) kb_message_suffix = "" if document_id is not None: 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 index 0bd044695..59e471097 100644 --- 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 @@ -161,16 +161,39 @@ def create_create_gmail_draft_tool( 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: + if not cca_id: return { "status": "error", "message": "Composio connected account ID not found for this Gmail connector.", } + + from app.services.composio_service import ComposioService + + ( + draft_id, + draft_message_id, + draft_thread_id, + error, + ) = await ComposioService().create_gmail_draft( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + to=final_to, + subject=final_subject, + body=final_body, + cc=final_cc, + bcc=final_bcc, + ) + if error: + return {"status": "error", "message": error} + created = { + "id": draft_id, + "message": { + "id": draft_message_id, + "threadId": draft_thread_id, + }, + } + logger.info(f"Gmail draft created via Composio: id={draft_id}") else: from google.oauth2.credentials import Credentials @@ -208,63 +231,65 @@ def create_create_gmail_draft_tool( expiry=datetime.fromisoformat(exp) if exp else None, ) - from googleapiclient.discovery import build + from googleapiclient.discovery import build - gmail_service = build("gmail", "v1", credentials=creds) + 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() + 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: + created = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .drafts() + .create(userId="me", body={"message": {"raw": raw}}) + .execute() + ), ) - try: - from sqlalchemy.orm.attributes import flag_modified + except Exception as api_err: + from googleapiclient.errors import HttpError - _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: + if isinstance(api_err, HttpError) and api_err.resp.status == 403: logger.warning( - "Failed to persist auth_expired for connector %s", - actual_connector_id, - exc_info=True, + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" ) - return { - "status": "insufficient_permissions", - "connector_id": actual_connector_id, - "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", - } - raise + try: + from sqlalchemy.orm.attributes import flag_modified - logger.info(f"Gmail draft created: id={created.get('id')}") + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", + } + raise + + logger.info( + f"Gmail draft created via Google API: id={created.get('id')}" + ) kb_message_suffix = "" try: 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 index deec1627c..39526f25e 100644 --- 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 @@ -50,7 +50,56 @@ def create_read_gmail_email_tool( "message": "No Gmail connector found. Please connect Gmail in your workspace settings.", } - from app.agents.new_chat.tools.gmail.search_emails import _build_credentials + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + from app.agents.new_chat.tools.gmail.search_emails import ( + _format_gmail_summary, + ) + from app.services.composio_service import ComposioService + + detail, error = await ComposioService().get_gmail_message_detail( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + message_id=message_id, + ) + if error: + return {"status": "error", "message": error} + if not detail: + return { + "status": "not_found", + "message": f"Email with ID '{message_id}' not found.", + } + + summary = _format_gmail_summary(detail) + content = ( + f"# {summary['subject']}\n\n" + f"**From:** {summary['from']}\n" + f"**To:** {summary['to']}\n" + f"**Date:** {summary['date']}\n\n" + f"## Message Content\n\n" + f"{detail.get('messageText') or detail.get('snippet') or ''}\n\n" + f"## Message Details\n\n" + f"- **Message ID:** {summary['message_id']}\n" + f"- **Thread ID:** {summary['thread_id']}\n" + ) + return { + "status": "success", + "message_id": summary["message_id"] or message_id, + "content": content, + } + + from app.agents.new_chat.tools.gmail.search_emails import ( + _build_credentials, + ) creds = _build_credentials(connector) 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 index 2e363609e..a9d7cdedf 100644 --- 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 @@ -1,5 +1,4 @@ import logging -from datetime import datetime from typing import Any from langchain_core.tools import tool @@ -15,57 +14,6 @@ _GMAIL_TYPES = [ 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, @@ -110,6 +58,50 @@ def create_search_gmail_tool( "message": "No Gmail connector found. Please connect Gmail in your workspace settings.", } + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Gmail connector.", + } + + from app.agents.new_chat.tools.gmail.search_emails import ( + _format_gmail_summary, + ) + from app.services.composio_service import ComposioService + + ( + messages, + _next, + _estimate, + error, + ) = await ComposioService().get_gmail_messages( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + query=query, + max_results=max_results, + ) + if error: + return {"status": "error", "message": error} + + emails = [_format_gmail_summary(m) for m in messages] + if not emails: + return { + "status": "success", + "emails": [], + "total": 0, + "message": "No emails found.", + } + return {"status": "success", "emails": emails, "total": len(emails)} + + from app.agents.new_chat.tools.gmail.search_emails import ( + _build_credentials, + ) + creds = _build_credentials(connector) from app.connectors.google_gmail_connector import GoogleGmailConnector 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 index c3f0999f4..d5de24b62 100644 --- 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 @@ -162,16 +162,31 @@ def create_send_gmail_email_tool( 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: + if not cca_id: return { "status": "error", "message": "Composio connected account ID not found for this Gmail connector.", } + + from app.services.composio_service import ComposioService + + ( + sent_message_id, + sent_thread_id, + error, + ) = await ComposioService().send_gmail_email( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + to=final_to, + subject=final_subject, + body=final_body, + cc=final_cc, + bcc=final_bcc, + ) + if error: + return {"status": "error", "message": error} + sent = {"id": sent_message_id, "threadId": sent_thread_id} else: from google.oauth2.credentials import Credentials @@ -209,61 +224,61 @@ def create_send_gmail_email_tool( expiry=datetime.fromisoformat(exp) if exp else None, ) - from googleapiclient.discovery import build + from googleapiclient.discovery import build - gmail_service = build("gmail", "v1", credentials=creds) + 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() + 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: + sent = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .messages() + .send(userId="me", body={"raw": raw}) + .execute() + ), ) - try: - from sqlalchemy.orm.attributes import flag_modified + except Exception as api_err: + from googleapiclient.errors import HttpError - _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: + if isinstance(api_err, HttpError) and api_err.resp.status == 403: logger.warning( - "Failed to persist auth_expired for connector %s", - actual_connector_id, - exc_info=True, + f"Insufficient permissions for connector {actual_connector_id}: {api_err}" ) - return { - "status": "insufficient_permissions", - "connector_id": actual_connector_id, - "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", - } - raise + 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')}" 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 index 1f1f6227a..b78f88934 100644 --- 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 @@ -162,16 +162,22 @@ def create_trash_gmail_email_tool( 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: + if not cca_id: return { "status": "error", "message": "Composio connected account ID not found for this Gmail connector.", } + + from app.services.composio_service import ComposioService + + error = await ComposioService().trash_gmail_message( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + message_id=final_message_id, + ) + if error: + return {"status": "error", "message": error} else: from google.oauth2.credentials import Credentials @@ -209,49 +215,49 @@ def create_trash_gmail_email_tool( expiry=datetime.fromisoformat(exp) if exp else None, ) - from googleapiclient.discovery import build + from googleapiclient.discovery import build - gmail_service = build("gmail", "v1", credentials=creds) + 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: + await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .messages() + .trash(userId="me", id=final_message_id) + .execute() + ), ) - try: - from sqlalchemy.orm.attributes import flag_modified + except Exception as api_err: + from googleapiclient.errors import HttpError - if not connector.config.get("auth_expired"): - connector.config = { - **connector.config, - "auth_expired": True, - } - flag_modified(connector, "config") - await db_session.commit() - except Exception: + if isinstance(api_err, HttpError) and api_err.resp.status == 403: logger.warning( - "Failed to persist auth_expired for connector %s", - connector.id, - exc_info=True, + f"Insufficient permissions for connector {connector.id}: {api_err}" ) - return { - "status": "insufficient_permissions", - "connector_id": connector.id, - "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", - } - raise + 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}") 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 index 91178cd21..b6688ac53 100644 --- 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 @@ -192,16 +192,51 @@ def create_update_gmail_draft_tool( 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: + if not cca_id: return { "status": "error", "message": "Composio connected account ID not found for this Gmail connector.", } + + if not final_draft_id: + return { + "status": "error", + "message": ( + "Could not find this draft in Gmail. " + "It may have already been sent or deleted." + ), + } + + from app.services.composio_service import ComposioService + + ( + new_draft_id, + new_message_id, + error, + ) = await ComposioService().update_gmail_draft( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + draft_id=final_draft_id, + to=final_to or None, + subject=final_subject, + body=final_body, + cc=final_cc, + bcc=final_bcc, + ) + if error: + if "not found" in error.lower() or "no longer" in error.lower(): + return { + "status": "error", + "message": "Draft no longer exists in Gmail. It may have been sent or deleted.", + } + return {"status": "error", "message": error} + + updated = { + "id": new_draft_id or final_draft_id, + "message": {"id": new_message_id} if new_message_id else {}, + } + logger.info(f"Gmail draft updated via Composio: id={updated.get('id')}") else: from google.oauth2.credentials import Credentials @@ -239,88 +274,90 @@ def create_update_gmail_draft_tool( expiry=datetime.fromisoformat(exp) if exp else None, ) - from googleapiclient.discovery import build + from googleapiclient.discovery import build - gmail_service = build("gmail", "v1", credentials=creds) + 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}" + # 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 ) - 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: + if not final_draft_id: return { "status": "error", - "message": "Draft no longer exists in Gmail. It may have been sent or deleted.", + "message": ( + "Could not find this draft in Gmail. " + "It may have already been sent or deleted." + ), } - raise - logger.info(f"Gmail draft updated: id={updated.get('id')}") + message = MIMEText(final_body) + if final_to: + message["to"] = final_to + message["subject"] = final_subject + if final_cc: + message["cc"] = final_cc + if final_bcc: + message["bcc"] = final_bcc + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + + try: + updated = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ( + gmail_service.users() + .drafts() + .update( + userId="me", + id=final_draft_id, + body={"message": {"raw": raw}}, + ) + .execute() + ), + ) + except Exception as api_err: + from googleapiclient.errors import HttpError + + if isinstance(api_err, HttpError) and api_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {connector.id}: {api_err}" + ) + try: + from sqlalchemy.orm.attributes import flag_modified + + if not connector.config.get("auth_expired"): + connector.config = { + **connector.config, + "auth_expired": True, + } + flag_modified(connector, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + connector.id, + exc_info=True, + ) + return { + "status": "insufficient_permissions", + "connector_id": connector.id, + "message": "This Gmail account needs additional permissions. Please re-authenticate in connector settings.", + } + if isinstance(api_err, HttpError) and api_err.resp.status == 404: + return { + "status": "error", + "message": "Draft no longer exists in Gmail. It may have been sent or deleted.", + } + raise + + logger.info( + f"Gmail draft updated via Google API: id={updated.get('id')}" + ) kb_message_suffix = "" if document_id: 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 index f36db8f3f..9e9a30429 100644 --- 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 @@ -179,59 +179,96 @@ def create_create_google_drive_file_tool( f"Creating Google Drive file: name='{final_name}', type='{final_file_type}', connector={actual_connector_id}" ) - pre_built_creds = None + async def _flag_auth_expired() -> None: + try: + from sqlalchemy.orm.attributes import flag_modified + + _res = await db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == actual_connector_id + ) + ) + _conn = _res.scalar_one_or_none() + if _conn and not _conn.config.get("auth_expired"): + _conn.config = {**_conn.config, "auth_expired": True} + flag_modified(_conn, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + actual_connector_id, + exc_info=True, + ) + if ( connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR ): - 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) + if not cca_id: + return { + "status": "error", + "message": "Composio connected account ID not found for this Google Drive connector.", + } - client = GoogleDriveClient( - session=db_session, - connector_id=actual_connector_id, - credentials=pre_built_creds, - ) - try: - created = await client.create_file( + from app.services.composio_service import ComposioService + + created, error = await ComposioService().create_drive_file_from_text( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", name=final_name, mime_type=mime_type, - parent_folder_id=final_parent_folder_id, content=final_content, + parent_id=final_parent_folder_id, ) - 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: + if error or not created: + err_lower = (error or "").lower() + if ( + "insufficient" in err_lower + or "permission" in err_lower + or "403" in err_lower + ): logger.warning( - "Failed to persist auth_expired for connector %s", - actual_connector_id, - exc_info=True, + f"Insufficient permissions for Composio Drive connector {actual_connector_id}: {error}" ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + logger.error( + f"Composio Drive create_file failed for connector {actual_connector_id}: {error}" + ) return { - "status": "insufficient_permissions", - "connector_id": actual_connector_id, - "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", } - raise + else: + client = GoogleDriveClient( + session=db_session, + connector_id=actual_connector_id, + ) + try: + created = await client.create_file( + name=final_name, + mime_type=mime_type, + parent_folder_id=final_parent_folder_id, + content=final_content, + ) + except HttpError as http_err: + if http_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {actual_connector_id}: {http_err}" + ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": actual_connector_id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + raise logger.info( f"Google Drive file created: id={created.get('id')}, name={created.get('name')}" 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 index 832afff0d..f7531cf3d 100644 --- 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 @@ -158,51 +158,84 @@ def create_delete_google_drive_file_tool( f"Deleting Google Drive file: file_id='{final_file_id}', connector={final_connector_id}" ) - pre_built_creds = None + async def _flag_auth_expired() -> None: + try: + from sqlalchemy.orm.attributes import flag_modified + + if not connector.config.get("auth_expired"): + connector.config = { + **connector.config, + "auth_expired": True, + } + flag_modified(connector, "config") + await db_session.commit() + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + connector.id, + exc_info=True, + ) + if ( connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR ): - 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, - ) + if not cca_id: return { - "status": "insufficient_permissions", - "connector_id": connector.id, - "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + "status": "error", + "message": "Composio connected account ID not found for this Google Drive connector.", } - raise + + from app.services.composio_service import ComposioService + + error = await ComposioService().trash_drive_file( + connected_account_id=cca_id, + entity_id=f"surfsense_{user_id}", + file_id=final_file_id, + ) + if error: + err_lower = error.lower() + if ( + "insufficient" in err_lower + or "permission" in err_lower + or "403" in err_lower + ): + logger.warning( + f"Insufficient permissions for Composio Drive connector {connector.id}: {error}" + ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": connector.id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + logger.error( + f"Composio Drive trash_file failed for connector {connector.id}: {error}" + ) + return { + "status": "error", + "message": "Something went wrong while trashing the file. Please try again.", + } + else: + client = GoogleDriveClient( + session=db_session, + connector_id=connector.id, + ) + try: + await client.trash_file(file_id=final_file_id) + except HttpError as http_err: + if http_err.resp.status == 403: + logger.warning( + f"Insufficient permissions for connector {connector.id}: {http_err}" + ) + await _flag_auth_expired() + return { + "status": "insufficient_permissions", + "connector_id": connector.id, + "message": "This Google Drive account needs additional permissions. Please re-authenticate in connector settings.", + } + raise logger.info( f"Google Drive file deleted (moved to trash): file_id={final_file_id}" diff --git a/surfsense_backend/app/services/composio_service.py b/surfsense_backend/app/services/composio_service.py index edfab1d15..d73a0d4ce 100644 --- a/surfsense_backend/app/services/composio_service.py +++ b/surfsense_backend/app/services/composio_service.py @@ -1027,6 +1027,505 @@ class ComposioService: logger.error(f"Failed to list Calendar events: {e!s}") return [], str(e) + @staticmethod + def _unwrap_response_data(data: Any) -> Any: + """Composio responses often nest the meaningful payload under + ``data.data.response_data``. Walk that envelope safely and return + whichever inner dict actually has the result keys.""" + if not isinstance(data, dict): + return data + inner = data.get("data", data) + if isinstance(inner, dict): + return inner.get("response_data", inner) + return inner + + @staticmethod + def _split_email_csv(value: str | None) -> list[str] | None: + """Tools accept comma-separated cc/bcc strings; Composio expects an array.""" + if not value: + return None + addrs = [e.strip() for e in value.split(",") if e.strip()] + return addrs or None + + # ===== Gmail write methods ===== + + async def send_gmail_email( + self, + connected_account_id: str, + entity_id: str, + to: str, + subject: str, + body: str, + cc: str | None = None, + bcc: str | None = None, + is_html: bool = False, + ) -> tuple[str | None, str | None, str | None]: + """Send a Gmail message via the Composio ``GMAIL_SEND_EMAIL`` toolkit. + + Returns: + Tuple of (message_id, thread_id, error). On success ``error`` is + None and at least one of the IDs is populated when Composio + returns them; on failure both IDs are None. + """ + try: + params: dict[str, Any] = { + "recipient_email": to, + "subject": subject, + "body": body, + "is_html": is_html, + } + if cc: + cc_list = self._split_email_csv(cc) + if cc_list: + params["cc"] = cc_list + if bcc: + bcc_list = self._split_email_csv(bcc) + if bcc_list: + params["bcc"] = bcc_list + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_SEND_EMAIL", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + message_id = None + thread_id = None + if isinstance(payload, dict): + message_id = ( + payload.get("id") + or payload.get("message_id") + or payload.get("messageId") + ) + thread_id = payload.get("threadId") or payload.get("thread_id") + return message_id, thread_id, None + except Exception as e: + logger.error(f"Failed to send Gmail email: {e!s}") + return None, None, str(e) + + async def create_gmail_draft( + self, + connected_account_id: str, + entity_id: str, + to: str, + subject: str, + body: str, + cc: str | None = None, + bcc: str | None = None, + is_html: bool = False, + ) -> tuple[str | None, str | None, str | None, str | None]: + """Create a Gmail draft via the Composio ``GMAIL_CREATE_EMAIL_DRAFT`` toolkit. + + Returns: + Tuple of (draft_id, message_id, thread_id, error). On success + ``error`` is None and ``draft_id`` is populated. + """ + try: + params: dict[str, Any] = { + "recipient_email": to, + "subject": subject, + "body": body, + "is_html": is_html, + } + cc_list = self._split_email_csv(cc) + if cc_list: + params["cc"] = cc_list + bcc_list = self._split_email_csv(bcc) + if bcc_list: + params["bcc"] = bcc_list + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_CREATE_EMAIL_DRAFT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + draft_id = None + message_id = None + thread_id = None + if isinstance(payload, dict): + draft_id = payload.get("id") or payload.get("draft_id") + draft_message = payload.get("message") or {} + if isinstance(draft_message, dict): + message_id = draft_message.get("id") or draft_message.get( + "message_id" + ) + thread_id = draft_message.get("threadId") or draft_message.get( + "thread_id" + ) + if message_id is None: + message_id = payload.get("message_id") or payload.get("messageId") + if thread_id is None: + thread_id = payload.get("thread_id") or payload.get("threadId") + return draft_id, message_id, thread_id, None + except Exception as e: + logger.error(f"Failed to create Gmail draft: {e!s}") + return None, None, None, str(e) + + async def update_gmail_draft( + self, + connected_account_id: str, + entity_id: str, + draft_id: str, + to: str | None = None, + subject: str | None = None, + body: str | None = None, + cc: str | None = None, + bcc: str | None = None, + is_html: bool = False, + ) -> tuple[str | None, str | None, str | None]: + """Update an existing Gmail draft via ``GMAIL_UPDATE_DRAFT``. + + Returns: + Tuple of (draft_id, message_id, error). + """ + try: + params: dict[str, Any] = { + "draft_id": draft_id, + "is_html": is_html, + } + if to: + params["recipient_email"] = to + if subject is not None: + params["subject"] = subject + if body is not None: + params["body"] = body + cc_list = self._split_email_csv(cc) + if cc_list: + params["cc"] = cc_list + bcc_list = self._split_email_csv(bcc) + if bcc_list: + params["bcc"] = bcc_list + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_UPDATE_DRAFT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + new_draft_id = draft_id + message_id = None + if isinstance(payload, dict): + new_draft_id = payload.get("id") or payload.get("draft_id") or draft_id + draft_message = payload.get("message") or {} + if isinstance(draft_message, dict): + message_id = draft_message.get("id") or draft_message.get( + "message_id" + ) + if message_id is None: + message_id = payload.get("message_id") or payload.get("messageId") + return new_draft_id, message_id, None + except Exception as e: + logger.error(f"Failed to update Gmail draft: {e!s}") + return None, None, str(e) + + async def trash_gmail_message( + self, + connected_account_id: str, + entity_id: str, + message_id: str, + ) -> str | None: + """Move a Gmail message to trash via ``GMAIL_MOVE_TO_TRASH``. + + Returns the error message on failure, ``None`` on success. + """ + try: + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GMAIL_MOVE_TO_TRASH", + params={"message_id": message_id}, + entity_id=entity_id, + ) + if not result.get("success"): + return result.get("error", "Unknown error") + return None + except Exception as e: + logger.error(f"Failed to trash Gmail message: {e!s}") + return str(e) + + # ===== Google Calendar write methods ===== + + async def create_calendar_event( + self, + connected_account_id: str, + entity_id: str, + summary: str, + start_datetime: str, + end_datetime: str, + timezone: str | None = None, + description: str | None = None, + location: str | None = None, + attendees: list[str] | None = None, + calendar_id: str = "primary", + ) -> tuple[str | None, str | None, str | None]: + """Create a Google Calendar event via ``GOOGLECALENDAR_CREATE_EVENT``. + + Composio strips trailing timezone info on ``start_datetime`` / + ``end_datetime`` and uses the ``timezone`` field as the IANA name, + so callers may pass ISO 8601 strings with or without offsets. + + Returns: + Tuple of (event_id, html_link, error). + """ + try: + params: dict[str, Any] = { + "summary": summary, + "start_datetime": start_datetime, + "end_datetime": end_datetime, + "calendar_id": calendar_id, + } + if timezone: + params["timezone"] = timezone + if description: + params["description"] = description + if location: + params["location"] = location + if attendees: + params["attendees"] = [a for a in attendees if a] + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLECALENDAR_CREATE_EVENT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + event_id = None + html_link = None + if isinstance(payload, dict): + event_id = payload.get("id") or payload.get("event_id") + html_link = payload.get("htmlLink") or payload.get("html_link") + return event_id, html_link, None + except Exception as e: + logger.error(f"Failed to create Calendar event: {e!s}") + return None, None, str(e) + + async def update_calendar_event( + self, + connected_account_id: str, + entity_id: str, + event_id: str, + summary: str | None = None, + start_time: str | None = None, + end_time: str | None = None, + timezone: str | None = None, + description: str | None = None, + location: str | None = None, + attendees: list[str] | None = None, + calendar_id: str = "primary", + ) -> tuple[str | None, str | None, str | None]: + """Patch an existing Google Calendar event via ``GOOGLECALENDAR_PATCH_EVENT``. + + Uses PATCH (not PUT) semantics so omitted fields are preserved. + + Returns: + Tuple of (event_id, html_link, error). + """ + try: + params: dict[str, Any] = { + "event_id": event_id, + "calendar_id": calendar_id, + } + if summary is not None: + params["summary"] = summary + if start_time is not None: + params["start_time"] = start_time + if end_time is not None: + params["end_time"] = end_time + if timezone: + params["timezone"] = timezone + if description is not None: + params["description"] = description + if location is not None: + params["location"] = location + if attendees is not None: + params["attendees"] = [a for a in attendees if a] + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLECALENDAR_PATCH_EVENT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + new_event_id = event_id + html_link = None + if isinstance(payload, dict): + new_event_id = payload.get("id") or payload.get("event_id") or event_id + html_link = payload.get("htmlLink") or payload.get("html_link") + return new_event_id, html_link, None + except Exception as e: + logger.error(f"Failed to patch Calendar event: {e!s}") + return None, None, str(e) + + async def delete_calendar_event( + self, + connected_account_id: str, + entity_id: str, + event_id: str, + calendar_id: str = "primary", + ) -> str | None: + """Delete a Google Calendar event via ``GOOGLECALENDAR_DELETE_EVENT``. + + Returns the error message on failure, ``None`` on success (idempotent + on already-deleted events). + """ + try: + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLECALENDAR_DELETE_EVENT", + params={ + "event_id": event_id, + "calendar_id": calendar_id, + }, + entity_id=entity_id, + ) + if not result.get("success"): + return result.get("error", "Unknown error") + return None + except Exception as e: + logger.error(f"Failed to delete Calendar event: {e!s}") + return str(e) + + # ===== Google Drive write methods ===== + + @staticmethod + def _drive_web_view_link(file_id: str, mime_type: str | None) -> str: + """Synthesize a Google Drive ``webViewLink`` from id + mimeType. + + Composio's ``GOOGLEDRIVE_CREATE_FILE_FROM_TEXT`` returns flat + metadata (id, name, mimeType) but does not always include a + ``webViewLink``. We rebuild the canonical UI URL based on the + Workspace MIME type so callers can keep using a single field. + """ + if not file_id: + return "" + mt = (mime_type or "").lower() + if mt == "application/vnd.google-apps.document": + return f"https://docs.google.com/document/d/{file_id}/edit" + if mt == "application/vnd.google-apps.spreadsheet": + return f"https://docs.google.com/spreadsheets/d/{file_id}/edit" + if mt == "application/vnd.google-apps.presentation": + return f"https://docs.google.com/presentation/d/{file_id}/edit" + if mt == "application/vnd.google-apps.folder": + return f"https://drive.google.com/drive/folders/{file_id}" + return f"https://drive.google.com/file/d/{file_id}/view" + + async def create_drive_file_from_text( + self, + connected_account_id: str, + entity_id: str, + name: str, + mime_type: str, + content: str | None = None, + parent_id: str | None = None, + ) -> tuple[dict[str, Any] | None, str | None]: + """Create a Google Drive file from text via ``GOOGLEDRIVE_CREATE_FILE_FROM_TEXT``. + + Composio's tool requires ``text_content`` even for "empty" files; + an empty string is accepted. Native Workspace types (Docs, Sheets) + are produced by setting ``mime_type`` to the Google Apps MIME, and + Drive auto-converts the text payload (e.g. CSV → Sheet). + + Returns: + Tuple of (file_meta, error). ``file_meta`` keys: + ``id``, ``name``, ``mimeType``, ``webViewLink``. + """ + try: + params: dict[str, Any] = { + "file_name": name, + "mime_type": mime_type, + "text_content": content if content is not None else "", + } + if parent_id: + params["parent_id"] = parent_id + + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLEDRIVE_CREATE_FILE_FROM_TEXT", + params=params, + entity_id=entity_id, + ) + if not result.get("success"): + return None, result.get("error", "Unknown error") + + payload = self._unwrap_response_data(result.get("data", {})) + file_id: str | None = None + file_name: str | None = name + mime: str | None = mime_type + web_view_link: str | None = None + + if isinstance(payload, dict): + file_id = ( + payload.get("id") or payload.get("file_id") or payload.get("fileId") + ) + file_name = payload.get("name") or payload.get("file_name") or name + mime = payload.get("mimeType") or payload.get("mime_type") or mime_type + web_view_link = payload.get("webViewLink") or payload.get( + "web_view_link" + ) + + if not file_id: + return None, "Composio response did not include a file id" + + if not web_view_link: + web_view_link = self._drive_web_view_link(file_id, mime) + + return ( + { + "id": file_id, + "name": file_name, + "mimeType": mime, + "webViewLink": web_view_link, + }, + None, + ) + except Exception as e: + logger.error(f"Failed to create Drive file: {e!s}") + return None, str(e) + + async def trash_drive_file( + self, + connected_account_id: str, + entity_id: str, + file_id: str, + ) -> str | None: + """Move a Google Drive file to trash via ``GOOGLEDRIVE_TRASH_FILE``. + + Returns the error message on failure, ``None`` on success. + """ + try: + result = await self.execute_tool( + connected_account_id=connected_account_id, + tool_name="GOOGLEDRIVE_TRASH_FILE", + params={"file_id": file_id}, + entity_id=entity_id, + ) + if not result.get("success"): + return result.get("error", "Unknown error") + return None + except Exception as e: + logger.error(f"Failed to trash Drive file: {e!s}") + return str(e) + # ===== User Info Methods ===== async def get_connected_account_email( From 0654662d29c31f8859c6434ad2a69636e9517557 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 5 May 2026 19:10:35 -0700 Subject: [PATCH 129/131] refactor(plate-editor): replace markdown deserialization with safeDeserializeMarkdown utility --- .../components/editor/plate-editor.tsx | 28 ++++---- .../editor/utils/safe-deserialize.ts | 64 +++++++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 surfsense_web/components/editor/utils/safe-deserialize.ts diff --git a/surfsense_web/components/editor/plate-editor.tsx b/surfsense_web/components/editor/plate-editor.tsx index c42cb991e..51ad7d700 100644 --- a/surfsense_web/components/editor/plate-editor.tsx +++ b/surfsense_web/components/editor/plate-editor.tsx @@ -11,6 +11,7 @@ import { EditorSaveContext } from "@/components/editor/editor-save-context"; import { CitationKit, injectCitationNodes } from "@/components/editor/plugins/citation-kit"; import { type EditorPreset, presetMap } from "@/components/editor/presets"; import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx"; +import { safeDeserializeMarkdown } from "@/components/editor/utils/safe-deserialize"; import { Editor, EditorContainer } from "@/components/ui/editor"; import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser"; @@ -169,15 +170,17 @@ export function PlateEditor({ : markdown ? (editor) => { if (!enableCitations) { - return editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(markdown)); + return safeDeserializeMarkdown( + editor, + escapeMdxExpressions(markdown) + ) as Value; } const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown); - const value = editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(rewritten)); - return injectCitationNodes(value as Descendant[], urlMap) as Value; + const value = safeDeserializeMarkdown( + editor, + escapeMdxExpressions(rewritten) + ); + return injectCitationNodes(value, urlMap) as Value; } : undefined, }); @@ -200,14 +203,13 @@ export function PlateEditor({ let newValue: Descendant[]; if (enableCitations) { const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown); - const deserialized = editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(rewritten)) as Descendant[]; + const deserialized = safeDeserializeMarkdown( + editor, + escapeMdxExpressions(rewritten) + ); newValue = injectCitationNodes(deserialized, urlMap); } else { - newValue = editor - .getApi(MarkdownPlugin) - .markdown.deserialize(escapeMdxExpressions(markdown)) as Descendant[]; + newValue = safeDeserializeMarkdown(editor, escapeMdxExpressions(markdown)); } editor.tf.reset(); editor.tf.setValue(newValue as Value); diff --git a/surfsense_web/components/editor/utils/safe-deserialize.ts b/surfsense_web/components/editor/utils/safe-deserialize.ts new file mode 100644 index 000000000..e359a7791 --- /dev/null +++ b/surfsense_web/components/editor/utils/safe-deserialize.ts @@ -0,0 +1,64 @@ +// --------------------------------------------------------------------------- +// Safe markdown deserialization for the Plate editor +// --------------------------------------------------------------------------- +// `remark-mdx` treats any HTML-like tag as JSX, so unbalanced inline HTML +// (very common in GitHub READMEs, web-scraped pages, PDF conversions) makes +// it throw "Expected a closing tag for ``" and crash the editor. +// +// Per the MDX maintainers' guidance (mdx-js/mdx, ipikuka/next-mdx-remote-client +// #14), MDX is the wrong format for untrusted markdown and the recommended +// fix is to fall back to plain markdown parsing. `MarkdownPlugin.deserialize` +// accepts a per-call `remarkPlugins` override, so we can: +// +// 1. Try with `remarkMdx` (rich MDX features, e.g. JSX-style components). +// 2. On failure, retry without `remarkMdx` (lenient HTML, like GitHub). +// 3. As a last resort, render the raw source in a paragraph so the user +// never sees a crashed editor. +// --------------------------------------------------------------------------- + +import { MarkdownPlugin, remarkMdx } from "@platejs/markdown"; +import type { Descendant } from "platejs"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import type { PlateEditorInstance } from "@/components/editor/plate-editor"; + +const STRICT_PLUGINS = [remarkGfm, remarkMath, remarkMdx]; +const LENIENT_PLUGINS = [remarkGfm, remarkMath]; + +function plainTextFallback(markdown: string): Descendant[] { + return [ + { + type: "p", + children: [{ text: markdown }], + } as unknown as Descendant, + ]; +} + +/** + * Deserialize markdown into a Plate value, gracefully degrading when the + * MDX-strict parser rejects raw HTML. Always returns a renderable value; + * never throws. + */ +export function safeDeserializeMarkdown( + editor: PlateEditorInstance, + markdown: string +): Descendant[] { + const api = editor.getApi(MarkdownPlugin).markdown; + + try { + return api.deserialize(markdown, { remarkPlugins: STRICT_PLUGINS }) as Descendant[]; + } catch (mdxError) { + if (process.env.NODE_ENV !== "production") { + console.warn( + "[plate-editor] MDX parse failed, retrying without remark-mdx:", + mdxError + ); + } + try { + return api.deserialize(markdown, { remarkPlugins: LENIENT_PLUGINS }) as Descendant[]; + } catch (fallbackError) { + console.error("[plate-editor] markdown deserialize failed:", fallbackError); + return plainTextFallback(markdown); + } + } +} From a2ad697a2990b55f292581b03c9f52427fd58e06 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 5 May 2026 19:13:38 -0700 Subject: [PATCH 130/131] feat(next.config): enable remote SVG support with enhanced content security policy --- surfsense_web/next.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/next.config.ts b/surfsense_web/next.config.ts index 6cfcb5187..81f349f26 100644 --- a/surfsense_web/next.config.ts +++ b/surfsense_web/next.config.ts @@ -29,6 +29,13 @@ const nextConfig: NextConfig = { hostname: "**", }, ], + // Allow remote SVGs (e.g. README badges from img.shields.io, trendshift.io, + // etc.) which are otherwise blocked by next/image. The CSP below sandboxes + // the SVG and forbids any embedded scripts, which is the mitigation + // recommended by Vercel's NEXTJS_SAFE_SVG_IMAGES conformance rule. + dangerouslyAllowSVG: true, + contentDispositionType: "attachment", + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", }, experimental: { optimizePackageImports: [ From 499c6be0997c62b885b1ab61aab74796381f54c6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 5 May 2026 19:21:43 -0700 Subject: [PATCH 131/131] feat: bumped version to 0.0.23 --- VERSION | 2 +- surfsense_backend/pyproject.toml | 2 +- surfsense_backend/uv.lock | 2 +- surfsense_browser_extension/package.json | 2 +- surfsense_desktop/package.json | 2 +- surfsense_web/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index 818944f5b..df5db66fe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.22 +0.0.23 diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 4235ac962..523a8a1ac 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "surf-new-backend" -version = "0.0.22" +version = "0.0.23" description = "SurfSense Backend" requires-python = ">=3.12" dependencies = [ diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index 4dd5156e7..812be636a 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -7947,7 +7947,7 @@ wheels = [ [[package]] name = "surf-new-backend" -version = "0.0.22" +version = "0.0.23" source = { editable = "." } dependencies = [ { name = "alembic" }, diff --git a/surfsense_browser_extension/package.json b/surfsense_browser_extension/package.json index b8b5cb2ec..82c0a349a 100644 --- a/surfsense_browser_extension/package.json +++ b/surfsense_browser_extension/package.json @@ -1,7 +1,7 @@ { "name": "surfsense_browser_extension", "displayName": "Surfsense Browser Extension", - "version": "0.0.22", + "version": "0.0.23", "description": "Extension to collect Browsing History for SurfSense.", "author": "https://github.com/MODSetter", "engines": { diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index 744ab65ab..4ef624760 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -1,6 +1,6 @@ { "name": "surfsense-desktop", - "version": "0.0.22", + "version": "0.0.23", "description": "SurfSense Desktop App", "main": "dist/main.js", "scripts": { diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 2adec8638..782409c3c 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -1,6 +1,6 @@ { "name": "surfsense_web", - "version": "0.0.22", + "version": "0.0.23", "private": true, "description": "SurfSense Frontend", "scripts": {