Merge commit 'fa7075fde6' into dev

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-07 00:39:58 -07:00
commit b477d33cba
1421 changed files with 59474 additions and 27447 deletions

View file

@ -1,16 +1,16 @@
"""Minimal anonymous / free-chat agent.
The no-login chat experience must stay dead simple: the user asks a question
and the model answers, optionally using ``web_search`` and an optionally
uploaded **read-only** document. We deliberately bypass the full SurfSense deep
agent stack (filesystem, file-intent, knowledge-base persistence, subagents,
skills, memory) because those middlewares stage or persist "documents" that an
anonymous session can never see again -- which produced phantom
"I saved it to a file" answers for free users.
and the model answers over an optionally uploaded **read-only** document. We
deliberately bypass the full SurfSense deep agent stack (filesystem,
file-intent, knowledge-base persistence, subagents, skills, memory) because
those middlewares stage or persist "documents" that an anonymous session can
never see again -- which produced phantom "I saved it to a file" answers for
free users.
For any other SurfSense capability the model is instructed (via the system
prompt built here) to tell the user to create a free account instead of
pretending to perform the action.
For any other SurfSense capability (including web search) the model is
instructed (via the system prompt built here) to tell the user to create a
free account instead of pretending to perform the action.
"""
from __future__ import annotations
@ -22,7 +22,6 @@ from deepagents.backends import StateBackend
from langchain.agents import create_agent
from langchain.agents.middleware import (
ModelCallLimitMiddleware,
ToolCallLimitMiddleware,
)
from langchain_core.language_models import BaseChatModel
from langgraph.types import Checkpointer
@ -32,7 +31,6 @@ from app.agents.chat.shared.middleware import (
RetryAfterMiddleware,
create_surfsense_compaction_middleware,
)
from app.agents.chat.shared.tools.web_search import create_web_search_tool
# Cap how much of an uploaded document we inline into the system prompt. The
# upload endpoint allows files up to several MB, but the doc is re-sent on
@ -43,9 +41,12 @@ _MAX_DOC_CHARS = 50_000
def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str:
"""Build the system prompt for the minimal anonymous chat agent.
The prompt keeps the assistant focused on plain Q/A + web search, inlines
any uploaded document as read-only context, and redirects every other
SurfSense feature to account registration.
The prompt keeps the assistant focused on plain Q/A from model knowledge,
inlines any uploaded document as read-only context, and treats the chat as
a registration funnel: every other SurfSense capability (scraping, live
data, deliverables, knowledge base, automations) redirects to sign-up, and
the assistant softly suggests an account when the conversation reveals a
competitive-intelligence need the platform serves.
"""
today = datetime.now(UTC).strftime("%A, %B %d, %Y")
@ -72,30 +73,56 @@ def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str
return (
"You are SurfSense's free AI assistant, available to everyone without "
"login.\n\n"
"login. SurfSense is the open-source competitive intelligence platform: "
"registered users get specialist agents that pull live market data from "
"Reddit, YouTube, Google Maps, Google Search, and the open web, turn it "
"into cited briefs, reports, podcasts, and presentations, keep findings "
"in a searchable knowledge base, and run scheduled monitoring "
"automations — plus a REST scraping API and MCP server for their own "
"agents.\n\n"
f"Today's date is {today}.\n\n"
"## How to help\n"
"- Answer the user's questions directly and conversationally. You are "
"a straightforward question-and-answer assistant.\n"
"- When a question needs current, real-time, or factual information "
"from the internet (news, prices, weather, recent events, live data), "
"use the `web_search` tool. Otherwise, answer directly from your own "
"knowledge.\n"
"- Answer from your own knowledge. You do NOT have web access here, so "
"for current, real-time, or fast-changing facts (news, prices, "
"weather, recent events, live data) say you can't look them up in the "
"free experience and may be out of date.\n"
"- Be concise, accurate, and helpful. Use Markdown formatting when it "
"improves readability."
f"{doc_section}\n\n"
"## What is not available here\n"
"This is the free, no-login experience. You CANNOT save files or "
"notes, generate reports, podcasts, resumes, presentations, or images, "
"search or build a knowledge base, connect to apps (Gmail, Google "
"Drive, Notion, Slack, Calendar, Discord, and similar), set up "
"automations, or remember anything across sessions.\n\n"
"This is the free, no-login experience. You CANNOT search the web or "
"scrape any platform (Reddit, YouTube, Google Maps, Google Search, "
"websites), save files or notes, upload additional files, generate "
"reports, podcasts, resumes, presentations, or images, search or build "
"a knowledge base, connect to apps (Gmail, Google Drive, Notion, "
"Slack, Calendar, Discord, and similar), set up automations or "
"monitoring, use the scraping API or MCP server, or remember anything "
"across sessions.\n\n"
"If the user asks for any of these, do NOT pretend to do them and "
"never claim you saved, created, or stored anything. Instead, briefly "
"let them know the feature requires a free SurfSense account and "
"invite them to create one at https://www.surfsense.com. Then offer to "
"help with what you can do here (answering questions and searching the "
"web)."
"never claim you saved, created, scraped, or stored anything. Instead, "
"briefly say that this needs a free SurfSense account and invite them "
"to sign up at https://www.surfsense.com/register — name the specific "
"capability that would solve their request (e.g. 'a SurfSense account "
"can scrape those Reddit threads live and turn them into a cited "
"brief'). Then offer to help with what you can do here (answering from "
"your own knowledge and about any uploaded document).\n\n"
"## Suggesting SurfSense when it genuinely fits\n"
"You are also the front door to SurfSense. When the conversation "
"reveals a need the full platform serves — researching competitors, "
"tracking pricing or rankings, monitoring brand mentions or reviews, "
"gauging Reddit/YouTube sentiment, generating leads, needing current "
"web data, or wanting recurring reports — first answer as well as you "
"can from your own knowledge, then add ONE short sentence pointing out "
"that a free SurfSense account can do that with live data, linking "
"https://www.surfsense.com/register.\n"
"- Be helpful first, never salesy: the answer is the product; the "
"suggestion is a footnote.\n"
"- At most one suggestion per response, and stop suggesting entirely "
"if the user declines or ignores it.\n"
"- Do not suggest it for needs SurfSense does not serve (casual chat, "
"coding help, homework, creative writing)."
)
@ -105,14 +132,13 @@ async def create_anonymous_chat_agent(
checkpointer: Checkpointer,
anon_session_id: str | None = None,
anon_doc: dict[str, Any] | None = None,
enable_web_search: bool = True,
):
"""Create a minimal Q/A agent for anonymous / free chat.
Unlike :func:`create_surfsense_deep_agent`, this agent has no filesystem,
file-intent, knowledge-base persistence, subagent, skills, or memory
middleware. Its only tool is ``web_search`` (when ``enable_web_search`` is
True), and any uploaded document is injected into the system prompt as
middleware -- and no tools at all. It answers purely from the model's own
knowledge; any uploaded document is injected into the system prompt as
read-only context.
Args:
@ -120,36 +146,23 @@ async def create_anonymous_chat_agent(
checkpointer: LangGraph checkpointer for the ephemeral anon thread.
anon_session_id: Anonymous session id (used only for telemetry/metadata).
anon_doc: Optional ``{"title", "content"}`` for an uploaded document.
enable_web_search: When False, the agent runs as a pure LLM with no
tools (used when the user toggles web search off).
"""
tools = (
[create_web_search_tool(search_space_id=None, available_connectors=None)]
if enable_web_search
else []
)
# Reliability-only middleware. Nothing here touches the database or
# filesystem: call limits guard against loops, compaction summarises long
# histories into in-graph state, and retry handles provider rate limits.
# filesystem: the call limit guards against loops, compaction summarises
# long histories into in-graph state, and retry handles provider rate
# limits.
middleware: list[Any] = [
ModelCallLimitMiddleware(thread_limit=120, run_limit=80, exit_behavior="end"),
create_surfsense_compaction_middleware(llm, StateBackend),
RetryAfterMiddleware(max_retries=3),
]
if tools:
middleware.append(
ToolCallLimitMiddleware(
thread_limit=300, run_limit=80, exit_behavior="continue"
)
)
middleware.append(create_surfsense_compaction_middleware(llm, StateBackend))
middleware.append(RetryAfterMiddleware(max_retries=3))
system_prompt = build_anonymous_system_prompt(anon_doc)
agent = create_agent(
llm,
system_prompt=system_prompt,
tools=tools,
tools=[],
middleware=middleware,
context_schema=SurfSenseContextSchema,
checkpointer=checkpointer,

View file

@ -2,43 +2,74 @@
from __future__ import annotations
# Connected apps (hosted MCP + interim-native Gmail/Calendar) all route to the
# single ``mcp_discovery`` subagent. File connectors stay native (they enrich
# the knowledge base). Deprecated connectors (Discord/Teams/Luma) are omitted:
# they have no agent subagent, so their rows produce no tools.
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_GMAIL_CONNECTOR": "mcp_discovery",
"COMPOSIO_GMAIL_CONNECTOR": "mcp_discovery",
"GOOGLE_CALENDAR_CONNECTOR": "mcp_discovery",
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "mcp_discovery",
"LINEAR_CONNECTOR": "mcp_discovery",
"JIRA_CONNECTOR": "mcp_discovery",
"CLICKUP_CONNECTOR": "mcp_discovery",
"SLACK_CONNECTOR": "mcp_discovery",
"AIRTABLE_CONNECTOR": "mcp_discovery",
"NOTION_CONNECTOR": "mcp_discovery",
"CONFLUENCE_CONNECTOR": "mcp_discovery",
"MCP_CONNECTOR": "mcp_discovery",
"GOOGLE_DRIVE_CONNECTOR": "google_drive",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "google_drive",
"DROPBOX_CONNECTOR": "dropbox",
"ONEDRIVE_CONNECTOR": "onedrive",
}
# ``mcp_discovery`` is gated any-of: present iff the workspace has at least one
# connected app. Tokens are searchable-type strings (Composio Gmail/Calendar
# map to the GOOGLE_* tokens in connector_searchable_types).
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"deliverables": frozenset(),
"knowledge_base": frozenset(),
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
"clickup": frozenset({"CLICKUP_CONNECTOR"}),
"confluence": frozenset({"CONFLUENCE_CONNECTOR"}),
"discord": frozenset({"DISCORD_CONNECTOR"}),
"web_crawler": frozenset(),
"youtube": frozenset(),
"google_maps": frozenset(),
"google_search": frozenset(),
"reddit": frozenset(),
"mcp_discovery": frozenset(
{
"SLACK_CONNECTOR",
"JIRA_CONNECTOR",
"LINEAR_CONNECTOR",
"CLICKUP_CONNECTOR",
"AIRTABLE_CONNECTOR",
"NOTION_CONNECTOR",
"CONFLUENCE_CONNECTOR",
"GOOGLE_GMAIL_CONNECTOR",
"GOOGLE_CALENDAR_CONNECTOR",
"MCP_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"}),
}
# Old per-connector subagent names, kept working for checkpoint resume: a
# ``task(subagent_type="gmail")`` paused before the MCP consolidation resolves
# to the consolidated ``mcp_discovery`` subagent instead of hard-failing
# "subagent does not exist". New routing never emits these names.
LEGACY_SUBAGENT_ALIASES: dict[str, str] = {
"gmail": "mcp_discovery",
"calendar": "mcp_discovery",
"linear": "mcp_discovery",
"jira": "mcp_discovery",
"clickup": "mcp_discovery",
"slack": "mcp_discovery",
"airtable": "mcp_discovery",
"notion": "mcp_discovery",
"confluence": "mcp_discovery",
"discord": "mcp_discovery",
"teams": "mcp_discovery",
"luma": "mcp_discovery",
}

View file

@ -31,7 +31,7 @@ def build_compiled_agent_graph_sync(
final_system_prompt: str,
backend_resolver: Any,
filesystem_mode: FilesystemMode,
search_space_id: int,
workspace_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
@ -53,7 +53,7 @@ def build_compiled_agent_graph_sync(
tools=tools,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,

View file

@ -14,7 +14,7 @@ def build_action_log_mw(
*,
flags: AgentFeatureFlags,
thread_id: int | None,
search_space_id: int,
workspace_id: int,
user_id: str | None,
) -> ActionLogMiddleware | None:
if not enabled(flags, "enable_action_log") or thread_id is None:
@ -25,7 +25,7 @@ def build_action_log_mw(
# tool via ``ToolDefinition.reverse`` and can be wired here when used.
return ActionLogMiddleware(
thread_id=thread_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
)
except Exception: # pragma: no cover - defensive

View file

@ -84,7 +84,7 @@ class ActionLogMiddleware(AgentMiddleware):
Args:
thread_id: The current chat thread's primary-key id. Required to
persist a row; if ``None`` the middleware silently no-ops.
search_space_id: Search-space id for cascade-on-delete safety.
workspace_id: Workspace id for cascade-on-delete safety.
user_id: UUID string of the user driving this turn (nullable in
anonymous mode).
tool_definitions: Optional mapping of tool name -> :class:`ToolDefinition`
@ -98,13 +98,13 @@ class ActionLogMiddleware(AgentMiddleware):
self,
*,
thread_id: int | None,
search_space_id: int,
workspace_id: int,
user_id: str | None,
tool_definitions: dict[str, ToolDefinition] | None = None,
) -> None:
super().__init__()
self._thread_id = thread_id
self._search_space_id = search_space_id
self._workspace_id = workspace_id
self._user_id = user_id
self._tool_definitions = dict(tool_definitions or {})
@ -187,7 +187,7 @@ class ActionLogMiddleware(AgentMiddleware):
row = AgentActionLog(
thread_id=thread_id,
user_id=self._user_id,
search_space_id=self._search_space_id,
workspace_id=self._workspace_id,
# ``turn_id`` is the deprecated alias of ``tool_call_id``
# kept for one release for safe rollback. New consumers
# should read ``tool_call_id`` directly.

View file

@ -40,7 +40,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
subagents: list[SubAgent | CompiledSubAgent],
system_prompt: str | None = TASK_SYSTEM_PROMPT,
task_description: str | None = None,
search_space_id: int | None = None,
workspace_id: int | None = None,
) -> None:
self._surf_checkpointer = checkpointer
super(SubAgentMiddleware, self).__init__()
@ -50,11 +50,11 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
)
self._backend = backend
self._subagents = subagents
# Search-space id is captured at build time (the orchestrator runs in
# exactly one search space for its lifetime). The spawn-paused kill
# Workspace id is captured at build time (the orchestrator runs in
# exactly one workspace for its lifetime). The spawn-paused kill
# switch keys on it so an operator can quarantine one workspace
# without affecting the rest of the deployment.
self._search_space_id = search_space_id
self._workspace_id = workspace_id
# Lazy subagent compilation. Compiling a subagent graph via
# ``create_agent`` is expensive (~250-400ms each) and there can be up
@ -75,7 +75,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
task_tool = build_task_tool_with_parent_config(
descriptors,
task_description,
search_space_id=search_space_id,
workspace_id=workspace_id,
resolve_subagent=self._resolve_subagent,
)
if system_prompt and descriptors:

View file

@ -1,12 +1,12 @@
"""Per-search-space spawn-paused kill switch for the ``task`` boundary.
"""Per-workspace spawn-paused kill switch for the ``task`` boundary.
When operators see a runaway loop, a vendor outage, or a billing event
that requires immediate cessation of subagent traffic for a specific
workspace, they flip a Redis flag and the ``task`` tool short-circuits
without touching downstream services. The flag is **per-search-space**
without touching downstream services. The flag is **per-workspace**
so one tenant's incident never silences the rest of the deployment.
Flag key: ``surfsense:spawn_paused:{search_space_id}``
Flag key: ``surfsense:spawn_paused:{workspace_id}``
Flag value: any string-truthy value (we read presence, not contents).
TTL: set by whoever toggles the flag this module never expires
keys on its own, since "the flag is on" is itself the signal
@ -43,18 +43,18 @@ _DISABLED = os.environ.get(
}
def _flag_key(search_space_id: int) -> str:
return f"surfsense:spawn_paused:{search_space_id}"
def _flag_key(workspace_id: int) -> str:
return f"surfsense:spawn_paused:{workspace_id}"
async def is_spawn_paused(search_space_id: int | None) -> bool:
async def is_spawn_paused(workspace_id: int | None) -> bool:
"""Return ``True`` iff the workspace's spawn-paused flag is set in Redis.
A ``None`` search-space (e.g. dev paths that did not plumb the id
A ``None`` workspace (e.g. dev paths that did not plumb the id
through yet) bypasses the check. So does a Redis outage see module
docstring for the fail-open rationale.
"""
if _DISABLED or search_space_id is None:
if _DISABLED or workspace_id is None:
return False
try:
# Local import keeps the cold-path import cheap and lets routes
@ -63,7 +63,7 @@ async def is_spawn_paused(search_space_id: int | None) -> bool:
client = aioredis.from_url(config.REDIS_APP_URL, decode_responses=True)
try:
raw = await client.get(_flag_key(search_space_id))
raw = await client.get(_flag_key(workspace_id))
finally:
# ``aclose()`` is the async-safe variant on redis-py >=5; fall back
# to ``close()`` for older clients pinned in tests.
@ -74,8 +74,8 @@ async def is_spawn_paused(search_space_id: int | None) -> bool:
return bool(raw)
except Exception:
logger.warning(
"spawn_paused check failed for search_space_id=%s; failing open.",
search_space_id,
"spawn_paused check failed for workspace_id=%s; failing open.",
workspace_id,
exc_info=True,
)
return False

View file

@ -23,6 +23,7 @@ from langchain_core.tools import StructuredTool
from langgraph.errors import GraphInterrupt
from langgraph.types import Command, Interrupt
from app.agents.chat.multi_agent_chat.constants import LEGACY_SUBAGENT_ALIASES
from app.agents.chat.multi_agent_chat.subagents.shared.invocation import (
EXCLUDED_STATE_KEYS,
subagent_invoke_config,
@ -142,7 +143,7 @@ def build_task_tool_with_parent_config(
subagents: list[dict[str, Any]],
task_description: str | None = None,
*,
search_space_id: int | None = None,
workspace_id: int | None = None,
resolve_subagent: Callable[[str], Runnable] | None = None,
) -> BaseTool:
"""Upstream ``_build_task_tool`` + parent ``runtime.config`` propagation + resume bridging.
@ -157,6 +158,26 @@ def build_task_tool_with_parent_config(
case a trivial dict-backed resolver is used.
"""
subagent_names: set[str] = {spec["name"] for spec in subagents}
def _canonical_subagent_type(subagent_type: str) -> str:
"""Resolve a legacy connector subagent name to its consolidated route.
Only rewrites when the requested name is unavailable but its alias is
(checkpoint resume of a pre-consolidation ``task(...)`` call). Current
routing never emits legacy names, so live traffic is untouched.
"""
if subagent_type in subagent_names:
return subagent_type
alias = LEGACY_SUBAGENT_ALIASES.get(subagent_type)
if alias is not None and alias in subagent_names:
logger.info(
"[hitl_route] aliasing legacy subagent %r -> %r",
subagent_type,
alias,
)
return alias
return subagent_type
if resolve_subagent is None:
_eager_graphs: dict[str, Runnable] = {
spec["name"]: spec["runnable"] for spec in subagents if "runnable" in spec
@ -482,6 +503,7 @@ def build_task_tool_with_parent_config(
batched HITL is intentionally out of scope.
"""
async with semaphore:
subagent_type = _canonical_subagent_type(subagent_type)
if subagent_type not in subagent_names:
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
return (
@ -658,6 +680,7 @@ def build_task_tool_with_parent_config(
"task: must provide either single-mode (`description`+`subagent_type`) "
"or batch-mode (`tasks`)."
)
subagent_type = _canonical_subagent_type(subagent_type)
if subagent_type not in subagent_names:
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
return (
@ -829,10 +852,10 @@ def build_task_tool_with_parent_config(
atask_start = time.perf_counter()
# Ops kill switch: short-circuit every task() call for this workspace
# so the orchestrator stops hammering downstream APIs.
if await is_spawn_paused(search_space_id):
if await is_spawn_paused(workspace_id):
logger.warning(
"[hitl_route] atask SPAWN_PAUSED: search_space_id=%s tool_call_id=%s",
search_space_id,
"[hitl_route] atask SPAWN_PAUSED: workspace_id=%s tool_call_id=%s",
workspace_id,
runtime.tool_call_id,
)
return (
@ -867,6 +890,7 @@ def build_task_tool_with_parent_config(
subagent_type,
runtime.tool_call_id,
)
subagent_type = _canonical_subagent_type(subagent_type)
if subagent_type not in subagent_names:
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
return (

View file

@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from langchain_core.tools import BaseTool
@ -25,7 +24,7 @@ def build_context_editing_mw(
flags: AgentFeatureFlags,
max_input_tokens: int | None,
tools: Sequence[BaseTool],
backend_resolver: Any,
workspace_id: int | None = None,
) -> SpillingContextEditingMiddleware | None:
if not enabled(flags, "enable_context_editing") or not max_input_tokens:
return None
@ -46,5 +45,5 @@ def build_context_editing_mw(
)
return SpillingContextEditingMiddleware(
edits=[spill_edit, clear_edit],
backend_resolver=backend_resolver,
workspace_id=workspace_id,
)

View file

@ -4,25 +4,31 @@ SpillToBackendEdit + SpillingContextEditingMiddleware.
LangChain's :class:`ClearToolUsesEdit` discards old ``ToolMessage.content``
when the context-editing budget triggers, replacing the body with a fixed
placeholder. That's lossy: anything the agent might want to revisit is
gone. The spill-to-disk pattern (originally from OpenCode's
gone. The spill pattern (originally from OpenCode's
``opencode/packages/opencode/src/tool/truncate.ts``) keeps the prune
behavior but writes the full original payload to the runtime backend
under ``/tool_outputs/{thread_id}/{message_id}.txt`` first. The
placeholder is then upgraded to point at the spill path so the agent
(or a subagent) can read it back on demand.
behavior but persists the full original payload first to the
``tool_output_spills`` table and upgrades the placeholder to a
``spill_<uuid>`` reference the agent can read back with the shared
``read_run``/``search_run`` tools on demand.
Why the DB and not the runtime filesystem backend: the previous version
wrote via ``backend.aupload_files``, which is a no-op on cloud
(``KBPostgresBackend`` raises ``NotImplementedError``) and mismatched on
desktop (paths must be ``/{mount_id}/...``), so spills were unrecoverable
in production. A table works on every deployment and needs no sandbox.
Why this is a middleware subclass instead of a plain ``ContextEdit``:
``ContextEdit.apply`` is sync, but writing to the backend is async. We
capture the spill payloads inside ``apply`` and flush them via
``await backend.aupload_files(...)`` from ``awrap_model_call`` *before*
delegating to the handler, so the explore subagent can always read what
the placeholder advertises.
``ContextEdit.apply`` is sync, but the DB write is async. We generate the
spill id and capture the payload inside ``apply`` (so the placeholder can
reference the final id immediately) and flush the rows from
``awrap_model_call`` *before* delegating to the handler.
"""
from __future__ import annotations
import logging
import threading
import uuid
from collections.abc import Awaitable, Callable, Sequence
from copy import deepcopy
from dataclasses import dataclass, field
@ -44,7 +50,6 @@ from langchain_core.messages.utils import count_tokens_approximately
from langgraph.config import get_config
if TYPE_CHECKING:
from deepagents.backends.protocol import BackendProtocol
from langchain.agents.middleware.types import (
ModelRequest,
ModelResponse,
@ -52,24 +57,27 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
DEFAULT_SPILL_PREFIX = "/tool_outputs"
# Namespace for deterministic spill ids: the same (thread, message) always maps
# to the same row, so re-running the edit on later model calls (edits apply to a
# per-call copy of the messages, never to persisted state) re-references the
# existing spill instead of inserting a duplicate every turn.
_SPILL_NAMESPACE = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
def _build_spill_placeholder(spill_path: str) -> str:
def _spill_id_for(thread_id: str | None, message_key: str) -> uuid.UUID:
return uuid.uuid5(_SPILL_NAMESPACE, f"{thread_id or 'no_thread'}:{message_key}")
def _build_spill_placeholder(spill_id: uuid.UUID) -> str:
"""Build the user-facing placeholder text shown to the model."""
return (
f"[cleared — full output at {spill_path}; ask the explore subagent to read it]"
f"[cleared — full output stored as spill_{spill_id}; "
"use read_run/search_run to read it]"
)
def _get_thread_id_or_session() -> str:
"""Best-effort thread_id discovery for the spill path.
Falls back to a process-stable string if no LangGraph config is
available (e.g. unit tests). The exact value doesn't matter as long
as it's stable within one stream so the placeholder paths line up
with the actual upload path.
"""
def _get_thread_id() -> str | None:
"""Best-effort ``configurable.thread_id`` for the spill row (``None`` if absent)."""
try:
config = get_config()
thread_id = config.get("configurable", {}).get("thread_id")
@ -77,17 +85,18 @@ def _get_thread_id_or_session() -> str:
return str(thread_id)
except RuntimeError:
pass
return "no_thread"
return None
@dataclass(slots=True)
class SpillToBackendEdit(ContextEdit):
"""Capture-and-replace context edit that spills full tool output to the backend.
"""Capture-and-replace context edit that spills full tool output to the DB.
Behaves like :class:`ClearToolUsesEdit` (same trigger / keep / exclude
semantics) **and** records the original ``ToolMessage.content`` in
:attr:`pending_spills` so the wrapping middleware can flush them
before the model call.
:attr:`pending_spills` so the wrapping middleware can flush the rows to
``tool_output_spills`` before the model call. The spill id is generated up
front so the placeholder can reference it immediately.
Args:
trigger: Token threshold above which the edit fires.
@ -97,8 +106,6 @@ class SpillToBackendEdit(ContextEdit):
exclude_tools: Names of tools whose output is NOT spilled.
clear_tool_inputs: Also clear the originating ``AIMessage.tool_calls``
args when their pair is cleared.
path_prefix: Path under the backend where spills are written.
Default ``"/tool_outputs"``.
"""
trigger: int = 100_000
@ -106,12 +113,16 @@ class SpillToBackendEdit(ContextEdit):
keep: int = 3
clear_tool_inputs: bool = False
exclude_tools: Sequence[str] = ()
path_prefix: str = DEFAULT_SPILL_PREFIX
pending_spills: list[tuple[str, bytes]] = field(default_factory=list)
# (spill_id, content_bytes, tool_name, thread_id)
pending_spills: list[tuple[uuid.UUID, bytes, str | None, str | None]] = field(
default_factory=list
)
_lock: threading.Lock = field(default_factory=threading.Lock)
def drain_pending(self) -> list[tuple[str, bytes]]:
def drain_pending(
self,
) -> list[tuple[uuid.UUID, bytes, str | None, str | None]]:
"""Return and clear the pending-spill list atomically."""
with self._lock:
out = list(self.pending_spills)
@ -139,7 +150,7 @@ class SpillToBackendEdit(ContextEdit):
if self.keep:
candidates = candidates[: -self.keep]
thread_id = _get_thread_id_or_session()
thread_id = _get_thread_id()
excluded_tools = set(self.exclude_tools)
for idx, tool_message in candidates:
@ -168,24 +179,23 @@ class SpillToBackendEdit(ContextEdit):
if tool_name in excluded_tools:
continue
message_id = tool_message.id or tool_message.tool_call_id or "unknown"
spill_path = f"{self.path_prefix}/{thread_id}/{message_id}.txt"
message_key = tool_message.id or tool_message.tool_call_id or "unknown"
spill_id = _spill_id_for(thread_id, message_key)
original = tool_message.content
payload = self._encode_payload(original)
with self._lock:
self.pending_spills.append((spill_path, payload))
self.pending_spills.append((spill_id, payload, tool_name, thread_id))
messages[idx] = tool_message.model_copy(
update={
"artifact": None,
"content": _build_spill_placeholder(spill_path),
"content": _build_spill_placeholder(spill_id),
"response_metadata": {
**tool_message.response_metadata,
"context_editing": {
"cleared": True,
"strategy": "spill_to_backend",
"spill_path": spill_path,
"strategy": "spill_to_db",
"spill_id": str(spill_id),
},
},
}
@ -243,52 +253,30 @@ class SpillToBackendEdit(ContextEdit):
)
BackendResolver = "Callable[[Any], BackendProtocol] | BackendProtocol"
class SpillingContextEditingMiddleware(ContextEditingMiddleware):
""":class:`ContextEditingMiddleware` that flushes :class:`SpillToBackendEdit` writes.
Runs the configured edits as the parent does, then flushes any
pending spills via the supplied backend resolver before delegating
to the model handler. Spill failures are logged but never abort the
model call the placeholder text is already in the message, so the
worst case is the agent gets a placeholder it cannot follow up on.
Runs the configured edits as the parent does, then persists any pending
spills to ``tool_output_spills`` before delegating to the model handler.
Spill failures are logged but never abort the model call the placeholder
text is already in the message, so the worst case is the agent gets a
placeholder it cannot follow up on.
"""
def __init__(
self,
*,
edits: Sequence[ContextEdit],
backend_resolver: BackendResolver | None = None,
workspace_id: int | None = None,
token_count_method: str = "approximate",
) -> None:
super().__init__(edits=list(edits), token_count_method=token_count_method) # type: ignore[arg-type]
self._backend_resolver = backend_resolver
self._workspace_id = workspace_id
def _resolve_backend(self, request: ModelRequest) -> BackendProtocol | None:
if self._backend_resolver is None:
return None
if callable(self._backend_resolver):
try:
from langchain.tools import ToolRuntime
tool_runtime = ToolRuntime(
state=getattr(request, "state", {}),
context=getattr(request.runtime, "context", None),
stream_writer=getattr(request.runtime, "stream_writer", None),
store=getattr(request.runtime, "store", None),
config=getattr(request.runtime, "config", None) or {},
tool_call_id=None,
)
return self._backend_resolver(tool_runtime)
except Exception:
logger.exception("Failed to resolve spill backend")
return None
return self._backend_resolver # type: ignore[return-value]
def _collect_pending(self) -> list[tuple[str, bytes]]:
out: list[tuple[str, bytes]] = []
def _collect_pending(
self,
) -> list[tuple[uuid.UUID, bytes, str | None, str | None]]:
out: list[tuple[uuid.UUID, bytes, str | None, str | None]] = []
for edit in self.edits:
if isinstance(edit, SpillToBackendEdit):
out.extend(edit.drain_pending())
@ -321,28 +309,37 @@ class SpillingContextEditingMiddleware(ContextEditingMiddleware):
pending = self._collect_pending()
if pending:
backend = self._resolve_backend(request)
if backend is not None:
try:
await backend.aupload_files(pending)
except Exception:
logger.exception(
"Spill-to-backend upload failed (%d files); placeholders "
"remain in messages but content is unrecoverable",
len(pending),
)
else:
logger.warning(
"SpillToBackendEdit produced %d pending spills but no backend "
"resolver was configured; content is unrecoverable",
len(pending),
)
await self._flush_spills(pending)
return await handler(request.override(messages=edited_messages))
async def _flush_spills(
self, pending: list[tuple[uuid.UUID, bytes, str | None, str | None]]
) -> None:
"""Persist spilled tool outputs to the DB (best-effort)."""
from app.capabilities.core.runs import record_spill
from app.db import async_session_maker
try:
async with async_session_maker() as session:
for spill_id, payload, tool_name, thread_id in pending:
await record_spill(
session,
spill_id=spill_id,
content=payload.decode("utf-8", errors="replace"),
workspace_id=self._workspace_id,
thread_id=thread_id,
tool_name=tool_name,
)
except Exception:
logger.exception(
"Spill-to-DB flush failed (%d rows); placeholders remain in "
"messages but content is unrecoverable",
len(pending),
)
__all__ = [
"DEFAULT_SPILL_PREFIX",
"ClearToolUsesEdit",
"SpillToBackendEdit",
"SpillingContextEditingMiddleware",

View file

@ -12,14 +12,14 @@ from .middleware import (
def build_kb_persistence_mw(
*,
filesystem_mode: FilesystemMode,
search_space_id: int,
workspace_id: int,
user_id: str | None,
thread_id: int | None,
) -> KnowledgeBasePersistenceMiddleware | None:
if filesystem_mode != FilesystemMode.CLOUD:
return None
return KnowledgeBasePersistenceMiddleware(
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=user_id,
filesystem_mode=filesystem_mode,
thread_id=thread_id,

View file

@ -83,11 +83,11 @@ def _basename(path: str) -> str:
async def _ensure_folder_hierarchy(
session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
created_by_id: str | None,
folder_parts: list[str],
) -> int | None:
"""Ensure a chain of folder names exists under the search space.
"""Ensure a chain of folder names exists under the workspace.
Returns the leaf folder id, or ``None`` if ``folder_parts`` is empty
(i.e. a document directly under ``/documents/``).
@ -98,7 +98,7 @@ async def _ensure_folder_hierarchy(
for raw in folder_parts:
name = safe_folder_segment(str(raw))
query = select(Folder).where(
Folder.search_space_id == search_space_id,
Folder.workspace_id == workspace_id,
Folder.name == name,
)
if parent_id is None:
@ -111,9 +111,7 @@ async def _ensure_folder_hierarchy(
sibling_query = (
select(Folder.position).order_by(Folder.position.desc()).limit(1)
)
sibling_query = sibling_query.where(
Folder.search_space_id == search_space_id
)
sibling_query = sibling_query.where(Folder.workspace_id == workspace_id)
if parent_id is None:
sibling_query = sibling_query.where(Folder.parent_id.is_(None))
else:
@ -124,7 +122,7 @@ async def _ensure_folder_hierarchy(
name=name,
position=generate_key_between(last_position, None),
parent_id=parent_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_at=datetime.now(UTC),
)
@ -137,7 +135,7 @@ async def _ensure_folder_hierarchy(
async def _resolve_folder_id(
session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
folder_parts: list[str],
) -> int | None:
"""Look up an existing folder chain without creating anything.
@ -151,7 +149,7 @@ async def _resolve_folder_id(
for raw in folder_parts:
name = safe_folder_segment(str(raw))
query = select(Folder).where(
Folder.search_space_id == search_space_id,
Folder.workspace_id == workspace_id,
Folder.name == name,
)
query = (
@ -185,7 +183,7 @@ async def _create_document(
*,
virtual_path: str,
content: str,
search_space_id: int,
workspace_id: int,
created_by_id: str | None,
) -> Document:
"""Create a NOTE Document + Chunks for ``virtual_path``."""
@ -194,21 +192,21 @@ async def _create_document(
raise ValueError(f"invalid /documents path '{virtual_path}'")
folder_id = await _ensure_folder_hierarchy(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
folder_parts=folder_parts,
)
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
virtual_path,
search_space_id,
workspace_id,
)
# Pre-check the path-derived unique_identifier_hash so a duplicate path
# surfaces as a clean ValueError instead of an INSERT IntegrityError that
# poisons the session. Content is intentionally not unique (cp a b).
path_collision = await session.execute(
select(Document.id).where(
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
Document.unique_identifier_hash == unique_identifier_hash,
)
)
@ -217,7 +215,7 @@ async def _create_document(
f"a document already exists at path '{virtual_path}' "
"(unique_identifier_hash collision)"
)
content_hash = generate_content_hash(content, search_space_id)
content_hash = generate_content_hash(content, workspace_id)
doc = Document(
title=title,
document_type=DocumentType.NOTE,
@ -226,7 +224,7 @@ async def _create_document(
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
source_markdown=content,
search_space_id=search_space_id,
workspace_id=workspace_id,
folder_id=folder_id,
created_by_id=created_by_id,
updated_at=datetime.now(UTC),
@ -261,13 +259,13 @@ async def _update_document(
doc_id: int,
content: str,
virtual_path: str,
search_space_id: int,
workspace_id: int,
) -> Document | None:
"""Update an existing Document's content + chunks."""
result = await session.execute(
select(Document).where(
Document.id == doc_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document = result.scalar_one_or_none()
@ -276,7 +274,7 @@ async def _update_document(
document.content = content
document.source_markdown = content
document.content_hash = generate_content_hash(content, search_space_id)
document.content_hash = generate_content_hash(content, workspace_id)
document.updated_at = datetime.now(UTC)
metadata = dict(document.document_metadata or {})
metadata["virtual_path"] = virtual_path
@ -284,7 +282,7 @@ async def _update_document(
document.unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
virtual_path,
search_space_id,
workspace_id,
)
summary_embedding = (await asyncio.to_thread(embed_texts, [content]))[0]
@ -318,7 +316,7 @@ async def _update_document(
async def _apply_move(
session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
created_by_id: str | None,
move: dict[str, Any],
doc_id_by_path: dict[str, int],
@ -341,14 +339,14 @@ async def _apply_move(
result = await session.execute(
select(Document).where(
Document.id == doc_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document = result.scalar_one_or_none()
if document is None:
document = await virtual_path_to_doc(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
virtual_path=source,
)
if document is None:
@ -364,7 +362,7 @@ async def _apply_move(
return None
folder_id = await _ensure_folder_hierarchy(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
folder_parts=folder_parts,
)
@ -377,7 +375,7 @@ async def _apply_move(
document.unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
dest,
search_space_id,
workspace_id,
)
document.updated_at = datetime.now(UTC)
@ -501,7 +499,7 @@ async def _snapshot_document_pre_write(
*,
doc: Document,
action_id: int | None,
search_space_id: int,
workspace_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -517,7 +515,7 @@ async def _snapshot_document_pre_write(
payload = _doc_revision_payload(doc, chunks_before=chunks)
rev = DocumentRevision(
document_id=doc.id,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_turn_id=turn_id,
agent_action_id=action_id,
**payload,
@ -544,7 +542,7 @@ async def _snapshot_document_pre_create(
session: AsyncSession,
*,
action_id: int | None,
search_space_id: int,
workspace_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -558,7 +556,7 @@ async def _snapshot_document_pre_create(
async with session.begin_nested():
rev = DocumentRevision(
document_id=None,
search_space_id=search_space_id,
workspace_id=workspace_id,
content_before=None,
title_before=None,
folder_id_before=None,
@ -586,7 +584,7 @@ async def _snapshot_document_pre_move(
*,
doc: Document,
action_id: int | None,
search_space_id: int,
workspace_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -596,7 +594,7 @@ async def _snapshot_document_pre_move(
payload = _doc_revision_payload(doc, chunks_before=None)
rev = DocumentRevision(
document_id=doc.id,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_turn_id=turn_id,
agent_action_id=action_id,
**payload,
@ -624,7 +622,7 @@ async def _snapshot_folder_pre_mkdir(
*,
folder: Folder,
action_id: int | None,
search_space_id: int,
workspace_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -637,7 +635,7 @@ async def _snapshot_folder_pre_mkdir(
async with session.begin_nested():
rev = FolderRevision(
folder_id=folder.id,
search_space_id=search_space_id,
workspace_id=workspace_id,
name_before=None,
parent_id_before=None,
position_before=None,
@ -670,7 +668,7 @@ async def _snapshot_folder_pre_mkdir(
async def commit_staged_filesystem_state(
state: dict[str, Any] | AgentState,
*,
search_space_id: int,
workspace_id: int,
created_by_id: str | None,
filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
thread_id: int | None = None,
@ -814,7 +812,7 @@ async def commit_staged_filesystem_state(
continue
folder_id = await _ensure_folder_hierarchy(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
folder_parts=folder_parts_full,
)
@ -833,7 +831,7 @@ async def commit_staged_filesystem_state(
session,
folder=folder_row,
action_id=action_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
@ -851,14 +849,14 @@ async def commit_staged_filesystem_state(
res_pre = await session.execute(
select(Document).where(
Document.id == doc_id_pre,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document_pre = res_pre.scalar_one_or_none()
if document_pre is None:
document_pre = await virtual_path_to_doc(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
virtual_path=source,
)
if document_pre is not None:
@ -866,14 +864,14 @@ async def commit_staged_filesystem_state(
session,
doc=document_pre,
action_id=action_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
applied = await _apply_move(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
move=move,
doc_id_by_path=doc_id_by_path,
@ -937,7 +935,7 @@ async def commit_staged_filesystem_state(
# INSERT (which would hit the path-derived unique hash).
existing = await virtual_path_to_doc(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
virtual_path=path,
)
if existing is not None:
@ -948,7 +946,7 @@ async def commit_staged_filesystem_state(
result_doc = await session.execute(
select(Document).where(
Document.id == doc_id,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
existing_doc = result_doc.scalar_one_or_none()
@ -957,7 +955,7 @@ async def commit_staged_filesystem_state(
session,
doc=existing_doc,
action_id=action_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
@ -966,7 +964,7 @@ async def commit_staged_filesystem_state(
doc_id=doc_id,
content=content,
virtual_path=path,
search_space_id=search_space_id,
workspace_id=workspace_id,
)
if updated is not None:
committed_updates.append(
@ -974,7 +972,7 @@ async def commit_staged_filesystem_state(
"id": updated.id,
"title": updated.title,
"documentType": DocumentType.NOTE.value,
"searchSpaceId": search_space_id,
"workspaceId": workspace_id,
"folderId": updated.folder_id,
"createdById": str(created_by_id)
if created_by_id
@ -991,7 +989,7 @@ async def commit_staged_filesystem_state(
placeholder_revision_id = await _snapshot_document_pre_create(
session,
action_id=action_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
@ -1001,7 +999,7 @@ async def commit_staged_filesystem_state(
session,
virtual_path=path,
content=content,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
)
except ValueError as exc:
@ -1045,7 +1043,7 @@ async def commit_staged_filesystem_state(
"id": new_doc.id,
"title": new_doc.title,
"documentType": DocumentType.NOTE.value,
"searchSpaceId": search_space_id,
"workspaceId": workspace_id,
"folderId": new_doc.folder_id,
"createdById": str(created_by_id)
if created_by_id
@ -1069,14 +1067,14 @@ async def commit_staged_filesystem_state(
result = await session.execute(
select(Document).where(
Document.id == doc_id_for_delete,
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
)
)
document_to_delete = result.scalar_one_or_none()
if document_to_delete is None:
document_to_delete = await virtual_path_to_doc(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
virtual_path=final,
)
if document_to_delete is None:
@ -1100,7 +1098,7 @@ async def commit_staged_filesystem_state(
)
rev = DocumentRevision(
document_id=doc_pk,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_turn_id=tcid,
agent_action_id=action_id,
**payload,
@ -1130,7 +1128,7 @@ async def commit_staged_filesystem_state(
"id": doc_pk,
"title": doc_title,
"documentType": DocumentType.NOTE.value,
"searchSpaceId": search_space_id,
"workspaceId": workspace_id,
"folderId": doc_folder_id,
"createdById": str(created_by_id) if created_by_id else None,
"virtualPath": final,
@ -1151,7 +1149,7 @@ async def commit_staged_filesystem_state(
continue
folder_id = await _resolve_folder_id(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
folder_parts=folder_parts,
)
if folder_id is None:
@ -1163,7 +1161,7 @@ async def commit_staged_filesystem_state(
docs_in_folder = await session.execute(
select(Document.id)
.where(Document.folder_id == folder_id)
.where(Document.search_space_id == search_space_id)
.where(Document.workspace_id == workspace_id)
.limit(1)
)
if docs_in_folder.scalar_one_or_none() is not None:
@ -1175,7 +1173,7 @@ async def commit_staged_filesystem_state(
child_folders = await session.execute(
select(Folder.id)
.where(Folder.parent_id == folder_id)
.where(Folder.search_space_id == search_space_id)
.where(Folder.workspace_id == workspace_id)
.limit(1)
)
if child_folders.scalar_one_or_none() is not None:
@ -1203,7 +1201,7 @@ async def commit_staged_filesystem_state(
if snapshot_enabled and action_id is not None:
rev = FolderRevision(
folder_id=folder_pk,
search_space_id=search_space_id,
workspace_id=workspace_id,
name_before=folder_name,
parent_id_before=folder_parent_id,
position_before=folder_position,
@ -1232,7 +1230,7 @@ async def commit_staged_filesystem_state(
{
"id": folder_pk,
"name": folder_name,
"searchSpaceId": search_space_id,
"workspaceId": workspace_id,
"parentId": folder_parent_id,
"virtualPath": final,
}
@ -1241,9 +1239,7 @@ async def commit_staged_filesystem_state(
await session.commit()
except Exception: # pragma: no cover - rollback safety net
logger.exception(
"kb_persistence: commit failed (search_space=%s)", search_space_id
)
logger.exception("kb_persistence: commit failed (workspace=%s)", workspace_id)
# Outer commit raised: everything above rolled back, so drop the
# deferred dispatches.
deferred_dispatches.clear()
@ -1402,9 +1398,9 @@ async def commit_staged_filesystem_state(
_ = turn_id_for_revision # diagnostic-only; silence unused lint
logger.info(
"kb_persistence: commit (search_space=%s) creates=%d updates=%d "
"kb_persistence: commit (workspace=%s) creates=%d updates=%d "
"moves=%d staged_dirs=%d deletes=%d folder_deletes=%d discarded=%d",
search_space_id,
workspace_id,
len(committed_creates),
len(committed_updates),
len(applied_moves),
@ -1430,12 +1426,12 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type-
def __init__(
self,
*,
search_space_id: int,
workspace_id: int,
created_by_id: str | None,
filesystem_mode: FilesystemMode,
thread_id: int | None = None,
) -> None:
self.search_space_id = search_space_id
self.workspace_id = workspace_id
self.created_by_id = created_by_id
self.filesystem_mode = filesystem_mode
self.thread_id = thread_id
@ -1450,7 +1446,7 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type-
return None
return await commit_staged_filesystem_state(
state,
search_space_id=self.search_space_id,
workspace_id=self.workspace_id,
created_by_id=self.created_by_id,
filesystem_mode=self.filesystem_mode,
thread_id=self._resolve_thread_id(),

View file

@ -12,13 +12,13 @@ from .middleware import KnowledgeTreeMiddleware
def build_knowledge_tree_mw(
*,
filesystem_mode: FilesystemMode,
search_space_id: int,
workspace_id: int,
llm: BaseChatModel,
) -> KnowledgeTreeMiddleware | None:
if filesystem_mode != FilesystemMode.CLOUD:
return None
return KnowledgeTreeMiddleware(
search_space_id=search_space_id,
workspace_id=workspace_id,
filesystem_mode=filesystem_mode,
llm=llm,
inject_system_message=False,

View file

@ -1,7 +1,7 @@
"""Workspace-tree middleware for the SurfSense agent.
Renders the full ``Folder``+``Document`` tree under ``/documents/`` once per
turn (cloud only), caches it by ``(search_space_id, tree_version)``, and
turn (cloud only), caches it by ``(workspace_id, tree_version)``, and
injects the result as a ``<workspace_tree>`` system message immediately
before the latest human turn.
@ -106,14 +106,14 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
def __init__(
self,
*,
search_space_id: int,
workspace_id: int,
filesystem_mode: FilesystemMode,
llm: BaseChatModel | None = None,
max_entries: int = MAX_TREE_ENTRIES,
max_tokens: int = MAX_TREE_TOKENS,
inject_system_message: bool = True, # For backwards compatibility
) -> None:
self.search_space_id = search_space_id
self.workspace_id = workspace_id
self.filesystem_mode = filesystem_mode
self.llm = llm
self.max_entries = max_entries
@ -141,7 +141,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
cache_outcome = "anon"
else:
version = int(state.get("tree_version") or 0)
cache_key = (self.search_space_id, version, False)
cache_key = (self.workspace_id, version, False)
cache_outcome = "hit" if cache_key in self._cache else "miss"
tree_msg = await self._render_kb_tree(state)
@ -158,7 +158,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
cache_outcome,
len(tree_msg),
time.perf_counter() - start,
self.search_space_id,
self.workspace_id,
)
return update
@ -190,17 +190,17 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
async def _render_kb_tree(self, state: AgentState) -> str:
version = int(state.get("tree_version") or 0)
cache_key = (self.search_space_id, version, False)
cache_key = (self.workspace_id, version, False)
cached = self._cache.get(cache_key)
if cached is not None:
return cached
try:
async with shielded_async_session() as session:
index = await build_path_index(session, self.search_space_id)
index = await build_path_index(session, self.workspace_id)
doc_rows = await session.execute(
select(Document.id, Document.title, Document.folder_id).where(
Document.search_space_id == self.search_space_id
Document.workspace_id == self.workspace_id
)
)
docs = list(doc_rows.all())

View file

@ -10,11 +10,11 @@ from .middleware import MemoryInjectionMiddleware
def build_memory_mw(
*,
user_id: str | None,
search_space_id: int,
workspace_id: int,
visibility: ChatVisibility,
) -> MemoryInjectionMiddleware:
return MemoryInjectionMiddleware(
user_id=user_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
thread_visibility=visibility,
)

View file

@ -18,7 +18,7 @@ from langgraph.runtime import Runtime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import ChatVisibility, SearchSpace, User, shielded_async_session
from app.db import ChatVisibility, User, Workspace, shielded_async_session
from app.services.memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
from app.utils.perf import get_perf_logger
@ -35,11 +35,11 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg]
self,
*,
user_id: str | UUID | None,
search_space_id: int,
workspace_id: int,
thread_visibility: ChatVisibility | None = None,
) -> None:
self.user_id = UUID(user_id) if isinstance(user_id, str) else user_id
self.search_space_id = search_space_id
self.workspace_id = workspace_id
self.visibility = thread_visibility or ChatVisibility.PRIVATE
async def abefore_agent( # type: ignore[override]
@ -149,8 +149,8 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg]
async def _load_team_memory(self, session: AsyncSession) -> str | None:
try:
result = await session.execute(
select(SearchSpace.shared_memory_md).where(
SearchSpace.id == self.search_space_id
select(Workspace.shared_memory_md).where(
Workspace.id == self.workspace_id
)
)
row = result.scalar_one_or_none()

View file

@ -21,7 +21,7 @@ from ..plugins.loader import (
def build_plugin_middlewares(
*,
flags: AgentFeatureFlags,
search_space_id: int,
workspace_id: int,
user_id: str | None,
visibility: ChatVisibility,
llm: BaseChatModel,
@ -34,7 +34,7 @@ def build_plugin_middlewares(
return []
return load_plugin_middlewares(
PluginContext.build(
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
thread_visibility=visibility,
llm=llm,

View file

@ -17,13 +17,13 @@ def build_skills_mw(
*,
flags: AgentFeatureFlags,
filesystem_mode: FilesystemMode,
search_space_id: int,
workspace_id: int,
) -> SkillsMiddleware | None:
if not enabled(flags, "enable_skills"):
return None
try:
skills_factory = build_skills_backend_factory(
search_space_id=search_space_id
workspace_id=workspace_id
if filesystem_mode == FilesystemMode.CLOUD
else None,
)

View file

@ -7,6 +7,11 @@ retrieval are owned by the ``knowledge_base`` subagent and reached via the
the main agent only sees the specialist's grounded summary. The stack here
computes the workspace tree, commits any subagent-side staged writes at end of
turn (cloud mode), and wires the supporting middleware.
One deliberate, read-only exception to the pure-router stance: the main agent
also carries the ``read_run``/``search_run`` tools (added in ``runtime/factory``)
so it can follow the context-editing spill placeholder evicted tool output is
persisted to ``tool_output_spills`` and the placeholder advertises those tools.
"""
from __future__ import annotations
@ -99,7 +104,7 @@ def build_main_agent_deepagent_middleware(
tools: Sequence[BaseTool],
backend_resolver: Any,
filesystem_mode: FilesystemMode,
search_space_id: int,
workspace_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
@ -120,7 +125,7 @@ def build_main_agent_deepagent_middleware(
memory_mw = build_memory_mw(
user_id=user_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
visibility=visibility,
)
@ -232,19 +237,19 @@ def build_main_agent_deepagent_middleware(
),
build_knowledge_tree_mw(
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
workspace_id=workspace_id,
llm=llm,
),
build_kb_persistence_mw(
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
thread_id=thread_id,
),
build_skills_mw(
flags=flags,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
workspace_id=workspace_id,
),
SurfSenseCheckpointedSubAgentMiddleware(
checkpointer=checkpointer,
@ -252,7 +257,7 @@ def build_main_agent_deepagent_middleware(
subagents=subagents,
system_prompt=None,
task_description=TASK_TOOL_DESCRIPTION,
search_space_id=search_space_id,
workspace_id=workspace_id,
),
resilience.model_call_limit,
resilience.tool_call_limit,
@ -260,7 +265,7 @@ def build_main_agent_deepagent_middleware(
flags=flags,
max_input_tokens=max_input_tokens,
tools=tools,
backend_resolver=backend_resolver,
workspace_id=workspace_id,
),
build_compaction_mw(llm),
build_noop_injection_mw(flags),
@ -272,14 +277,14 @@ def build_main_agent_deepagent_middleware(
build_action_log_mw(
flags=flags,
thread_id=thread_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
),
build_patch_tool_calls_mw(),
build_dedup_hitl_mw(tools),
*build_plugin_middlewares(
flags=flags,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
visibility=visibility,
llm=llm,

View file

@ -57,7 +57,7 @@ class PluginContext(dict):
Backed by ``dict`` so plugins can inspect the keys they care about
without coupling to a concrete dataclass shape. Required keys:
* ``search_space_id`` (int)
* ``workspace_id`` (int)
* ``user_id`` (str | None)
* ``thread_visibility`` (:class:`app.db.ChatVisibility`)
* ``llm`` (:class:`langchain_core.language_models.BaseChatModel`)
@ -72,13 +72,13 @@ class PluginContext(dict):
def build(
cls,
*,
search_space_id: int,
workspace_id: int,
user_id: str | None,
thread_visibility: ChatVisibility,
llm: BaseChatModel,
) -> PluginContext:
return cls(
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
thread_visibility=thread_visibility,
llm=llm,

View file

@ -7,7 +7,7 @@ result. This particular plugin is read-only and only transforms the
mutation).
The plugin is built as a factory function so the entry-point loader can
inject :class:`PluginContext` (containing the agent's LLM, search-space
inject :class:`PluginContext` (containing the agent's LLM, workspace
ID, etc.). The factory signature
``Callable[[PluginContext], AgentMiddleware]`` is the only contract --
SurfSense doesn't define a custom plugin protocol on top of LangChain's

View file

@ -42,7 +42,7 @@ async def build_agent_with_cache(
final_system_prompt: str,
backend_resolver: Any,
filesystem_mode: FilesystemMode,
search_space_id: int,
workspace_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
@ -69,7 +69,7 @@ async def build_agent_with_cache(
final_system_prompt=final_system_prompt,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,
@ -104,7 +104,7 @@ async def build_agent_with_cache(
config_id,
None if cross_thread else thread_id,
user_id,
search_space_id,
workspace_id,
visibility,
filesystem_mode,
anon_session_id,
@ -120,7 +120,7 @@ async def build_agent_with_cache(
sorted(disabled_tools) if disabled_tools else None,
# Bound into the generate_image subagent tool at construction time, so it
# must key the compiled-agent cache to avoid leaking one automation's
# image model into another with the same config_id/search_space.
# image model into another with the same config_id/workspace.
image_gen_model_id_override,
)
return await get_cache().get_or_build(cache_key, builder=_build)

View file

@ -26,7 +26,7 @@ Why a per-thread key (not a global pool)
----------------------------------------
Most middleware in the SurfSense stack captures per-thread state in
``__init__`` closures (``thread_id``, ``user_id``, ``search_space_id``,
``__init__`` closures (``thread_id``, ``user_id``, ``workspace_id``,
``filesystem_mode``, ``mentioned_document_ids``). Cross-thread reuse
would silently leak state across users and threads. Keying the cache on
``(llm_config_id, thread_id, ...)`` gives us safe reuse for repeated
@ -34,7 +34,7 @@ turns on the same thread without changing any middleware's behavior.
Phase 2 will move those captured fields onto :class:`SurfSenseContextSchema`
(read via ``runtime.context``) so the cache can collapse to a single
``(llm_config_id, search_space_id, ...)`` key shared across threads. Until
``(llm_config_id, workspace_id, ...)`` key shared across threads. Until
then, per-thread keying is the only safe option.
Cache shape
@ -111,7 +111,7 @@ def tools_signature(
* A tool is added or removed from the bound list (built-in toggles,
MCP tools loaded for the user changes, gating rules flip, etc.).
* The available connectors / document types for the search space
* The available connectors / document types for the workspace
change (new connector added, last connector removed, new document
type indexed). Connector gating derives disabled tools from
``available_connectors``, so the tool surface is technically already

View file

@ -1,10 +1,10 @@
"""Map configured connectors to the searchable document/connector types.
This is agent-agnostic infrastructure shared by every agent factory (single-
and multi-agent). It translates the connectors a search space has enabled into
the set of searchable type strings that pre-search middleware and ``web_search``
understand, and always layers in the document types that exist independently of
any connector (uploads, notes, extension captures, YouTube).
and multi-agent). It translates the connectors a workspace has enabled into
the set of searchable type strings that pre-search middleware understands, and
always layers in the document types that exist independently of any connector
(uploads, notes, extension captures, YouTube).
It lives in its own module rather than inside a specific agent factory so
that retiring or moving any single agent never disturbs the others' access to
@ -16,15 +16,8 @@ from __future__ import annotations
from typing import Any
# Maps SearchSourceConnectorType enum values to the searchable document/connector types
# used by pre-search middleware and web_search.
# Live search connectors (TAVILY_API, LINKUP_API, BAIDU_SEARCH_API) are routed to
# the web_search tool; all others are considered local/indexed data.
# used by KB pre-search middleware. All entries are local/indexed data.
_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
# Live search connectors (handled by web_search tool)
"TAVILY_API": "TAVILY_API",
"LINKUP_API": "LINKUP_API",
"BAIDU_SEARCH_API": "BAIDU_SEARCH_API",
# Local/indexed connectors (handled by KB pre-search middleware)
"SLACK_CONNECTOR": "SLACK_CONNECTOR",
"TEAMS_CONNECTOR": "TEAMS_CONNECTOR",
"NOTION_CONNECTOR": "NOTION_CONNECTOR",
@ -40,12 +33,14 @@ _CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
"AIRTABLE_CONNECTOR": "AIRTABLE_CONNECTOR",
"LUMA_CONNECTOR": "LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR": "ELASTICSEARCH_CONNECTOR",
"WEBCRAWLER_CONNECTOR": "CRAWLED_URL", # Maps to document type
"BOOKSTACK_CONNECTOR": "BOOKSTACK_CONNECTOR",
"CIRCLEBACK_CONNECTOR": "CIRCLEBACK", # Connector type differs from document type
"OBSIDIAN_CONNECTOR": "OBSIDIAN_CONNECTOR",
"DROPBOX_CONNECTOR": "DROPBOX_FILE", # Connector type differs from document type
"ONEDRIVE_CONNECTOR": "ONEDRIVE_FILE", # Connector type differs from document type
# Generic user-defined MCP server: unlocks the mcp_discovery subagent even
# in a workspace with no hosted-service connectors.
"MCP_CONNECTOR": "MCP_CONNECTOR",
# Composio connectors (unified to native document types).
# Reverse of NATIVE_TO_LEGACY_DOCTYPE in app.db.
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE",

View file

@ -58,7 +58,7 @@ _perf_log = get_perf_logger()
async def create_multi_agent_chat_deep_agent(
llm: BaseChatModel,
search_space_id: int,
workspace_id: int,
db_session: AsyncSession,
connector_service: ConnectorService,
checkpointer: Checkpointer,
@ -68,7 +68,6 @@ async def create_multi_agent_chat_deep_agent(
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,
@ -78,9 +77,9 @@ async def create_multi_agent_chat_deep_agent(
):
"""Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.
``image_gen_model_id`` overrides the search space's image model for
``image_gen_model_id`` overrides the workspace's image model for
this invocation (used by automations to run on their captured model). When
``None``, the ``generate_image`` tool resolves the live search-space pref.
``None``, the ``generate_image`` tool resolves the live workspace pref.
"""
_t_agent_total = time.perf_counter()
@ -89,7 +88,7 @@ async def create_multi_agent_chat_deep_agent(
filesystem_selection = filesystem_selection or FilesystemSelection()
backend_resolver = build_backend_resolver(
filesystem_selection,
search_space_id=search_space_id
workspace_id=workspace_id
if filesystem_selection.mode == FilesystemMode.CLOUD
else None,
)
@ -99,13 +98,11 @@ async def create_multi_agent_chat_deep_agent(
_t0 = time.perf_counter()
try:
connector_types = await connector_service.get_available_connectors(
search_space_id
)
connector_types = await connector_service.get_available_connectors(workspace_id)
available_connectors = map_connectors_to_searchable_types(connector_types)
available_document_types = await connector_service.get_available_document_types(
search_space_id
workspace_id
)
except Exception as e:
@ -136,10 +133,9 @@ async def create_multi_agent_chat_deep_agent(
)
dependencies: dict[str, Any] = {
"search_space_id": search_space_id,
"workspace_id": workspace_id,
"db_session": db_session,
"connector_service": connector_service,
"firecrawl_api_key": firecrawl_api_key,
"user_id": user_id,
"auth_context": auth_context,
"thread_id": thread_id,
@ -155,9 +151,7 @@ async def create_multi_agent_chat_deep_agent(
_t0 = time.perf_counter()
try:
mcp_tools_by_agent = await load_mcp_tools_by_connector(
db_session, search_space_id
)
mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, workspace_id)
except Exception as e:
# Degrade to builtins-only rather than aborting the turn: a transient
# DB or MCP-server hiccup should not deny the user a response.
@ -193,7 +187,7 @@ async def create_multi_agent_chat_deep_agent(
user_allowlist_by_subagent = await fetch_user_allowlist_rulesets(
db_session,
user_id=user_uuid,
search_space_id=search_space_id,
workspace_id=workspace_id,
)
except Exception as e:
logging.warning(
@ -230,6 +224,15 @@ async def create_multi_agent_chat_deep_agent(
additional_tools=list(additional_tools) if additional_tools else None,
)
# Read-only exception to the "main agent is a pure router" stance: the
# context-editing spill placeholder points at read_run/search_run, so the
# main agent needs those tools to follow it. See middleware/stack.py.
from app.agents.chat.multi_agent_chat.subagents.shared.run_reader import (
build_run_reader_tools,
)
tools = [*list(tools), *build_run_reader_tools(workspace_id=workspace_id)]
_flags: AgentFeatureFlags = get_flags()
if _flags.enable_tool_call_repair and INVALID_TOOL_NAME not in {
t.name for t in tools
@ -291,7 +294,7 @@ async def create_multi_agent_chat_deep_agent(
final_system_prompt=final_system_prompt,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_selection.mode,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,

View file

@ -17,12 +17,12 @@ Two backends are provided:
* :class:`BuiltinSkillsBackend` disk-backed read of bundled skills from
``app/agents/shared/skills/builtin/``.
* :class:`SearchSpaceSkillsBackend` a thin read-only wrapper over
* :class:`WorkspaceSkillsBackend` a thin read-only wrapper over
:class:`KBPostgresBackend` that filters notes under the privileged folder
``/documents/_skills/``.
Both backends are intentionally read-only: skill authoring happens out of band
(via filesystem or a search-space-admin route), so we never expose
(via filesystem or a workspace-admin route), so we never expose
``write`` / ``edit`` / ``upload_files``. The base class' ``NotImplementedError``
gives a clean failure mode if anything tries.
"""
@ -181,12 +181,12 @@ class BuiltinSkillsBackend(BackendProtocol):
return responses
class SearchSpaceSkillsBackend(BackendProtocol):
"""Read-only view of search-space-authored skills.
class WorkspaceSkillsBackend(BackendProtocol):
"""Read-only view of workspace-authored skills.
Wraps a :class:`KBPostgresBackend` and only ever reads under the privileged
folder ``/documents/_skills/`` (configurable). The folder is intended to be
writable only by search-space admins; this backend never writes.
writable only by workspace admins; this backend never writes.
The skills middleware expects a layout like::
@ -236,14 +236,14 @@ class SearchSpaceSkillsBackend(BackendProtocol):
# path falls back to ``asyncio.to_thread(...)`` in the base class. We
# keep this stub to satisfy abstract resolution; the middleware calls
# ``als_info``.
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
raise NotImplementedError("WorkspaceSkillsBackend is async-only")
async def als_info(self, path: str) -> list[FileInfo]:
kb_path = self._to_kb(path)
try:
infos = await self._kb.als_info(kb_path)
except Exception as exc: # pragma: no cover - defensive
logger.warning("SearchSpaceSkillsBackend.als_info failed: %s", exc)
logger.warning("WorkspaceSkillsBackend.als_info failed: %s", exc)
return []
remapped: list[FileInfo] = []
for info in infos:
@ -254,7 +254,7 @@ class SearchSpaceSkillsBackend(BackendProtocol):
return remapped
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
raise NotImplementedError("WorkspaceSkillsBackend is async-only")
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
kb_paths = [self._to_kb(p) for p in paths]
@ -274,16 +274,16 @@ SKILLS_SPACE_PREFIX = "/skills/space/"
def build_skills_backend_factory(
*,
builtin_root: Path | str | None = None,
search_space_id: int | None = None,
workspace_id: int | None = None,
) -> Callable[[ToolRuntime], BackendProtocol]:
"""Return a runtime-aware factory for the skills :class:`CompositeBackend`.
When ``search_space_id`` is provided the composite includes a
:class:`SearchSpaceSkillsBackend` route at ``/skills/space/`` over a fresh
When ``workspace_id`` is provided the composite includes a
:class:`WorkspaceSkillsBackend` route at ``/skills/space/`` over a fresh
per-runtime :class:`KBPostgresBackend`, mirroring how
:func:`build_backend_resolver` constructs the main filesystem backend.
When ``search_space_id`` is ``None`` (e.g., desktop-local mode or unit
When ``workspace_id`` is ``None`` (e.g., desktop-local mode or unit
tests) only the bundled :class:`BuiltinSkillsBackend` is exposed.
Returning a factory rather than a fixed instance is intentional: the
@ -293,7 +293,7 @@ def build_skills_backend_factory(
"""
builtin = BuiltinSkillsBackend(builtin_root)
if search_space_id is None:
if workspace_id is None:
def _factory_builtin_only(runtime: ToolRuntime) -> BackendProtocol:
# Default StateBackend is intentionally inert: any path outside the
@ -314,8 +314,8 @@ def build_skills_backend_factory(
KBPostgresBackend,
)
kb = KBPostgresBackend(search_space_id, runtime)
space = SearchSpaceSkillsBackend(kb)
kb = KBPostgresBackend(workspace_id, runtime)
space = WorkspaceSkillsBackend(kb)
return CompositeBackend(
default=StateBackend(runtime),
routes={
@ -336,7 +336,7 @@ __all__ = [
"SKILLS_BUILTIN_PREFIX",
"SKILLS_SPACE_PREFIX",
"BuiltinSkillsBackend",
"SearchSpaceSkillsBackend",
"WorkspaceSkillsBackend",
"build_skills_backend_factory",
"default_skills_sources",
]

View file

@ -1,7 +1,7 @@
---
name: kb-research
description: Structured approach to finding and synthesizing information from the user's knowledge base
allowed-tools: scrape_webpage, read_file, ls_tree, grep, web_search
allowed-tools: read_file, ls_tree, grep
---
# Knowledge-base research

View file

@ -1,7 +1,7 @@
---
name: meeting-prep
description: Pull together briefing materials before a scheduled meeting
allowed-tools: web_search, scrape_webpage, read_file
allowed-tools: task, read_file
---
# Meeting preparation
@ -12,11 +12,11 @@ The user mentions an upcoming meeting, call, or interview and asks you to "prep"
## Output structure
Always produce these sections (omit any with no signal — don't pad):
1. **Attendees & context** — who's in the room, their roles, what they care about. Pull from KB notes about prior interactions; supplement with public profile facts via `web_search` when names or companies are unfamiliar.
1. **Attendees & context** — who's in the room, their roles, what they care about. Pull from KB notes about prior interactions; supplement with public profile facts via `task(google_search, ...)` when names or companies are unfamiliar.
2. **Open threads** — outstanding action items, unresolved decisions, last-mentioned blockers from prior conversation history.
3. **Recent moves** — within the last 30 days: relevant launches, hires, news. Cite KB chunks when present, otherwise external sources.
4. **Suggested questions** — 3-5 questions the user could ask, tailored to the open threads and the attendees' likely priorities.
## Source ordering
- Always check the user's KB **first** for prior meeting notes, internal docs, or Slack threads about these attendees.
- Only fall back to `web_search` for *publicly verifiable* facts — never to fabricate a participant's preferences or relationships.
- Only fall back to `task(google_search, ...)` for *publicly verifiable* facts — never to fabricate a participant's preferences or relationships.

View file

@ -1,8 +1,8 @@
<citations>
Cite with one token: the bracket label `[n]`. Every citable result —
`web_search` results and prose from a `task` knowledge_base/research
specialist (including the knowledge_base specialist's `[n]`-labelled
workspace findings) — already carries `[n]` labels on a single shared count.
prose from a `task` knowledge_base/research specialist (including the
knowledge_base specialist's `[n]`-labelled workspace findings) — already
carries `[n]` labels on a single shared count.
Those labels are the only citation you write; the server resolves each one
back to its source after the turn.

View file

@ -1,8 +1,23 @@
<agent_identity>
You are **SurfSense's main agent**. Your job is to answer the user using their
knowledge base, lightweight web research, persistent memory, and **specialist
subagents** invoked via the `task` tool. You are an orchestrator — most
non-trivial work belongs on a specialist.
You are **SurfSense's main agent**, the orchestrator of an open-source
competitive intelligence platform. Users come to you to understand their
market: what competitors are doing, how audiences react, where rankings and
reviews are moving, and what is being said across the open web — and to put
that intelligence to work alongside their own knowledge base.
You do this by dispatching **specialist subagents** via the `task` tool:
- **Live market data** — Reddit, YouTube, Google Maps, Google Search, and the
web crawler return structured, current platform data (posts, comments,
transcripts, reviews, SERPs, full page content).
- **The user's own context** — their knowledge base, connected apps, and
persistent memory.
- **Deliverables** — reports, podcasts, and presentations built from what the
specialists find.
You are an orchestrator — most non-trivial work belongs on a specialist. Your
value is routing each request to the right specialist, synthesizing evidence
across sources, and answering with what the data shows rather than what you
assume.
Today (UTC): {resolved_today}
</agent_identity>

View file

@ -1,8 +1,23 @@
<agent_identity>
You are **SurfSense's main agent**. Your job is to answer the user using their
shared team knowledge base, lightweight web research, persistent memory, and
**specialist subagents** invoked via the `task` tool. You are an orchestrator
— most non-trivial work belongs on a specialist.
You are **SurfSense's main agent**, the orchestrator of an open-source
competitive intelligence platform. This team comes to you to understand its
market: what competitors are doing, how audiences react, where rankings and
reviews are moving, and what is being said across the open web — and to put
that intelligence to work alongside the team's shared knowledge base.
You do this by dispatching **specialist subagents** via the `task` tool:
- **Live market data** — Reddit, YouTube, Google Maps, Google Search, and the
web crawler return structured, current platform data (posts, comments,
transcripts, reviews, SERPs, full page content).
- **The team's own context** — its shared knowledge base, connected apps, and
persistent team memory.
- **Deliverables** — reports, podcasts, and presentations built from what the
specialists find.
You are an orchestrator — most non-trivial work belongs on a specialist. Your
value is routing each request to the right specialist, synthesizing evidence
across sources, and answering with what the data shows rather than what you
assume.
Today (UTC): {resolved_today}

View file

@ -1,18 +1,27 @@
<knowledge_base_first>
CRITICAL — ground factual answers in what you actually receive this turn:
- **live platform data** via the market specialists —
`task(reddit, ...)`, `task(youtube, ...)`, `task(google_maps, ...)`,
`task(google_search, ...)`, `task(web_crawler, ...)`. Anything about
competitors, markets, rankings, reviews, or audience sentiment is answered
from what these return **this turn**, never from your training data: your
general knowledge of companies, prices, and rankings is stale by definition,
- the user's knowledge base via `task(knowledge_base, ...)` (your PRIMARY
source for anything about their documents, notes, or connected data — the
`<workspace_tree>` only lists what exists, so delegate to the specialist to
search and read the actual content before answering),
source for anything about their own uploaded files, documents, and notes —
the `<workspace_tree>` only lists what exists, so delegate to the specialist
to search and read the actual content before answering),
- injected workspace context (see `<dynamic_context>`),
- results from your other tool calls (`web_search`, `scrape_webpage`),
- the user's connected apps via `task(mcp_discovery, ...)` (Slack, Jira,
Notion, Gmail, Calendar, etc. — live data that is NOT in the knowledge base),
- or substantive summaries returned by a `task` specialist you invoked.
For questions about the user's own workspace, dispatch
For questions about the user's own files and notes, dispatch
`task(knowledge_base, ...)` first rather than answering from the tree or from
memory. The knowledge_base specialist runs hybrid semantic/keyword search and
full-document reads, then returns a grounded summary with `[n]` citation
labels for you to carry through into your answer.
full-document reads over their personal files and notes, then returns a
grounded summary with `[n]` citation labels for you to carry into your answer.
For anything living in a connected third-party app, use
`task(mcp_discovery, ...)` instead — that content is not indexed in the KB.
Do **not** answer factual or informational questions from general knowledge
unless the user explicitly authorises it after you say you couldn't find

View file

@ -5,7 +5,7 @@ Structured reasoning:
- For non-trivial work, `<thinking>` / short `<plan>` before tool calls is fine.
Professional objectivity:
- Accuracy over flattery; verify with **web_search**, **scrape_webpage**, or **task** when unsure — dont invent connector access.
- Accuracy over flattery; verify with **task** (e.g. `task(web_crawler, …)` to read a page, `task(google_search, …)` for public facts) when unsure — dont invent connector access.
Task management:
- For 3+ steps, use todo tooling; update statuses promptly.

View file

@ -16,6 +16,6 @@ Output style:
Tool calls:
- Parallelise independent calls in one turn.
- For SurfSense-product questions, point the user to https://www.surfsense.com/docs;
use **web_search** / **scrape_webpage** for fresh public facts; integrations and
use **task(google_search, …)** / **task(web_crawler, …)** for fresh public facts; integrations and
heavy workflows → **task**.
</provider_hints>

View file

@ -1,4 +1,5 @@
<reminder>
Concise · KB-grounded · delegation-first · one `task` per turn · no direct
filesystem · persist memory when durable facts appear.
Concise · grounded in this turn's specialist data, never stale general
knowledge · delegation-first · no direct filesystem · persist memory when
durable facts appear.
</reminder>

View file

@ -3,8 +3,6 @@ You have two execution channels. Pick the one that owns the work — never
simulate one with the other.
### 1. Direct tools (you call them yourself)
- `web_search` — search the public web (anything outside the workspace KB).
- `scrape_webpage` — fetch the body of a specific public URL.
- `update_memory` — curate persistent memory (see `<memory_protocol>`).
- `write_todos` — maintain a structured plan when the turn series spans
multiple specialists or steps. Mark each item
@ -15,6 +13,63 @@ simulate one with the other.
connectors, feature behavior) — point the user to the documentation:
https://www.surfsense.com/docs. There is no docs-search tool; give the link.
**Search discovers — the crawler reads.** Search results (snippets, AI
overviews, a specialist's summary of a SERP) are pointers, not sources.
When the answer lives on a page — a team roster, a portfolio or directory
listing, a pricing table, docs — fetch the page before answering:
- One or a few known URLs → `task(web_crawler, …)` with those URLs (it
fetches only the seeds at `maxCrawlDepth=0`).
- A site section or many pages (a whole team + portfolio, every pricing
page of a list of companies, a paginated directory) →
`task(web_crawler, …)` with the seed URLs and a higher depth.
Never answer with "you can find it at <URL>" for public facts your tools
can retrieve — retrieve them, then answer with the facts and cite the page.
Large results are fine: extract and return them, don't ask permission for
bounded fan-out (≤20 sites) the user already requested.
**Audience sentiment lives on the platforms.** What people *say and feel*
about a brand, product, or topic is answered from the platform where they
say it — `task(reddit, …)` for community discussion and threads,
`task(youtube, …)` for video content, transcripts, and comment sections,
`task(google_maps, …)` for customer reviews of physical businesses. Web
search only finds articles *about* the conversation; the platform
specialists return the conversation itself, structured and current. For
competitive questions ("what are people saying about X", "how is Y
reviewed", "monitor Z"), go to the platform specialists first and cite
what they return.
**Places go to Maps, the open web goes to Search.** Discovering physical
businesses or venues of a type in a geography ("clinics in X", "tutoring
centers near Y", lead lists of local businesses) is the Maps specialist's
job — it returns structured name/address/phone/website per place, where
web search returns only snippets that need a second pass. Use the Search
specialist for entities without a storefront (online-only companies,
software vendors, publications), for facts and current events, and to
enrich places Maps already found. When a lead list needs both, run Maps
discovery first, then crawl (`task(web_crawler, …)`) or search the found
websites for contacts.
**Requested-N lists count distinct entities that fit the ask.** When the
user asks for N leads/items/results, every entry must be a distinct
*entity* — multiple branches, locations, sub-programs, or pages of the
same brand or parent organization are ONE entry, not several. A website
domain is an ownership signal: entries whose pages live on the same parent
domain (a government portal, a chain's site, a franchise system) share
that parent and count as one, judged against the parent. Entries must
also fit the user's stated segment: an item that belongs to an excluded
category (e.g. a local branch of a large chain when the user asked for
independents) does not qualify even if a specialist returned it — drop it,
don't relay it. If qualifying results fall short of N, widen the discovery
(another specialist call, adjacent geography or segment) to fill the gap
honestly; if it still falls short, deliver the smaller list with a
one-line note. An honest 10 beats a padded 15.
**Full datasets become files, not chat.** When the user wants a complete
large dataset (an entire roster, portfolio, or directory — or asks for a
CSV/file), do not paste or summarize hundreds of rows: instruct the
web_crawler specialist to crawl and then save the data with its
`export_run` CSV tool, and relay the saved workspace path and row count.
**You have NO filesystem tools.** Any read, write, edit, move, rename, or
search inside the user's workspace goes through `task(knowledge_base, …)`
never via `write_file`, `ls`, or any direct file operation.
@ -64,13 +119,42 @@ user: "Save these meeting notes to my KB: …"
<example>
user: "What did Maya say about the Q2 roadmap in Slack last week?"
→ task(slack, "Find messages from Maya about the Q2 roadmap from the past
week. Return the most relevant quotes with channel and timestamp.")
→ task(mcp_discovery, "In Slack, find messages from Maya about the Q2 roadmap
from the past week. Return the most relevant quotes with channel and
timestamp.")
</example>
<example>
user: "What are people saying about Cursor vs Windsurf lately?"
→ Audience sentiment — go to the platform, not web search. Independent
sources, so parallel `task` calls:
task(reddit, "Search Reddit for recent discussion comparing Cursor and
Windsurf (past month, sort by top). Return the strongest quotes with
subreddit, score, and post URL, and summarise which way sentiment
leans and why.")
task(youtube, "Find recent YouTube videos comparing Cursor and Windsurf.
For the top results return title, channel, views, publish date, and
the main takeaways from each (use subtitles where available).")
Then synthesise both into one answer, attributing claims to their source.
</example>
<example>
user: "What's the current USD/INR rate?"
→ web_search(query="current USD to INR exchange rate")
→ Public web lookup — delegate to the Google Search specialist:
task(google_search, "Search Google for the current USD to INR exchange
rate and return the rate with its source URL.")
</example>
<example>
user: "Get the a16z team and their portfolio companies."
→ Search only *locates* a16z.com/team/ and their investment list — the
answer is the CONTENT of those pages. Crawl them and return the extracted
people and companies, never just the links:
task(web_crawler, "Crawl https://a16z.com/team/ and
https://a16z.com/investment-list/ and return (1) the full team roster
with each person's name and role/department, and (2) the complete
portfolio company list. Use the pages' link records if the markdown
is sparse.")
</example>
<example>
@ -82,15 +166,16 @@ user: "Find my Q2 roadmap and summarise the milestones."
<example>
user: "Create a ClickUp ticket and a Linear ticket for the new feature flag."
→ Independent work — call both specialists in parallel:
→ Independent work, same specialist (connected apps) with non-overlapping
scopes — call it twice in parallel, naming the target app in each prompt:
write_todos([
{content: "Create ClickUp ticket for feature flag rollout", status: "in_progress"},
{content: "Create Linear ticket for feature flag rollout", status: "in_progress"},
])
task(clickup, "Create a ClickUp ticket titled 'Feature flag rollout'
in the default list. Description: <…>. Tell me the ticket URL.")
task(linear, "Create a Linear ticket titled 'Feature flag rollout'
in the default team. Description: <…>. Tell me the ticket URL.")
task(mcp_discovery, "In ClickUp, create a ticket titled 'Feature flag
rollout' in the default list. Description: <…>. Tell me the ticket URL.")
task(mcp_discovery, "In Linear, create a ticket titled 'Feature flag
rollout' in the default team. Description: <…>. Tell me the ticket URL.")
</example>
<example>
@ -100,24 +185,25 @@ user: "Find my Q2 roadmap doc in the KB and email a summary to Maya."
task(knowledge_base, "Find the Q2 roadmap document under /documents
and return its full text plus a 3-bullet summary.")
Next turn (with the returned summary in hand):
task(gmail, "Send an email to Maya with subject 'Q2 roadmap summary'
and the following body: <summary returned by knowledge_base>.")
task(mcp_discovery, "In Gmail, send an email to Maya with subject 'Q2
roadmap summary' and the following body: <summary returned by
knowledge_base>.")
</example>
<example>
user: "Create issues in Linear for each of these five bugs: <list>"
→ Many-shot independent fanout — use the batch shape:
task(tasks=[
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 1 title>' with body '<bug 1 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 2 title>' with body '<bug 2 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 3 title>' with body '<bug 3 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 4 title>' with body '<bug 4 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 5 title>' with body '<bug 5 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 1 title>' with body '<bug 1 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 2 title>' with body '<bug 2 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 3 title>' with body '<bug 3 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 4 title>' with body '<bug 4 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 5 title>' with body '<bug 5 body>'. Return the issue URL."},
])
Read back the `[task 0]``[task 4]` blocks in the combined ToolMessage and
verify each via its Receipt's `verifiable_url` per the `<verification>`
@ -154,16 +240,18 @@ user: "Make a 30-second podcast of this conversation."
<example>
user: "Post the launch announcement to #general and let me know when it's up."
→ Mutating subagent + user wants external confirmation. Apply the
`<verification>` teaching: the slack subagent's reply is a self-report;
check its `evidence.receipts` for a Receipt with `status="success"` and
a `verifiable_url`, then fetch that URL to confirm before reporting back.
`<verification>` teaching: the connected-apps subagent's reply is a
self-report; check its `evidence.receipts` for a Receipt with
`status="success"` and a `verifiable_url`, then fetch that URL to confirm
before reporting back.
This turn:
task(slack, "Post '<launch announcement text>' to #general.
Return the message permalink.")
task(mcp_discovery, "In Slack, post '<launch announcement text>' to
#general. Return the message permalink.")
Next turn (with the receipt's `verifiable_url` in hand):
scrape_webpage(url=<verifiable_url from slack receipt>)
task(web_crawler, "Crawl <verifiable_url from the receipt> and confirm
the post is live; return what you find.")
→ confirm the post is live, then tell the user it's up with the URL.
If the slack reply has NO Receipt with `status="success"`, treat it as a
If the reply has NO Receipt with `status="success"`, treat it as a
silent failure: surface the error verbatim, do not retry.
</example>
</routing>

View file

@ -1 +0,0 @@
"""``scrape_webpage`` — description + few-shot examples."""

View file

@ -1,11 +0,0 @@
- `scrape_webpage` — Fetch and extract readable content from a single URL.
- Use when the user wants the actual page body (article, table, dashboard
snapshot), not just search snippets.
- Try the tool when a URL is given or referenced; don't refuse without
attempting unless the URL is clearly unsafe or invalid.
- Public web only. For URLs behind a connector (Notion pages, Linear
issues, Confluence, anything that needs auth), use `task` with the
matching specialist instead.
- Args: `url`, `max_length` (default 50000).
- Returns title, metadata, and markdown-ish body. Summarise clearly and
link back with `[label](url)`.

View file

@ -1,24 +0,0 @@
<example>
user: "Check out https://dev.to/some-article"
→ scrape_webpage(url="https://dev.to/some-article")
(Respond with a structured analysis — key points, takeaways.)
</example>
<example>
user: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
→ scrape_webpage(url="https://example.com/blog/ai-trends")
(Thorough summary using headings and bullets.)
</example>
<example>
user: (after discussing https://example.com/stats) "Can you get the live data from that page?"
→ scrape_webpage(url="https://example.com/stats")
(Always attempt scraping first. Never refuse before trying.)
</example>
<example>
user: "https://example.com/blog/weekend-recipes"
→ 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.)
</example>

View file

@ -43,9 +43,9 @@
like podcasts/videos), the operation did not happen — treat as
failure and surface that to the user verbatim, do not retry blindly.
2. **`scrape_webpage`** — when a Receipt carries a `verifiable_url`
2. **`task(web_crawler, …)`** — when a Receipt carries a `verifiable_url`
(Notion page URL, Slack permalink, Jira issue URL, Linear identifier
URL, etc.), you can fetch that URL and confirm the operation
URL, etc.), you can crawl that URL and confirm the operation
externally. Use this for high-stakes mutations the user explicitly
called out (e.g. "send the launch email to the whole team") or when
the subagent's self-report contradicts what the user expected.
@ -54,7 +54,7 @@
- `status="success"`: the mutation already committed in the backend.
If a `verifiable_url` is present and the request was high-stakes,
you may `scrape_webpage` it to externally confirm. Otherwise trust
you may crawl it via `task(web_crawler, …)` to externally confirm. Otherwise trust
the Receipt and tell the user it is done. Celery-backed deliverables
(podcasts, video presentations) also land here — the subagent
already waited for the worker to finish, so a `success` Receipt
@ -67,6 +67,6 @@
their backend before returning. If you ever do see a pending
Receipt, tell the user the work has been **kicked off** (quote the
`external_id` / `preview` so they can find it later), do not
`scrape_webpage` it, and do not re-dispatch the same
crawl it, and do not re-dispatch the same
`task(...)` call hoping it will be done "this time".
</verification>

View file

@ -7,9 +7,9 @@ user: "Save these meeting notes to my KB: …"
<example>
user: "What did Maya say about the Q2 roadmap in Slack last week?"
→ task(subagent_type="slack", description="Find messages from Maya about
the Q2 roadmap from the past week. Return the most relevant quotes with
channel and timestamp.")
→ task(subagent_type="mcp_discovery", description="In Slack, find messages
from Maya about the Q2 roadmap from the past week. Return the most relevant
quotes with channel and timestamp.")
</example>
<example>

View file

@ -1,5 +1,5 @@
- `update_memory` — Curate the team's **shared** long-term memory document
for this search space.
for this workspace.
- The current memory (if any) appears in `<team_memory>` with usage vs limit.
- Call when a team member asks to remember or forget something, or when
the conversation surfaces durable team decisions, conventions,

View file

@ -1 +0,0 @@
"""``web_search`` — description + few-shot examples."""

View file

@ -1,13 +0,0 @@
- `web_search` — Search the public web.
- Use whenever an answer benefits from external sources — current events,
prices, weather, news, technical references, definitions, background
facts, anything outside SurfSense docs and the workspace KB. Reach for
it whenever freshness matters or you'd otherwise guess from memory.
- Don't refuse with "I lack network access" — call the tool.
- Returns a `<web_results>` block: each result is labelled `[n]`. Cite a
result by writing that `[n]` after the statement it supports (when
citations are enabled) — do not hand-write the URL as a markdown link.
- If results are thin, say so and offer to refine the query.
- Args: `query`, `top_k` (default 10, max 50).
- Follow up with `scrape_webpage` on the best URL when snippets are too
shallow.

View file

@ -1,15 +0,0 @@
<example>
user: "What's the current USD to INR exchange rate?"
→ web_search(query="current USD to INR exchange rate")
(Answer from snippets; scrape a top URL if needed.)
</example>
<example>
user: "What's the latest news about AI?"
→ web_search(query="latest AI news today")
</example>
<example>
user: "What's the weather in New York?"
→ web_search(query="weather New York today")
</example>

View file

@ -45,14 +45,14 @@ _JSON_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL)
def create_create_automation_tool(
*,
search_space_id: int,
workspace_id: int,
user_id: str | UUID,
llm: Any,
auth_context: AuthContext | None = None,
):
"""Factory for the ``create_automation`` tool.
``search_space_id`` is injected from the chat session (the model never
``workspace_id`` is injected from the chat session (the model never
has to guess it). ``llm`` is the drafting sub-model we reuse the main
agent's LLM and tag the call so it's identifiable in traces. A fresh
``AsyncSession`` is opened per call to avoid stale sessions on
@ -101,12 +101,12 @@ def create_create_automation_tool(
"""
# Models are chosen per-automation on the approval card (premium/BYOK
# selectors) and validated when persisted by ``AutomationService.create``
# — so there's no fail-fast search-space eligibility gate here. The
# search space's current chat/role model selection no longer constrains
# — so there's no fail-fast workspace eligibility gate here. The
# workspace's current chat/role model selection no longer constrains
# whether an automation can be drafted or saved.
# --- 1. Draft via sub-LLM ---
prompt = build_draft_prompt(search_space_id=search_space_id, intent=intent)
prompt = build_draft_prompt(workspace_id=workspace_id, intent=intent)
try:
response = await llm.ainvoke(
[HumanMessage(content=prompt)],
@ -125,8 +125,8 @@ def create_create_automation_tool(
"raw": raw_text,
}
# search_space_id is injected here so the sub-LLM never has to guess.
draft["search_space_id"] = search_space_id
# workspace_id is injected here so the sub-LLM never has to guess.
draft["workspace_id"] = workspace_id
try:
validated_draft = AutomationCreate.model_validate(draft)
except ValidationError as exc:
@ -139,14 +139,14 @@ def create_create_automation_tool(
# --- 2. HITL approval card ---
try:
card_params = validated_draft.model_dump(mode="json", by_alias=True)
# search_space_id is session-scoped, not user-editable.
card_params.pop("search_space_id", None)
# workspace_id is session-scoped, not user-editable.
card_params.pop("workspace_id", None)
result = request_approval(
action_type="automation_create",
tool_name="create_automation",
params=card_params,
context={"search_space_id": search_space_id},
context={"workspace_id": workspace_id},
tool_call_id=runtime.tool_call_id,
)
@ -157,7 +157,7 @@ def create_create_automation_tool(
}
# --- 3. Persist (re-validate in case the user edited) ---
final_payload = {**result.params, "search_space_id": search_space_id}
final_payload = {**result.params, "workspace_id": workspace_id}
try:
final_validated = AutomationCreate.model_validate(final_payload)
except ValidationError as exc:

View file

@ -34,7 +34,7 @@ into a SINGLE JSON object matching the AutomationCreate schema. Output
ONLY that JSON object no prose, no markdown fence, no commentary.
Current UTC time (for cron context): {now}
Target search_space_id: {search_space_id}
Target workspace_id: {workspace_id}
"""
@ -165,12 +165,12 @@ User intent:
"""
def build_draft_prompt(*, search_space_id: int, intent: str) -> str:
def build_draft_prompt(*, workspace_id: int, intent: str) -> str:
"""Render the drafting sub-LLM system prompt for the given intent."""
return (
_HEADER.format(
now=datetime.now(UTC).isoformat(timespec="seconds"),
search_space_id=search_space_id,
workspace_id=workspace_id,
)
+ _SCHEMA
+ _FEW_SHOTS

View file

@ -6,8 +6,6 @@ Connector integrations, MCP, deliverables, etc. are delegated via ``task`` subag
from __future__ import annotations
MAIN_AGENT_SURFSENSE_TOOL_NAMES_ORDERED: tuple[str, ...] = (
"web_search",
"scrape_webpage",
"update_memory",
"create_automation",
)

View file

@ -21,27 +21,14 @@ from typing import Any
from langchain_core.tools import BaseTool
from app.agents.chat.shared.tools.web_search import create_web_search_tool
from app.db import ChatVisibility
from .scrape_webpage import create_scrape_webpage_tool
from .update_memory import (
create_update_memory_tool,
create_update_team_memory_tool,
)
def _build_scrape_webpage_tool(deps: dict[str, Any]) -> BaseTool:
return create_scrape_webpage_tool(firecrawl_api_key=deps.get("firecrawl_api_key"))
def _build_web_search_tool(deps: dict[str, Any]) -> BaseTool:
return create_web_search_tool(
search_space_id=deps.get("search_space_id"),
available_connectors=deps.get("available_connectors"),
)
def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
# Deferred import: the automation package is a sibling under ``main_agent``
# and is only needed at build time, mirroring the shared registry's
@ -49,7 +36,7 @@ def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
from .automation import create_create_automation_tool
return create_create_automation_tool(
search_space_id=deps["search_space_id"],
workspace_id=deps["workspace_id"],
user_id=deps["user_id"],
auth_context=deps.get("auth_context"),
llm=deps["llm"],
@ -59,7 +46,7 @@ def _build_create_automation_tool(deps: dict[str, Any]) -> BaseTool:
def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool:
if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE:
return create_update_team_memory_tool(
search_space_id=deps["search_space_id"],
workspace_id=deps["workspace_id"],
db_session=deps["db_session"],
llm=deps.get("llm"),
)
@ -71,20 +58,18 @@ def _build_update_memory_tool(deps: dict[str, Any]) -> BaseTool:
# Ordered to match the historical main-agent binding order:
# scrape_webpage, web_search, create_automation, update_memory.
# create_automation, update_memory.
# Each entry is ``(factory, required_dependency_names)``.
_MAIN_AGENT_TOOL_FACTORIES: dict[
str, tuple[Callable[[dict[str, Any]], BaseTool], tuple[str, ...]]
] = {
"scrape_webpage": (_build_scrape_webpage_tool, ()),
"web_search": (_build_web_search_tool, ()),
"create_automation": (
_build_create_automation_tool,
("search_space_id", "user_id", "llm"),
("workspace_id", "user_id", "llm"),
),
"update_memory": (
_build_update_memory_tool,
("user_id", "search_space_id", "db_session", "thread_visibility", "llm"),
("user_id", "workspace_id", "db_session", "thread_visibility", "llm"),
),
}

View file

@ -1,303 +0,0 @@
"""
Web scraping tool for the SurfSense agent.
This module provides a tool for scraping and extracting content from webpages
using the existing WebCrawlerConnector. For YouTube URLs, it fetches the
transcript directly via the YouTubeTranscriptApi instead of crawling the page.
"""
import hashlib
import logging
import time
from typing import Any
from urllib.parse import urlparse
from fake_useragent import UserAgent
from langchain_core.tools import tool
from requests import Session
from scrapling.fetchers import AsyncFetcher
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 import get_proxy_url, 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
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
# Prefer truncating at a sentence/paragraph boundary.
truncated = content[:max_length]
last_period = truncated.rfind(".")
last_newline = truncated.rfind("\n\n")
boundary = max(last_period, last_newline)
if boundary > max_length * 0.8: # only if the boundary isn't 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:
oembed_fetch_start = time.perf_counter()
oembed_page = await AsyncFetcher.get(
oembed_url,
params=params,
proxy=get_proxy_url(),
stealthy_headers=True,
)
logger.info(
"[scrape_webpage][perf] source=oembed video=%s status=%s fetch_ms=%.1f",
video_id,
getattr(oembed_page, "status", None),
(time.perf_counter() - oembed_fetch_start) * 1000,
)
video_data = oembed_page.json()
except Exception:
video_data = {}
title = video_data.get("title", "YouTube Video")
author = video_data.get("author_name", "Unknown")
# --- Transcript via YouTubeTranscriptApi ---
try:
transcript_fetch_start = time.perf_counter()
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)
# Pick the first transcript (video's primary language) rather than
# defaulting to English.
transcript_list = ytt_api.list(video_id)
transcript = next(iter(transcript_list))
captions = transcript.fetch()
logger.info(
"[scrape_webpage][perf] source=transcript video=%s fetch_ms=%.1f",
video_id,
(time.perf_counter() - transcript_fetch_start) * 1000,
)
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}"
content = f"# {title}\n\n**Author:** {author}\n**Video ID:** {video_id}\n\n## Transcript\n\n{transcript_text}"
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)
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
try:
# YouTube URLs use the transcript API instead of crawling.
video_id = get_youtube_video_id(url)
if video_id:
return await _scrape_youtube_video(url, video_id, max_length)
connector = WebCrawlerConnector(firecrawl_api_key=firecrawl_api_key)
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",
}
content = result.get("content", "")
metadata = result.get("metadata", {})
title = metadata.get("title", "")
if not title:
title = domain or url.split("/")[-1] or "Webpage"
description = metadata.get("description", "")
if not description and content:
first_para = content.split("\n\n")[0] if content else ""
description = (
first_para[:300] + "..." if len(first_para) > 300 else first_para
)
content, was_truncated = truncate_content(content, max_length)
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

@ -53,7 +53,7 @@ def create_update_memory_tool(
def create_update_team_memory_tool(
search_space_id: int,
workspace_id: int,
db_session: AsyncSession,
llm: Any | None = None,
):
@ -62,7 +62,7 @@ def create_update_team_memory_tool(
@tool
async def update_memory(updated_memory: str) -> dict[str, Any]:
"""Update the team's shared memory document for this search space.
"""Update the team's shared memory document for this workspace.
The current team memory is shown in <team_memory>. Pass the FULL updated
markdown document, not a diff.
@ -71,7 +71,7 @@ def create_update_team_memory_tool(
async with async_session_maker() as db_session:
result = await save_memory(
scope=MemoryScope.TEAM,
target_id=search_space_id,
target_id=workspace_id,
content=updated_memory,
session=db_session,
llm=llm,

View file

@ -1,9 +1,8 @@
"""Render citable documents for the model: one shape for search, read, and web.
"""Render citable documents for the model: one shape for search and read.
``render_document`` emits one ``<document title= source= view="excerpt|full">``
block whose passages carry server-assigned ``[n]`` labels. ``render_search_context``
wraps KB excerpt blocks in ``<retrieved_context>``; ``render_web_results`` wraps web
excerpt blocks in ``<web_results>``. Both cite with the same ``[n]`` spine.
wraps KB excerpt blocks in ``<retrieved_context>`` and cites with the ``[n]`` spine.
"""
from __future__ import annotations
@ -12,7 +11,6 @@ from .document import render_document
from .models import DocumentView, RenderableDocument, RenderablePassage
from .search_context import render_search_context
from .source_label import source_label
from .web_results import render_web_results
__all__ = [
"DocumentView",
@ -20,6 +18,5 @@ __all__ = [
"RenderablePassage",
"render_document",
"render_search_context",
"render_web_results",
"source_label",
]

View file

@ -1,46 +0,0 @@
"""Wrap live web-search results in a ``<web_results>`` block.
Each result renders through the shared ``render_document`` (excerpt view), so a
web result is cited with ``[n]`` exactly like a knowledge-base passage. Only the
container and header differ they tell the model these came from the public web,
not the user's workspace.
"""
from __future__ import annotations
from app.agents.chat.multi_agent_chat.shared.citations import CitationRegistry
from .document import render_document
from .models import RenderableDocument
_HEADER = (
"These are live results from a public web search for this query. Each\n"
"<document> below is one result in excerpt view; cite a result with its [n]\n"
"after the statement it supports. Scrape the URL for full context before\n"
"making a definitive claim from a snippet."
)
def render_web_results(
documents: list[RenderableDocument],
registry: CitationRegistry,
) -> str | None:
"""Render web results as excerpt blocks inside ``<web_results>``.
Returns ``None`` when no result has content to show, so the caller can skip
the block. Mutates ``registry`` (find-or-create), so a URL seen again keeps
its original ``[n]``.
"""
blocks = [
block
for document in documents
if (block := render_document(document, view="excerpt", registry=registry))
is not None
]
if not blocks:
return None
return "<web_results>\n" + _HEADER + "\n" + "\n".join(blocks) + "\n</web_results>"
__all__ = ["render_web_results"]

View file

@ -120,8 +120,8 @@ class KBPostgresBackend(BackendProtocol):
_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
def __init__(self, search_space_id: int, runtime: ToolRuntime) -> None:
self.search_space_id = search_space_id
def __init__(self, workspace_id: int, runtime: ToolRuntime) -> None:
self.workspace_id = workspace_id
self.runtime = runtime
@property
@ -405,7 +405,7 @@ class KBPostgresBackend(BackendProtocol):
if not normalized_path.startswith(DOCUMENTS_ROOT):
return [], set()
index = await build_path_index(session, self.search_space_id)
index = await build_path_index(session, self.workspace_id)
target_folder_id: int | None = None
if normalized_path != DOCUMENTS_ROOT:
target_path = normalized_path
@ -418,7 +418,7 @@ class KBPostgresBackend(BackendProtocol):
result = await session.execute(
select(Document.id, Document.title, Document.folder_id, Document.updated_at)
.where(Document.search_space_id == self.search_space_id)
.where(Document.workspace_id == self.workspace_id)
.where(
Document.folder_id == target_folder_id
if target_folder_id is not None
@ -519,7 +519,7 @@ class KBPostgresBackend(BackendProtocol):
async with shielded_async_session() as session:
document_row = await virtual_path_to_doc(
session,
search_space_id=self.search_space_id,
workspace_id=self.workspace_id,
virtual_path=path,
)
if document_row is None:
@ -667,10 +667,10 @@ class KBPostgresBackend(BackendProtocol):
if normalized.startswith(DOCUMENTS_ROOT) or normalized == "/":
try:
async with shielded_async_session() as session:
index = await build_path_index(session, self.search_space_id)
index = await build_path_index(session, self.workspace_id)
rows = await session.execute(
select(Document.id, Document.title, Document.folder_id).where(
Document.search_space_id == self.search_space_id
Document.workspace_id == self.workspace_id
)
)
for row in rows.all():
@ -747,11 +747,11 @@ class KBPostgresBackend(BackendProtocol):
if normalized.startswith(DOCUMENTS_ROOT) or normalized == "/":
try:
async with shielded_async_session() as session:
index = await build_path_index(session, self.search_space_id)
index = await build_path_index(session, self.workspace_id)
sub = (
select(Chunk.document_id, Chunk.id, Chunk.content)
.join(Document, Document.id == Chunk.document_id)
.where(Document.search_space_id == self.search_space_id)
.where(Document.workspace_id == self.workspace_id)
.where(Chunk.content.ilike(f"%{pattern}%"))
.order_by(Chunk.document_id, Chunk.position, Chunk.id)
)
@ -849,14 +849,14 @@ class KBPostgresBackend(BackendProtocol):
try:
async with shielded_async_session() as session:
index = await build_path_index(session, self.search_space_id)
index = await build_path_index(session, self.workspace_id)
doc_rows_raw = await session.execute(
select(
Document.id,
Document.title,
Document.folder_id,
Document.updated_at,
).where(Document.search_space_id == self.search_space_id)
).where(Document.workspace_id == self.workspace_id)
)
doc_rows = list(doc_rows_raw.all())
except Exception as exc: # pragma: no cover

View file

@ -31,14 +31,14 @@ def _cached_multi_root_backend(
def build_backend_resolver(
selection: FilesystemSelection,
*,
search_space_id: int | None = None,
workspace_id: int | None = None,
) -> Callable[[ToolRuntime], BackendProtocol]:
"""Create deepagents backend resolver for the selected filesystem mode.
In cloud mode the resolver returns a fresh :class:`KBPostgresBackend`
bound to the current ``runtime`` so the backend can read staging state
(``staged_dirs``, ``pending_moves``, ``files`` cache, ``kb_anon_doc``)
for each tool call. When no ``search_space_id``
for each tool call. When no ``workspace_id``
is provided, the resolver falls back to :class:`StateBackend` (used by
sub-agents and tests that don't need DB-backed reads).
@ -55,10 +55,10 @@ def build_backend_resolver(
return _resolve_local
if search_space_id is not None:
if workspace_id is not None:
def _resolve_kb(runtime: ToolRuntime) -> BackendProtocol:
return KBPostgresBackend(search_space_id, runtime)
return KBPostgresBackend(workspace_id, runtime)
return _resolve_kb

View file

@ -13,7 +13,7 @@ def build_filesystem_mw(
*,
backend_resolver: Any,
filesystem_mode: FilesystemMode,
search_space_id: int,
workspace_id: int,
user_id: str | None,
thread_id: int | None,
read_only: bool = False,
@ -21,7 +21,7 @@ def build_filesystem_mw(
return SurfSenseFilesystemMiddleware(
backend=backend_resolver,
filesystem_mode=filesystem_mode,
search_space_id=search_space_id,
workspace_id=workspace_id,
created_by_id=user_id,
thread_id=thread_id,
read_only=read_only,

View file

@ -49,14 +49,14 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
*,
backend: Any = None,
filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
search_space_id: int | None = None,
workspace_id: int | None = None,
created_by_id: str | None = None,
thread_id: int | str | None = None,
tool_token_limit_before_evict: int | None = 20000,
read_only: bool = False,
) -> None:
self._filesystem_mode = filesystem_mode
self._search_space_id = search_space_id
self._workspace_id = workspace_id
self._created_by_id = created_by_id
self._thread_id = thread_id
self._read_only = read_only

View file

@ -33,10 +33,13 @@ from __future__ import annotations
from typing import Any, Literal, TypedDict
# Subagent that emitted this receipt.
# Subagent that emitted this receipt. ``mcp_discovery`` is the current
# connected-apps route; the per-connector literals below it are retained so
# historical receipts (persisted in old checkpoints) still type-check.
ReceiptRoute = Literal[
"deliverables",
"knowledge_base",
"mcp_discovery",
"notion",
"slack",
"gmail",
@ -102,7 +105,7 @@ class Receipt(TypedDict, total=False):
``None`` only when the operation failed before the backend assigned one."""
verifiable_url: str | None
"""URL the parent can pass to ``scrape_webpage`` to verify the
"""URL the parent can crawl (via ``task(web_crawler, …)``) to verify the
operation. ``None`` when no public URL exists (Gmail, KB, raw images
stored in the DB)."""

View file

@ -1,18 +1,17 @@
"""Knowledge-base retrieval: hybrid search rendered as citable evidence.
Public surface is the service (``search_knowledge_base_context``) and its input
value object (``SearchScope``); the rest are building blocks.
Public surface is ``build_context`` (rerank adapt render) and the
``SearchScope`` input value object; the rest are building blocks.
"""
from __future__ import annotations
from .models import ChunkHit, DocumentHit, SearchScope
from .service import build_context, search_knowledge_base_context
from .service import build_context
__all__ = [
"ChunkHit",
"DocumentHit",
"SearchScope",
"build_context",
"search_knowledge_base_context",
]

View file

@ -31,7 +31,7 @@ _SURFACE = "chunks"
async def search_chunks(
db_session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
query: str,
scope: SearchScope,
top_k: int,
@ -44,14 +44,14 @@ async def search_chunks(
"""
started = time.perf_counter()
with otel.kb_search_span(
search_space_id=search_space_id,
workspace_id=workspace_id,
query_chars=len(query),
extra={"search.surface": _SURFACE, "search.mode": "hybrid"},
) as span:
try:
documents = await _search(
db_session,
search_space_id=search_space_id,
workspace_id=workspace_id,
query=query,
scope=scope,
top_k=top_k,
@ -60,14 +60,14 @@ async def search_chunks(
finally:
elapsed_ms = (time.perf_counter() - started) * 1000
metrics.record_kb_search_duration(
elapsed_ms, search_space_id=search_space_id, surface=_SURFACE
elapsed_ms, workspace_id=workspace_id, surface=_SURFACE
)
span.set_attribute("result.count", len(documents))
get_perf_logger().info(
"[chunk_search] hybrid in %.3fs docs=%d space=%d",
elapsed_ms / 1000,
len(documents),
search_space_id,
workspace_id,
)
return documents
@ -75,7 +75,7 @@ async def search_chunks(
async def _search(
db_session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
query: str,
scope: SearchScope,
top_k: int,
@ -91,7 +91,7 @@ async def _search(
config.embedding_model_instance.embed, query
)
conditions = _base_conditions(search_space_id, scope, document_types)
conditions = _base_conditions(workspace_id, scope, document_types)
rows = await _fused_chunks(
db_session,
query=query,
@ -116,13 +116,13 @@ def _resolve_document_types(
def _base_conditions(
search_space_id: int,
workspace_id: int,
scope: SearchScope,
document_types: list[DocumentType] | None,
) -> list:
"""Filters shared by both search legs."""
conditions = [
Document.search_space_id == search_space_id,
Document.workspace_id == workspace_id,
func.coalesce(Document.status["state"].astext, "ready") != "deleting",
]
if document_types:

View file

@ -1,54 +1,27 @@
"""Search the knowledge base and render it as model-facing ``<retrieved_context>``.
"""Render knowledge-base hits as model-facing ``<retrieved_context>``.
The retrieval spine end to end: hybrid search rerank adapt render, with
each shown passage registered for ``[n]`` citation along the way.
The tail of the retrieval spine: rerank adapt render, registering each
shown passage for ``[n]`` citation. Hybrid search itself lives in
``hybrid_search``; callers (the ``search_knowledge_base`` tool) pass its hits
straight into :func:`build_context`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.chat.multi_agent_chat.shared.citations import CitationRegistry
from app.agents.chat.multi_agent_chat.shared.document_render import (
render_search_context,
)
from .adapter import to_renderable_document
from .hybrid_search import search_chunks
from .models import DocumentHit, SearchScope
from .models import DocumentHit
from .reranking import rerank_hits
if TYPE_CHECKING:
from app.services.reranker_service import RerankerService
_DEFAULT_TOP_K = 10
async def search_knowledge_base_context(
db_session: AsyncSession,
*,
search_space_id: int,
query: str,
registry: CitationRegistry,
scope: SearchScope | None = None,
reranker: RerankerService | None = None,
top_k: int = _DEFAULT_TOP_K,
) -> str | None:
"""Retrieve KB evidence for ``query`` and render it, registering each ``[n]``.
Returns ``None`` when nothing matched, so the caller can skip the block.
"""
hits = await search_chunks(
db_session,
search_space_id=search_space_id,
query=query,
scope=scope or SearchScope(),
top_k=top_k,
)
return build_context(query, hits, registry, reranker=reranker)
def build_context(
query: str,
@ -63,4 +36,4 @@ def build_context(
return render_search_context(documents, registry)
__all__ = ["build_context", "search_knowledge_base_context"]
__all__ = ["build_context"]

View file

@ -64,14 +64,6 @@ TOOL_CATALOG: list[ToolMetadata] = [
name="search_knowledge_base",
description="Search the user's knowledge base with hybrid semantic + keyword retrieval",
),
ToolMetadata(
name="scrape_webpage",
description="Scrape and extract the main content from a webpage",
),
ToolMetadata(
name="web_search",
description="Search the web for real-time information using configured search engines",
),
ToolMetadata(
name="create_automation",
description="Draft an automation from an NL intent; user approves the card; tool saves",

View file

@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
# artifact in the user's own workspace with no external visibility (drafts
# aren't sent; new files aren't shared). They still call ``request_approval``,
# which returns ``decision_type="auto_approved"`` without firing an interrupt.
# Per-search-space ``agent_permission_rules`` can re-enable prompting.
# Per-workspace ``agent_permission_rules`` can re-enable prompting.
DEFAULT_AUTO_APPROVED_TOOLS: frozenset[str] = frozenset(
{
"create_gmail_draft",

View file

@ -99,11 +99,11 @@ async def write_cached_tools(
def refresh_mcp_tools_cache_for_connector(
connector_id: int,
search_space_id: int,
workspace_id: int,
) -> None:
"""Maintain the MCP tool cache after a single-connector lifecycle event.
Synchronously evicts the in-process LRU for the connector's search space
Synchronously evicts the in-process LRU for the connector's workspace
(LRU keys are per-space, so eviction cannot be scoped finer), then schedules
a background live discovery for this connector alone so its persisted
``cached_tools`` row is refreshed before the next user query.
@ -116,11 +116,11 @@ def refresh_mcp_tools_cache_for_connector(
invalidate_mcp_tools_cache,
)
invalidate_mcp_tools_cache(search_space_id)
invalidate_mcp_tools_cache(workspace_id)
except Exception:
logger.debug(
"MCP in-process cache eviction skipped for space %d",
search_space_id,
workspace_id,
exc_info=True,
)

View file

@ -56,7 +56,7 @@ _MCP_CACHE_MAX_SIZE = 50
_MCP_DISCOVERY_TIMEOUT_SECONDS = 30
_TOOL_CALL_MAX_RETRIES = 3
_TOOL_CALL_RETRY_DELAY = 1.5 # seconds, doubles per attempt
# Keyed by ``(search_space_id, bypass_internal_hitl)`` so single-agent and
# Keyed by ``(workspace_id, bypass_internal_hitl)`` so single-agent and
# multi-agent paths cannot share tool closures with different HITL wiring.
_MCPCacheKey = tuple[int, bool]
_mcp_tools_cache: dict[_MCPCacheKey, tuple[float, list[StructuredTool]]] = {}
@ -649,14 +649,31 @@ async def _load_http_mcp_tools(
total_discovered = len(tool_definitions)
if allowed_set:
tool_definitions = [td for td in tool_definitions if td["name"] in allowed_set]
logger.info(
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
url,
connector_id,
len(tool_definitions),
total_discovered,
)
filtered = [td for td in tool_definitions if td["name"] in allowed_set]
if not filtered and total_discovered:
# The server renamed its tools out from under our allowlist
# (e.g. Notion's "notion-" prefix rename) — a fully-dead
# allowlist would silently disable the connector. Load
# everything instead: renamed tools won't match
# ``readonly_tools`` either, so every tool stays HITL-gated.
logger.warning(
"HTTP MCP server '%s' (connector %d): allowlist matched 0/%d "
"advertised tools — server likely renamed its tools. "
"Loading all tools (HITL-gated). Update the registry allowlist: %s",
url,
connector_id,
total_discovered,
sorted(td["name"] for td in tool_definitions),
)
else:
tool_definitions = filtered
logger.info(
"HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter",
url,
connector_id,
len(tool_definitions),
total_discovered,
)
else:
logger.info(
"Discovered %d tools from HTTP MCP server '%s' (connector %d) — no allowlist, loading all",
@ -814,7 +831,7 @@ async def _refresh_connector_token(
await session.commit()
await session.refresh(connector)
invalidate_mcp_tools_cache(connector.search_space_id)
invalidate_mcp_tools_cache(connector.workspace_id)
return new_access
@ -990,7 +1007,7 @@ async def _mark_connector_auth_expired(connector_id: int) -> None:
"Marked MCP connector %s as auth_expired after unrecoverable 401",
connector_id,
)
invalidate_mcp_tools_cache(connector.search_space_id)
invalidate_mcp_tools_cache(connector.workspace_id)
except Exception:
logger.warning(
@ -1000,10 +1017,10 @@ async def _mark_connector_auth_expired(connector_id: int) -> None:
)
def invalidate_mcp_tools_cache(search_space_id: int | None = None) -> None:
def invalidate_mcp_tools_cache(workspace_id: int | None = None) -> None:
"""Invalidate cached MCP tools (both ``bypass_internal_hitl`` variants together)."""
if search_space_id is not None:
for key in [k for k in _mcp_tools_cache if k[0] == search_space_id]:
if workspace_id is not None:
for key in [k for k in _mcp_tools_cache if k[0] == workspace_id]:
_mcp_tools_cache.pop(key, None)
else:
_mcp_tools_cache.clear()
@ -1099,27 +1116,27 @@ async def discover_single_mcp_connector(connector_id: int) -> None:
async def load_mcp_tools(
session: AsyncSession,
search_space_id: int,
workspace_id: int,
*,
bypass_internal_hitl: bool = False,
) -> list[StructuredTool]:
"""Load all MCP tools from the user's active MCP server connectors.
Results are cached per ``(search_space_id, bypass_internal_hitl)`` for up
Results are cached per ``(workspace_id, bypass_internal_hitl)`` for up
to 5 minutes; bypass is keyed because each variant builds a different tool
closure (with vs. without the in-wrapper ``request_approval`` gate).
"""
_evict_expired_mcp_cache()
now = time.monotonic()
cache_key: _MCPCacheKey = (search_space_id, bypass_internal_hitl)
cache_key: _MCPCacheKey = (workspace_id, bypass_internal_hitl)
cached = _mcp_tools_cache.get(cache_key)
if cached is not None:
cached_at, cached_tools = cached
if now - cached_at < _MCP_CACHE_TTL_SECONDS:
logger.info(
"Using cached MCP tools for search space %s (%d tools, age=%.0fs, bypass_hitl=%s)",
search_space_id,
"Using cached MCP tools for workspace %s (%d tools, age=%.0fs, bypass_hitl=%s)",
workspace_id,
len(cached_tools),
now - cached_at,
bypass_internal_hitl,
@ -1132,7 +1149,7 @@ async def load_mcp_tools(
# Cast JSON -> JSONB so we can use has_key to filter by the presence of "server_config".
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
cast(SearchSourceConnector.config, JSONB).has_key("server_config"),
),
)
@ -1322,9 +1339,9 @@ async def load_mcp_tools(
del _mcp_tools_cache[oldest_key]
logger.info(
"Loaded %d MCP tools for search space %d (bypass_hitl=%s)",
"Loaded %d MCP tools for workspace %d (bypass_hitl=%s)",
len(tools),
search_space_id,
workspace_id,
bypass_internal_hitl,
)
return tools

View file

@ -1,4 +1,4 @@
"""Image generation via litellm; resolves model config from the search space and returns UI-ready payloads."""
"""Image generation via litellm; resolves model config from the workspace and returns UI-ready payloads."""
import hashlib
import logging
@ -18,7 +18,7 @@ from app.config import config
from app.db import (
ImageGeneration,
Model,
SearchSpace,
Workspace,
shielded_async_session,
)
from app.services.auto_model_pin_service import (
@ -48,14 +48,14 @@ def _get_global_connection(connection_id: int) -> dict | None:
def create_generate_image_tool(
search_space_id: int,
workspace_id: int,
db_session: AsyncSession,
image_gen_model_id_override: int | None = None,
):
"""Create ``generate_image`` with bound search space; DB work uses a per-call session.
"""Create ``generate_image`` with bound workspace; DB work uses a per-call session.
``image_gen_model_id_override``: when set (automations running on a
captured model), use this model id instead of reading the search space's
captured model), use this model id instead of reading the workspace's
live ``image_gen_model_id``.
"""
del db_session # tool uses a fresh per-call session instead
@ -102,23 +102,21 @@ def create_generate_image_tool(
# 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)
select(Workspace).filter(Workspace.id == workspace_id)
)
search_space = result.scalars().first()
if not search_space:
workspace = result.scalars().first()
if not workspace:
return _failed(
{"error": "Search space not found"},
error="Search space not found",
{"error": "Workspace not found"},
error="Workspace not found",
)
if image_gen_model_id_override is not None:
# Automation run: use the captured image model, insulated from
# later search-space changes. No search-space read needed.
# later workspace changes. No workspace read needed.
config_id = image_gen_model_id_override or IMAGE_GEN_AUTO_MODE_ID
else:
config_id = (
search_space.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
)
config_id = workspace.image_gen_model_id or IMAGE_GEN_AUTO_MODE_ID
# size/quality/style are intentionally omitted: valid values
# differ per model, so we let each model use its own defaults.
@ -129,8 +127,8 @@ def create_generate_image_tool(
if is_image_gen_auto_mode(config_id):
candidates = await auto_model_candidates(
session,
search_space_id=search_space_id,
user_id=search_space.user_id,
workspace_id=workspace_id,
user_id=workspace.user_id,
capability="image_gen",
)
if not candidates:
@ -140,7 +138,7 @@ def create_generate_image_tool(
)
return _failed({"error": err}, error=err)
config_id = int(
choose_auto_model_candidate(candidates, search_space_id)["id"]
choose_auto_model_candidate(candidates, workspace_id)["id"]
)
provider_base_url: str | None = None
@ -186,15 +184,12 @@ def create_generate_image_tool(
return _failed({"error": err}, error=err)
conn = db_model.connection
if (
conn.search_space_id is not None
and conn.search_space_id != search_space_id
conn.workspace_id is not None
and conn.workspace_id != workspace_id
):
err = f"Image generation model {config_id} not found"
return _failed({"error": err}, error=err)
if (
conn.user_id is not None
and conn.user_id != search_space.user_id
):
if conn.user_id is not None and conn.user_id != workspace.user_id:
err = f"Image generation model {config_id} not found"
return _failed({"error": err}, error=err)
if not has_capability(db_model, "image_gen"):
@ -225,7 +220,7 @@ def create_generate_image_tool(
n=n,
image_gen_model_id=config_id,
response_data=response_dict,
search_space_id=search_space_id,
workspace_id=workspace_id,
access_token=access_token,
)
session.add(db_image_gen)

View file

@ -28,28 +28,28 @@ def load_tools(
d = {**(dependencies or {}), **kwargs}
return [
create_generate_podcast_tool(
search_space_id=d["search_space_id"],
workspace_id=d["workspace_id"],
db_session=d["db_session"],
thread_id=d["thread_id"],
),
create_generate_video_presentation_tool(
search_space_id=d["search_space_id"],
workspace_id=d["workspace_id"],
db_session=d["db_session"],
thread_id=d["thread_id"],
),
create_generate_report_tool(
search_space_id=d["search_space_id"],
workspace_id=d["workspace_id"],
thread_id=d["thread_id"],
connector_service=d.get("connector_service"),
available_connectors=d.get("available_connectors"),
available_document_types=d.get("available_document_types"),
),
create_generate_resume_tool(
search_space_id=d["search_space_id"],
workspace_id=d["workspace_id"],
thread_id=d["thread_id"],
),
create_generate_image_tool(
search_space_id=d["search_space_id"],
workspace_id=d["workspace_id"],
db_session=d["db_session"],
image_gen_model_id_override=d.get("image_gen_model_id_override"),
),

View file

@ -28,11 +28,11 @@ logger = logging.getLogger(__name__)
def create_generate_podcast_tool(
search_space_id: int,
workspace_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."""
"""Create ``generate_podcast`` with bound workspace and thread; DB writes use a tool-local session."""
del db_session # writes use a fresh tool-local session, see below
@tool
@ -77,13 +77,13 @@ def create_generate_podcast_tool(
service = PodcastService(session)
podcast = await service.create(
title=podcast_title,
search_space_id=search_space_id,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
)
podcast.source_content = source_content
spec = await propose_brief(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
focus=user_prompt,
)
await service.attach_brief(podcast, spec)

View file

@ -31,7 +31,7 @@ def _report_search_types(
"""Build the document-type scope for the shared KB search.
``None`` means "search every indexed type"; a tuple narrows the scope to the
connectors/document types the search space actually has.
connectors/document types the workspace actually has.
"""
types: set[str] = set()
if available_document_types:
@ -561,7 +561,7 @@ async def _revise_with_sections(
def create_generate_report_tool(
search_space_id: int,
workspace_id: int,
thread_id: int | None = None,
connector_service: ConnectorService | None = None,
available_connectors: list[str] | None = None,
@ -728,7 +728,7 @@ def create_generate_report_tool(
"error_message": error_msg,
},
report_style=report_style,
search_space_id=search_space_id,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
report_group_id=report_group_id,
)
@ -769,7 +769,7 @@ def create_generate_report_tool(
"creating standalone report"
)
llm = await get_agent_llm(read_session, search_space_id)
llm = await get_agent_llm(read_session, workspace_id)
if not llm:
error_msg = (
@ -846,7 +846,7 @@ def create_generate_report_tool(
async with shielded_async_session() as kb_session:
return await search_chunks(
kb_session,
search_space_id=search_space_id,
workspace_id=workspace_id,
query=q,
scope=scope,
top_k=10,
@ -1044,7 +1044,7 @@ def create_generate_report_tool(
content=report_content,
report_metadata=metadata,
report_style=report_style,
search_space_id=search_space_id,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
report_group_id=report_group_id,
)

View file

@ -421,7 +421,7 @@ def _validate_max_pages(max_pages: int) -> int:
def create_generate_resume_tool(
search_space_id: int,
workspace_id: int,
thread_id: int | None = None,
):
"""
@ -531,7 +531,7 @@ def create_generate_resume_tool(
"error_message": error_msg,
},
report_style="resume",
search_space_id=search_space_id,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
report_group_id=report_group_id,
)
@ -581,7 +581,7 @@ def create_generate_resume_tool(
f"(group {report_group_id})"
)
llm = await get_agent_llm(read_session, search_space_id)
llm = await get_agent_llm(read_session, workspace_id)
if not llm:
error_msg = (
@ -819,7 +819,7 @@ def create_generate_resume_tool(
content_type="typst",
report_metadata=metadata,
report_style="resume",
search_space_id=search_space_id,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
report_group_id=report_group_id,
)

View file

@ -31,11 +31,11 @@ logger = logging.getLogger(__name__)
def create_generate_video_presentation_tool(
search_space_id: int,
workspace_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."""
"""Create ``generate_video_presentation`` with bound workspace and thread; writes use a tool-local session."""
del db_session # writes use a fresh tool-local session, see below
@tool
@ -60,7 +60,7 @@ def create_generate_video_presentation_tool(
video_pres = VideoPresentation(
title=video_title,
status=VideoPresentationStatus.PENDING,
search_space_id=search_space_id,
workspace_id=workspace_id,
thread_id=resolve_root_thread_id(runtime, thread_id),
)
session.add(video_pres)
@ -75,7 +75,7 @@ def create_generate_video_presentation_tool(
task = generate_video_presentation_task.delay(
video_presentation_id=video_pres_id,
source_content=source_content,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_prompt=user_prompt,
)

View file

@ -0,0 +1 @@
"""``google_maps`` builtin subagent: structured Google Maps place/review data."""

View file

@ -1,9 +1,4 @@
"""``luma`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
"""``google_maps`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
from __future__ import annotations
@ -33,7 +28,7 @@ def build_subagent(
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles luma tasks for this workspace."
or "Pulls structured data from Google Maps — places and their reviews."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(

View file

@ -0,0 +1,2 @@
Google Maps specialist: pulls structured data from Google Maps — finds places by search query (optionally scoped to a location), or resolves a Maps URL / place ID into place details (name, address, category, phone, website, rating, review count, coordinates, opening hours), and fetches a place's reviews (author, text, star rating, owner response, dates). Also compares fresh Maps data against earlier findings in this chat.
Use whenever the task involves Google Maps places, local businesses, or a google.com/maps link. Triggers include "find <business type> near/in X", "get details for this place", "phone/address/hours/rating of X", "how many reviews", "get the reviews for this place", "what are people saying about this business", and comparisons against earlier Maps results in this chat. Not for general web pages (use the web crawling specialist) or YouTube (use the YouTube specialist).

View file

@ -0,0 +1,66 @@
You are the SurfSense Google Maps sub-agent.
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
<goal>
Answer the delegated question from live Google Maps data gathered with your verbs, comparing against earlier results already in this conversation when the task calls for it.
</goal>
<available_tools>
- `google_maps_scrape`
- `google_maps_reviews`
- `read_run` / `search_run` (free readers for stored scrape output)
</available_tools>
<playbook>
- Finding places on a topic: call `google_maps_scrape` with `search_queries`, adding `location` to scope them (e.g. "coffee shops" in "Austin, USA").
- Known place links or IDs: call `google_maps_scrape` with the links in `urls` or the IDs in `place_ids`.
- Need richer detail (opening hours, popular times, extra contact info): set `include_details=true`.
- Reviews / sentiment on specific places: call `google_maps_reviews` with the place `urls` or `place_ids`.
- Batch multiple queries, URLs, or place IDs into one call rather than many single-item calls.
- Exclusion criteria are strict: when the task excludes a category or brand, drop every place whose name, website domain, or category matches it — a local branch or satellite location of an excluded chain/system is still that chain, never reinterpret it as acceptable. Fill the gap with more or wider queries instead.
- The website domain is an ownership signal: a place whose site lives on a parent organization's domain (a government portal, a chain's site, a health-system or franchise domain) rather than its own belongs to that parent — judge include/exclude criteria against the parent, and treat multiple locations sharing one parent domain as one organization.
<include snippet="run_reader"/>
- Comparison requests: pull the current values, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, old -> new).
</playbook>
<tool_policy>
- Use only tools in `<available_tools>`.
- An item whose `status` is not `success` returned no data — report it unavailable, never invent it.
- Report only deltas you can point to in the evidence. Never fabricate facts, counts, quotes, ratings, or URLs.
</tool_policy>
<out_of_scope>
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
- Non-Maps web pages belong to the web crawling specialist; YouTube belongs to the YouTube specialist.
</out_of_scope>
<safety>
- Report uncertainty explicitly when evidence is incomplete or conflicting.
- Never present unverified claims as facts.
</safety>
<failure_policy>
- Underspecified request — no usable search query, URL, or place ID — return `status=blocked` with the missing fields.
- Tool failure: return `status=error` with a concise recovery `next_step`.
- No useful evidence: return `status=blocked` with a narrower query or the URLs/IDs you still need.
</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
}
<include snippet="output_contract_base"/>
Route-specific rules:
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct fact or delta. Do not paste raw payloads.
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
</output_contract>

View file

@ -0,0 +1,28 @@
"""``google_maps`` sub-agent tools: the Google Maps scrape + reviews capability verbs."""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
from app.capabilities.core.access.agent import build_capability_tools
from app.capabilities.google_maps.reviews.definition import GOOGLE_MAPS_REVIEWS
from app.capabilities.google_maps.scrape.definition import GOOGLE_MAPS_SCRAPE
NAME = "google_maps"
RULESET = Ruleset(origin=NAME, rules=[])
_CI_VERBS = [GOOGLE_MAPS_SCRAPE, GOOGLE_MAPS_REVIEWS]
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
return build_capability_tools(
workspace_id=d.get("workspace_id"),
capabilities=_CI_VERBS,
)

View file

@ -0,0 +1 @@
"""``google_search`` builtin subagent: structured Google Search results."""

View file

@ -1,9 +1,4 @@
"""``gmail`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Tools self-gate inside their bodies via :func:`request_approval`; the
empty :data:`tools.index.RULESET` is layered into a per-subagent
:class:`PermissionMiddleware` for uniformity.
"""
"""``google_search`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
from __future__ import annotations
@ -33,7 +28,7 @@ def build_subagent(
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Handles gmail tasks for this workspace."
or "Searches Google and returns structured results for a query."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(

View file

@ -0,0 +1,2 @@
Google Search specialist: runs Google searches and returns structured SERP data — organic results (title, URL, description), related queries, people-also-ask, and any AI overview. Searches by plain query (optionally scoped to a country/language or restricted to a single site) or scrapes a Google Search URL as-is, and can page deeper with max_pages_per_query. Also compares fresh search results against earlier findings in this chat.
This is the sole path for public web search: use it for any real-time or public-fact lookup (current events, news, prices, weather, exchange rates, live data) as well as to find pages or sources on the open web via Google, discover which sites rank for a topic, or gather a list of result URLs to hand off for crawling. Triggers include "search Google for X", "what's the current X", "latest news on X", "find websites/pages about X", "who ranks for X", "top results for X", "find X's website", and comparisons against earlier search results in this chat. Not for reading a specific page's content (use the web crawling specialist), Google Maps places (use the Google Maps specialist), or YouTube (use the YouTube specialist).

View file

@ -0,0 +1,65 @@
You are the SurfSense Google Search sub-agent.
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
<goal>
Answer the delegated question from live Google Search data gathered with your verb, comparing against earlier results already in this conversation when the task calls for it.
</goal>
<available_tools>
- `google_search_scrape`
- `read_run` / `search_run` (free readers for stored scrape output)
</available_tools>
<playbook>
- Finding pages on a topic: call `google_search_scrape` with `queries`, scoping with `country_code`/`language_code` when locale matters.
- Restricting to one website: set `site` (e.g. "example.com") to only return results from that domain.
- Scraping a specific results page: pass the full Google Search URL in `queries`.
- Need more results: raise `max_pages_per_query` to page beyond the first page.
- Batch multiple search terms into one call rather than many single-term calls.
<include snippet="run_reader"/>
- Handing URLs off for crawling: return the organic result URLs so the supervisor can route them to the web crawling specialist.
- Comparison requests: pull the current results, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, moved up/down).
</playbook>
<tool_policy>
- Use only tools in `<available_tools>`.
- Report only results present in the tool output. Never invent titles, URLs, snippets, or rankings.
</tool_policy>
<out_of_scope>
- Do not read or extract a specific page's content — return the URLs for the web crawling specialist.
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
- Google Maps places belong to the Google Maps specialist; YouTube belongs to the YouTube specialist.
- Discovering physical businesses or venues of a type in a geography ("find X businesses in Y") is the Google Maps specialist's job — if that is the whole task, return `status=blocked` with a `next_step` pointing the supervisor to the Maps specialist instead of approximating it from search snippets.
</out_of_scope>
<safety>
- Report uncertainty explicitly when evidence is incomplete or conflicting.
- Never present unverified claims as facts.
</safety>
<failure_policy>
- Underspecified request — no usable query or URL — return `status=blocked` with the missing fields.
- Tool failure: return `status=error` with a concise recovery `next_step`.
- No useful evidence: return `status=blocked` with a narrower query or the scope you still need.
</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
}
<include snippet="output_contract_base"/>
Route-specific rules:
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct result or delta. Do not paste raw payloads.
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
</output_contract>

View file

@ -0,0 +1,27 @@
"""``google_search`` sub-agent tools: the Google Search scrape capability verb."""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
from app.capabilities.core.access.agent import build_capability_tools
from app.capabilities.google_search.scrape.definition import GOOGLE_SEARCH_SCRAPE
NAME = "google_search"
RULESET = Ruleset(origin=NAME, rules=[])
_CI_VERBS = [GOOGLE_SEARCH_SCRAPE]
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
return build_capability_tools(
workspace_id=d.get("workspace_id"),
capabilities=_CI_VERBS,
)

View file

@ -16,6 +16,9 @@ from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.filesystem_selection import FilesystemMode
from app.agents.chat.multi_agent_chat.shared.permissions import Rule, Ruleset
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
append_today_utc,
)
from .middleware_stack import build_kb_middleware
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
@ -36,7 +39,7 @@ _KB_READONLY_RULESET = Ruleset(origin=READONLY_NAME, rules=[])
def _build_search_knowledge_base_tool(dependencies: dict[str, Any]) -> BaseTool:
"""Construct the hybrid-RAG ``search_knowledge_base`` tool from shared deps."""
return create_search_knowledge_base_tool(
search_space_id=dependencies["search_space_id"],
workspace_id=dependencies["workspace_id"],
available_connectors=dependencies.get("available_connectors"),
available_document_types=dependencies.get("available_document_types"),
)
@ -57,7 +60,7 @@ def build_subagent(
{
"name": NAME,
"description": load_description(),
"system_prompt": load_system_prompt(filesystem_mode),
"system_prompt": append_today_utc(load_system_prompt(filesystem_mode)),
"model": llm,
"tools": [_build_search_knowledge_base_tool(dependencies)],
"middleware": build_kb_middleware(
@ -86,7 +89,9 @@ def build_readonly_subagent(
{
"name": READONLY_NAME,
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
"system_prompt": load_readonly_system_prompt(filesystem_mode),
"system_prompt": append_today_utc(
load_readonly_system_prompt(filesystem_mode)
),
"model": llm,
"tools": [_build_search_knowledge_base_tool(dependencies)],
"middleware": build_kb_middleware(

View file

@ -115,7 +115,7 @@ def build_kb_middleware(
fs_mw = build_filesystem_mw(
backend_resolver=dependencies["backend_resolver"],
filesystem_mode=filesystem_mode,
search_space_id=dependencies["search_space_id"],
workspace_id=dependencies["workspace_id"],
user_id=dependencies.get("user_id"),
thread_id=dependencies.get("thread_id"),
read_only=read_only,

View file

@ -38,13 +38,15 @@ _DEFAULT_TOP_K = 5
_MAX_TOP_K = 20
_TOOL_DESCRIPTION = (
"Search the user's knowledge base (their indexed documents, files, and "
"connector content) for passages relevant to a query, using hybrid "
"semantic + keyword retrieval.\n\n"
"Search the user's knowledge base — their own uploaded files, documents, "
"and notes — for passages relevant to a query, using hybrid semantic + "
"keyword retrieval.\n\n"
"Use this FIRST to ground any factual or informational answer about the "
"user's own documents, notes, or connected sources. It returns a "
"<retrieved_context> block: each matched passage is labelled [n]. Cite a "
"passage by writing that [n] after the statement it supports.\n\n"
"user's personal files and notes. It returns a <retrieved_context> block: "
"each matched passage is labelled [n]. Cite a passage by writing that [n] "
"after the statement it supports.\n\n"
"This searches only the user's stored files and notes — live data in "
"connected apps (Slack, Jira, Notion, Gmail, etc.) is not indexed here.\n\n"
"Write a focused, specific query containing the concrete entities, "
"acronyms, people, projects, or terms you are looking for."
)
@ -89,7 +91,7 @@ def _resolve_mention_pins(
async def _build_search_scope(
session: AsyncSession,
*,
search_space_id: int,
workspace_id: int,
document_types: tuple[str, ...] | None,
runtime: ToolRuntime[None, SurfSenseFilesystemState],
) -> SearchScope:
@ -97,7 +99,7 @@ async def _build_search_scope(
mentioned_document_ids, mentioned_folder_ids = _resolve_mention_pins(runtime)
document_ids = await referenced_document_ids(
session,
search_space_id=search_space_id,
workspace_id=workspace_id,
document_ids=mentioned_document_ids,
folder_ids=mentioned_folder_ids,
)
@ -109,13 +111,13 @@ async def _build_search_scope(
def create_search_knowledge_base_tool(
*,
search_space_id: int,
workspace_id: int,
available_connectors: list[str] | None = None,
available_document_types: list[str] | None = None,
) -> BaseTool:
"""Factory for the on-demand ``search_knowledge_base`` tool."""
_space_id = search_space_id
_space_id = workspace_id
_document_types = _search_types(available_connectors, available_document_types)
async def _impl(
@ -140,13 +142,13 @@ def create_search_knowledge_base_tool(
async with shielded_async_session() as session:
scope = await _build_search_scope(
session,
search_space_id=_space_id,
workspace_id=_space_id,
document_types=_document_types,
runtime=runtime,
)
hits = await search_chunks(
session,
search_space_id=_space_id,
workspace_id=_space_id,
query=cleaned_query,
scope=scope,
top_k=clamped_top_k,

View file

@ -0,0 +1,8 @@
"""``mcp_discovery`` builtin subagent: the user's connected apps via MCP.
Consolidates every MCP-backed connector (Slack, Jira, Linear, ClickUp,
Airtable, Notion, Confluence, generic user MCP servers) plus the interim
native Gmail/Calendar tools behind a single subagent. Tools are injected
directly (opencode/hermes pattern) so the tool-name-keyed permission and
"Always Allow" systems keep working unchanged.
"""

View file

@ -0,0 +1,92 @@
"""``mcp_discovery`` route: ``SurfSenseSubagentSpec`` builder for deepagents.
Consolidates every MCP-backed connector plus interim native Gmail/Calendar
tools. The permission ruleset is derived from the runtime tool set (not a
static constant) because the MCP tool roster is per-user.
"""
from __future__ import annotations
from typing import Any
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Rule, Ruleset
from app.agents.chat.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .tools.index import NAME, build_ruleset, load_tools
def _augment_allowlist_for_collision_prefixes(
dependencies: dict[str, Any],
tools: list[BaseTool],
) -> dict[str, Any]:
"""Keep "Always Allow" working for tools that got collision-prefixed.
A tool trusted under its bare name (``search``) can later be renamed to
``notion_5_search`` once a second app also exposes ``search`` (Phase 2b
collision resolution). The user's persisted allow-rule still keys on the
bare name, so we add an alias allow-rule for the new exposed name when the
original is already trusted. Returns ``dependencies`` unchanged when there
is nothing to do.
"""
by_subagent = dependencies.get("user_allowlist_by_subagent") or {}
existing = by_subagent.get(NAME)
if not isinstance(existing, Ruleset) or not existing.rules:
return dependencies
trusted_names = {r.permission for r in existing.rules if r.action == "allow"}
alias_rules: list[Rule] = []
for tool in tools:
meta = getattr(tool, "metadata", None) or {}
if not meta.get("mcp_collision_prefixed"):
continue
original = meta.get("mcp_original_tool_name")
if original in trusted_names and tool.name not in trusted_names:
alias_rules.append(Rule(permission=tool.name, pattern="*", action="allow"))
if not alias_rules:
return dependencies
merged = Ruleset(
origin=existing.origin,
rules=[*existing.rules, *alias_rules],
)
return {
**dependencies,
"user_allowlist_by_subagent": {**by_subagent, NAME: merged},
}
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = load_tools(dependencies=dependencies, mcp_tools=mcp_tools)
ruleset = build_ruleset(tools)
dependencies = _augment_allowlist_for_collision_prefixes(dependencies, tools)
description = (
read_md_file(__package__, "description").strip()
or "Acts on the user's connected apps (Slack, Jira, Notion, Gmail, ...) via MCP."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
ruleset=ruleset,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -0,0 +1,2 @@
Connected-apps specialist: acts on the user's connected third-party services through MCP and native integrations — Slack, Jira, Confluence, Linear, ClickUp, Airtable, Notion, Gmail, Google Calendar, and any generic MCP server the user has added. Searches, reads, creates, and updates records in those services (send/read email, manage calendar events, search issues/pages/messages, create or edit issues and pages, query bases, etc.), asking for confirmation before write actions.
Use whenever the task is to look something up in, or take an action on, one of the user's connected apps: "search my Slack for X", "create a Jira issue", "what's on my calendar", "draft an email to Y", "find the Notion page about Z", "update the Linear issue". Call `get_connected_accounts` first when it's unclear which account or workspace to act on, or which apps are even connected. Not for reading the user's own uploaded files or notes (use the knowledge base specialist), general web pages (web crawler), or platform scrapers like Reddit/YouTube.

View file

@ -0,0 +1,67 @@
You are the SurfSense connected-apps specialist.
You act on the user's connected third-party services (Slack, Jira, Confluence, Linear, ClickUp, Airtable, Notion, Gmail, Google Calendar, and any generic MCP servers) on behalf of a supervisor agent, and return structured results for supervisor synthesis.
<goal>
Complete the delegated task against the user's connected apps using only the tools present in your runtime tool list, discovering any identifier or scope the request leaves unspecified before acting.
</goal>
<tools>
Your available tools are injected at runtime from whatever apps the user has connected — the set changes per user, so read the tool list and each tool's description rather than assuming a fixed roster. Tools are grouped by app; a tool's description names its MCP server or account.
- `get_connected_accounts`: lists the user's connected apps and account metadata (workspace/team/site names, ids). Read-only. Call it first whenever it is unclear which apps are connected, or which account/workspace/site an action should target.
- Tool names are normally the app's native tool names (e.g. `searchJiraIssuesUsingJql`, `create-pages`, `send_gmail_email`). When the same tool name exists on more than one connected app, the colliding tools are disambiguated with a `{app}_{id}_` prefix and their descriptions carry an `[Account: ...]` or `[MCP server: ...]` tag — pick the one whose tag matches the intended account.
</tools>
<playbook>
1. Read the supervisor's request and your runtime tool list. Identify which tools are discovery (list/get/search) and which are mutations (create/update/send/delete) from their descriptions.
2. If the request does not pin down the target app, account, or scope, call `get_connected_accounts` (and discovery tools) to resolve it instead of asking the supervisor.
3. Run the minimum discovery chain needed to resolve identifiers, then perform the requested action.
</playbook>
<resolution_principle>
Proactively look up any identifier, name, value, or scope the request leaves unspecified — target ids, workspace/team/site ids, user ids, page/issue ids, channel names — using discovery tools rather than asking the supervisor. Most requests reference targets by title or paraphrase, not by id. Search for them.
When discovery for a single slot returns multiple plausible candidates and you cannot confidently pick one, return `status=blocked` with up to 5 options in `evidence.matched_candidates` and the unresolved slot in `missing_fields`. When discovery returns zero matches for a required slot, return `status=blocked` with a `next_step` suggesting alternative filters.
</resolution_principle>
<mutation_guardrails>
- Resolve every required id via discovery before calling a mutation tool. Chain discovery calls when a mutation has dependencies (e.g. resolve the site/team before creating within it).
- Never invent ids, names, or mutation outcomes. Every field in `evidence` must come from a tool result.
- Write tools ask the user for approval before running. If a mutation is approval-rejected (HITL), return `status=blocked` with `next_step="user declined; do not retry"`.
- One operation per delegation. For multi-mutation requests, complete the highest-priority one and return `status=partial` with the remainder in `next_step`.
</mutation_guardrails>
<tool_policy>
- Use only tools present in your runtime tool list. If no connected app can serve the request, return `status=blocked` explaining which app the user would need to connect.
- Report only results present in tool output. Never fabricate records, ids, or messages.
</tool_policy>
<failure_policy>
- Underspecified request with no resolvable target: return `status=blocked` with the missing fields.
- Tool failure: return `status=error` with the underlying message in `action_summary` and a concise recovery in `next_step`.
- No useful evidence after reasonable narrowing: return `status=blocked` with filter suggestions.
</failure_policy>
<output_contract>
Return **only** one JSON object (no markdown, no prose):
{
"status": "success" | "partial" | "blocked" | "error",
"action_summary": string,
"evidence": {
"findings": string[],
"sources": string[],
"matched_candidates": [
{ "id": string, "label": string }
] | null,
"confidence": "high" | "medium" | "low"
},
"next_step": string | null,
"missing_fields": string[] | null,
"assumptions": string[] | null
}
<include snippet="output_contract_base"/>
Route-specific rules:
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct record, message, or action result. Do not paste raw payloads.
- `evidence.sources`: app URLs or identifiers backing the findings, one per finding when applicable.
- For blocked ambiguity, populate `evidence.matched_candidates` with up to 5 options (`id` + `label`).
</output_contract>

View file

@ -0,0 +1,2 @@
"""``mcp_discovery`` subagent tools: interim native Gmail/Calendar, the
``get_connected_accounts`` helper, and MCP tools injected at runtime."""

View file

@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
def create_create_calendar_event_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
workspace_id: int | None = None,
user_id: str | None = None,
):
@tool
@ -62,7 +62,7 @@ def create_create_calendar_event_tool(
f"create_calendar_event called: summary='{summary}', start='{start_datetime}', end='{end_datetime}'"
)
if db_session is None or search_space_id is None or user_id is None:
if db_session is None or workspace_id is None or user_id is None:
return {
"status": "error",
"message": "Google Calendar tool not properly configured. Please contact support.",
@ -70,9 +70,7 @@ def create_create_calendar_event_tool(
try:
metadata_service = GoogleCalendarToolMetadataService(db_session)
context = await metadata_service.get_creation_context(
search_space_id, user_id
)
context = await metadata_service.get_creation_context(workspace_id, user_id)
if "error" in context:
logger.error(f"Failed to fetch creation context: {context['error']}")
@ -138,7 +136,7 @@ def create_create_calendar_event_tool(
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_calendar_types),
)
@ -153,7 +151,7 @@ def create_create_calendar_event_tool(
else:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_calendar_types),
)
@ -318,7 +316,7 @@ def create_create_calendar_event_tool(
html_link=created.get("htmlLink"),
description=final_description,
connector_id=actual_connector_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
)
if kb_result["status"] == "success":

View file

@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
def create_delete_calendar_event_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
workspace_id: int | None = None,
user_id: str | None = None,
):
@tool
@ -56,7 +56,7 @@ def create_delete_calendar_event_tool(
f"delete_calendar_event called: event_ref='{event_title_or_id}', delete_from_kb={delete_from_kb}"
)
if db_session is None or search_space_id is None or user_id is None:
if db_session is None or workspace_id is None or user_id is None:
return {
"status": "error",
"message": "Google Calendar tool not properly configured. Please contact support.",
@ -65,7 +65,7 @@ def create_delete_calendar_event_tool(
try:
metadata_service = GoogleCalendarToolMetadataService(db_session)
context = await metadata_service.get_deletion_context(
search_space_id, user_id, event_title_or_id
workspace_id, user_id, event_title_or_id
)
if "error" in context:
@ -143,7 +143,7 @@ def create_delete_calendar_event_tool(
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_calendar_types),
)

View file

@ -28,7 +28,7 @@ def load_tools(
d = {**(dependencies or {}), **kwargs}
common = {
"db_session": d["db_session"],
"search_space_id": d["search_space_id"],
"workspace_id": d["workspace_id"],
"user_id": d["user_id"],
}
return [

View file

@ -28,7 +28,7 @@ def _to_calendar_boundary(value: str, *, is_end: bool) -> str:
def create_search_calendar_events_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
workspace_id: int | None = None,
user_id: str | None = None,
):
@tool
@ -48,7 +48,7 @@ def create_search_calendar_events_tool(
Dictionary with status and a list of events including
event_id, summary, start, end, location, attendees.
"""
if db_session is None or search_space_id is None or user_id is None:
if db_session is None or workspace_id is None or user_id is None:
return {
"status": "error",
"message": "Calendar tool not properly configured.",
@ -59,7 +59,7 @@ def create_search_calendar_events_tool(
try:
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_CALENDAR_TYPES),
)

View file

@ -32,7 +32,7 @@ def _build_time_body(value: str, context: dict[str, Any] | Any) -> dict[str, str
def create_update_calendar_event_tool(
db_session: AsyncSession | None = None,
search_space_id: int | None = None,
workspace_id: int | None = None,
user_id: str | None = None,
):
@tool
@ -76,7 +76,7 @@ def create_update_calendar_event_tool(
"""
logger.info(f"update_calendar_event called: event_ref='{event_title_or_id}'")
if db_session is None or search_space_id is None or user_id is None:
if db_session is None or workspace_id is None or user_id is None:
return {
"status": "error",
"message": "Google Calendar tool not properly configured. Please contact support.",
@ -85,7 +85,7 @@ def create_update_calendar_event_tool(
try:
metadata_service = GoogleCalendarToolMetadataService(db_session)
context = await metadata_service.get_update_context(
search_space_id, user_id, event_title_or_id
workspace_id, user_id, event_title_or_id
)
if "error" in context:
@ -176,7 +176,7 @@ def create_update_calendar_event_tool(
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.workspace_id == workspace_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_(_calendar_types),
)
@ -363,7 +363,7 @@ def create_update_calendar_event_tool(
document_id=document_id,
event_id=final_event_id,
connector_id=actual_connector_id,
search_space_id=search_space_id,
workspace_id=workspace_id,
user_id=user_id,
)
if kb_result["status"] == "success":

View file

@ -0,0 +1,99 @@
"""``get_connected_accounts`` — list the workspace's MCP-capable connectors.
Read-only helper the connected-apps subagent calls to discover which apps
are connected and to disambiguate multi-account / multi-site scenarios. Only
the whitelisted ``MCPServiceConfig.account_metadata_keys`` (plus
``display_name``) are exposed to the LLM tokens, secrets, and raw
``server_config`` are never returned.
"""
from __future__ import annotations
import json
import logging
from langchain_core.tools import BaseTool, StructuredTool
from sqlalchemy import select
from app.services.mcp_oauth.registry import get_service_by_connector_type
logger = logging.getLogger(__name__)
_GENERIC_MCP_CONNECTOR_TYPE = "MCP_CONNECTOR"
_TOOL_DESCRIPTION = (
"List the user's connected apps (Slack, Jira, Confluence, Linear, "
"ClickUp, Airtable, Notion, Gmail, Google Calendar, and generic MCP "
"servers) with their account/workspace/site metadata. Use this to find "
"out which apps are connected and to pick the right account, workspace, "
"or site before acting when more than one could match. Read-only."
)
def _connector_type_value(connector_type: object) -> str:
return (
connector_type.value
if hasattr(connector_type, "value")
else str(connector_type)
)
def create_get_connected_accounts_tool(*, workspace_id: int) -> BaseTool:
"""Factory for the read-only ``get_connected_accounts`` tool."""
_workspace_id = workspace_id
async def _impl() -> str:
# Open a fresh session inside the closure: the factory-time session may
# be closed by the time the LLM calls this tool.
from app.db import SearchSourceConnector, async_session_maker
accounts: list[dict[str, object]] = []
try:
async with async_session_maker() as session:
result = await session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.workspace_id == _workspace_id,
)
)
connectors = list(result.scalars())
except Exception:
logger.exception("get_connected_accounts: connector query failed")
return json.dumps({"accounts": [], "error": "query_failed"})
for connector in connectors:
ct = _connector_type_value(connector.connector_type)
svc = get_service_by_connector_type(ct)
is_generic = ct == _GENERIC_MCP_CONNECTOR_TYPE
if svc is None and not is_generic:
continue # not an MCP-capable / connected-app connector
cfg = connector.config if isinstance(connector.config, dict) else {}
metadata_keys = list(svc.account_metadata_keys) if svc else []
account_meta: dict[str, object] = {
key: cfg[key] for key in metadata_keys if cfg.get(key) is not None
}
display_name = cfg.get("display_name")
if display_name and "display_name" not in account_meta:
account_meta["display_name"] = display_name
accounts.append(
{
"connector_id": connector.id,
"name": connector.name,
"connector_type": ct,
"app": svc.name if svc else "Custom MCP server",
# ``server_config`` presence == the connector produces agent
# tools. Native rows without it are connected for indexing
# only and need a reconnect via MCP.
"usable_in_chat": isinstance(cfg, dict) and "server_config" in cfg,
"account": account_meta,
}
)
return json.dumps({"accounts": accounts})
return StructuredTool.from_function(
name="get_connected_accounts",
description=_TOOL_DESCRIPTION,
coroutine=_impl,
)

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