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

View file

@ -87,7 +87,7 @@ class RateLimitError(Exception):
def _build_agent(primary: BaseChatModel, fallback: BaseChatModel):
from langchain.agents import create_agent
from app.agents.new_chat.middleware.scoped_model_fallback import (
from app.agents.shared.middleware.scoped_model_fallback import (
ScopedModelFallbackMiddleware,
)

View file

@ -1,117 +0,0 @@
"""Tests for ``_resolve_prompt_model_name`` in :mod:`app.agents.new_chat.chat_deepagent`.
The helper picks the model id fed to ``detect_provider_variant`` so the
right ``<provider_hints>`` block lands in the system prompt. The tests
below pin its preference order:
1. ``agent_config.litellm_params["base_model"]`` (Azure-correct).
2. ``agent_config.model_name``.
3. ``getattr(llm, "model", None)``.
Without (1) an Azure deployment named e.g. ``"prod-chat-001"`` would
silently miss every provider regex.
"""
from __future__ import annotations
import pytest
from app.agents.new_chat.chat_deepagent import _resolve_prompt_model_name
from app.agents.shared.llm_config import AgentConfig
pytestmark = pytest.mark.unit
def _make_cfg(**overrides) -> AgentConfig:
"""Build an ``AgentConfig`` with sensible defaults for the helper test."""
defaults = {
"provider": "OPENAI",
"model_name": "x",
"api_key": "k",
}
return AgentConfig(**{**defaults, **overrides})
class _FakeLLM:
"""Stand-in for a ``ChatLiteLLM`` / ``ChatLiteLLMRouter`` instance.
The resolver only reads the ``.model`` attribute via ``getattr``,
matching the established idiom in ``knowledge_search.py`` /
``stream_new_chat.py`` / ``document_summarizer.py``.
"""
def __init__(self, model: str | None) -> None:
self.model = model
def test_prefers_litellm_params_base_model_over_deployment_name() -> None:
"""Azure deployment slug must NOT shadow the underlying model family.
This is the failure mode the helper exists to prevent: a deployment
named ``"azure/prod-chat-001"`` would not match any provider regex
on its own, but the family ``"gpt-4o"`` lives in
``litellm_params["base_model"]`` and routes to ``openai_classic``.
"""
cfg = _make_cfg(
model_name="azure/prod-chat-001",
litellm_params={"base_model": "gpt-4o"},
)
assert _resolve_prompt_model_name(cfg, _FakeLLM("azure/prod-chat-001")) == "gpt-4o"
def test_falls_back_to_model_name_when_litellm_params_is_none() -> None:
cfg = _make_cfg(
model_name="anthropic/claude-3-5-sonnet",
litellm_params=None,
)
got = _resolve_prompt_model_name(cfg, _FakeLLM("anthropic/claude-3-5-sonnet"))
assert got == "anthropic/claude-3-5-sonnet"
def test_handles_litellm_params_without_base_model_key() -> None:
cfg = _make_cfg(
model_name="openai/gpt-4o",
litellm_params={"temperature": 0.5},
)
assert _resolve_prompt_model_name(cfg, _FakeLLM("openai/gpt-4o")) == "openai/gpt-4o"
def test_ignores_blank_base_model() -> None:
"""Whitespace-only ``base_model`` must not shadow ``model_name``."""
cfg = _make_cfg(
model_name="openai/gpt-4o",
litellm_params={"base_model": " "},
)
assert _resolve_prompt_model_name(cfg, _FakeLLM("openai/gpt-4o")) == "openai/gpt-4o"
def test_ignores_non_string_base_model() -> None:
"""Defensive: a non-string ``base_model`` should not crash the resolver."""
cfg = _make_cfg(
model_name="openai/gpt-4o",
litellm_params={"base_model": 42},
)
assert _resolve_prompt_model_name(cfg, _FakeLLM("openai/gpt-4o")) == "openai/gpt-4o"
def test_falls_back_to_llm_model_when_no_agent_config() -> None:
"""No ``agent_config`` -> use ``llm.model`` directly. Defensive path
for direct callers; production callers always supply a config."""
assert (
_resolve_prompt_model_name(None, _FakeLLM("openai/gpt-4o-mini"))
== "openai/gpt-4o-mini"
)
def test_returns_none_when_nothing_available() -> None:
"""``compose_system_prompt`` treats ``None`` as the ``"default"``
variant and emits no provider block."""
assert _resolve_prompt_model_name(None, _FakeLLM(None)) is None
def test_auto_mode_resolves_to_auto_string() -> None:
"""Auto mode -> ``"auto"``. ``detect_provider_variant("auto")``
returns ``"default"``, which is correct: the child model isn't
known until the LiteLLM Router dispatches."""
cfg = AgentConfig.from_auto_mode()
assert _resolve_prompt_model_name(cfg, _FakeLLM("auto")) == "auto"

View file

@ -1,337 +0,0 @@
"""Tests for the specialized subagents (explore / report_writer / connector_negotiator)."""
from __future__ import annotations
from langchain_core.tools import tool
from app.agents.shared.middleware.permission import PermissionMiddleware
from app.agents.new_chat.subagents import (
build_connector_negotiator_subagent,
build_explore_subagent,
build_report_writer_subagent,
build_specialized_subagents,
)
from app.agents.new_chat.subagents.config import (
EXPLORE_READ_TOOLS,
REPORT_WRITER_TOOLS,
WRITE_TOOL_DENY_PATTERNS,
)
# ---------------------------------------------------------------------------
# Fake tools used to verify filtering & permission behavior
# ---------------------------------------------------------------------------
@tool
def web_search(query: str) -> str:
"""Search the public web."""
return ""
@tool
def scrape_webpage(url: str) -> str:
"""Scrape a single webpage."""
return ""
@tool
def read_file(path: str) -> str:
"""Read a file."""
return ""
@tool
def ls_tree(path: str) -> str:
"""List a tree."""
return ""
@tool
def grep(pattern: str) -> str:
"""Grep."""
return ""
@tool
def update_memory(content: str) -> str:
"""Update the user's memory."""
return ""
@tool
def edit_file(path: str, old: str, new: str) -> str:
"""Edit a file."""
return ""
@tool
def linear_create_issue(title: str) -> str:
"""Create a Linear issue."""
return ""
@tool
def slack_send_message(channel: str, text: str) -> str:
"""Send a Slack message."""
return ""
@tool
def get_connected_accounts() -> str:
"""List connected accounts."""
return ""
@tool
def generate_report(topic: str) -> str:
"""Generate a report artifact."""
return ""
ALL_TOOLS = [
web_search,
scrape_webpage,
read_file,
ls_tree,
grep,
update_memory,
edit_file,
linear_create_issue,
slack_send_message,
get_connected_accounts,
generate_report,
]
class TestExploreSubagent:
def test_only_read_tools_are_exposed(self) -> None:
spec = build_explore_subagent(tools=ALL_TOOLS)
names = {t.name for t in spec["tools"]} # type: ignore[index]
assert names == EXPLORE_READ_TOOLS & {t.name for t in ALL_TOOLS}
assert "update_memory" not in names
assert "linear_create_issue" not in names
assert "edit_file" not in names
def test_includes_permission_middleware_with_deny_rules(self) -> None:
spec = build_explore_subagent(tools=ALL_TOOLS)
permission_mws = [
m
for m in spec["middleware"]
if isinstance(m, PermissionMiddleware) # type: ignore[index]
]
assert len(permission_mws) == 1
ruleset = permission_mws[0]._static_rulesets[0]
assert ruleset.origin == "subagent_explore"
deny_patterns = {r.permission for r in ruleset.rules if r.action == "deny"}
assert "update_memory" in deny_patterns
assert "edit_file" in deny_patterns
assert "*create*" in deny_patterns
assert "*send*" in deny_patterns
def test_skills_inherits_default_sources(self) -> None:
spec = build_explore_subagent(tools=ALL_TOOLS)
assert spec["skills"] == ["/skills/builtin/", "/skills/space/"] # type: ignore[index]
def test_name_and_description_match_contract(self) -> None:
spec = build_explore_subagent(tools=ALL_TOOLS)
assert spec["name"] == "explore"
assert "read-only" in spec["description"].lower()
def test_includes_dedup_and_patch_middleware(self) -> None:
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agents.shared.middleware import DedupHITLToolCallsMiddleware
spec = build_explore_subagent(tools=ALL_TOOLS)
types = {type(m) for m in spec["middleware"]} # type: ignore[index]
assert PatchToolCallsMiddleware in types
assert DedupHITLToolCallsMiddleware in types
class TestReportWriterSubagent:
def test_exposes_only_report_writing_tools(self) -> None:
spec = build_report_writer_subagent(tools=ALL_TOOLS)
names = {t.name for t in spec["tools"]} # type: ignore[index]
assert names == REPORT_WRITER_TOOLS & {t.name for t in ALL_TOOLS}
assert "generate_report" in names
assert "read_file" in names
def test_deny_rules_block_writes_but_allow_generate_report(self) -> None:
spec = build_report_writer_subagent(tools=ALL_TOOLS)
permission_mws = [
m
for m in spec["middleware"]
if isinstance(m, PermissionMiddleware) # type: ignore[index]
]
ruleset = permission_mws[0]._static_rulesets[0]
deny_patterns = {r.permission for r in ruleset.rules if r.action == "deny"}
assert "update_memory" in deny_patterns
# generate_report MUST not be denied — it's the whole point of the subagent.
assert "generate_report" not in deny_patterns
# No deny pattern should match `generate_report` either.
assert all(
not _wildcard_matches(pattern, "generate_report")
for pattern in deny_patterns
)
class TestConnectorNegotiatorSubagent:
def test_inherits_all_parent_tools(self) -> None:
spec = build_connector_negotiator_subagent(tools=ALL_TOOLS)
names = {t.name for t in spec["tools"]} # type: ignore[index]
# Every parent tool is inherited; the deny ruleset enforces behavior
# at execution time instead of trimming the tool list.
assert names == {t.name for t in ALL_TOOLS}
def test_get_connected_accounts_is_present(self) -> None:
spec = build_connector_negotiator_subagent(tools=ALL_TOOLS)
names = {t.name for t in spec["tools"]} # type: ignore[index]
assert "get_connected_accounts" in names
def test_deny_ruleset_blocks_mutating_connector_tools(self) -> None:
spec = build_connector_negotiator_subagent(tools=ALL_TOOLS)
permission_mws = [
m
for m in spec["middleware"]
if isinstance(m, PermissionMiddleware) # type: ignore[index]
]
ruleset = permission_mws[0]._static_rulesets[0]
deny_patterns = {r.permission for r in ruleset.rules if r.action == "deny"}
# `linear_create_issue` matches the `*_create` deny pattern.
assert any(_wildcard_matches(p, "linear_create_issue") for p in deny_patterns)
assert any(_wildcard_matches(p, "slack_send_message") for p in deny_patterns)
class TestBuildSpecializedSubagents:
def test_returns_five_specs(self) -> None:
specs = build_specialized_subagents(tools=ALL_TOOLS)
names = [s["name"] for s in specs] # type: ignore[index]
assert names == [
"explore",
"report_writer",
"linear_specialist",
"slack_specialist",
"connector_negotiator",
]
def test_all_specs_have_unique_names(self) -> None:
specs = build_specialized_subagents(tools=ALL_TOOLS)
names = [s["name"] for s in specs] # type: ignore[index]
assert len(set(names)) == len(names)
def test_extra_middleware_is_prepended_to_each_spec(self) -> None:
"""Sentinel middleware passed via ``extra_middleware`` must appear
in each subagent's ``middleware`` list, before the local rules.
This guards against the regression where specialized subagents
promised filesystem tools (``read_file``, ``ls``, ``grep``) in
their system prompts but had no filesystem middleware mounted.
"""
class _Sentinel:
pass
sentinel = _Sentinel()
specs = build_specialized_subagents(
tools=ALL_TOOLS, extra_middleware=[sentinel]
)
for spec in specs:
mws = spec["middleware"] # type: ignore[index]
assert sentinel in mws
# The sentinel must appear *before* the permission middleware
# (subagent-local rules), preserving the documented composition
# order: extra → custom → patch → dedup.
sentinel_idx = mws.index(sentinel)
perm_idx = next(
(i for i, m in enumerate(mws) if isinstance(m, PermissionMiddleware)),
None,
)
assert perm_idx is not None
assert sentinel_idx < perm_idx
class TestFilterToolsWarningSuppression:
"""Names provided by middleware (read_file, ls, grep, …) must not
trigger the spurious "missing" warning in :func:`_filter_tools`."""
def test_middleware_provided_names_are_silent(self, caplog) -> None:
import logging
from app.agents.new_chat.subagents.config import _filter_tools
with caplog.at_level(
logging.INFO, logger="app.agents.new_chat.subagents.config"
):
# Allowed set asks for two registry tools (one present, one
# not) plus a bunch of middleware-provided names.
_filter_tools(
[web_search],
allowed_names={
"web_search",
"scrape_webpage", # legitimately missing → should warn
"read_file", # mw-provided → suppressed
"ls",
"grep",
"glob",
"write_todos",
},
)
warnings = [r.message for r in caplog.records if r.levelno >= logging.INFO]
# Exactly one warning, and it should mention scrape_webpage but not
# any middleware-provided name. Inspect the rendered "missing"
# list (between the brackets) so we don't false-match substrings
# like ``ls`` inside ``available``.
assert len(warnings) == 1, warnings
msg = warnings[0]
assert "scrape_webpage" in msg
bracket_section = msg.split("missing: ", 1)[1]
for noisy in ("read_file", "ls", "grep", "glob", "write_todos"):
assert f"'{noisy}'" not in bracket_section, msg
class TestDenyPatternsCoverage:
def test_deny_patterns_cover_canonical_write_tools(self) -> None:
canonical_writes = [
"update_memory",
"edit_file",
"write_file",
"move_file",
"mkdir",
"linear_create_issue",
"linear_update_issue",
"linear_delete_issue",
"slack_send_message",
"create_index",
"update_account",
"delete_record",
"send_email",
]
for tool_name in canonical_writes:
assert any(
_wildcard_matches(pattern, tool_name)
for pattern in WRITE_TOOL_DENY_PATTERNS
), f"no deny pattern matches {tool_name!r}"
def test_deny_patterns_do_not_match_safe_read_tools(self) -> None:
canonical_reads = [
"read_file",
"ls_tree",
"grep",
"web_search",
"scrape_webpage",
"get_connected_accounts",
"generate_report",
]
for tool_name in canonical_reads:
assert not any(
_wildcard_matches(pattern, tool_name)
for pattern in WRITE_TOOL_DENY_PATTERNS
), f"deny pattern incorrectly matches read tool {tool_name!r}"
def _wildcard_matches(pattern: str, value: str) -> bool:
"""Helper using the same matcher the rule evaluator does."""
from app.agents.shared.permissions import wildcard_match
return wildcard_match(value, pattern)

View file

@ -436,39 +436,3 @@ def test_turn_status_sse_contract_exists():
assert 'type: "data-turn-status"' in state_source
assert 'case "data-turn-status":' in pipeline_source
assert "end_turn(str(chat_id))" in stream_source
def test_chat_deepagent_forwards_resolved_model_name_to_both_builders():
"""Regression guard: both system-prompt builders in chat_deepagent.py
must receive ``model_name=_resolve_prompt_model_name(...)`` so the
provider-variant dispatch can render the right ``<provider_hints>``
block. Without this the prompt silently falls back to the empty
``"default"`` variant the original bug being fixed.
This test mirrors :func:`test_stream_error_emission_keeps_machine_error_codes`
in style: it inspects module source text + a regex to enforce the
call-site shape, not just the wrapper layer (the wrappers already
forward ``model_name`` correctly, so testing them would not catch
the actual missed plumbing).
"""
import app.agents.new_chat.chat_deepagent as chat_deepagent_module
source = inspect.getsource(chat_deepagent_module)
# Helper itself must be defined.
assert "def _resolve_prompt_model_name(" in source
# Both builder calls must forward the resolved model name. Match
# across newlines + whitespace because the kwargs are split over
# multiple lines.
pattern = re.compile(
r"build_(?:surfsense|configurable)_system_prompt\([^)]*"
r"model_name=_resolve_prompt_model_name\(",
re.DOTALL,
)
matches = pattern.findall(source)
assert len(matches) == 2, (
"Expected both system-prompt builder call sites to forward "
"`model_name=_resolve_prompt_model_name(...)`, found "
f"{len(matches)}"
)