From adb52fb57550a252dae61c2ceadbd59a8b8179b9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 14 May 2026 17:41:07 +0200 Subject: [PATCH] multi_agent_chat: KB owns its ruleset, drop interrupt_on duplication --- .../multi_agent_chat/middleware/stack.py | 13 ++- .../builtins/knowledge_base/agent.py | 94 ++++++++++++------- .../knowledge_base/middleware_stack.py | 27 +++++- .../builtins/knowledge_base/tools/index.py | 18 +--- .../multi_agent_chat/subagents/registry.py | 29 ++++-- .../multi_agent_chat/subagents/shared/spec.py | 30 ++++++ 6 files changed, 150 insertions(+), 61 deletions(-) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py index cc52633fa..0b1e5f410 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/stack.py @@ -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, diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py index 555911910..9a99eae62 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py @@ -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) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py index 7b2d54c59..bbfe38bc3 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py @@ -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(), ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/index.py index 555160a64..eddde6ac2 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/tools/index.py @@ -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"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py index e3f4ca83b..27e3ba42d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/registry.py @@ -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: diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py new file mode 100644 index 000000000..e587b7b8c --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py @@ -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"]