multi_agent_chat: pack_subagent owns per-subagent PermissionMiddleware via Ruleset

This commit is contained in:
CREDO23 2026-05-14 20:09:29 +02:00
parent 67142e68b1
commit d45dfbfbd6
4 changed files with 38 additions and 27 deletions

View file

@ -12,7 +12,6 @@ from deepagents.middleware.subagents import (
SubAgentMiddleware, SubAgentMiddleware,
) )
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.chat_models import init_chat_model from langchain.chat_models import init_chat_model
from langgraph.types import Checkpointer from langgraph.types import Checkpointer
@ -81,10 +80,6 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
middleware: list[Any] = list(spec.get("middleware", [])) 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( specs.append(
{ {
"name": spec["name"], "name": spec["name"],

View file

@ -9,9 +9,9 @@ matching OpenCode's ``permission/index.ts`` evaluation order):
needs to *deny* what the user has explicitly forbidden; the default needs to *deny* what the user has explicitly forbidden; the default
``ask`` fallback would otherwise double-prompt every safe read-only ``ask`` fallback would otherwise double-prompt every safe read-only
call. call.
2. ``extra_rulesets`` caller-supplied rulesets. The KB subagent contributes 2. ``extra_rulesets`` caller-supplied rulesets. Each subagent
its destructive-FS ``ask`` rules here; connectors will follow once contributes its own (KB: destructive-FS ``ask`` rules; connectors:
they migrate off ``interrupt_on``. per-tool ``allow``/``ask``).
Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules`` Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules``
is intentionally NOT replicated: the multi-agent orchestrator already is intentionally NOT replicated: the multi-agent orchestrator already

View file

@ -9,7 +9,12 @@ from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from langchain_core.language_models import BaseChatModel from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool 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( def pack_subagent(
@ -18,27 +23,35 @@ def pack_subagent(
description: str, description: str,
system_prompt: str, system_prompt: str,
tools: list[BaseTool], tools: list[BaseTool],
ruleset: Ruleset,
flags: AgentFeatureFlags,
model: BaseChatModel | None = None, model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None, middleware_stack: dict[str, Any] | None = None,
interrupt_on: dict[str, bool] | None = None, ) -> SurfSenseSubagentSpec:
) -> SubAgent: """Pack the route-local pieces into one sub-agent spec + its Ruleset.
"""Pack the route-local pieces passed in into one sub-agent spec.
``middleware_stack`` is the shared subagent middleware stack (see Tool gating is uniformly performed by a per-subagent
``build_subagent_middleware_stack``). Every non-``None`` value is :class:`PermissionMiddleware` built from the subagent's own
prepended to this subagent's middleware list in insertion order. ``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(): if not system_prompt.strip():
msg = f"Subagent {name!r}: system_prompt is empty" msg = f"Subagent {name!r}: system_prompt is empty"
raise ValueError(msg) raise ValueError(msg)
prepended = [m for m in (middleware_stack or {}).values() if m is not None] per_subagent_perm = build_permission_mw(flags=flags, extra_rulesets=[ruleset])
middleware: list[Any] = [ prepended: list[Any] = []
*prepended, for slot, mw in (middleware_stack or {}).items():
PatchToolCallsMiddleware(), if mw is None:
DedupHITLToolCallsMiddleware(agent_tools=tools), continue
] if slot == "permission":
spec: dict[str, Any] = { 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, "name": name,
"description": description, "description": description,
"system_prompt": system_prompt, "system_prompt": system_prompt,
@ -46,7 +59,5 @@ def pack_subagent(
"middleware": middleware, "middleware": middleware,
} }
if model is not None: if model is not None:
spec["model"] = model spec_dict["model"] = model
if interrupt_on: return SurfSenseSubagentSpec(spec=cast(SubAgent, spec_dict), ruleset=ruleset)
spec["interrupt_on"] = interrupt_on
return cast(SubAgent, spec)

View file

@ -22,6 +22,8 @@ from langchain_core.outputs import ChatGeneration, ChatResult
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent, pack_subagent,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags
from app.agents.new_chat.permissions import Ruleset
class RateLimitError(Exception): class RateLimitError(Exception):
@ -73,14 +75,17 @@ async def test_subagent_recovers_when_primary_llm_fails():
responses=[AIMessage(content="recovered via fallback")] responses=[AIMessage(content="recovered via fallback")]
) )
spec = pack_subagent( result = pack_subagent(
name="resilience_test", name="resilience_test",
description="test subagent", description="test subagent",
system_prompt="be helpful", system_prompt="be helpful",
tools=[], tools=[],
ruleset=Ruleset(origin="resilience_test", rules=[]),
flags=AgentFeatureFlags(),
model=primary, model=primary,
middleware_stack={"fallback": ModelFallbackMiddleware(fallback)}, middleware_stack={"fallback": ModelFallbackMiddleware(fallback)},
) )
spec = result.spec
agent = create_agent( agent = create_agent(
model=spec["model"], model=spec["model"],