mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +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 (
|
from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.ask_knowledge_base_tool import (
|
||||||
build_ask_knowledge_base_tool,
|
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.feature_flags import AgentFeatureFlags
|
||||||
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
from app.db import ChatVisibility
|
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.kb_context_projection import build_kb_context_projection_mw
|
||||||
from .shared.memory import build_memory_mw
|
from .shared.memory import build_memory_mw
|
||||||
from .shared.patch_tool_calls import build_patch_tool_calls_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.resilience import build_resilience_middlewares
|
||||||
from .shared.todos import build_todos_mw
|
from .shared.todos import build_todos_mw
|
||||||
from .subagent.middleware_stack import build_subagent_middleware_stack
|
from .subagent.middleware_stack import build_subagent_middleware_stack
|
||||||
|
|
@ -100,14 +101,19 @@ def build_main_agent_deepagent_middleware(
|
||||||
**subagent_dependencies,
|
**subagent_dependencies,
|
||||||
"backend_resolver": backend_resolver,
|
"backend_resolver": backend_resolver,
|
||||||
"filesystem_mode": filesystem_mode,
|
"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,
|
dependencies=subagent_dependencies,
|
||||||
model=llm,
|
model=llm,
|
||||||
middleware_stack=shared_subagent_middleware,
|
middleware_stack=shared_subagent_middleware,
|
||||||
)
|
)
|
||||||
|
kb_readonly_spec = kb_readonly.spec
|
||||||
kb_readonly_runnable = create_agent(
|
kb_readonly_runnable = create_agent(
|
||||||
llm,
|
llm,
|
||||||
system_prompt=kb_readonly_spec["system_prompt"],
|
system_prompt=kb_readonly_spec["system_prompt"],
|
||||||
|
|
@ -182,6 +188,7 @@ def build_main_agent_deepagent_middleware(
|
||||||
resilience.retry,
|
resilience.retry,
|
||||||
resilience.fallback,
|
resilience.fallback,
|
||||||
build_repair_mw(flags=flags, tools=tools),
|
build_repair_mw(flags=flags, tools=tools),
|
||||||
|
build_permission_mw(flags=flags),
|
||||||
build_doom_loop_mw(flags),
|
build_doom_loop_mw(flags),
|
||||||
build_action_log_mw(
|
build_action_log_mw(
|
||||||
flags=flags,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -7,41 +13,54 @@ from typing import Any, cast
|
||||||
from deepagents import SubAgent
|
from deepagents import SubAgent
|
||||||
from langchain_core.language_models import BaseChatModel
|
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.filesystem_selection import FilesystemMode
|
||||||
|
from app.agents.new_chat.permissions import Rule, Ruleset
|
||||||
|
|
||||||
from .middleware_stack import build_kb_middleware
|
from .middleware_stack import build_kb_middleware
|
||||||
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
|
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"
|
NAME = "knowledge_base"
|
||||||
READONLY_NAME = "knowledge_base_readonly"
|
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(
|
def build_subagent(
|
||||||
*,
|
*,
|
||||||
dependencies: dict[str, Any],
|
dependencies: dict[str, Any],
|
||||||
model: BaseChatModel | None = None,
|
model: BaseChatModel | None = None,
|
||||||
middleware_stack: dict[str, Any] | None = None,
|
middleware_stack: dict[str, Any] | None = None,
|
||||||
extra_tools_bucket: ToolsPermissions | None = None, # noqa: ARG001 — KB ships fixed tools
|
extra_tools_bucket: ToolsPermissions | None = None,
|
||||||
) -> SubAgent:
|
) -> SurfSenseSubagentSpec:
|
||||||
|
del extra_tools_bucket
|
||||||
llm = model if model is not None else dependencies["llm"]
|
llm = model if model is not None else dependencies["llm"]
|
||||||
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
||||||
spec: dict[str, Any] = {
|
spec = cast(
|
||||||
"name": NAME,
|
SubAgent,
|
||||||
"description": load_description(),
|
{
|
||||||
"system_prompt": load_system_prompt(filesystem_mode),
|
"name": NAME,
|
||||||
"model": llm,
|
"description": load_description(),
|
||||||
"tools": [],
|
"system_prompt": load_system_prompt(filesystem_mode),
|
||||||
"middleware": build_kb_middleware(
|
"model": llm,
|
||||||
llm=llm,
|
"tools": [],
|
||||||
dependencies=dependencies,
|
"middleware": build_kb_middleware(
|
||||||
middleware_stack=middleware_stack,
|
llm=llm,
|
||||||
read_only=False,
|
dependencies=dependencies,
|
||||||
),
|
middleware_stack=middleware_stack,
|
||||||
"interrupt_on": destructive_fs_interrupt_on(),
|
read_only=False,
|
||||||
}
|
ruleset=KB_RULESET,
|
||||||
return cast(SubAgent, spec)
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return SurfSenseSubagentSpec(spec=spec, ruleset=KB_RULESET)
|
||||||
|
|
||||||
|
|
||||||
def build_readonly_subagent(
|
def build_readonly_subagent(
|
||||||
|
|
@ -49,21 +68,24 @@ def build_readonly_subagent(
|
||||||
dependencies: dict[str, Any],
|
dependencies: dict[str, Any],
|
||||||
model: BaseChatModel | None = None,
|
model: BaseChatModel | None = None,
|
||||||
middleware_stack: dict[str, Any] | None = None,
|
middleware_stack: dict[str, Any] | None = None,
|
||||||
) -> SubAgent:
|
) -> SurfSenseSubagentSpec:
|
||||||
llm = model if model is not None else dependencies["llm"]
|
llm = model if model is not None else dependencies["llm"]
|
||||||
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
||||||
spec: dict[str, Any] = {
|
spec = cast(
|
||||||
"name": READONLY_NAME,
|
SubAgent,
|
||||||
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
|
{
|
||||||
"system_prompt": load_readonly_system_prompt(filesystem_mode),
|
"name": READONLY_NAME,
|
||||||
"model": llm,
|
"description": "Read-only knowledge_base specialist (invoked via ask_knowledge_base).",
|
||||||
"tools": [],
|
"system_prompt": load_readonly_system_prompt(filesystem_mode),
|
||||||
"middleware": build_kb_middleware(
|
"model": llm,
|
||||||
llm=llm,
|
"tools": [],
|
||||||
dependencies=dependencies,
|
"middleware": build_kb_middleware(
|
||||||
middleware_stack=middleware_stack,
|
llm=llm,
|
||||||
read_only=True,
|
dependencies=dependencies,
|
||||||
),
|
middleware_stack=middleware_stack,
|
||||||
"interrupt_on": {},
|
read_only=True,
|
||||||
}
|
ruleset=None,
|
||||||
return cast(SubAgent, spec)
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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
|
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 (
|
from app.agents.multi_agent_chat.middleware.shared.patch_tool_calls import (
|
||||||
build_patch_tool_calls_mw,
|
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.filesystem_selection import FilesystemMode
|
||||||
|
from app.agents.new_chat.permissions import Ruleset
|
||||||
|
|
||||||
|
|
||||||
def build_kb_middleware(
|
def build_kb_middleware(
|
||||||
|
|
@ -30,9 +40,18 @@ def build_kb_middleware(
|
||||||
dependencies: dict[str, Any],
|
dependencies: dict[str, Any],
|
||||||
middleware_stack: dict[str, Any] | None,
|
middleware_stack: dict[str, Any] | None,
|
||||||
read_only: bool,
|
read_only: bool,
|
||||||
|
ruleset: Ruleset | None = None,
|
||||||
) -> list[Any]:
|
) -> 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 {}
|
mws = middleware_stack or {}
|
||||||
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
||||||
|
flags: AgentFeatureFlags | None = dependencies.get("flags")
|
||||||
resilience_mws = [
|
resilience_mws = [
|
||||||
m
|
m
|
||||||
for m in (
|
for m in (
|
||||||
|
|
@ -43,6 +62,11 @@ def build_kb_middleware(
|
||||||
)
|
)
|
||||||
if m is not None
|
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 [
|
return [
|
||||||
mws["todos"],
|
mws["todos"],
|
||||||
build_kb_context_projection_mw(),
|
build_kb_context_projection_mw(),
|
||||||
|
|
@ -56,6 +80,7 @@ def build_kb_middleware(
|
||||||
),
|
),
|
||||||
build_compaction_mw(llm),
|
build_compaction_mw(llm),
|
||||||
build_patch_tool_calls_mw(),
|
build_patch_tool_calls_mw(),
|
||||||
|
*([permission_mw] if permission_mw is not None else []),
|
||||||
*resilience_mws,
|
*resilience_mws,
|
||||||
build_anthropic_cache_mw(),
|
build_anthropic_cache_mw(),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
"""Route-local FS tool policy.
|
"""Route-local FS tool policy.
|
||||||
|
|
||||||
The KB subagent's actual ``BaseTool`` instances are provided at runtime by
|
The KB subagent's actual ``BaseTool`` instances are provided at runtime by
|
||||||
``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module only
|
``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module
|
||||||
carries policy that the subagent spec needs to declare up front — which
|
only carries the *names* of destructive ops so the agent can convert them
|
||||||
destructive ops require explicit user confirmation via ``interrupt_on``.
|
into permission rules — see :data:`KB_RULESET` in ``agent.py``.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -22,9 +17,4 @@ DESTRUCTIVE_FS_OPS: tuple[str, ...] = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def destructive_fs_interrupt_on() -> dict[str, bool]:
|
__all__ = ["DESTRUCTIVE_FS_OPS"]
|
||||||
"""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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
from app.agents.multi_agent_chat.subagents.shared.md_file_reader import (
|
||||||
read_md_file,
|
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,
|
ToolsPermissions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -84,7 +85,19 @@ class SubagentBuilder(Protocol):
|
||||||
model: BaseChatModel | None = None,
|
model: BaseChatModel | None = None,
|
||||||
middleware_stack: dict[str, Any] | None = None,
|
middleware_stack: dict[str, Any] | None = None,
|
||||||
extra_tools_bucket: ToolsPermissions | 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] = {
|
SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = {
|
||||||
|
|
@ -203,11 +216,13 @@ def build_subagents(
|
||||||
if name in excluded:
|
if name in excluded:
|
||||||
continue
|
continue
|
||||||
builder = SUBAGENT_BUILDERS_BY_NAME[name]
|
builder = SUBAGENT_BUILDERS_BY_NAME[name]
|
||||||
spec = builder(
|
spec = _unwrap_spec(
|
||||||
dependencies=dependencies,
|
builder(
|
||||||
model=model,
|
dependencies=dependencies,
|
||||||
middleware_stack=middleware_stack,
|
model=model,
|
||||||
extra_tools_bucket=mcp.get(name),
|
middleware_stack=middleware_stack,
|
||||||
|
extra_tools_bucket=mcp.get(name),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
_filter_disabled_tools_in_place(spec, disabled_names)
|
_filter_disabled_tools_in_place(spec, disabled_names)
|
||||||
if ask_kb_tool is not None:
|
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