diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py index da8a62cdc..8f51ffed7 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/main_agent/checkpointed_subagent_middleware/middleware.py @@ -12,7 +12,6 @@ from deepagents.middleware.subagents import ( SubAgentMiddleware, ) from langchain.agents import create_agent -from langchain.agents.middleware import HumanInTheLoopMiddleware from langchain.chat_models import init_chat_model from langgraph.types import Checkpointer @@ -81,10 +80,6 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware): middleware: list[Any] = list(spec.get("middleware", [])) - interrupt_on = spec.get("interrupt_on") - if interrupt_on: - middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on)) - specs.append( { "name": spec["name"], diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py index bde5c1387..00220d269 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py @@ -9,9 +9,9 @@ matching OpenCode's ``permission/index.ts`` evaluation order): needs to *deny* what the user has explicitly forbidden; the default ``ask`` fallback would otherwise double-prompt every safe read-only call. -2. ``extra_rulesets`` — caller-supplied rulesets. The KB subagent contributes - its destructive-FS ``ask`` rules here; connectors will follow once - they migrate off ``interrupt_on``. +2. ``extra_rulesets`` — caller-supplied rulesets. Each subagent + contributes its own (KB: destructive-FS ``ask`` rules; connectors: + per-tool ``allow``/``ask``). Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules`` is intentionally NOT replicated: the multi-agent orchestrator already diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py index a4a1f84d4..76a6f14f3 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py @@ -9,7 +9,12 @@ from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool -from app.agents.new_chat.middleware import DedupHITLToolCallsMiddleware +from app.agents.multi_agent_chat.middleware.shared.permissions import ( + build_permission_mw, +) +from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.permissions import Ruleset def pack_subagent( @@ -18,27 +23,35 @@ def pack_subagent( description: str, system_prompt: str, tools: list[BaseTool], + ruleset: Ruleset, + flags: AgentFeatureFlags, model: BaseChatModel | None = None, middleware_stack: dict[str, Any] | None = None, - interrupt_on: dict[str, bool] | None = None, -) -> SubAgent: - """Pack the route-local pieces passed in into one sub-agent spec. +) -> SurfSenseSubagentSpec: + """Pack the route-local pieces into one sub-agent spec + its Ruleset. - ``middleware_stack`` is the shared subagent middleware stack (see - ``build_subagent_middleware_stack``). Every non-``None`` value is - prepended to this subagent's middleware list in insertion order. + Tool gating is uniformly performed by a per-subagent + :class:`PermissionMiddleware` built from the subagent's own + ``ruleset`` (layered on top of the SurfSense defaults). The shared + ``permission`` slot from ``middleware_stack`` is dropped so each + subagent owns its own rule surface. """ if not system_prompt.strip(): msg = f"Subagent {name!r}: system_prompt is empty" raise ValueError(msg) - prepended = [m for m in (middleware_stack or {}).values() if m is not None] - middleware: list[Any] = [ - *prepended, - PatchToolCallsMiddleware(), - DedupHITLToolCallsMiddleware(agent_tools=tools), - ] - spec: dict[str, Any] = { + per_subagent_perm = build_permission_mw(flags=flags, extra_rulesets=[ruleset]) + prepended: list[Any] = [] + for slot, mw in (middleware_stack or {}).items(): + if mw is None: + continue + if slot == "permission": + continue + prepended.append(mw) + if per_subagent_perm is not None: + prepended.append(per_subagent_perm) + middleware: list[Any] = [*prepended, PatchToolCallsMiddleware()] + spec_dict: dict[str, Any] = { "name": name, "description": description, "system_prompt": system_prompt, @@ -46,7 +59,5 @@ def pack_subagent( "middleware": middleware, } if model is not None: - spec["model"] = model - if interrupt_on: - spec["interrupt_on"] = interrupt_on - return cast(SubAgent, spec) + spec_dict["model"] = model + return SurfSenseSubagentSpec(spec=cast(SubAgent, spec_dict), ruleset=ruleset) diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py index 123bdc09f..4dc98665c 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py @@ -22,6 +22,8 @@ from langchain_core.outputs import ChatGeneration, ChatResult from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.permissions import Ruleset class RateLimitError(Exception): @@ -73,14 +75,17 @@ async def test_subagent_recovers_when_primary_llm_fails(): responses=[AIMessage(content="recovered via fallback")] ) - spec = pack_subagent( + result = pack_subagent( name="resilience_test", description="test subagent", system_prompt="be helpful", tools=[], + ruleset=Ruleset(origin="resilience_test", rules=[]), + flags=AgentFeatureFlags(), model=primary, middleware_stack={"fallback": ModelFallbackMiddleware(fallback)}, ) + spec = result.spec agent = create_agent( model=spec["model"],