refactor(agents): delete single-agent stack + new_chat shim package (bucket B3/B4)

With multi-agent the only live factory (B1), the single-agent stack is dead.
Remove app/agents/new_chat/ entirely: chat_deepagent.py, subagents/, and all
re-export shims (errors/context/llm_config/permissions/tools/middleware/...) that
existed only to serve frozen single-agent code. Live code already imports the
shared kernel (app.agents.shared.*) directly.

Tests: delete single-agent-only suites (test_resolve_prompt_model_name,
test_specialized_subagents) and the chat_deepagent source-shape contract assertion;
repoint test_scoped_model_fallback to the shared middleware path. Suite green
(2710 passed).
This commit is contained in:
CREDO23 2026-06-04 13:40:44 +02:00
parent 724bbd6deb
commit 14bbea0854
29 changed files with 1 additions and 2930 deletions

View file

@ -1,91 +0,0 @@
"""
SurfSense New Chat Agent Module.
This module provides the SurfSense deep agent with configurable tools,
middleware, and preloaded knowledge-base filesystem behavior.
Directory Structure:
- tools/: All agent tools (podcast, generate_image, web, memory, etc.)
- middleware/: Custom middleware (knowledge search, filesystem, dedup, etc.)
- chat_deepagent.py: Main agent factory
- system_prompt.py: System prompts and instructions
- context.py: Context schema for the agent
- checkpointer.py: LangGraph checkpointer setup
- llm_config.py: LLM configuration utilities
- utils.py: Shared utilities
"""
# Agent factory
from .chat_deepagent import create_surfsense_deep_agent
# Context
from .context import SurfSenseContextSchema
# LLM config
from .llm_config import (
create_chat_litellm_from_config,
load_global_llm_config_by_id,
load_llm_config_from_yaml,
)
# Middleware
from .middleware import (
DedupHITLToolCallsMiddleware,
KnowledgeBaseSearchMiddleware,
SurfSenseFilesystemMiddleware,
)
# System prompt
from .system_prompt import (
SURFSENSE_CITATION_INSTRUCTIONS,
SURFSENSE_SYSTEM_PROMPT,
build_surfsense_system_prompt,
)
# Tools - registry exports
# Tools - factory exports (for direct use)
# Tools - knowledge base utilities
from .tools import (
BUILTIN_TOOLS,
ToolDefinition,
build_tools,
create_generate_podcast_tool,
create_scrape_webpage_tool,
format_documents_for_context,
get_all_tool_names,
get_default_enabled_tools,
get_tool_by_name,
search_knowledge_base_async,
)
__all__ = [
# Tools registry
"BUILTIN_TOOLS",
# System prompt
"SURFSENSE_CITATION_INSTRUCTIONS",
"SURFSENSE_SYSTEM_PROMPT",
# Middleware
"DedupHITLToolCallsMiddleware",
"KnowledgeBaseSearchMiddleware",
# Context
"SurfSenseContextSchema",
"SurfSenseFilesystemMiddleware",
"ToolDefinition",
"build_surfsense_system_prompt",
"build_tools",
# LLM config
"create_chat_litellm_from_config",
# Tool factories
"create_generate_podcast_tool",
"create_scrape_webpage_tool",
# Agent factory
"create_surfsense_deep_agent",
# Knowledge base utilities
"format_documents_for_context",
"get_all_tool_names",
"get_default_enabled_tools",
"get_tool_by_name",
"load_global_llm_config_by_id",
"load_llm_config_from_yaml",
"search_knowledge_base_async",
]

View file

@ -1,23 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.agent_cache``. Re-exported here for the frozen
single-agent stack (``chat_deepagent``) until that stack is retired.
"""
from app.agents.shared.agent_cache import (
flags_signature,
get_cache,
reload_for_tests,
stable_hash,
system_prompt_hash,
tools_signature,
)
__all__ = [
"flags_signature",
"get_cache",
"reload_for_tests",
"stable_hash",
"system_prompt_hash",
"tools_signature",
]

File diff suppressed because it is too large Load diff

View file

@ -1,11 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.connector_searchable_types``. Re-exported here for
the frozen single-agent stack (``chat_deepagent``) until that stack is retired.
"""
from app.agents.shared.connector_searchable_types import (
map_connectors_to_searchable_types,
)
__all__ = ["map_connectors_to_searchable_types"]

View file

@ -1,20 +0,0 @@
"""Backward-compatible shim.
The agent context schema moved to :mod:`app.agents.shared.context` as part of
promoting the shared agent toolkit out of ``new_chat`` into the cross-agent
kernel. Import from there directly; this re-export keeps the remaining
importers (the not-yet-retired single-agent stack and the ``new_chat`` package
__init__) working during the migration and will be removed with them.
"""
from __future__ import annotations
from app.agents.shared.context import (
FileOperationContractState,
SurfSenseContextSchema,
)
__all__ = [
"FileOperationContractState",
"SurfSenseContextSchema",
]

View file

@ -1,22 +0,0 @@
"""Backward-compatible shim.
The agent feature-flag resolver moved to :mod:`app.agents.shared.feature_flags`
as part of promoting the shared agent toolkit out of ``new_chat`` into the
cross-agent kernel. Import from there directly; this re-export keeps the
not-yet-retired single-agent stack working during the migration and will be
removed with it.
"""
from __future__ import annotations
from app.agents.shared.feature_flags import (
AgentFeatureFlags,
get_flags,
reload_for_tests,
)
__all__ = [
"AgentFeatureFlags",
"get_flags",
"reload_for_tests",
]

View file

@ -1,9 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.filesystem_backends``. Re-exported here for the
frozen single-agent stack (``chat_deepagent``) until that stack is retired.
"""
from app.agents.shared.filesystem_backends import build_backend_resolver
__all__ = ["build_backend_resolver"]

View file

@ -1,24 +0,0 @@
"""Backward-compatible shim.
The filesystem mode contracts moved to :mod:`app.agents.shared.filesystem_selection`
as part of promoting the shared agent toolkit out of ``new_chat`` into the
cross-agent kernel. Import from there directly; this re-export keeps the
not-yet-retired single-agent stack working during the migration and will be
removed with it.
"""
from __future__ import annotations
from app.agents.shared.filesystem_selection import (
ClientPlatform,
FilesystemMode,
FilesystemSelection,
LocalFilesystemMount,
)
__all__ = [
"ClientPlatform",
"FilesystemMode",
"FilesystemSelection",
"LocalFilesystemMount",
]

View file

@ -1,33 +0,0 @@
"""Backward-compatible shim.
The LLM configuration layer now lives in the shared agent kernel at
``app.agents.shared.llm_config``. This module re-exports it so frozen
single-agent code (``chat_deepagent``) keeps working until that stack is
retired.
"""
from __future__ import annotations
from app.agents.shared.llm_config import (
AgentConfig,
SanitizedChatLiteLLM,
create_chat_litellm_from_agent_config,
create_chat_litellm_from_config,
load_agent_config,
load_agent_llm_config_for_search_space,
load_global_llm_config_by_id,
load_llm_config_from_yaml,
load_new_llm_config_from_db,
)
__all__ = [
"AgentConfig",
"SanitizedChatLiteLLM",
"create_chat_litellm_from_agent_config",
"create_chat_litellm_from_config",
"load_agent_config",
"load_agent_llm_config_for_search_space",
"load_global_llm_config_by_id",
"load_llm_config_from_yaml",
"load_new_llm_config_from_db",
]

View file

@ -1,69 +0,0 @@
"""Backward-compatible shim package.
The agent middleware now lives in the shared kernel at
``app.agents.shared.middleware``. This package re-exports it so frozen
single-agent code (``chat_deepagent`` and ``subagents/*``) keeps working
until that stack is retired.
"""
from app.agents.shared.middleware import (
ActionLogMiddleware,
AnonymousDocumentMiddleware,
BuiltinSkillsBackend,
BusyMutexMiddleware,
ClearToolUsesEdit,
DedupHITLToolCallsMiddleware,
DoomLoopMiddleware,
FileIntentMiddleware,
FlattenSystemMessageMiddleware,
KnowledgeBasePersistenceMiddleware,
KnowledgeBaseSearchMiddleware,
KnowledgePriorityMiddleware,
KnowledgeTreeMiddleware,
MemoryInjectionMiddleware,
NoopInjectionMiddleware,
OtelSpanMiddleware,
PermissionMiddleware,
RetryAfterMiddleware,
SearchSpaceSkillsBackend,
SpillingContextEditingMiddleware,
SpillToBackendEdit,
SurfSenseCompactionMiddleware,
SurfSenseFilesystemMiddleware,
ToolCallNameRepairMiddleware,
build_skills_backend_factory,
commit_staged_filesystem_state,
create_surfsense_compaction_middleware,
default_skills_sources,
)
__all__ = [
"ActionLogMiddleware",
"AnonymousDocumentMiddleware",
"BuiltinSkillsBackend",
"BusyMutexMiddleware",
"ClearToolUsesEdit",
"DedupHITLToolCallsMiddleware",
"DoomLoopMiddleware",
"FileIntentMiddleware",
"FlattenSystemMessageMiddleware",
"KnowledgeBasePersistenceMiddleware",
"KnowledgeBaseSearchMiddleware",
"KnowledgePriorityMiddleware",
"KnowledgeTreeMiddleware",
"MemoryInjectionMiddleware",
"NoopInjectionMiddleware",
"OtelSpanMiddleware",
"PermissionMiddleware",
"RetryAfterMiddleware",
"SearchSpaceSkillsBackend",
"SpillToBackendEdit",
"SpillingContextEditingMiddleware",
"SurfSenseCompactionMiddleware",
"SurfSenseFilesystemMiddleware",
"ToolCallNameRepairMiddleware",
"build_skills_backend_factory",
"commit_staged_filesystem_state",
"create_surfsense_compaction_middleware",
"default_skills_sources",
]

View file

@ -1,17 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.middleware.permission``. Re-exported here for the
frozen single-agent stack (``chat_deepagent``/``subagents``).
"""
from app.agents.shared.middleware.permission import (
PatternResolver,
PermissionMiddleware,
_normalize_permission_decision,
)
__all__ = [
"PatternResolver",
"PermissionMiddleware",
"_normalize_permission_decision",
]

View file

@ -1,11 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.middleware.scoped_model_fallback``. Re-exported here
for the frozen single-agent stack (``chat_deepagent``).
"""
from app.agents.shared.middleware.scoped_model_fallback import (
ScopedModelFallbackMiddleware,
)
__all__ = ["ScopedModelFallbackMiddleware"]

View file

@ -1,23 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.middleware.skills_backends``. Re-exported here for
the frozen single-agent stack (``subagents/config``).
"""
from app.agents.shared.middleware.skills_backends import (
SKILLS_BUILTIN_PREFIX,
SKILLS_SPACE_PREFIX,
BuiltinSkillsBackend,
SearchSpaceSkillsBackend,
build_skills_backend_factory,
default_skills_sources,
)
__all__ = [
"SKILLS_BUILTIN_PREFIX",
"SKILLS_SPACE_PREFIX",
"BuiltinSkillsBackend",
"SearchSpaceSkillsBackend",
"build_skills_backend_factory",
"default_skills_sources",
]

View file

@ -1,29 +0,0 @@
"""Backward-compatible shim.
The permission evaluator now lives in the shared agent kernel at
``app.agents.shared.permissions``. This module re-exports it so frozen
single-agent code (``chat_deepagent`` and ``subagents/*``) keeps working
until that stack is retired.
"""
from __future__ import annotations
from app.agents.shared.permissions import (
Rule,
RuleAction,
Ruleset,
aggregate_action,
evaluate,
evaluate_many,
wildcard_match,
)
__all__ = [
"Rule",
"RuleAction",
"Ruleset",
"aggregate_action",
"evaluate",
"evaluate_many",
"wildcard_match",
]

View file

@ -1,19 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.plugin_loader``. Re-exported here for the frozen
single-agent stack (``chat_deepagent``) until that stack is retired.
"""
from app.agents.shared.plugin_loader import (
PLUGIN_ENTRY_POINT_GROUP,
PluginContext,
load_allowed_plugin_names_from_env,
load_plugin_middlewares,
)
__all__ = [
"PLUGIN_ENTRY_POINT_GROUP",
"PluginContext",
"load_allowed_plugin_names_from_env",
"load_plugin_middlewares",
]

View file

@ -1,13 +0,0 @@
"""Backward-compatible shim.
The LiteLLM prompt-caching helper now lives in the shared agent kernel at
``app.agents.shared.prompt_caching``. This module re-exports it so frozen
single-agent code (``chat_deepagent``) keeps working until that stack is
retired.
"""
from __future__ import annotations
from app.agents.shared.prompt_caching import apply_litellm_prompt_caching
__all__ = ["apply_litellm_prompt_caching"]

View file

@ -1,33 +0,0 @@
"""Specialized user-facing subagents for the SurfSense agent.
The :class:`deepagents.SubAgentMiddleware` already provides the
materialization machinery (each :class:`deepagents.SubAgent` typed-dict
spec is compiled into an ephemeral runnable invoked via the ``task``
tool); what's specific to SurfSense is the *seeding* of those subagents
with declarative deny rules.
Per-subagent permission rules are injected as a
:class:`PermissionMiddleware` entry inside the subagent's ``middleware``
field. The auto-deny pattern (e.g. forbid ``task``/``todowrite``
recursion, block write tools for read-only research roles) is borrowed
from OpenCode's ``packages/opencode/src/tool/task.ts``, which has
analogous logic for restricting child sessions.
"""
from .config import (
build_connector_negotiator_subagent,
build_explore_subagent,
build_report_writer_subagent,
build_specialized_subagents,
)
from .providers.linear import build_linear_specialist_subagent
from .providers.slack import build_slack_specialist_subagent
__all__ = [
"build_connector_negotiator_subagent",
"build_explore_subagent",
"build_linear_specialist_subagent",
"build_report_writer_subagent",
"build_slack_specialist_subagent",
"build_specialized_subagents",
]

View file

@ -1,436 +0,0 @@
"""Builders for specialized SurfSense subagents.
Each subagent is built from three pieces:
1. A name + description + system prompt (the user-facing contract for
when ``task`` should delegate to this role).
2. A filtered tool list (subset of the parent's bound tools).
3. A :class:`PermissionMiddleware` instance carrying a deny ruleset that
prevents the subagent from acting outside its scope (e.g. an
explore-only role cannot mutate state).
Skill sources (``/skills/builtin/`` + ``/skills/space/``) are inherited
from the parent unconditionally every subagent benefits from the same
authored guidance documents.
"""
from __future__ import annotations
import logging
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING, Any
from app.agents.new_chat.middleware.skills_backends import default_skills_sources
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.new_chat.subagents.providers.linear import (
build_linear_specialist_subagent,
)
from app.agents.new_chat.subagents.providers.slack import (
build_slack_specialist_subagent,
)
if TYPE_CHECKING:
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool name constants
# ---------------------------------------------------------------------------
# Read-only tools that ``explore`` is permitted to use. Names match the
# tools provided by the deepagents ``FilesystemMiddleware`` (``ls``, ``read_file``,
# ``glob``, ``grep``) plus the SurfSense-side read tools.
EXPLORE_READ_TOOLS: frozenset[str] = frozenset(
{
"web_search",
"scrape_webpage",
"read_file",
"ls",
"glob",
"grep",
}
)
# Tools ``report_writer`` may call. The set is intentionally narrow so the
# subagent doesn't drift into tangential research; if richer source-gathering
# is needed, the parent should hand off to ``explore`` first.
REPORT_WRITER_TOOLS: frozenset[str] = frozenset(
{
"read_file",
"generate_report",
}
)
# Wildcard patterns that match write tools we deny by default in read-only
# subagents. Anchored at start AND end via :func:`Rule` semantics. We use
# substring-style ``*verb*`` patterns because connector tool names typically
# put the verb in the middle (``linear_create_issue``, ``slack_send_message``,
# ``notion_update_page``); strict suffix patterns (``*_create``) miss those.
#
# A handful of canonical exact-match names is appended so that bare verbs
# (``edit``, ``write``) are also blocked even when a connector dropped the
# usual prefix.
WRITE_TOOL_DENY_PATTERNS: tuple[str, ...] = (
"*create*",
"*update*",
"*delete*",
"*send*",
"*write*",
"*edit*",
"*move*",
"*mkdir*",
"*upload*",
"edit_file",
"write_file",
"move_file",
"mkdir",
"rm",
"rmdir",
"update_memory",
"update_memory_team",
"update_memory_private",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Tool names that are NOT in the registry's ``tools`` list because they
# are provided dynamically by middleware at compile time. We don't pass
# them through ``_filter_tools`` (the actual ``BaseTool`` instances live
# inside the middleware), but we do exempt them from the "missing" warning
# below — operators were seeing spurious noise like
# ``missing: ['glob', 'grep', 'ls', 'read_file']`` even though those
# tools are reachable via :class:`SurfSenseFilesystemMiddleware` once the
# subagent is compiled.
_MIDDLEWARE_PROVIDED_TOOL_NAMES: frozenset[str] = frozenset(
{
"ls",
"read_file",
"write_file",
"edit_file",
"glob",
"grep",
"execute",
"write_todos",
"task",
}
)
def _filter_tools(
tools: Sequence[BaseTool],
allowed_names: Iterable[str],
) -> list[BaseTool]:
"""Return only tools whose ``name`` appears in ``allowed_names``.
Tools are looked up by exact name. Names matching
:data:`_MIDDLEWARE_PROVIDED_TOOL_NAMES` are intentionally absent from
``tools`` (they're injected by middleware at compile time) and are
silently excluded from the "missing" warning so operators don't see
false positives every build.
"""
allowed = set(allowed_names)
selected = [t for t in tools if t.name in allowed]
missing = sorted(
(allowed - {t.name for t in selected}) - _MIDDLEWARE_PROVIDED_TOOL_NAMES
)
if missing:
logger.info(
"Subagent build: %d/%d registry tools available; missing: %s",
len(selected),
len(allowed - _MIDDLEWARE_PROVIDED_TOOL_NAMES),
missing,
)
return selected
def _read_only_deny_rules() -> list[Rule]:
"""Synthesize a list of deny rules covering common write-tool patterns."""
return [
Rule(permission=pattern, pattern="*", action="deny")
for pattern in WRITE_TOOL_DENY_PATTERNS
]
def _build_permission_middleware(deny_rules: list[Rule], origin: str):
"""Construct a :class:`PermissionMiddleware` seeded with ``deny_rules``.
Imported lazily because the middleware module pulls in interrupt/HITL
machinery we don't want at import time of this config file.
"""
from app.agents.new_chat.middleware.permission import PermissionMiddleware
return PermissionMiddleware(
rulesets=[Ruleset(rules=deny_rules, origin=origin)],
)
def _wrap_with_subagent_essentials(
custom_middleware: list,
*,
agent_tools: Sequence[BaseTool],
extra_middleware: Sequence[Any] | None = None,
):
"""Compose the final middleware list for a specialized subagent.
Order, outer to inner:
1. ``extra_middleware`` provided by the caller (typically the parent
agent's ``SurfSenseFilesystemMiddleware`` and ``TodoListMiddleware``)
so the subagent inherits the parent's filesystem/todo view. These
run **before** the subagent-local middleware so their tools are
wired up before permissioning kicks in.
2. ``custom_middleware`` subagent-local rules (e.g. permission deny
lists).
3. :class:`PatchToolCallsMiddleware` normalizes tool-call shapes.
4. :class:`DedupHITLToolCallsMiddleware` collapses duplicate HITL
calls using metadata declared at registry time.
Without ``extra_middleware`` the subagent will only have the registry
tools listed in its ``tools`` field meaning ``read_file``, ``ls``,
``grep``, etc. won't exist. Always pass ``extra_middleware`` from the
parent unless you specifically want a sandboxed subagent.
"""
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware
return [
*(extra_middleware or []),
*custom_middleware,
PatchToolCallsMiddleware(),
DedupHITLToolCallsMiddleware(agent_tools=list(agent_tools)),
]
# ---------------------------------------------------------------------------
# System prompts
# ---------------------------------------------------------------------------
EXPLORE_SYSTEM_PROMPT = """You are the **explore** subagent for SurfSense.
## Your job
Conduct read-only research across the user's knowledge base, the web, and any documents the parent agent has surfaced. Return a synthesized answer with explicit citations — never speculate beyond the sources you have actually inspected.
## Tools available
- `web_search` only when the user's KB clearly does not contain the answer.
- `scrape_webpage` to read a URL the user or the search results provided.
- `read_file`, `ls`, `glob`, `grep` to inspect specific documents or trees the parent has flagged.
## Rules
- Read-only. You cannot create, edit, delete, send, or move anything.
- Cite every claim. Use `[citation:chunk_id]` exactly as the chunk tag specifies.
- If a sub-question has no support in the inspected sources, say so explicitly. Do not fabricate.
- Return the most useful synthesis in your single final message. The parent agent will not be able to follow up.
"""
REPORT_WRITER_SYSTEM_PROMPT = """You are the **report_writer** subagent for SurfSense.
## Your job
Produce a single high-quality report deliverable using `generate_report`. The parent has already gathered (or knows where to gather) the underlying sources.
## Workflow
1. **Outline first.** Before calling `generate_report`, write a one-paragraph outline of the sections you plan to produce. Confirm the outline reflects the parent's instructions.
2. **Source resolution.** Decide whether to call `read_file` for any final-checks, or whether the parent's earlier tool calls already cover the source set.
3. **One report.** Call `generate_report` exactly once with `source_strategy` chosen per the topic and chat history (see the `report-writing` skill).
4. **Confirm.** End with a one-sentence summary in your final message never paste the report back into chat; the artifact card renders itself.
"""
CONNECTOR_NEGOTIATOR_SYSTEM_PROMPT = """You are the **connector_negotiator** subagent for SurfSense.
## Your job
Coordinate cross-connector workflows: chains where the result of one service's tool feeds into another's. Common shapes include "find Linear issues mentioned in last week's Slack messages", "draft a Gmail reply citing a Notion doc", or "list Linear tickets opened by the same person who filed Jira FOO-123".
## Workflow
1. **Plan.** Identify the connector hops needed and the order they should run in. Write a short plan in your first message.
2. **Verify access.** Use `get_connected_accounts` to confirm the relevant connectors are actually wired up before issuing tool calls. If a connector is missing, stop and report do not fabricate.
3. **Execute.** Run each hop, citing IDs (issue keys, message ts, page IDs) in your scratch notes so the parent can audit.
4. **Hand back.** Return a structured summary with the final answer plus the chain of evidence (issue message page, etc.).
## Caveats
- If a hop fails, do not retry blindly return the partial result and explain.
- Mutating tools (create, update, delete, send) require parent permission; you are NOT cleared to call them on your own.
"""
# ---------------------------------------------------------------------------
# Subagent builders
# ---------------------------------------------------------------------------
def build_explore_subagent(
*,
tools: Sequence[BaseTool],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
) -> SubAgent:
"""Build the read-only ``explore`` subagent spec.
Pass ``extra_middleware`` (typically the parent's filesystem + todo
middleware) so the subagent can actually use ``read_file``, ``ls``,
``grep``, ``glob`` which its system prompt promises but which only
exist when their middleware is mounted.
"""
from deepagents import SubAgent # noqa: F401 (TypedDict for type clarity)
selected_tools = _filter_tools(tools, EXPLORE_READ_TOOLS)
deny_rules = _read_only_deny_rules()
permission_mw = _build_permission_middleware(deny_rules, origin="subagent_explore")
spec: dict = {
"name": "explore",
"description": (
"Read-only research across the user's knowledge base and the web. "
"Use when the parent needs deeply-cited synthesis without "
"modifying anything."
),
"system_prompt": EXPLORE_SYSTEM_PROMPT,
"tools": selected_tools,
"middleware": _wrap_with_subagent_essentials(
[permission_mw],
agent_tools=selected_tools,
extra_middleware=extra_middleware,
),
"skills": default_skills_sources(),
}
if model is not None:
spec["model"] = model
return spec # type: ignore[return-value]
def build_report_writer_subagent(
*,
tools: Sequence[BaseTool],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
) -> SubAgent:
"""Build the ``report_writer`` subagent spec.
Read-only deny ruleset still applies the subagent should call
``generate_report`` and nothing else mutating. ``generate_report``
creates a report artifact via a backend service and is intentionally
**not** denied.
Pass ``extra_middleware`` (typically the parent's filesystem + todo
middleware) so the subagent can run ``read_file`` for source-checks
before calling ``generate_report``.
"""
selected_tools = _filter_tools(tools, REPORT_WRITER_TOOLS)
deny_rules = _read_only_deny_rules()
permission_mw = _build_permission_middleware(
deny_rules, origin="subagent_report_writer"
)
spec: dict = {
"name": "report_writer",
"description": (
"Produce a single Markdown report artifact via generate_report, "
"using the outline-then-fill protocol. Use when the parent has "
"decided a deliverable is needed."
),
"system_prompt": REPORT_WRITER_SYSTEM_PROMPT,
"tools": selected_tools,
"middleware": _wrap_with_subagent_essentials(
[permission_mw],
agent_tools=selected_tools,
extra_middleware=extra_middleware,
),
"skills": default_skills_sources(),
}
if model is not None:
spec["model"] = model
return spec # type: ignore[return-value]
def build_connector_negotiator_subagent(
*,
tools: Sequence[BaseTool],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
) -> SubAgent:
"""Build the ``connector_negotiator`` subagent spec.
Inherits all MCP / connector tools the parent has plus
``get_connected_accounts``. Read-only by default; permission rules deny
write/mutation patterns. The parent agent re-asks for permission if a
connector mutation is genuinely needed.
Pass ``extra_middleware`` (typically the parent's filesystem + todo
middleware) so this subagent shares the parent's filesystem view when
citing evidence across hops.
"""
parent_tool_names = {t.name for t in tools}
allowed: set[str] = set()
if "get_connected_accounts" in parent_tool_names:
allowed.add("get_connected_accounts")
# Inherit anything that smells connector- or MCP-related but is not a
# bulk-write API. Heuristic: keep all parent tools; rely on the deny
# ruleset to block mutation patterns. This mirrors the plan: "all
# MCP/connector tools the parent has".
for name in parent_tool_names:
allowed.add(name)
selected_tools = _filter_tools(tools, allowed)
deny_rules = _read_only_deny_rules()
permission_mw = _build_permission_middleware(
deny_rules, origin="subagent_connector_negotiator"
)
spec: dict = {
"name": "connector_negotiator",
"description": (
"Coordinate read-only chains across connectors (Slack → Linear, "
"Notion → Gmail, etc.). Returns a structured summary with the "
"evidence chain. Cannot mutate connector state."
),
"system_prompt": CONNECTOR_NEGOTIATOR_SYSTEM_PROMPT,
"tools": selected_tools,
"middleware": _wrap_with_subagent_essentials(
[permission_mw],
agent_tools=selected_tools,
extra_middleware=extra_middleware,
),
"skills": default_skills_sources(),
}
if model is not None:
spec["model"] = model
return spec # type: ignore[return-value]
def build_specialized_subagents(
*,
tools: Sequence[BaseTool],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
) -> list[SubAgent]:
"""Return the canonical list of specialized subagents to register.
Order matters only for the order they appear in the ``task`` tool
description most useful first.
"""
return [
build_explore_subagent(
tools=tools, model=model, extra_middleware=extra_middleware
),
build_report_writer_subagent(
tools=tools, model=model, extra_middleware=extra_middleware
),
build_linear_specialist_subagent(
tools=tools, model=model, extra_middleware=extra_middleware
),
build_slack_specialist_subagent(
tools=tools, model=model, extra_middleware=extra_middleware
),
build_connector_negotiator_subagent(
tools=tools, model=model, extra_middleware=extra_middleware
),
]

View file

@ -1,35 +0,0 @@
"""Shared constants for provider subagent safety policies."""
from __future__ import annotations
# Generic mutation-deny patterns for read-only specialist roles.
WRITE_TOOL_DENY_PATTERNS: tuple[str, ...] = (
"*create*",
"*update*",
"*delete*",
"*send*",
"*write*",
"*edit*",
"*move*",
"*mkdir*",
"*upload*",
"edit_file",
"write_file",
"move_file",
"mkdir",
"update_memory",
"update_memory_team",
"update_memory_private",
)
# Tools that mutate virtual KB filesystem or parent/global chat state.
# Provider specialists should not mutate these surfaces directly.
NON_PROVIDER_STATE_MUTATION_DENY: frozenset[str] = frozenset(
{
# Exact tool names from shared deny patterns.
*{name for name in WRITE_TOOL_DENY_PATTERNS if "*" not in name},
# Additional non-provider state mutation controls.
"write_todos",
"task",
}
)

View file

@ -1,162 +0,0 @@
"""Linear provider specialist subagent.
This file is intentionally standalone so provider specialists can be reviewed
and evolved independently (one provider per file).
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.new_chat.subagents.constants import NON_PROVIDER_STATE_MUTATION_DENY
from app.services.mcp_oauth.registry import (
LINEAR_MCP_READONLY_TOOL_NAMES,
linear_mcp_original_tool_name,
)
if TYPE_CHECKING:
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
# Read vs write Linear MCP tools are defined in
# ``app.services.mcp_oauth.registry`` (``LINEAR_MCP_READONLY_TOOL_NAMES`` /
# ``LINEAR_MCP_WRITE_TOOL_NAMES``). Any other Linear-domain tool requires approval.
LINEAR_SYSTEM_PROMPT = """You are the linear_specialist subagent for SurfSense.
Role:
- You are the Linear domain specialist. Handle Linear-only requests accurately.
Primary objective:
- Resolve the user's Linear task and return a concise, auditable result.
Routing boundary:
- Use this subagent for Linear-domain tasks (issues, status, assignees, labels,
teams, and project references).
- If the task is primarily non-Linear or cross-connector orchestration, return
status=needs_input and hand control back to the parent with the exact next hop.
Execution steps:
1) Verify Linear access first (use get_connected_accounts if needed).
2) Prefer read/list tools first to gather current issue facts before concluding.
3) Track key identifiers in your reasoning: issue ID, issue key, team ID, label ID.
4) If required identifiers are missing, ask the parent for exactly what is missing.
5) Return a compact result with findings + evidence references.
Output format:
- status: success | needs_input | blocked | error
- summary: one short paragraph
- evidence: bullet list of concrete IDs / issue keys used
- next_step: one sentence (only when blocked or needs_input)
Constraints:
- Do not invent issue keys, IDs, or workflow state names.
- Mutating Linear operations are allowed only with explicit approval.
- If Linear connector access is unavailable, stop and return status=blocked.
"""
def _select_linear_tools(tools: Sequence[BaseTool]) -> list[BaseTool]:
"""Keep Linear tools plus minimal shared read utilities."""
allowed_exact = {
"get_connected_accounts",
"read_file",
"ls",
"glob",
"grep",
}
selected: list[BaseTool] = []
for tool in tools:
if tool.name in allowed_exact:
selected.append(tool)
continue
if linear_mcp_original_tool_name(tool.name) is not None:
selected.append(tool)
continue
if tool.name.startswith("linear_") or tool.name.endswith("_linear_issue"):
selected.append(tool)
return selected
def _is_linear_readonly_tool_name(name: str) -> bool:
"""Return True when a tool name maps to a read-only Linear MCP operation."""
base = linear_mcp_original_tool_name(name)
return base is not None and base in LINEAR_MCP_READONLY_TOOL_NAMES
def _is_linear_domain_tool_name(name: str) -> bool:
"""Return True for Linear-domain tools handled by this specialist."""
if linear_mcp_original_tool_name(name) is not None:
return True
return name.startswith("linear_") or name.endswith("_linear_issue")
def _permission_middleware(*, selected_tools: Sequence[BaseTool]) -> Any:
"""Permission policy for Linear specialist."""
from app.agents.new_chat.middleware.permission import PermissionMiddleware
ask_tools = sorted(
{
tool.name
for tool in selected_tools
if _is_linear_domain_tool_name(tool.name)
and not _is_linear_readonly_tool_name(tool.name)
}
)
rules: list[Rule] = [Rule(permission="*", pattern="*", action="allow")]
rules.extend(
Rule(permission=name, pattern="*", action="deny")
for name in NON_PROVIDER_STATE_MUTATION_DENY
)
rules.extend(Rule(permission=name, pattern="*", action="ask") for name in ask_tools)
return PermissionMiddleware(
rulesets=[Ruleset(rules=rules, origin="subagent_linear_specialist")]
)
def _wrap_subagent_middleware(
*,
selected_tools: Sequence[BaseTool],
extra_middleware: Sequence[Any] | None,
) -> list[Any]:
"""Apply standard middleware chain used by other subagents."""
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware
return [
*(extra_middleware or []),
_permission_middleware(selected_tools=selected_tools),
PatchToolCallsMiddleware(),
DedupHITLToolCallsMiddleware(agent_tools=list(selected_tools)),
]
def build_linear_specialist_subagent(
*,
tools: Sequence[BaseTool],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
) -> SubAgent:
"""Build the ``linear_specialist`` provider subagent spec."""
selected_tools = _select_linear_tools(tools)
spec: dict[str, Any] = {
"name": "linear_specialist",
"description": (
"Linear operations specialist for issue and workflow requests, "
"with strict evidence tracking and approval-gated mutating operations."
),
"system_prompt": LINEAR_SYSTEM_PROMPT,
"tools": selected_tools,
"middleware": _wrap_subagent_middleware(
selected_tools=selected_tools,
extra_middleware=extra_middleware,
),
}
if model is not None:
spec["model"] = model
return spec # type: ignore[return-value]

View file

@ -1,170 +0,0 @@
"""Slack provider specialist subagent.
This file is intentionally standalone so provider specialists can be reviewed
and evolved independently (one provider per file).
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from app.agents.new_chat.permissions import Rule, Ruleset
from app.agents.new_chat.subagents.constants import NON_PROVIDER_STATE_MUTATION_DENY
if TYPE_CHECKING:
from deepagents import SubAgent
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
# Official references:
# - https://docs.slack.dev/ai/slack-mcp-server
# - https://www.npmjs.com/package/@modelcontextprotocol/server-slack
#
# Policy: only known read-only Slack tools are auto-allowed. Any other
# ``slack_*`` tool is treated as mutating and requires explicit approval.
SLACK_READONLY_TOOL_NAMES: frozenset[str] = frozenset(
{
# Slack-hosted MCP read tools
"slack_search_channels",
"slack_read_channel",
"slack_read_thread",
"slack_read_canvas",
"slack_read_user_profile",
# modelcontextprotocol/server-slack read tools
"slack_list_channels",
"slack_get_channel_history",
"slack_get_thread_replies",
"slack_get_users",
"slack_get_user_profile",
}
)
SLACK_SYSTEM_PROMPT = """You are the slack_specialist subagent for SurfSense.
Role:
- You are the Slack domain specialist. Handle Slack-only requests accurately.
Primary objective:
- Resolve the user's Slack task and return a concise, auditable result.
Routing boundary:
- Use this subagent for Slack-domain tasks (channels, threads, users, messages,
and Slack canvases).
- If the task is primarily non-Slack or cross-connector orchestration, return
status=needs_input and hand control back to the parent with the exact next hop.
Execution steps:
1) Verify Slack access first (use get_connected_accounts if needed).
2) Prefer read/list tools first to gather facts before concluding.
3) Track key identifiers in your reasoning: channel ID, message ts, thread ts, user ID.
4) If required identifiers are missing, ask the parent for exactly what is missing.
5) Return a compact result with findings + evidence references.
Output format:
- status: success | needs_input | blocked | error
- summary: one short paragraph
- evidence: bullet list of concrete IDs / timestamps used
- next_step: one sentence (only when blocked or needs_input)
Constraints:
- Do not invent Slack IDs, channels, users, or message content.
- Mutating Slack operations are allowed only with explicit approval.
- If Slack connector access is unavailable, stop and return status=blocked.
"""
def _select_slack_tools(tools: Sequence[BaseTool]) -> list[BaseTool]:
"""Keep Slack tools plus minimal shared read utilities."""
allowed_exact = {
"get_connected_accounts",
"read_file",
"ls",
"glob",
"grep",
}
slack_prefix = "slack_"
selected: list[BaseTool] = []
for tool in tools:
if tool.name in allowed_exact:
selected.append(tool)
continue
if tool.name.startswith(slack_prefix):
selected.append(tool)
return selected
def _permission_middleware(*, selected_tools: Sequence[BaseTool]) -> Any:
"""Permission policy for Slack specialist.
Intent:
- Allow Slack-domain operations by default.
- Gate Slack mutating operations behind approval (`ask`).
- Hard-deny non-Slack state mutations, especially KB virtual filesystem
mutation and parent-context mutation tools.
"""
from app.agents.new_chat.middleware.permission import PermissionMiddleware
ask_tools = sorted(
{
tool.name
for tool in selected_tools
if tool.name.startswith("slack_")
and tool.name not in SLACK_READONLY_TOOL_NAMES
}
)
rules: list[Rule] = [Rule(permission="*", pattern="*", action="allow")]
rules.extend(
Rule(permission=name, pattern="*", action="deny")
for name in NON_PROVIDER_STATE_MUTATION_DENY
)
rules.extend(Rule(permission=name, pattern="*", action="ask") for name in ask_tools)
return PermissionMiddleware(
rulesets=[Ruleset(rules=rules, origin="subagent_slack_specialist")]
)
def _wrap_subagent_middleware(
*,
selected_tools: Sequence[BaseTool],
extra_middleware: Sequence[Any] | None,
) -> list[Any]:
"""Apply standard middleware chain used by other subagents."""
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware
return [
*(extra_middleware or []),
_permission_middleware(selected_tools=selected_tools),
PatchToolCallsMiddleware(),
DedupHITLToolCallsMiddleware(agent_tools=list(selected_tools)),
]
def build_slack_specialist_subagent(
*,
tools: Sequence[BaseTool],
model: BaseChatModel | None = None,
extra_middleware: Sequence[Any] | None = None,
) -> SubAgent:
"""Build the ``slack_specialist`` provider subagent spec."""
selected_tools = _select_slack_tools(tools)
spec: dict[str, Any] = {
"name": "slack_specialist",
"description": (
"Slack operations specialist for any Slack-domain request "
"(channels, threads, users, and messages), with strict evidence "
"tracking and approval-gated mutating operations."
),
"system_prompt": SLACK_SYSTEM_PROMPT,
"tools": selected_tools,
"middleware": _wrap_subagent_middleware(
selected_tools=selected_tools,
extra_middleware=extra_middleware,
),
}
if model is not None:
spec["model"] = model
return spec # type: ignore[return-value]

View file

@ -1,29 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.system_prompt``. Re-exported here for the frozen
single-agent stack (``chat_deepagent``) until that stack is retired.
"""
from app.agents.shared.system_prompt import (
SURFSENSE_CITATION_INSTRUCTIONS,
SURFSENSE_NO_CITATION_INSTRUCTIONS,
SURFSENSE_SYSTEM_INSTRUCTIONS_TEMPLATE,
SURFSENSE_SYSTEM_PROMPT,
build_configurable_system_prompt,
build_surfsense_system_prompt,
compose_system_prompt,
detect_provider_variant,
get_default_system_instructions,
)
__all__ = [
"SURFSENSE_CITATION_INSTRUCTIONS",
"SURFSENSE_NO_CITATION_INSTRUCTIONS",
"SURFSENSE_SYSTEM_INSTRUCTIONS_TEMPLATE",
"SURFSENSE_SYSTEM_PROMPT",
"build_configurable_system_prompt",
"build_surfsense_system_prompt",
"compose_system_prompt",
"detect_provider_variant",
"get_default_system_instructions",
]

View file

@ -1,44 +0,0 @@
"""Backward-compatible shim package.
The agent tools now live in the shared kernel at ``app.agents.shared.tools``.
This package re-exports the public surface (and keeps ``invalid_tool`` /
``registry`` submodule shims) so the frozen single-agent stack
(``new_chat.__init__`` and ``chat_deepagent``) keeps working until that stack is
retired. All live code imports from ``app.agents.shared.tools`` directly.
"""
from app.agents.shared.tools import (
BUILTIN_TOOLS,
CONNECTOR_DESCRIPTIONS,
ToolDefinition,
build_tools,
create_generate_image_tool,
create_generate_podcast_tool,
create_generate_video_presentation_tool,
create_scrape_webpage_tool,
create_update_memory_tool,
create_update_team_memory_tool,
format_documents_for_context,
get_all_tool_names,
get_default_enabled_tools,
get_tool_by_name,
search_knowledge_base_async,
)
__all__ = [
"BUILTIN_TOOLS",
"CONNECTOR_DESCRIPTIONS",
"ToolDefinition",
"build_tools",
"create_generate_image_tool",
"create_generate_podcast_tool",
"create_generate_video_presentation_tool",
"create_scrape_webpage_tool",
"create_update_memory_tool",
"create_update_team_memory_tool",
"format_documents_for_context",
"get_all_tool_names",
"get_default_enabled_tools",
"get_tool_by_name",
"search_knowledge_base_async",
]

View file

@ -1,17 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.tools.invalid_tool``. Re-exported here for the
frozen single-agent stack (``chat_deepagent``) until that stack is retired.
"""
from app.agents.shared.tools.invalid_tool import (
INVALID_TOOL_DESCRIPTION,
INVALID_TOOL_NAME,
invalid_tool,
)
__all__ = [
"INVALID_TOOL_DESCRIPTION",
"INVALID_TOOL_NAME",
"invalid_tool",
]

View file

@ -1,19 +0,0 @@
"""Backward-compatible shim.
Moved to ``app.agents.shared.tools.registry``. Re-exported here for the frozen
single-agent stack (``chat_deepagent``) until that stack is retired.
"""
from app.agents.shared.tools.registry import (
BUILTIN_TOOLS,
ToolDefinition,
build_tools_async,
get_connector_gated_tools,
)
__all__ = [
"BUILTIN_TOOLS",
"ToolDefinition",
"build_tools_async",
"get_connector_gated_tools",
]