multi_agent_chat: KB owns its ruleset, drop interrupt_on duplication

This commit is contained in:
CREDO23 2026-05-14 17:41:07 +02:00
parent d68280113b
commit adb52fb575
6 changed files with 150 additions and 61 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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(),
]

View file

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

View file

@ -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:

View file

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