mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
multi_agent_chat: KB owns its ruleset, drop interrupt_on duplication
This commit is contained in:
parent
d68280113b
commit
adb52fb575
6 changed files with 150 additions and 61 deletions
|
|
@ -31,7 +31,7 @@ from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.agent import
|
|||
from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.ask_knowledge_base_tool import (
|
||||
build_ask_knowledge_base_tool,
|
||||
)
|
||||
from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions
|
||||
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import ToolsPermissions
|
||||
from app.agents.new_chat.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||
from app.db import ChatVisibility
|
||||
|
|
@ -61,6 +61,7 @@ from .shared.compaction import build_compaction_mw
|
|||
from .shared.kb_context_projection import build_kb_context_projection_mw
|
||||
from .shared.memory import build_memory_mw
|
||||
from .shared.patch_tool_calls import build_patch_tool_calls_mw
|
||||
from .shared.permissions import build_permission_mw
|
||||
from .shared.resilience import build_resilience_middlewares
|
||||
from .shared.todos import build_todos_mw
|
||||
from .subagent.middleware_stack import build_subagent_middleware_stack
|
||||
|
|
@ -100,14 +101,19 @@ def build_main_agent_deepagent_middleware(
|
|||
**subagent_dependencies,
|
||||
"backend_resolver": backend_resolver,
|
||||
"filesystem_mode": filesystem_mode,
|
||||
"flags": flags,
|
||||
}
|
||||
shared_subagent_middleware = build_subagent_middleware_stack(resilience=resilience)
|
||||
shared_subagent_middleware = build_subagent_middleware_stack(
|
||||
resilience=resilience,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
kb_readonly_spec = build_kb_readonly_subagent(
|
||||
kb_readonly = build_kb_readonly_subagent(
|
||||
dependencies=subagent_dependencies,
|
||||
model=llm,
|
||||
middleware_stack=shared_subagent_middleware,
|
||||
)
|
||||
kb_readonly_spec = kb_readonly.spec
|
||||
kb_readonly_runnable = create_agent(
|
||||
llm,
|
||||
system_prompt=kb_readonly_spec["system_prompt"],
|
||||
|
|
@ -182,6 +188,7 @@ def build_main_agent_deepagent_middleware(
|
|||
resilience.retry,
|
||||
resilience.fallback,
|
||||
build_repair_mw(flags=flags, tools=tools),
|
||||
build_permission_mw(flags=flags),
|
||||
build_doom_loop_mw(flags),
|
||||
build_action_log_mw(
|
||||
flags=flags,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
"""`knowledge_base` route: full and read-only ``SubAgent`` specs."""
|
||||
"""``knowledge_base`` route: full and read-only ``SurfSenseSubagentSpec`` builders.
|
||||
|
||||
KB owns the destructive-FS approval policy: rules live in :data:`KB_RULESET`
|
||||
and are layered into KB's :class:`PermissionMiddleware` (built inside
|
||||
``build_kb_middleware``). The legacy ``interrupt_on`` kwarg is gone — one
|
||||
emitter, one wire format, one source of truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -7,41 +13,54 @@ from typing import Any, cast
|
|||
from deepagents import SubAgent
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from app.agents.multi_agent_chat.subagents.shared.permissions import ToolsPermissions
|
||||
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
|
||||
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import ToolsPermissions
|
||||
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
||||
|
||||
from .middleware_stack import build_kb_middleware
|
||||
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
|
||||
from .tools.index import destructive_fs_interrupt_on
|
||||
from .tools.index import DESTRUCTIVE_FS_OPS
|
||||
|
||||
NAME = "knowledge_base"
|
||||
READONLY_NAME = "knowledge_base_readonly"
|
||||
|
||||
KB_RULESET = Ruleset(
|
||||
origin=NAME,
|
||||
rules=[Rule(permission=op, pattern="*", action="ask") for op in DESTRUCTIVE_FS_OPS],
|
||||
)
|
||||
|
||||
_KB_READONLY_RULESET = Ruleset(origin=READONLY_NAME, rules=[])
|
||||
|
||||
|
||||
def build_subagent(
|
||||
*,
|
||||
dependencies: dict[str, Any],
|
||||
model: BaseChatModel | None = None,
|
||||
middleware_stack: dict[str, Any] | None = None,
|
||||
extra_tools_bucket: ToolsPermissions | None = None, # noqa: ARG001 — KB ships fixed tools
|
||||
) -> SubAgent:
|
||||
extra_tools_bucket: ToolsPermissions | None = None,
|
||||
) -> SurfSenseSubagentSpec:
|
||||
del extra_tools_bucket
|
||||
llm = model if model is not None else dependencies["llm"]
|
||||
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
||||
spec: dict[str, Any] = {
|
||||
"name": NAME,
|
||||
"description": load_description(),
|
||||
"system_prompt": load_system_prompt(filesystem_mode),
|
||||
"model": llm,
|
||||
"tools": [],
|
||||
"middleware": build_kb_middleware(
|
||||
llm=llm,
|
||||
dependencies=dependencies,
|
||||
middleware_stack=middleware_stack,
|
||||
read_only=False,
|
||||
),
|
||||
"interrupt_on": destructive_fs_interrupt_on(),
|
||||
}
|
||||
return cast(SubAgent, spec)
|
||||
spec = cast(
|
||||
SubAgent,
|
||||
{
|
||||
"name": NAME,
|
||||
"description": load_description(),
|
||||
"system_prompt": load_system_prompt(filesystem_mode),
|
||||
"model": llm,
|
||||
"tools": [],
|
||||
"middleware": build_kb_middleware(
|
||||
llm=llm,
|
||||
dependencies=dependencies,
|
||||
middleware_stack=middleware_stack,
|
||||
read_only=False,
|
||||
ruleset=KB_RULESET,
|
||||
),
|
||||
},
|
||||
)
|
||||
return SurfSenseSubagentSpec(spec=spec, ruleset=KB_RULESET)
|
||||
|
||||
|
||||
def build_readonly_subagent(
|
||||
|
|
@ -49,21 +68,24 @@ def build_readonly_subagent(
|
|||
dependencies: dict[str, Any],
|
||||
model: BaseChatModel | None = None,
|
||||
middleware_stack: dict[str, Any] | None = None,
|
||||
) -> SubAgent:
|
||||
) -> SurfSenseSubagentSpec:
|
||||
llm = model if model is not None else dependencies["llm"]
|
||||
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
||||
spec: dict[str, Any] = {
|
||||
"name": READONLY_NAME,
|
||||
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
|
||||
"system_prompt": load_readonly_system_prompt(filesystem_mode),
|
||||
"model": llm,
|
||||
"tools": [],
|
||||
"middleware": build_kb_middleware(
|
||||
llm=llm,
|
||||
dependencies=dependencies,
|
||||
middleware_stack=middleware_stack,
|
||||
read_only=True,
|
||||
),
|
||||
"interrupt_on": {},
|
||||
}
|
||||
return cast(SubAgent, spec)
|
||||
spec = cast(
|
||||
SubAgent,
|
||||
{
|
||||
"name": READONLY_NAME,
|
||||
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
|
||||
"system_prompt": load_readonly_system_prompt(filesystem_mode),
|
||||
"model": llm,
|
||||
"tools": [],
|
||||
"middleware": build_kb_middleware(
|
||||
llm=llm,
|
||||
dependencies=dependencies,
|
||||
middleware_stack=middleware_stack,
|
||||
read_only=True,
|
||||
ruleset=None,
|
||||
),
|
||||
},
|
||||
)
|
||||
return SurfSenseSubagentSpec(spec=spec, ruleset=_KB_READONLY_RULESET)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
"""Middleware list shared by the full and read-only knowledge_base compiles."""
|
||||
"""Middleware list shared by the full and read-only knowledge_base compiles.
|
||||
|
||||
The KB-owned :class:`PermissionMiddleware` slot is what enforces
|
||||
"ask before destructive FS op" for KB tools — replacing the legacy
|
||||
``interrupt_on`` kwarg that used to live on the subagent spec.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -21,7 +26,12 @@ from app.agents.multi_agent_chat.middleware.shared.kb_context_projection import
|
|||
from app.agents.multi_agent_chat.middleware.shared.patch_tool_calls import (
|
||||
build_patch_tool_calls_mw,
|
||||
)
|
||||
from app.agents.multi_agent_chat.middleware.shared.permissions import (
|
||||
build_permission_mw,
|
||||
)
|
||||
from app.agents.new_chat.feature_flags import AgentFeatureFlags
|
||||
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||
from app.agents.new_chat.permissions import Ruleset
|
||||
|
||||
|
||||
def build_kb_middleware(
|
||||
|
|
@ -30,9 +40,18 @@ def build_kb_middleware(
|
|||
dependencies: dict[str, Any],
|
||||
middleware_stack: dict[str, Any] | None,
|
||||
read_only: bool,
|
||||
ruleset: Ruleset | None = None,
|
||||
) -> list[Any]:
|
||||
"""Compose the KB subagent's middleware list.
|
||||
|
||||
``ruleset`` is the KB-owned permission policy (typically the
|
||||
destructive-FS ask rules). When provided, a dedicated
|
||||
:class:`PermissionMiddleware` is appended so KB enforces approval at
|
||||
the rule layer instead of the legacy ``interrupt_on`` kwarg.
|
||||
"""
|
||||
mws = middleware_stack or {}
|
||||
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
||||
flags: AgentFeatureFlags | None = dependencies.get("flags")
|
||||
resilience_mws = [
|
||||
m
|
||||
for m in (
|
||||
|
|
@ -43,6 +62,11 @@ def build_kb_middleware(
|
|||
)
|
||||
if m is not None
|
||||
]
|
||||
permission_mw = (
|
||||
build_permission_mw(flags=flags, extra_rulesets=[ruleset])
|
||||
if (ruleset is not None and flags is not None)
|
||||
else None
|
||||
)
|
||||
return [
|
||||
mws["todos"],
|
||||
build_kb_context_projection_mw(),
|
||||
|
|
@ -56,6 +80,7 @@ def build_kb_middleware(
|
|||
),
|
||||
build_compaction_mw(llm),
|
||||
build_patch_tool_calls_mw(),
|
||||
*([permission_mw] if permission_mw is not None else []),
|
||||
*resilience_mws,
|
||||
build_anthropic_cache_mw(),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
"""Route-local FS tool policy.
|
||||
|
||||
The KB subagent's actual ``BaseTool`` instances are provided at runtime by
|
||||
``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module only
|
||||
carries policy that the subagent spec needs to declare up front — which
|
||||
destructive ops require explicit user confirmation via ``interrupt_on``.
|
||||
|
||||
Mirrors the ``desktop_safety`` ruleset in
|
||||
``multi_agent_chat.middleware.shared.permissions.context``: in desktop mode
|
||||
those rules guard the main-agent FS toolset; in cloud mode the same toolset
|
||||
lives on the KB subagent and the same policy is enforced here instead.
|
||||
``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module
|
||||
only carries the *names* of destructive ops so the agent can convert them
|
||||
into permission rules — see :data:`KB_RULESET` in ``agent.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,9 +17,4 @@ DESTRUCTIVE_FS_OPS: tuple[str, ...] = (
|
|||
)
|
||||
|
||||
|
||||
def destructive_fs_interrupt_on() -> dict[str, bool]:
|
||||
"""Fresh ``interrupt_on`` dict for the KB subagent spec."""
|
||||
return {op: True for op in DESTRUCTIVE_FS_OPS}
|
||||
|
||||
|
||||
__all__ = ["DESTRUCTIVE_FS_OPS", "destructive_fs_interrupt_on"]
|
||||
__all__ = ["DESTRUCTIVE_FS_OPS"]
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ from app.agents.multi_agent_chat.subagents.connectors.teams.agent import (
|
|||
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
|
||||
read_md_file,
|
||||
)
|
||||
from app.agents.multi_agent_chat.subagents.shared.permissions import (
|
||||
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
|
||||
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
|
||||
ToolsPermissions,
|
||||
)
|
||||
|
||||
|
|
@ -84,7 +85,19 @@ class SubagentBuilder(Protocol):
|
|||
model: BaseChatModel | None = None,
|
||||
middleware_stack: dict[str, Any] | None = None,
|
||||
extra_tools_bucket: ToolsPermissions | None = None,
|
||||
) -> SubAgent: ...
|
||||
) -> SubAgent | SurfSenseSubagentSpec: ...
|
||||
|
||||
|
||||
def _unwrap_spec(result: SubAgent | SurfSenseSubagentSpec) -> SubAgent:
|
||||
"""Project a builder's return value down to the deepagents-shaped dict.
|
||||
|
||||
Transitional helper while subagents migrate to ``SurfSenseSubagentSpec``.
|
||||
Once every builder returns the new container, this becomes a single
|
||||
``return result.spec``.
|
||||
"""
|
||||
if isinstance(result, SurfSenseSubagentSpec):
|
||||
return result.spec
|
||||
return result
|
||||
|
||||
|
||||
SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = {
|
||||
|
|
@ -203,11 +216,13 @@ def build_subagents(
|
|||
if name in excluded:
|
||||
continue
|
||||
builder = SUBAGENT_BUILDERS_BY_NAME[name]
|
||||
spec = builder(
|
||||
dependencies=dependencies,
|
||||
model=model,
|
||||
middleware_stack=middleware_stack,
|
||||
extra_tools_bucket=mcp.get(name),
|
||||
spec = _unwrap_spec(
|
||||
builder(
|
||||
dependencies=dependencies,
|
||||
model=model,
|
||||
middleware_stack=middleware_stack,
|
||||
extra_tools_bucket=mcp.get(name),
|
||||
)
|
||||
)
|
||||
_filter_disabled_tools_in_place(spec, disabled_names)
|
||||
if ask_kb_tool is not None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
"""SurfSense's subagent contribution: deepagents spec + permission policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from deepagents import SubAgent
|
||||
|
||||
from app.agents.new_chat.permissions import Ruleset
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SurfSenseSubagentSpec:
|
||||
"""A subagent contribution from a SurfSense route.
|
||||
|
||||
Attributes:
|
||||
spec: The deepagents-shaped dict handed to ``create_agent``. Holds
|
||||
only fields ``deepagents.SubAgent`` recognises.
|
||||
ruleset: Permission rules this subagent contributes. The orchestrator
|
||||
layers them into the subagent's :class:`PermissionMiddleware`,
|
||||
so each subagent owns its own policy without aliasing the
|
||||
shared rule engine.
|
||||
"""
|
||||
|
||||
spec: SubAgent
|
||||
ruleset: Ruleset
|
||||
|
||||
|
||||
__all__ = ["SurfSenseSubagentSpec"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue