mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
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:
parent
724bbd6deb
commit
14bbea0854
29 changed files with 1 additions and 2930 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
|
@ -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"]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue