Compare commits

...

104 commits

Author SHA1 Message Date
DESKTOP-RTLN3BA\$punk
3e8da2bbcc Merge commit '29c25b4ce5' into dev
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
2026-05-04 17:36:22 -07:00
Rohan Verma
ce6d9233bc
Merge pull request #1335 from AnishSarkar22/fix/memory-extraction
refactor(memory): streamline memory extraction
2026-05-04 17:26:27 -07:00
Rohan Verma
7c00840e9a
Merge pull request #1326 from CREDO23/feature/multi-agent
[Feature] Opt-in multi-agent chat with bundled human approval
2026-05-04 17:25:03 -07:00
CREDO23
5119915f4f Merge upstream/dev into feature/multi-agent 2026-05-05 01:44:46 +02:00
CREDO23
9e35cdaec7 Fail loud on registry subagent build error. 2026-05-05 00:38:34 +02:00
CREDO23
6a9433c8ff Apply per-card decisions in HITL bundle resume. 2026-05-05 00:22:10 +02:00
CREDO23
0af2c28a8d Stabilize HITL bundle UX and resume. 2026-05-04 23:58:53 +02:00
CREDO23
972650909c Rename package: multi_agent_chat 2026-05-04 21:57:05 +02:00
CREDO23
d675d4df3f Remove unwired multi_agent_chat package. 2026-05-04 21:45:57 +02:00
CREDO23
216a678f1a Address LLM review findings; trim comments. 2026-05-04 21:32:42 +02:00
CREDO23
65f1f8f73c Harden multi-agent for production: resume cleanup, busy-mutex race, deny propagation, disabled-tools. 2026-05-04 20:48:55 +02:00
CREDO23
7735becd02 Skip middleware gate for native body-gated tools to restore approval-card context. 2026-05-04 19:25:50 +02:00
CREDO23
277bd50f37 Harden HITL for multi-step tasks: bypass internal MCP gate, full-args dedup, and decision-envelope normalization. 2026-05-04 19:25:27 +02:00
CREDO23
4ac3f0b304 Forward HITL decisions from the streaming layer to subagents via the config side-channel. 2026-05-04 18:42:58 +02:00
CREDO23
ba2138c164 Wire SurfSenseCheckpointedSubAgentMiddleware into the multi-agent stack. 2026-05-04 18:42:46 +02:00
CREDO23
acd2fdda8a Add SurfSenseCheckpointedSubAgentMiddleware to bridge HITL into deepagents subagents. 2026-05-04 18:42:39 +02:00
Rohan Verma
29c25b4ce5
Merge pull request #1342 from MODSetter/dev
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
feat: version bump to 0.0.21
2026-05-04 03:17:48 -07:00
Anish Sarkar
e38e20b484 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.
2026-05-04 12:18:09 +05:30
Anish Sarkar
b981b51ab1 Merge remote-tracking branch 'upstream/dev' into fix/memory-extraction 2026-05-04 12:03:44 +05:30
Anish Sarkar
9975e085aa refactor(memory): streamline memory extraction by utilizing extract_text_content utility 2026-05-02 16:10:30 +05:30
CREDO23
4fd3c4fb27 Inject dynamic registry subagents into the main prompt and simplify connector discovery. 2026-05-02 00:44:02 +02:00
CREDO23
ffed829cf8 Skip registry subagents missing connectors when assembling the stack. 2026-05-02 00:43:56 +02:00
CREDO23
06cef04e57 Gate builds via exclude list and expose registry helpers from subagents package. 2026-05-02 00:43:52 +02:00
CREDO23
eefdd1d7f0 Add connector routing entries and per-subagent connector token map. 2026-05-02 00:43:46 +02:00
CREDO23
2bce2a2f55 Remove obsolete main-agent connector permission helpers. 2026-05-02 00:43:42 +02:00
CREDO23
fbcff79a58 Export deep-agent factory and omit memory/research from task registry. 2026-05-01 23:18:17 +02:00
CREDO23
eb4b570265 Wire main-agent async factory and export create_surfsense_deep_agent. 2026-05-01 23:18:10 +02:00
CREDO23
fc540e962b Add main-agent system prompt markdown and builder. 2026-05-01 23:17:55 +02:00
CREDO23
7a6c253711 Add main-agent graph compile and middleware stack. 2026-05-01 23:17:51 +02:00
CREDO23
083a9f7946 Add main-agent tool allowlist plus permission and prune helpers. 2026-05-01 23:17:46 +02:00
CREDO23
d9c873b2e1 Adjust supervisor routing integration for delegated routes. 2026-05-01 20:30:20 +02:00
CREDO23
dcc348d72a Add registry and factory for route subagent specs. 2026-05-01 20:30:20 +02:00
CREDO23
745bcec390 Add OneDrive, Slack, and Teams connector route slices. 2026-05-01 20:30:20 +02:00
CREDO23
6e54fb00e3 Add Linear, Luma, and Notion connector route slices. 2026-05-01 20:30:20 +02:00
CREDO23
96ab91b719 Add Gmail, Google Drive, and Jira connector route slices. 2026-05-01 20:30:20 +02:00
CREDO23
f24eb3496c Add Confluence, Discord, and Dropbox connector route slices. 2026-05-01 20:30:20 +02:00
CREDO23
4f0e84c6a3 Add Airtable, Calendar, and Clickup connector route slices. 2026-05-01 20:30:20 +02:00
CREDO23
ba57eae2bb Add builtin deliverables route slice for delegated agents. 2026-05-01 20:30:20 +02:00
CREDO23
ff307dd923 Add builtin memory route slice for delegated agents. 2026-05-01 20:30:20 +02:00
CREDO23
b9bc06e7b4 Add builtin research route slice for delegated agents. 2026-05-01 20:30:20 +02:00
CREDO23
7080b787d1 Add MCP tool loading and connector partitioning. 2026-05-01 20:30:20 +02:00
CREDO23
cf3acd87aa Add MCP allow and ask name tables per integration. 2026-05-01 20:30:20 +02:00
CREDO23
5f84d46f96 Add subagent spec packing and shared package exports. 2026-05-01 20:30:20 +02:00
CREDO23
77f52af6cc Add shared tool permission types and markdown file reader. 2026-05-01 20:30:20 +02:00
CREDO23
30cd530ac6 Add connector routing constants for delegated routes. 2026-05-01 20:30:20 +02:00
CREDO23
5d3b8b9ca9 Merge remote-tracking branch 'upstream/dev' into feature/multi-agent 2026-05-01 00:05:20 +02:00
CREDO23
d157ceaabc Remove multi-agent planning docs and fix stream_new_chat logger. 2026-04-30 23:42:21 +02:00
CREDO23
a6540b21c7 Fix stream_new_chat interrupt resolution using LangGraph snapshot.interrupts. 2026-04-30 21:42:58 +02:00
CREDO23
ba131f5736 Refactor multi-agent supervisor prompts and wiring; thread citations into streaming. 2026-04-30 19:33:57 +02:00
CREDO23
74337b462a Tighten supervisor delegation policy and align deliverables wording across prompts. 2026-04-30 16:43:44 +02:00
CREDO23
c077939522 Gate chat on MULTI_AGENT_CHAT_ENABLED and route to the multi-agent graph when on. 2026-04-30 16:43:31 +02:00
CREDO23
8f8d7540f0 Coerce deliverables thread_id and invoke domain agents asynchronously. 2026-04-30 16:41:19 +02:00
CREDO23
5bc33626b9 Tune memory visibility and disable generic MCP supervisor routing. 2026-04-30 12:11:43 +02:00
CREDO23
f7ddb36c97 Render supervisor specialist guidance dynamically from available tools. 2026-04-30 12:08:54 +02:00
CREDO23
6858bdb726 Validate domain-agent JSON outputs before returning to supervisor. 2026-04-30 12:08:48 +02:00
CREDO23
f9275be56b Normalize Linear, Slack, and generic MCP bridge prompts. 2026-04-30 12:07:53 +02:00
CREDO23
9d18f9f214 Standardize Airtable, ClickUp, and Jira MCP bridge contracts. 2026-04-30 12:07:23 +02:00
CREDO23
3cd2bf9302 Align collaboration connector prompts with strict JSON rules. 2026-04-30 12:07:13 +02:00
CREDO23
7083023929 Strengthen Gmail and workspace connector expert prompt contracts. 2026-04-30 12:07:04 +02:00
CREDO23
6825d3d8d5 Normalize built-in deliverables and research expert output contracts. 2026-04-30 12:06:46 +02:00
CREDO23
cdb307361a Wrap delegated tasks in explicit XML context tags. 2026-04-30 12:06:33 +02:00
CREDO23
5148f5dfef Add a prompt tuning playbook for multi-agent quality. 2026-04-30 12:05:49 +02:00
CREDO23
362d462f92 Align multi-agent docs and Gmail/Calendar expert copy with registry routing. 2026-04-30 03:53:29 +02:00
CREDO23
2ab4c411fe Compose supervisor LangChain agent with SurfSense middleware and connector discovery. 2026-04-30 03:53:22 +02:00
CREDO23
33fc457dcc Gate supervisor connector routes by searchable inventory and simplify routing specs. 2026-04-30 03:53:15 +02:00
CREDO23
91e12bd31e Document multi-agent layout and supervisor delegation prompts. 2026-04-30 02:36:30 +02:00
CREDO23
388e86ebc9 Compose async multi-agent chat entrypoint and drop legacy supervisor scaffolding. 2026-04-30 02:36:22 +02:00
CREDO23
5497f472b2 Wire supervisor routing specs and registry-backed expert routes. 2026-04-30 02:36:16 +02:00
CREDO23
1f7343298c Add expert_agent vertical slices and MCP bridge prompts. 2026-04-30 02:36:11 +02:00
CREDO23
c974fcefe6 Add multi-agent core registry, delegation, and MCP partitioning. 2026-04-30 02:36:06 +02:00
CREDO23
0c8ea2085e Add multi_agent_chat package exports and implementation layout doc. 2026-04-30 01:00:01 +02:00
CREDO23
e18675a40a Add multi_agent_chat integration entrypoint create_multi_agent_chat. 2026-04-30 00:59:59 +02:00
CREDO23
f1a5f1c683 Add multi_agent_chat routing tools and supervisor routing composition. 2026-04-30 00:59:58 +02:00
CREDO23
0fcb2acfdc Add multi_agent_chat supervisor agent graph and supervisor prompt. 2026-04-30 00:59:57 +02:00
CREDO23
5a0a265b2b Add multi_agent_chat calendar slice with connector tools, agent, and domain prompt. 2026-04-30 00:59:56 +02:00
CREDO23
09a46a282a Add multi_agent_chat gmail slice with connector tools, agent, and domain prompt. 2026-04-30 00:59:55 +02:00
CREDO23
5ff2678253 Add multi_agent_chat shared helpers for prompts, domain agents, deps, and invoke parsing. 2026-04-30 00:59:47 +02:00
CREDO23
b9132f8544 Export supervisor baseline create_surfsense_deep_agent. 2026-04-29 23:41:29 +02:00
CREDO23
93fb4011e9 Add async factory for tool-free supervisor baseline agent. 2026-04-29 23:41:29 +02:00
CREDO23
53adac0cc9 Compose supervisor prompt from SurfSense fragments and composer blocks. 2026-04-29 23:41:29 +02:00
CREDO23
06f4b9b4d5 Clarify memory limits without update_memory tool. 2026-04-29 23:41:29 +02:00
CREDO23
fc68cbf22f Add team-space KB-first policy for supervisor prompts. 2026-04-29 23:41:29 +02:00
CREDO23
26095805a8 Add private-space KB-first policy for supervisor prompts. 2026-04-29 23:41:29 +02:00
CREDO23
b506c79611 Describe LangGraph supervisor scope for baseline. 2026-04-29 23:41:29 +02:00
CREDO23
c1273c41b9 Add prompts package for supervisor baseline fragments. 2026-04-29 23:41:29 +02:00
CREDO23
615a23b3ec Build compiled LangGraph agent without tool-injecting middleware. 2026-04-29 23:41:29 +02:00
CREDO23
15e105454d Map connector enums to searchable KB types. 2026-04-29 23:41:29 +02:00
CREDO23
525527d802 Add deep_agent package for supervisor baseline. 2026-04-29 23:41:29 +02:00
CREDO23
3908131105 Fix stale feature flags cache so specialized subagents respect env. 2026-04-29 21:36:34 +02:00
CREDO23
bf9b606a61 Wire Linear and Slack specialists and prompt routing. 2026-04-29 20:51:06 +02:00
CREDO23
41cb4a567b Harden Linear and Slack MCP subagent permissions. 2026-04-29 20:24:21 +02:00
CREDO23
3dec2a7327 Add a Slack operations specialist subagent. 2026-04-29 16:25:36 +02:00
CREDO23
8ddfa6ac6f Add shared safety constants for provider subagents. 2026-04-29 16:25:28 +02:00
CREDO23
dce08e8b25 Merge remote-tracking branch 'upstream/dev_mod' into feature/multi-agent-subagents-gmail-calendar 2026-04-29 16:22:13 +02:00
CREDO23
dcae196eea Revert "feat(chat): add multi-agent mode routing scaffold and telemetry."
This reverts commit 7b9a218d62.
2026-04-29 16:21:57 +02:00
CREDO23
2eed81d059 Revert "feat(subagents): export registry helpers."
This reverts commit 8d0a679805.
2026-04-29 16:21:57 +02:00
CREDO23
6e5206e47c Revert "feat(subagents): add shared subagent utility helpers."
This reverts commit 755d20f6ea.
2026-04-29 16:21:57 +02:00
CREDO23
14dbcf0874 Revert "feat(subagents): add read-only calendar subagent."
This reverts commit 6afe65a449.
2026-04-29 16:21:57 +02:00
CREDO23
fa5a209e5a Revert "feat(subagents): add read-only gmail subagent."
This reverts commit e57633dab2.
2026-04-29 16:21:57 +02:00
CREDO23
e57633dab2 feat(subagents): add read-only gmail subagent. 2026-04-28 18:37:32 +02:00
CREDO23
6afe65a449 feat(subagents): add read-only calendar subagent. 2026-04-28 18:37:32 +02:00
CREDO23
755d20f6ea feat(subagents): add shared subagent utility helpers. 2026-04-28 18:37:32 +02:00
CREDO23
8d0a679805 feat(subagents): export registry helpers. 2026-04-28 18:37:32 +02:00
CREDO23
7b9a218d62 feat(chat): add multi-agent mode routing scaffold and telemetry. 2026-04-28 15:35:14 +02:00
280 changed files with 19006 additions and 327 deletions

View file

@ -282,6 +282,9 @@ LANGSMITH_PROJECT=surfsense
# =============================================================================
# OPTIONAL: New-chat agent feature flags
# =============================================================================
# Multi-agent orchestrator switch for authenticated chat streaming.
# MULTI_AGENT_CHAT_ENABLED=false
# Master kill-switch — when true, every flag below is forced OFF.
# SURFSENSE_DISABLE_NEW_AGENT_STACK=false

View file

@ -0,0 +1,7 @@
"""Deepagents-backed routes: ``subagents/``; main-agent graph under ``main_agent/`` (SRP subpackages)."""
from __future__ import annotations
from .main_agent import create_surfsense_deep_agent
__all__ = ["create_surfsense_deep_agent"]

View file

@ -0,0 +1,43 @@
"""Connector-type to subagent name; subagent name to availability tokens for build_subagents."""
from __future__ import annotations
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = {
"GOOGLE_GMAIL_CONNECTOR": "gmail",
"COMPOSIO_GMAIL_CONNECTOR": "gmail",
"GOOGLE_CALENDAR_CONNECTOR": "calendar",
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "calendar",
"DISCORD_CONNECTOR": "discord",
"TEAMS_CONNECTOR": "teams",
"LUMA_CONNECTOR": "luma",
"LINEAR_CONNECTOR": "linear",
"JIRA_CONNECTOR": "jira",
"CLICKUP_CONNECTOR": "clickup",
"SLACK_CONNECTOR": "slack",
"AIRTABLE_CONNECTOR": "airtable",
"NOTION_CONNECTOR": "notion",
"CONFLUENCE_CONNECTOR": "confluence",
"GOOGLE_DRIVE_CONNECTOR": "google_drive",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "google_drive",
"DROPBOX_CONNECTOR": "dropbox",
"ONEDRIVE_CONNECTOR": "onedrive",
}
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"deliverables": frozenset(),
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
"clickup": frozenset({"CLICKUP_CONNECTOR"}),
"confluence": frozenset({"CONFLUENCE_CONNECTOR"}),
"discord": frozenset({"DISCORD_CONNECTOR"}),
"dropbox": frozenset({"DROPBOX_FILE"}),
"gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR"}),
"google_drive": frozenset({"GOOGLE_DRIVE_FILE"}),
"jira": frozenset({"JIRA_CONNECTOR"}),
"linear": frozenset({"LINEAR_CONNECTOR"}),
"luma": frozenset({"LUMA_CONNECTOR"}),
"notion": frozenset({"NOTION_CONNECTOR"}),
"onedrive": frozenset({"ONEDRIVE_FILE"}),
"slack": frozenset({"SLACK_CONNECTOR"}),
"teams": frozenset({"TEAMS_CONNECTOR"}),
}

View file

@ -0,0 +1,7 @@
"""Main-agent deep agent: ``runtime/`` (factory), ``graph/`` (compile), ``system_prompt/``, etc."""
from __future__ import annotations
from .runtime import create_surfsense_deep_agent
__all__ = ["create_surfsense_deep_agent"]

View file

@ -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"]

View file

@ -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)

View file

@ -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"]

View file

@ -0,0 +1,85 @@
"""Synchronous graph compile (middleware + ``create_agent``)."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from deepagents import __version__ as deepagents_version
from langchain.agents import create_agent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from langgraph.types import Checkpointer
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from app.agents.new_chat.context import SurfSenseContextSchema
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.db import ChatVisibility
from .middleware import build_main_agent_deepagent_middleware
def build_compiled_agent_graph_sync(
*,
llm: BaseChatModel,
tools: Sequence[BaseTool],
final_system_prompt: str,
backend_resolver: Any,
filesystem_mode: FilesystemMode,
search_space_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
anon_session_id: str | None,
available_connectors: list[str] | None,
available_document_types: list[str] | None,
mentioned_document_ids: list[int] | None,
max_input_tokens: int | None,
flags: AgentFeatureFlags,
checkpointer: Checkpointer,
subagent_dependencies: dict[str, Any],
mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None,
disabled_tools: list[str] | None = None,
):
"""Sync compile: middleware + ``create_agent`` (run via ``asyncio.to_thread``)."""
main_agent_middleware = build_main_agent_deepagent_middleware(
llm=llm,
tools=tools,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,
anon_session_id=anon_session_id,
available_connectors=available_connectors,
available_document_types=available_document_types,
mentioned_document_ids=mentioned_document_ids,
max_input_tokens=max_input_tokens,
flags=flags,
subagent_dependencies=subagent_dependencies,
checkpointer=checkpointer,
mcp_tools_by_agent=mcp_tools_by_agent,
disabled_tools=disabled_tools,
)
agent = create_agent(
llm,
system_prompt=final_system_prompt,
tools=list(tools),
middleware=main_agent_middleware,
context_schema=SurfSenseContextSchema,
checkpointer=checkpointer,
)
return agent.with_config(
{
"recursion_limit": 10_000,
"metadata": {
"ls_integration": "deepagents",
"versions": {"deepagents": deepagents_version},
},
}
)

View file

@ -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"]

View file

@ -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"]

View file

@ -0,0 +1,44 @@
"""RunnableConfig wiring for nested subagent invocations.
Forwards the parent's ``runtime.config`` (thread_id, …) into the subagent and
exposes the side-channel ``stream_resume_chat`` uses to ferry resume payloads.
"""
from __future__ import annotations
from typing import Any
from langchain.tools import ToolRuntime
from .constants import DEFAULT_SUBAGENT_RECURSION_LIMIT
def subagent_invoke_config(runtime: ToolRuntime) -> dict[str, Any]:
"""RunnableConfig for the nested invoke; raises ``recursion_limit`` to the parent's budget."""
merged: dict[str, Any] = dict(runtime.config) if runtime.config else {}
current_limit = merged.get("recursion_limit")
try:
current_int = int(current_limit) if current_limit is not None else 0
except (TypeError, ValueError):
current_int = 0
if current_int < DEFAULT_SUBAGENT_RECURSION_LIMIT:
merged["recursion_limit"] = DEFAULT_SUBAGENT_RECURSION_LIMIT
return merged
def consume_surfsense_resume(runtime: ToolRuntime) -> Any:
"""Pop the resume payload; siblings share ``configurable`` by reference."""
cfg = runtime.config or {}
configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
if not isinstance(configurable, dict):
return None
return configurable.pop("surfsense_resume_value", None)
def has_surfsense_resume(runtime: ToolRuntime) -> bool:
"""True iff a resume payload is queued on this runtime (non-destructive)."""
cfg = runtime.config or {}
configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
if not isinstance(configurable, dict):
return False
return "surfsense_resume_value" in configurable

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,76 @@
"""Resume-payload shaping and pending-interrupt detection for subagents.
Splits the work of "given a state snapshot and a parent-stashed resume value,
produce the right ``Command(resume=...)`` for the subagent" into pure helpers.
"""
from __future__ import annotations
from typing import Any
from langgraph.types import Command
def hitlrequest_action_count(pending_value: Any) -> int:
"""Bundle size for a LangChain ``HITLRequest`` payload; ``0`` for non-bundle interrupts."""
if not isinstance(pending_value, dict):
return 0
actions = pending_value.get("action_requests")
if isinstance(actions, list):
return len(actions)
return 0
def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any:
"""Legacy fallback: pad a 1-decision resume to N for an ``action_requests=N`` bundle.
Modern frontend submits N decisions per bundle (one per action_request) so
this is a no-op; kept for backwards compatibility with old in-flight
threads or non-bundle clients that send a single decision.
"""
if expected_count <= 1:
return resume_value
if not isinstance(resume_value, dict):
return resume_value
decisions = resume_value.get("decisions")
if not isinstance(decisions, list) or len(decisions) >= expected_count:
return resume_value
if not decisions:
return resume_value
padded = list(decisions) + [decisions[-1]] * (expected_count - len(decisions))
return {**resume_value, "decisions": padded}
def get_first_pending_subagent_interrupt(state: Any) -> tuple[str | None, Any]:
"""First pending ``(interrupt_id, value)``; ``(None, None)`` if no interrupt.
Assumes at most one pending interrupt per snapshot (sequential tool nodes).
Parallel tool nodes would need an id-aware lookup instead of first-wins.
"""
if state is None:
return None, None
for it in getattr(state, "interrupts", None) or ():
value = getattr(it, "value", None)
interrupt_id = getattr(it, "id", None)
if value is not None:
return (
interrupt_id if isinstance(interrupt_id, str) else None,
value,
)
for sub_task in getattr(state, "tasks", None) or ():
for it in getattr(sub_task, "interrupts", None) or ():
value = getattr(it, "value", None)
interrupt_id = getattr(it, "id", None)
if value is not None:
return (
interrupt_id if isinstance(interrupt_id, str) else None,
value,
)
return None, None
def build_resume_command(resume_value: Any, pending_id: str | None) -> Command:
"""``Command(resume={id: value})`` when ``id`` is known, else fall back to scalar."""
if pending_id is None:
return Command(resume=resume_value)
return Command(resume={pending_id: resume_value})

View file

@ -0,0 +1,231 @@
"""Build the ``task`` tool that invokes subagents with HITL bridging.
The tool's body is the only place where the parent and the subagent meet at
runtime: it reads the parent's stashed resume value, decides whether to send
fresh state or a targeted ``Command(resume=...)`` to the subagent, then
re-raises any new pending interrupt back to the parent.
"""
from __future__ import annotations
import logging
from typing import Annotated, Any
from deepagents.middleware.subagents import TASK_TOOL_DESCRIPTION
from langchain.tools import BaseTool, ToolRuntime
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.runnables import Runnable
from langchain_core.tools import StructuredTool
from langgraph.types import Command
from .config import (
consume_surfsense_resume,
has_surfsense_resume,
subagent_invoke_config,
)
from .constants import EXCLUDED_STATE_KEYS
from .propagation import (
amaybe_propagate_subagent_interrupt,
maybe_propagate_subagent_interrupt,
)
from .resume import (
build_resume_command,
fan_out_decisions_to_match,
get_first_pending_subagent_interrupt,
hitlrequest_action_count,
)
logger = logging.getLogger(__name__)
def build_task_tool_with_parent_config(
subagents: list[dict[str, Any]],
task_description: str | None = None,
) -> BaseTool:
"""Upstream ``_build_task_tool`` + parent ``runtime.config`` propagation + resume bridging."""
subagent_graphs: dict[str, Runnable] = {
spec["name"]: spec["runnable"] for spec in subagents
}
subagent_description_str = "\n".join(
f"- {s['name']}: {s['description']}" for s in subagents
)
if task_description is None:
description = TASK_TOOL_DESCRIPTION.format(
available_agents=subagent_description_str
)
elif "{available_agents}" in task_description:
description = task_description.format(available_agents=subagent_description_str)
else:
description = task_description
def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command:
if "messages" not in result:
msg = (
"CompiledSubAgent must return a state containing a 'messages' key. "
"Custom StateGraphs used with CompiledSubAgent should include 'messages' "
"in their state schema to communicate results back to the main agent."
)
raise ValueError(msg)
state_update = {k: v for k, v in result.items() if k not in EXCLUDED_STATE_KEYS}
message_text = (
result["messages"][-1].text.rstrip() if result["messages"][-1].text else ""
)
return Command(
update={
**state_update,
"messages": [ToolMessage(message_text, tool_call_id=tool_call_id)],
}
)
def _validate_and_prepare_state(
subagent_type: str, description: str, runtime: ToolRuntime
) -> tuple[Runnable, dict]:
subagent = subagent_graphs[subagent_type]
subagent_state = {
k: v for k, v in runtime.state.items() if k not in EXCLUDED_STATE_KEYS
}
subagent_state["messages"] = [HumanMessage(content=description)]
return subagent, subagent_state
def task(
description: Annotated[
str,
"A detailed description of the task for the subagent to perform autonomously. Include all necessary context and specify the expected output format.",
],
subagent_type: Annotated[
str,
"The type of subagent to use. Must be one of the available agent types listed in the tool description.",
],
runtime: ToolRuntime,
) -> str | Command:
if subagent_type not in subagent_graphs:
allowed_types = ", ".join([f"`{k}`" for k in subagent_graphs])
return (
f"We cannot invoke subagent {subagent_type} because it does not exist, "
f"the only allowed types are {allowed_types}"
)
if not runtime.tool_call_id:
raise ValueError("Tool call ID is required for subagent invocation")
subagent, subagent_state = _validate_and_prepare_state(
subagent_type, description, runtime
)
sub_config = subagent_invoke_config(runtime)
# Resume bridge: forward the parent's stashed decision into the
# subagent's pending ``interrupt()``, targeted by id.
pending_id: str | None = None
pending_value: Any = None
get_state = getattr(subagent, "get_state", None)
if callable(get_state):
try:
snapshot = get_state(sub_config)
pending_id, pending_value = get_first_pending_subagent_interrupt(
snapshot
)
except Exception:
# Fail loud if a resume is queued: silent fallback would
# replay the original interrupt to the user.
if has_surfsense_resume(runtime):
logger.exception(
"Subagent %r get_state raised with resume queued; re-raising.",
subagent_type,
)
raise
logger.debug(
"Subagent get_state failed; falling back to fresh invoke",
exc_info=True,
)
if pending_value is not None:
resume_value = consume_surfsense_resume(runtime)
if resume_value is None:
# Bridge invariant: a queued resume must accompany any pending
# subagent interrupt. Fall-through replay would silently re-prompt
# the user; raise so the streaming layer surfaces a clear error.
raise RuntimeError(
f"Subagent {subagent_type!r} has a pending interrupt but no "
"surfsense_resume_value on config; resume bridge is broken."
)
expected = hitlrequest_action_count(pending_value)
resume_value = fan_out_decisions_to_match(resume_value, expected)
result = subagent.invoke(
build_resume_command(resume_value, pending_id),
config=sub_config,
)
else:
result = subagent.invoke(subagent_state, config=sub_config)
maybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
return _return_command_with_state_update(result, runtime.tool_call_id)
async def atask(
description: Annotated[
str,
"A detailed description of the task for the subagent to perform autonomously. Include all necessary context and specify the expected output format.",
],
subagent_type: Annotated[
str,
"The type of subagent to use. Must be one of the available agent types listed in the tool description.",
],
runtime: ToolRuntime,
) -> str | Command:
if subagent_type not in subagent_graphs:
allowed_types = ", ".join([f"`{k}`" for k in subagent_graphs])
return (
f"We cannot invoke subagent {subagent_type} because it does not exist, "
f"the only allowed types are {allowed_types}"
)
if not runtime.tool_call_id:
raise ValueError("Tool call ID is required for subagent invocation")
subagent, subagent_state = _validate_and_prepare_state(
subagent_type, description, runtime
)
sub_config = subagent_invoke_config(runtime)
# Resume bridge — see ``task`` above.
pending_id: str | None = None
pending_value: Any = None
aget_state = getattr(subagent, "aget_state", None)
if callable(aget_state):
try:
snapshot = await aget_state(sub_config)
pending_id, pending_value = get_first_pending_subagent_interrupt(
snapshot
)
except Exception:
if has_surfsense_resume(runtime):
logger.exception(
"Subagent %r aget_state raised with resume queued; re-raising.",
subagent_type,
)
raise
logger.debug(
"Subagent aget_state failed; falling back to fresh ainvoke",
exc_info=True,
)
if pending_value is not None:
resume_value = consume_surfsense_resume(runtime)
if resume_value is None:
raise RuntimeError(
f"Subagent {subagent_type!r} has a pending interrupt but no "
"surfsense_resume_value on config; resume bridge is broken."
)
expected = hitlrequest_action_count(pending_value)
resume_value = fan_out_decisions_to_match(resume_value, expected)
result = await subagent.ainvoke(
build_resume_command(resume_value, pending_id),
config=sub_config,
)
else:
result = await subagent.ainvoke(subagent_state, config=sub_config)
await amaybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
return _return_command_with_state_update(result, runtime.tool_call_id)
return StructuredTool.from_function(
name="task",
func=task,
coroutine=atask,
description=description,
)

View file

@ -0,0 +1,506 @@
"""Assemble the main-agent deep-agent middleware list (LangChain + SurfSense + deepagents)."""
from __future__ import annotations
import logging
from collections.abc import Sequence
from typing import Any
from deepagents import SubAgent
from deepagents.backends import StateBackend
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from deepagents.middleware.skills import SkillsMiddleware
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
from langchain.agents.middleware import (
LLMToolSelectorMiddleware,
ModelCallLimitMiddleware,
ModelFallbackMiddleware,
TodoListMiddleware,
ToolCallLimitMiddleware,
)
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from langgraph.types import Checkpointer
from app.agents.multi_agent_chat.subagents import (
build_subagents,
get_subagents_to_exclude,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.middleware import (
ActionLogMiddleware,
AnonymousDocumentMiddleware,
BusyMutexMiddleware,
ClearToolUsesEdit,
DedupHITLToolCallsMiddleware,
DoomLoopMiddleware,
FileIntentMiddleware,
KnowledgeBasePersistenceMiddleware,
KnowledgePriorityMiddleware,
KnowledgeTreeMiddleware,
MemoryInjectionMiddleware,
NoopInjectionMiddleware,
OtelSpanMiddleware,
PermissionMiddleware,
RetryAfterMiddleware,
SpillingContextEditingMiddleware,
SpillToBackendEdit,
SurfSenseFilesystemMiddleware,
ToolCallNameRepairMiddleware,
build_skills_backend_factory,
create_surfsense_compaction_middleware,
default_skills_sources,
)
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.new_chat.plugin_loader import (
PluginContext,
load_allowed_plugin_names_from_env,
load_plugin_middlewares,
)
from app.agents.new_chat.tools.registry import BUILTIN_TOOLS
from app.db import ChatVisibility
from ...context_prune.prune_tool_names import safe_exclude_tools
from .checkpointed_subagent_middleware import SurfSenseCheckpointedSubAgentMiddleware
def build_main_agent_deepagent_middleware(
*,
llm: BaseChatModel,
tools: Sequence[BaseTool],
backend_resolver: Any,
filesystem_mode: FilesystemMode,
search_space_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
anon_session_id: str | None,
available_connectors: list[str] | None,
available_document_types: list[str] | None,
mentioned_document_ids: list[int] | None,
max_input_tokens: int | None,
flags: AgentFeatureFlags,
subagent_dependencies: dict[str, Any],
checkpointer: Checkpointer,
mcp_tools_by_agent: dict[str, ToolsPermissions] | None = None,
disabled_tools: list[str] | None = None,
) -> list[Any]:
"""Build ordered middleware for ``create_agent`` (Nones already stripped)."""
_memory_middleware = MemoryInjectionMiddleware(
user_id=user_id,
search_space_id=search_space_id,
thread_visibility=visibility,
)
gp_middleware = [
TodoListMiddleware(),
_memory_middleware,
FileIntentMiddleware(llm=llm),
SurfSenseFilesystemMiddleware(
backend=backend_resolver,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
created_by_id=user_id,
thread_id=thread_id,
),
create_surfsense_compaction_middleware(llm, StateBackend),
PatchToolCallsMiddleware(),
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
]
# Build permission rulesets up front so the GP subagent can mirror ``ask``
# rules into ``interrupt_on``: tool calls emitted from within ``task`` runs
# never reach the parent's ``PermissionMiddleware``.
is_desktop_fs = filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER
permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack
permission_rulesets: list[Ruleset] = []
if permission_enabled or is_desktop_fs:
permission_rulesets.append(
Ruleset(
rules=[Rule(permission="*", pattern="*", action="allow")],
origin="surfsense_defaults",
)
)
if is_desktop_fs:
permission_rulesets.append(
Ruleset(
rules=[
Rule(permission="rm", pattern="*", action="ask"),
Rule(permission="rmdir", pattern="*", action="ask"),
Rule(permission="move_file", pattern="*", action="ask"),
Rule(permission="edit_file", pattern="*", action="ask"),
Rule(permission="write_file", pattern="*", action="ask"),
],
origin="desktop_safety",
)
)
# Tools that self-prompt via ``request_approval`` must not also appear
# as ``ask`` rules — that would double-prompt the user for one call.
_tool_names_in_use = {t.name for t in tools}
# Deny parent-bound tools whose ``required_connector`` is missing.
# No-op today (connector subagents are pruned upstream); guards future
# additions to the parent's tool list.
if permission_enabled:
_available_set = set(available_connectors or [])
_synthesized: list[Rule] = []
for tool_def in BUILTIN_TOOLS:
if tool_def.name not in _tool_names_in_use:
continue
rc = tool_def.required_connector
if rc and rc not in _available_set:
_synthesized.append(
Rule(permission=tool_def.name, pattern="*", action="deny")
)
if _synthesized:
permission_rulesets.append(
Ruleset(rules=_synthesized, origin="connector_synthesized")
)
gp_interrupt_on: dict[str, bool] = {
rule.permission: True
for rs in permission_rulesets
for rule in rs.rules
if rule.action == "ask" and rule.permission in _tool_names_in_use
}
general_purpose_spec: SubAgent = { # type: ignore[typeddict-unknown-key]
**GENERAL_PURPOSE_SUBAGENT,
"model": llm,
"tools": tools,
"middleware": gp_middleware,
}
if gp_interrupt_on:
general_purpose_spec["interrupt_on"] = gp_interrupt_on
# Deny-only on subagents: ``task`` runs bypass the parent's
# PermissionMiddleware, while bucket-based ask gates own the ask path.
subagent_deny_rulesets: list[Ruleset] = [
Ruleset(
rules=[r for r in rs.rules if r.action == "deny"],
origin=rs.origin,
)
for rs in permission_rulesets
]
subagent_deny_rulesets = [rs for rs in subagent_deny_rulesets if rs.rules]
subagent_deny_permission_mw: PermissionMiddleware | None = (
PermissionMiddleware(rulesets=subagent_deny_rulesets)
if subagent_deny_rulesets
else None
)
if subagent_deny_permission_mw is not None:
# Run deny check on already-repaired tool calls; insert before
# PatchToolCallsMiddleware (append if the slot moves).
_patch_idx = next(
(
i
for i, m in enumerate(gp_middleware)
if isinstance(m, PatchToolCallsMiddleware)
),
len(gp_middleware),
)
gp_middleware.insert(_patch_idx, subagent_deny_permission_mw)
registry_subagents: list[SubAgent] = []
try:
subagent_extra_middleware: list[Any] = [
TodoListMiddleware(),
SurfSenseFilesystemMiddleware(
backend=backend_resolver,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
created_by_id=user_id,
thread_id=thread_id,
),
]
if subagent_deny_permission_mw is not None:
subagent_extra_middleware.append(subagent_deny_permission_mw)
registry_subagents = build_subagents(
dependencies=subagent_dependencies,
model=llm,
extra_middleware=subagent_extra_middleware,
mcp_tools_by_agent=mcp_tools_by_agent or {},
exclude=get_subagents_to_exclude(available_connectors),
disabled_tools=disabled_tools,
)
logging.info(
"Registry subagents: %s",
[s["name"] for s in registry_subagents],
)
except Exception:
logging.exception("Registry subagent build failed")
raise
subagent_specs: list[SubAgent] = [general_purpose_spec, *registry_subagents]
summarization_mw = create_surfsense_compaction_middleware(llm, StateBackend)
context_edit_mw = None
if (
flags.enable_context_editing
and not flags.disable_new_agent_stack
and max_input_tokens
):
spill_edit = SpillToBackendEdit(
trigger=int(max_input_tokens * 0.55),
clear_at_least=int(max_input_tokens * 0.15),
keep=5,
exclude_tools=safe_exclude_tools(tools),
clear_tool_inputs=True,
)
clear_edit = ClearToolUsesEdit(
trigger=int(max_input_tokens * 0.55),
clear_at_least=int(max_input_tokens * 0.15),
keep=5,
exclude_tools=safe_exclude_tools(tools),
clear_tool_inputs=True,
placeholder="[cleared - older tool output trimmed for context]",
)
context_edit_mw = SpillingContextEditingMiddleware(
edits=[spill_edit, clear_edit],
backend_resolver=backend_resolver,
)
retry_mw = (
RetryAfterMiddleware(max_retries=3)
if flags.enable_retry_after and not flags.disable_new_agent_stack
else None
)
fallback_mw: ModelFallbackMiddleware | None = None
if flags.enable_model_fallback and not flags.disable_new_agent_stack:
try:
fallback_mw = ModelFallbackMiddleware(
"openai:gpt-4o-mini",
"anthropic:claude-3-5-haiku-20241022",
)
except Exception:
logging.warning("ModelFallbackMiddleware init failed; skipping.")
fallback_mw = None
model_call_limit_mw = (
ModelCallLimitMiddleware(
thread_limit=120,
run_limit=80,
exit_behavior="end",
)
if flags.enable_model_call_limit and not flags.disable_new_agent_stack
else None
)
tool_call_limit_mw = (
ToolCallLimitMiddleware(
thread_limit=300, run_limit=80, exit_behavior="continue"
)
if flags.enable_tool_call_limit and not flags.disable_new_agent_stack
else None
)
noop_mw = (
NoopInjectionMiddleware()
if flags.enable_compaction_v2 and not flags.disable_new_agent_stack
else None
)
repair_mw = None
if flags.enable_tool_call_repair and not flags.disable_new_agent_stack:
registered_names: set[str] = {t.name for t in tools}
registered_names |= {
"write_todos",
"ls",
"read_file",
"write_file",
"edit_file",
"glob",
"grep",
"execute",
"task",
"mkdir",
"cd",
"pwd",
"move_file",
"rm",
"rmdir",
"list_tree",
"execute_code",
}
repair_mw = ToolCallNameRepairMiddleware(
registered_tool_names=registered_names,
fuzzy_match_threshold=None,
)
doom_loop_mw = (
DoomLoopMiddleware(threshold=3)
if flags.enable_doom_loop and not flags.disable_new_agent_stack
else None
)
permission_mw: PermissionMiddleware | None = (
PermissionMiddleware(rulesets=permission_rulesets)
if permission_rulesets
else None
)
action_log_mw: ActionLogMiddleware | None = None
if (
flags.enable_action_log
and not flags.disable_new_agent_stack
and thread_id is not None
):
try:
tool_defs_by_name = {td.name: td for td in BUILTIN_TOOLS}
action_log_mw = ActionLogMiddleware(
thread_id=thread_id,
search_space_id=search_space_id,
user_id=user_id,
tool_definitions=tool_defs_by_name,
)
except Exception: # pragma: no cover - defensive
logging.warning(
"ActionLogMiddleware init failed; running without it.",
exc_info=True,
)
action_log_mw = None
busy_mutex_mw: BusyMutexMiddleware | None = (
BusyMutexMiddleware()
if flags.enable_busy_mutex and not flags.disable_new_agent_stack
else None
)
otel_mw: OtelSpanMiddleware | None = (
OtelSpanMiddleware()
if flags.enable_otel and not flags.disable_new_agent_stack
else None
)
plugin_middlewares: list[Any] = []
if flags.enable_plugin_loader and not flags.disable_new_agent_stack:
try:
allowed_names = load_allowed_plugin_names_from_env()
if allowed_names:
plugin_middlewares = load_plugin_middlewares(
PluginContext.build(
search_space_id=search_space_id,
user_id=user_id,
thread_visibility=visibility,
llm=llm,
),
allowed_plugin_names=allowed_names,
)
except Exception: # pragma: no cover - defensive
logging.warning(
"Plugin loader failed; continuing without plugins.",
exc_info=True,
)
plugin_middlewares = []
skills_mw: SkillsMiddleware | None = None
if flags.enable_skills and not flags.disable_new_agent_stack:
try:
skills_factory = build_skills_backend_factory(
search_space_id=search_space_id
if filesystem_mode == FilesystemMode.CLOUD
else None,
)
skills_mw = SkillsMiddleware(
backend=skills_factory,
sources=default_skills_sources(),
)
except Exception as exc: # pragma: no cover - defensive
logging.warning("SkillsMiddleware init failed; skipping: %s", exc)
skills_mw = None
selector_mw: LLMToolSelectorMiddleware | None = None
if (
flags.enable_llm_tool_selector
and not flags.disable_new_agent_stack
and len(tools) > 30
):
try:
selector_mw = LLMToolSelectorMiddleware(
model="openai:gpt-4o-mini",
max_tools=12,
always_include=[
name
for name in (
"update_memory",
"get_connected_accounts",
"scrape_webpage",
)
if name in {t.name for t in tools}
],
)
except Exception:
logging.warning("LLMToolSelectorMiddleware init failed; skipping.")
selector_mw = None
deepagent_middleware = [
busy_mutex_mw,
otel_mw,
TodoListMiddleware(),
_memory_middleware,
AnonymousDocumentMiddleware(
anon_session_id=anon_session_id,
)
if filesystem_mode == FilesystemMode.CLOUD
else None,
KnowledgeTreeMiddleware(
search_space_id=search_space_id,
filesystem_mode=filesystem_mode,
llm=llm,
)
if filesystem_mode == FilesystemMode.CLOUD
else None,
KnowledgePriorityMiddleware(
llm=llm,
search_space_id=search_space_id,
filesystem_mode=filesystem_mode,
available_connectors=available_connectors,
available_document_types=available_document_types,
mentioned_document_ids=mentioned_document_ids,
),
FileIntentMiddleware(llm=llm),
SurfSenseFilesystemMiddleware(
backend=backend_resolver,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
created_by_id=user_id,
thread_id=thread_id,
),
KnowledgeBasePersistenceMiddleware(
search_space_id=search_space_id,
created_by_id=user_id,
filesystem_mode=filesystem_mode,
thread_id=thread_id,
)
if filesystem_mode == FilesystemMode.CLOUD
else None,
skills_mw,
SurfSenseCheckpointedSubAgentMiddleware(
checkpointer=checkpointer,
backend=StateBackend,
subagents=subagent_specs,
),
selector_mw,
model_call_limit_mw,
tool_call_limit_mw,
context_edit_mw,
summarization_mw,
noop_mw,
retry_mw,
fallback_mw,
repair_mw,
permission_mw,
doom_loop_mw,
action_log_mw,
PatchToolCallsMiddleware(),
DedupHITLToolCallsMiddleware(agent_tools=list(tools)),
*plugin_middlewares,
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
]
return [m for m in deepagent_middleware if m is not None]

View file

@ -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"]

View file

@ -0,0 +1,230 @@
"""Async factory: tools, system prompt, MCP buckets for subagents, then sync graph compile."""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Sequence
from typing import Any
from deepagents.graph import BASE_AGENT_PROMPT
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from langgraph.types import Checkpointer
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.multi_agent_chat.subagents import (
get_subagents_to_exclude,
main_prompt_registry_subagent_lines,
)
from app.agents.multi_agent_chat.subagents.mcp_tools.index import (
load_mcp_tools_by_connector,
)
from app.agents.new_chat.chat_deepagent import _map_connectors_to_searchable_types
from app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags
from app.agents.new_chat.filesystem_backends import build_backend_resolver
from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection
from app.agents.new_chat.llm_config import AgentConfig
from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME, invalid_tool
from app.agents.new_chat.tools.registry import build_tools_async
from app.db import ChatVisibility
from app.services.connector_service import ConnectorService
from app.utils.perf import get_perf_logger
from ..graph.compile_graph_sync import build_compiled_agent_graph_sync
from ..system_prompt import build_main_agent_system_prompt
from ..tools import (
MAIN_AGENT_SURFSENSE_TOOL_NAMES,
MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED,
)
_perf_log = get_perf_logger()
async def create_surfsense_deep_agent(
llm: BaseChatModel,
search_space_id: int,
db_session: AsyncSession,
connector_service: ConnectorService,
checkpointer: Checkpointer,
user_id: str | None = None,
thread_id: int | None = None,
agent_config: AgentConfig | None = None,
enabled_tools: list[str] | None = None,
disabled_tools: list[str] | None = None,
additional_tools: Sequence[BaseTool] | None = None,
firecrawl_api_key: str | None = None,
thread_visibility: ChatVisibility | None = None,
mentioned_document_ids: list[int] | None = None,
anon_session_id: str | None = None,
filesystem_selection: FilesystemSelection | None = None,
):
"""Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled."""
_t_agent_total = time.perf_counter()
filesystem_selection = filesystem_selection or FilesystemSelection()
backend_resolver = build_backend_resolver(
filesystem_selection,
search_space_id=search_space_id
if filesystem_selection.mode == FilesystemMode.CLOUD
else None,
)
available_connectors: list[str] | None = None
available_document_types: list[str] | None = None
_t0 = time.perf_counter()
try:
connector_types = await connector_service.get_available_connectors(
search_space_id
)
available_connectors = _map_connectors_to_searchable_types(connector_types)
available_document_types = await connector_service.get_available_document_types(
search_space_id
)
except Exception as e:
logging.warning("Failed to discover available connectors/document types: %s", e)
_perf_log.info(
"[create_agent] Connector/doc-type discovery in %.3fs",
time.perf_counter() - _t0,
)
visibility = thread_visibility or ChatVisibility.PRIVATE
_model_profile = getattr(llm, "profile", None)
_max_input_tokens: int | None = (
_model_profile.get("max_input_tokens")
if isinstance(_model_profile, dict)
else None
)
dependencies: dict[str, Any] = {
"search_space_id": search_space_id,
"db_session": db_session,
"connector_service": connector_service,
"firecrawl_api_key": firecrawl_api_key,
"user_id": user_id,
"thread_id": thread_id,
"thread_visibility": visibility,
"available_connectors": available_connectors,
"available_document_types": available_document_types,
"max_input_tokens": _max_input_tokens,
"llm": llm,
}
_t0 = time.perf_counter()
mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, search_space_id)
_perf_log.info(
"[create_agent] load_mcp_tools_by_connector in %.3fs (%d buckets)",
time.perf_counter() - _t0,
len(mcp_tools_by_agent),
)
modified_disabled_tools = list(disabled_tools) if disabled_tools else []
if "search_knowledge_base" not in modified_disabled_tools:
modified_disabled_tools.append("search_knowledge_base")
if enabled_tools is not None:
main_agent_enabled_tools = [
n for n in enabled_tools if n in MAIN_AGENT_SURFSENSE_TOOL_NAMES
]
else:
main_agent_enabled_tools = list(MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED)
_t0 = time.perf_counter()
tools = await build_tools_async(
dependencies=dependencies,
enabled_tools=main_agent_enabled_tools,
disabled_tools=modified_disabled_tools,
additional_tools=list(additional_tools) if additional_tools else None,
include_mcp_tools=False,
)
_flags: AgentFeatureFlags = get_flags()
if _flags.enable_tool_call_repair and INVALID_TOOL_NAME not in {
t.name for t in tools
}:
tools = [*list(tools), invalid_tool]
_perf_log.info(
"[create_agent] build_tools_async in %.3fs (%d tools)",
time.perf_counter() - _t0,
len(tools),
)
_t0 = time.perf_counter()
_enabled_tool_names = {t.name for t in tools}
_user_disabled_tool_names = set(disabled_tools) if disabled_tools else set()
_model_name: str | None = None
prof = getattr(llm, "model_name", None) or getattr(llm, "model", None)
if isinstance(prof, str):
_model_name = prof
_connector_exclude = get_subagents_to_exclude(available_connectors)
_registry_subagent_prompt_lines = main_prompt_registry_subagent_lines(
_connector_exclude
)
if agent_config is not None:
system_prompt = build_main_agent_system_prompt(
today=None,
thread_visibility=thread_visibility,
enabled_tool_names=_enabled_tool_names,
disabled_tool_names=_user_disabled_tool_names,
custom_system_instructions=agent_config.system_instructions,
use_default_system_instructions=agent_config.use_default_system_instructions,
citations_enabled=agent_config.citations_enabled,
model_name=_model_name or getattr(agent_config, "model_name", None),
registry_subagent_prompt_lines=_registry_subagent_prompt_lines,
)
else:
system_prompt = build_main_agent_system_prompt(
thread_visibility=thread_visibility,
enabled_tool_names=_enabled_tool_names,
disabled_tool_names=_user_disabled_tool_names,
citations_enabled=True,
model_name=_model_name,
registry_subagent_prompt_lines=_registry_subagent_prompt_lines,
)
_perf_log.info(
"[create_agent] System prompt built in %.3fs", time.perf_counter() - _t0
)
final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT
_t0 = time.perf_counter()
agent = await asyncio.to_thread(
build_compiled_agent_graph_sync,
llm=llm,
tools=tools,
final_system_prompt=final_system_prompt,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_selection.mode,
search_space_id=search_space_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,
anon_session_id=anon_session_id,
available_connectors=available_connectors,
available_document_types=available_document_types,
mentioned_document_ids=mentioned_document_ids,
max_input_tokens=_max_input_tokens,
flags=_flags,
checkpointer=checkpointer,
subagent_dependencies=dependencies,
mcp_tools_by_agent=mcp_tools_by_agent,
disabled_tools=disabled_tools,
)
_perf_log.info(
"[create_agent] Middleware stack + graph compiled in %.3fs",
time.perf_counter() - _t0,
)
_perf_log.info(
"[create_agent] Total agent creation in %.3fs",
time.perf_counter() - _t_agent_total,
)
return agent

View file

@ -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"]

View file

@ -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"]

View file

@ -0,0 +1,53 @@
"""Assemble the **main-agent** deep-agent system string only.
Sections (order matters): core instructions provider citations dynamic
``<registry_subagents>`` SurfSense ``<tools>``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from app.db import ChatVisibility
from .sections.citations import build_citations_section
from .sections.provider import build_provider_section
from .sections.registry_subagents import build_registry_subagents_section
from .sections.system_instruction import build_default_system_instruction_xml
from .sections.tools import build_tools_section
def build_main_agent_system_prompt(
*,
today: datetime | None = None,
thread_visibility: ChatVisibility | None = None,
enabled_tool_names: set[str] | None = None,
disabled_tool_names: set[str] | None = None,
custom_system_instructions: str | None = None,
use_default_system_instructions: bool = True,
citations_enabled: bool = True,
model_name: str | None = None,
registry_subagent_prompt_lines: list[tuple[str, str]] | None = None,
) -> str:
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
visibility = thread_visibility or ChatVisibility.PRIVATE
if custom_system_instructions and custom_system_instructions.strip():
system_block = custom_system_instructions.format(resolved_today=resolved_today)
elif use_default_system_instructions:
system_block = build_default_system_instruction_xml(
visibility=visibility,
resolved_today=resolved_today,
)
else:
system_block = ""
system_block += build_provider_section(model_name=model_name)
system_block += build_citations_section(citations_enabled=citations_enabled)
system_block += build_registry_subagents_section(registry_subagent_prompt_lines)
system_block += build_tools_section(
visibility=visibility,
enabled_tool_names=enabled_tool_names,
disabled_tool_names=disabled_tool_names,
)
return system_block

View file

@ -0,0 +1,16 @@
"""Load main-agent-only markdown from ``system_prompt/markdown/`` (``importlib.resources``)."""
from __future__ import annotations
from importlib import resources
_PROMPTS_PACKAGE = "app.agents.multi_agent_chat.main_agent.system_prompt.markdown"
def read_prompt_md(filename: str) -> str:
"""Load ``markdown/{filename}`` (e.g. ``agent_private.md`` or ``tools/_preamble.md``)."""
ref = resources.files(_PROMPTS_PACKAGE).joinpath(filename)
if not ref.is_file():
return ""
text = ref.read_text(encoding="utf-8")
return text[:-1] if text.endswith("\n") else text

View file

@ -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 ""

View file

@ -0,0 +1 @@
"""Rendered slices of the main-agent system prompt."""

View file

@ -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 ""

View file

@ -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))

View file

@ -0,0 +1,27 @@
"""Dynamic ``<registry_subagents>`` 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<registry_subagents>\n"
"No registry specialists are listed for **task** in this workspace.\n"
"</registry_subagents>\n"
)
bullets = "\n".join(
f"- **{name}** — {desc}" for name, desc in registry_subagent_lines
)
return (
"\n<registry_subagents>\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"
"</registry_subagents>\n"
)

View file

@ -0,0 +1,35 @@
"""Default ``<system_instruction>`` 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<system_instruction>\n{body}\n\n</system_instruction>\n".format(
resolved_today=resolved_today,
)

View file

@ -0,0 +1,20 @@
"""Main-agent ``<tools>`` 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,
)

View file

@ -0,0 +1,86 @@
"""``<tools>`` + ``<tool_call_examples>`` 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</tools>\n")
if examples:
parts.append("<tool_call_examples>")
parts.extend(examples)
parts.append("</tool_call_examples>\n")
return "".join(parts)

View file

@ -0,0 +1 @@
"""Markdown fragments for the **main-agent** system prompt only (`importlib.resources`)."""

View file

@ -0,0 +1,9 @@
You are SurfSenses **main agent**: you answer using the users knowledge context,
lightweight research tools, and memory — and you **delegate** integrations and
specialized work via **task** (see `<tool_routing>` 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.

View file

@ -0,0 +1,11 @@
You are SurfSenses **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 `<tool_routing>` 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.

View file

@ -0,0 +1,15 @@
<citation_instructions>
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”.
</citation_instructions>

View file

@ -0,0 +1,15 @@
<citation_instructions>
This block appears **before** `<tools>` so it wins over any tool-example wording below.
Apply chunk citations **only** when the runtime injects `<document>` / `<chunk id='…'>` 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 `<chunk id='…'>`.
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.
</citation_instructions>

View file

@ -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.

View file

@ -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")`

View file

@ -0,0 +1,16 @@
- <user_name>Alex</user_name>, <user_memory> 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")

View file

@ -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...")

View file

@ -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")`

View file

@ -0,0 +1,19 @@
<knowledge_base_only_policy>
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 `<tool_routing>`
</knowledge_base_only_policy>

View file

@ -0,0 +1,19 @@
<knowledge_base_only_policy>
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 `<tool_routing>`
</knowledge_base_only_policy>

View file

@ -0,0 +1,27 @@
<tool_routing>
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
`<registry_subagents>` (later in this prompt).
Your **direct** SurfSense tools are only: **update_memory**, **web_search**,
**scrape_webpage**, and **search_surfsense_docs**. The runtime may also attach
deep-agent helpers (e.g. todos, filesystem, **task** itself). Use **task** whenever
the user needs capabilities **not** listed in the `<tools>` section (that section appears
later in this system prompt, after citation rules).
Do not treat live third-party state as if it were already in the indexed knowledge
base; reach it via **task**.
Never emit more than one **task** tool call in the same turn. Bundle related work
for the same specialist into a single **task** invocation (the subagent itself can
call its own tools in parallel inside that one run). Parallel **task** calls would
fan out into multiple concurrent subagent runs whose human-approval interrupts
cannot be coordinated; one **task** at a time is required.
</tool_routing>
<!-- TODO: lift the single-task constraint once the runtime supports parallel task
interrupts end-to-end (multi-interrupt SSE + interrupt-id-keyed Command(resume)
+ keyed surfsense_resume_value side-channel). Until then this nudge is the only
guard; the parent graph's resume cannot address multiple pending interrupts. -->

View file

@ -0,0 +1,6 @@
<memory_protocol>
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.
</memory_protocol>

View file

@ -0,0 +1,6 @@
<memory_protocol>
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.
</memory_protocol>

View file

@ -0,0 +1,15 @@
<parameter_resolution>
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.
</parameter_resolution>

View file

@ -0,0 +1,16 @@
<provider_hints>
You are running on an Anthropic Claude model (SurfSense **main agent**).
Structured reasoning:
- For non-trivial work, `<thinking>` / short `<plan>` before tool calls is fine.
Professional objectivity:
- Accuracy over flattery; verify with **search_surfsense_docs**, **web_search**, **scrape_webpage**, or **task** when unsure — dont 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.
</provider_hints>

View file

@ -0,0 +1,18 @@
<provider_hints>
You are running on a DeepSeek model (SurfSense **main agent**).
Reasoning hygiene (R1-aware):
- Keep internal scratch separate from the user-facing answer; dont 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.
- Dont invent paths, chunk ids, or URLs — only values from tools or the user.
</provider_hints>

View file

@ -0,0 +1,18 @@
<provider_hints>
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 `<tools>` and `<tool_routing>`). 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.
</provider_hints>

View file

@ -0,0 +1,16 @@
<provider_hints>
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; dont 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.
</provider_hints>

View file

@ -0,0 +1,21 @@
<provider_hints>
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; dont fabricate chunk ids or connector outcomes.
- Keep it stupidly simple. Don't overcomplicate.
</provider_hints>

View file

@ -0,0 +1,20 @@
<provider_hints>
You are running on a classic OpenAI chat model (GPT-4 family), SurfSense **main agent**.
Persistence:
- Finish the users request in the same turn when tools allow — dont 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 — dont 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**.
</provider_hints>

View file

@ -0,0 +1,13 @@
<provider_hints>
You are running on an OpenAI Codex-class model (SurfSense **main agent**).
Output style:
- Concise; dont 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.
- Dont ask permission for obvious safe defaults — state what you did.
</provider_hints>

View file

@ -0,0 +1,22 @@
<provider_hints>
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**.
</provider_hints>

View file

@ -0,0 +1,9 @@
<tools>
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.

View file

@ -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; dont 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.

View file

@ -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:…]`.

View file

@ -0,0 +1,12 @@
- update_memory: Curate the **personal** long-term memory document for this user.
- Current memory (if any) appears in `<user_memory>` with usage vs limit.
- Call when the user asks to remember/forget, or shares durable facts/preferences/instructions.
- Use the first name from `<user_name>` 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 — dont 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 `<user_memory>`.

View file

@ -0,0 +1,26 @@
- update_memory: Update the team's shared memory document for this search space.
- Your current team memory is already in <team_memory> 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 <team_memory>.
- 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.

View file

@ -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.

View file

@ -0,0 +1,10 @@
"""Main-agent SurfSense tool allowlist."""
from __future__ import annotations
from .index import (
MAIN_AGENT_SURFSENSE_TOOL_NAMES,
MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED,
)
__all__ = ["MAIN_AGENT_SURFSENSE_TOOL_NAMES", "MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED"]

View file

@ -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,
)

View file

@ -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",
]

View file

@ -0,0 +1,55 @@
"""`deliverables` route: ``SubAgent`` spec for deepagents."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .tools.index import load_tools
NAME = "deliverables"
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles deliverables tasks for this workspace."
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
model=model,
extra_middleware=extra_middleware,
)

View file

@ -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.

View file

@ -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.
<goal>
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.
</goal>
<available_tools>
- `generate_report`
- `generate_podcast`
- `generate_video_presentation`
- `generate_resume`
- `generate_image`
</available_tools>
<tool_policy>
- Use only tools in `<available_tools>`.
- 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.
</tool_policy>
<out_of_scope>
- Do not perform connector data mutations unrelated to artifact generation.
</out_of_scope>
<safety>
- Avoid generating artifacts with missing critical constraints.
- Prefer one complete artifact over partial multi-artifact output.
</safety>
<failure_policy>
- On generation failure, return `status=error` with best retry guidance.
- On missing constraints, return `status=blocked` with required fields.
</failure_policy>
<output_contract>
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.
</output_contract>

View file

@ -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",
]

View file

@ -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

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from .generate_image import create_generate_image_tool
from .podcast import create_generate_podcast_tool
from .report import create_generate_report_tool
from .resume import create_generate_resume_tool
from .video_presentation import create_generate_video_presentation_tool
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
podcast = create_generate_podcast_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
thread_id=resolved_dependencies["thread_id"],
)
video = create_generate_video_presentation_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
thread_id=resolved_dependencies["thread_id"],
)
report = create_generate_report_tool(
search_space_id=resolved_dependencies["search_space_id"],
thread_id=resolved_dependencies["thread_id"],
connector_service=resolved_dependencies.get("connector_service"),
available_connectors=resolved_dependencies.get("available_connectors"),
available_document_types=resolved_dependencies.get("available_document_types"),
)
resume = create_generate_resume_tool(
search_space_id=resolved_dependencies["search_space_id"],
thread_id=resolved_dependencies["thread_id"],
)
image = create_generate_image_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
)
return {
"allow": [
{"name": getattr(podcast, "name", "") or "", "tool": podcast},
{"name": getattr(video, "name", "") or "", "tool": video},
{"name": getattr(report, "name", "") or "", "tool": report},
{"name": getattr(resume, "name", "") or "", "tool": resume},
{"name": getattr(image, "name", "") or "", "tool": image},
],
"ask": [],
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,55 @@
"""`memory` route: ``SubAgent`` spec for deepagents."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .tools.index import load_tools
NAME = "memory"
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles memory tasks for this workspace."
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
model=model,
extra_middleware=extra_middleware,
)

View file

@ -0,0 +1 @@
Use for storing durable user memory (private team variant selected at runtime).

View file

@ -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.
<goal>
Persist durable preferences/facts/instructions with `update_memory` while avoiding transient or unsafe storage.
</goal>
<visibility_scope>
{{MEMORY_VISIBILITY_POLICY}}
</visibility_scope>
<available_tools>
- `update_memory`
</available_tools>
<tool_policy>
- 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.
</tool_policy>
<out_of_scope>
- Do not execute non-memory tool actions.
- Do not store irrelevant, transient, or speculative information.
</out_of_scope>
<safety>
- Prefer minimal-memory writes over over-collection.
- Never claim memory was updated unless `update_memory` succeeded.
</safety>
<failure_policy>
- On tool failure, return `status=error` with concise recovery steps.
- When intent is ambiguous, return `status=blocked` with required disambiguation fields.
</failure_policy>
<output_contract>
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.
</output_contract>

View file

@ -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",
]

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from app.db import ChatVisibility
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
if resolved_dependencies.get("thread_visibility") == ChatVisibility.SEARCH_SPACE:
mem = create_update_team_memory_tool(
search_space_id=resolved_dependencies["search_space_id"],
db_session=resolved_dependencies["db_session"],
llm=resolved_dependencies.get("llm"),
)
return {
"allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}],
"ask": [],
}
mem = create_update_memory_tool(
user_id=resolved_dependencies["user_id"],
db_session=resolved_dependencies["db_session"],
llm=resolved_dependencies.get("llm"),
)
return {"allow": [{"name": getattr(mem, "name", "") or "", "tool": mem}], "ask": []}

View file

@ -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.
<memory_document>
{content}
</memory_document>"""
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 <user_memory> 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 <team_memory> 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

View file

@ -0,0 +1,55 @@
"""`research` route: ``SubAgent`` spec for deepagents."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .tools.index import load_tools
NAME = "research"
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles research tasks for this workspace."
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
model=model,
extra_middleware=extra_middleware,
)

View file

@ -0,0 +1 @@
Use for external research: find sources on the web, extract evidence, and answer documentation questions.

View file

@ -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.
<goal>
Gather and synthesize evidence using SurfSense research tools with clear citations and uncertainty reporting.
</goal>
<available_tools>
- `web_search`
- `scrape_webpage`
- `search_surfsense_docs`
</available_tools>
<tool_policy>
- Use only tools in `<available_tools>`.
- 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.
</tool_policy>
<out_of_scope>
- Do not execute connector mutations (email/calendar/docs/chat writes) or deliverable generation.
</out_of_scope>
<safety>
- Report uncertainty explicitly when evidence is incomplete or conflicting.
- Never present unverified claims as facts.
</safety>
<failure_policy>
- On tool failure, return `status=error` with a concise recovery `next_step`.
- On no useful evidence, return `status=blocked` with recommended narrower filters.
</failure_policy>
<output_contract>
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.
</output_contract>

View file

@ -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",
]

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
)
from .scrape_webpage import create_scrape_webpage_tool
from .search_surfsense_docs import create_search_surfsense_docs_tool
from .web_search import create_web_search_tool
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
resolved_dependencies = {**(dependencies or {}), **kwargs}
web = create_web_search_tool(
search_space_id=resolved_dependencies.get("search_space_id"),
available_connectors=resolved_dependencies.get("available_connectors"),
)
scrape = create_scrape_webpage_tool(
firecrawl_api_key=resolved_dependencies.get("firecrawl_api_key")
)
docs = create_search_surfsense_docs_tool(
db_session=resolved_dependencies["db_session"]
)
return {
"allow": [
{"name": getattr(web, "name", "") or "", "tool": web},
{"name": getattr(scrape, "name", "") or "", "tool": scrape},
{"name": getattr(docs, "name", "") or "", "tool": docs},
],
"ask": [],
}

View file

@ -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

View file

@ -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("<document>")
parts.append("<document_metadata>")
parts.append(f" <document_id>{g['document_id']}</document_id>")
parts.append(f" <document_type>{g['document_type']}</document_type>")
parts.append(f" <title><![CDATA[{g['title']}]]></title>")
parts.append(f" <url><![CDATA[{g['url']}]]></url>")
parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
parts.append("</document_metadata>")
parts.append("")
parts.append("<document_content>")
for ch in g["chunks"]:
parts.append(
f" <chunk id='{ch['chunk_id']}'><![CDATA[{ch['content']}]]></chunk>"
)
parts.append("</document_content>")
parts.append("</document>")
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

View file

@ -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(
[
"<document>",
"<document_metadata>",
f" <document_type>{source}</document_type>",
f" <title><![CDATA[{title}]]></title>",
f" <url><![CDATA[{url}]]></url>",
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>",
"</document_metadata>",
"<document_content>",
f" <chunk id='{url}'><![CDATA[{content}]]></chunk>",
"</document_content>",
"</document>",
"",
]
)
if total_chars + len(doc_xml) > max_chars:
parts.append("<!-- Output truncated to fit context window -->")
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,
)

View file

@ -0,0 +1,55 @@
"""`airtable` route: ``SubAgent`` spec for deepagents."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.multi_agent_chat.subagents.shared.permissions import (
ToolsPermissions,
merge_tools_permissions,
middleware_gated_interrupt_on,
)
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .tools.index import load_tools
NAME = "airtable"
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
extra_tools_bucket: ToolsPermissions | None = None,
) -> SubAgent:
buckets = load_tools(dependencies=dependencies)
merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket)
tools = [
row["tool"]
for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"])
if row.get("tool") is not None
]
interrupt_on = middleware_gated_interrupt_on(merged_tools_bucket)
description = read_md_file(__package__, "description").strip()
if not description:
description = "Handles airtable tasks for this workspace."
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
interrupt_on=interrupt_on,
model=model,
extra_middleware=extra_middleware,
)

View file

@ -0,0 +1 @@
Use for Airtable structured data operations: locate bases/tables and create/read/update records.

Some files were not shown because too many files have changed in this diff Show more