From 8eaab1297199cca331ff48793b410d7707030e0b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 14 May 2026 17:40:12 +0200 Subject: [PATCH] multi_agent_chat/permissions: restructure slice + simplify factory --- .../middleware/shared/permissions/__init__.py | 19 ++-- .../{interrupt => ask}/__init__.py | 0 .../shared/permissions/ask/decision.py | 73 +++++++++++++++ .../shared/permissions/ask/edit/__init__.py | 10 ++ .../shared/permissions/ask/edit/merge.py | 22 +++++ .../shared/permissions/ask/payload.py | 79 ++++++++++++++++ .../permissions/{interrupt => ask}/request.py | 21 +++-- .../middleware/shared/permissions/decision.py | 91 ------------------- .../permissions/interrupt/edit/__init__.py | 6 -- .../permissions/interrupt/edit/extract.py | 34 ------- .../permissions/interrupt/edit/merge.py | 25 ----- .../shared/permissions/interrupt/payload.py | 43 --------- .../shared/permissions/middleware/core.py | 16 ++-- .../permissions/middleware/evaluation.py | 2 +- .../shared/permissions/middleware/factory.py | 68 ++++++++++++++ .../{ => middleware}/pattern_resolver.py | 0 .../{ => middleware}/runtime_promote.py | 0 .../middleware/subagent/middleware_stack.py | 21 ++++- 18 files changed, 299 insertions(+), 231 deletions(-) rename surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/{interrupt => ask}/__init__.py (100%) create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/decision.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/merge.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py rename surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/{interrupt => ask}/request.py (54%) delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/decision.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/__init__.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/extract.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/merge.py delete mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/payload.py create mode 100644 surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py rename surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/{ => middleware}/pattern_resolver.py (100%) rename surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/{ => middleware}/runtime_promote.py (100%) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py index 95f62d3f1..c25c2b281 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/__init__.py @@ -1,16 +1,11 @@ -"""Pattern-based allow/deny/ask middleware with HITL fallback. +"""Pattern-based allow/deny/ask middleware with HITL fallback (vertical slice). -Public surface: :class:`PermissionMiddleware` plus -:func:`normalize_permission_decision` for the streaming layer and the -:data:`PatternResolver` type for callers that register per-tool resolvers. +Public surface (one entry point only — every other symbol is an internal of +the rule engine and stays inside ``middleware/``, ``ask/``, or ``deny.py``): + +- :func:`build_permission_mw` — construction recipe shared by every stack. """ -from .decision import normalize_permission_decision -from .middleware import PermissionMiddleware -from .pattern_resolver import PatternResolver +from .middleware.factory import build_permission_mw -__all__ = [ - "PatternResolver", - "PermissionMiddleware", - "normalize_permission_decision", -] +__all__ = ["build_permission_mw"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/__init__.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/__init__.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/__init__.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/decision.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/decision.py new file mode 100644 index 000000000..0fcad2ca0 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/decision.py @@ -0,0 +1,73 @@ +"""Translate the unified langchain HITL envelope into permission-domain semantics. + +``PermissionMiddleware`` works with the canonical shape +``{decision_type: "once" | "always" | "reject", feedback?: str, edited_args?: dict}``. +The wire envelope arriving from langgraph already lives in the LC HITL shape +(parsed once in :mod:`hitl_wire.decision`); this module performs the small +domain mapping (``approve|edit`` → ``once``, ``always`` → ``always``, +anything else → ``reject``) without re-implementing the envelope walk. + +Failing closed: any unrecognised decision becomes ``reject`` (with a warning) +so the middleware never proceeds on ambiguous input. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.hitl.wire import ( + LC_DECISION_APPROVE, + LC_DECISION_EDIT, + LC_DECISION_REJECT, + SURFSENSE_DECISION_ALWAYS, + parse_lc_envelope, +) + +logger = logging.getLogger(__name__) + + +# ``approve`` and ``edit`` both mean "let this call go through this once". The +# legacy SurfSense bare-scalar values (``once`` / ``always`` / ``reject``) +# pass through unchanged so historical resume payloads still work. +_LC_TO_PERMISSION: dict[str, str] = { + LC_DECISION_APPROVE: "once", + LC_DECISION_EDIT: "once", + SURFSENSE_DECISION_ALWAYS: "always", + LC_DECISION_REJECT: "reject", + "once": "once", + "always": "always", + "reject": "reject", +} + + +def normalize_permission_decision(envelope: Any) -> dict[str, Any]: + """Project the user's reply into the canonical permission decision shape. + + Args: + envelope: The raw resume value from langgraph (LC HITL envelope, a + bare scalar string, or a pre-canonical dict). + + Returns: + ``{"decision_type": "once"|"always"|"reject"}`` plus optional + ``feedback`` (``reject`` with a user message) and ``edited_args`` + (``edit`` reply with non-empty arg overrides). + """ + parsed = parse_lc_envelope(envelope) + mapped = _LC_TO_PERMISSION.get(parsed.decision_type) + if mapped is None: + logger.warning( + "Unknown permission decision %r; treating as reject", + parsed.decision_type, + ) + mapped = "reject" + + out: dict[str, Any] = {"decision_type": mapped} + if parsed.message: + out["feedback"] = parsed.message + if parsed.edited_args: + out["edited_args"] = parsed.edited_args + return out + + +__all__ = ["normalize_permission_decision"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/__init__.py new file mode 100644 index 000000000..2921cbe70 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/__init__.py @@ -0,0 +1,10 @@ +"""Apply ``edit`` permission decisions to tool calls. + +Edited-arg extraction now lives in :mod:`hitl_wire.decision` (single parser +for all approval paths); this module owns the merge step that produces a +fresh tool-call dict for the orchestrator. +""" + +from .merge import merge_edited_args + +__all__ = ["merge_edited_args"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/merge.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/merge.py new file mode 100644 index 000000000..21474ad52 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/edit/merge.py @@ -0,0 +1,22 @@ +"""Apply edited args to a tool call (shallow merge, no mutation). + +Edited values override originals; keys absent from ``edited_args`` keep +their original values, so partial edits are safe. Returns a NEW tool-call +dict so the caller can swap it into ``AIMessage.tool_calls`` without +aliasing the live message object. +""" + +from __future__ import annotations + +from typing import Any + + +def merge_edited_args( + tool_call: dict[str, Any], edited_args: dict[str, Any] +) -> dict[str, Any]: + original_args = tool_call.get("args") or {} + merged_args = {**original_args, **edited_args} + return {**tool_call, "args": merged_args} + + +__all__ = ["merge_edited_args"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py new file mode 100644 index 000000000..270a3888d --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py @@ -0,0 +1,79 @@ +"""Build the permission-ask interrupt payload (LC HITL wire + SurfSense context). + +The FE's PermissionCard renders from: + +- Standard langchain fields (``action_requests``, ``review_configs``) — drive + the action chrome and the parallel-HITL routing layer (``task_tool``, + ``resume_routing``) that batches concurrent approvals. +- ``interrupt_type="permission_ask"`` — selects the permission card variant. +- ``context.patterns`` / ``context.rules`` — explain *why* the ask fired. +- ``context.always`` — the patterns the user can promote to a permanent + allow rule with a single ``"always"`` reply. +""" + +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_chat.subagents.shared.hitl.wire import ( + LC_DECISION_APPROVE, + LC_DECISION_EDIT, + LC_DECISION_REJECT, + SURFSENSE_DECISION_ALWAYS, + build_lc_hitl_payload, +) +from app.agents.new_chat.permissions import Rule + +PERMISSION_ASK_INTERRUPT_TYPE = "permission_ask" + +# The full palette a permission card may surface: approve once, edit-then- +# approve, reject, or "always" to promote the matched pattern. +_PERMISSION_ASK_DECISIONS: list[str] = [ + LC_DECISION_APPROVE, + LC_DECISION_REJECT, + LC_DECISION_EDIT, + SURFSENSE_DECISION_ALWAYS, +] + + +def build_permission_ask_payload( + *, + tool_name: str, + args: dict[str, Any], + patterns: list[str], + rules: list[Rule], +) -> dict[str, Any]: + """Build the permission-ask interrupt payload. + + Args: + tool_name: The tool whose call is being reviewed. + args: The tool call arguments shown in the card. + patterns: Wildcard patterns the call matched (drives ``always``). + rules: Matched ruleset entries surfaced for explainability. + + Returns: + A dict suitable for ``langgraph.types.interrupt(...)`` carrying both + the LC HITL standard fields and SurfSense-specific context. + """ + context: dict[str, Any] = { + "patterns": patterns, + "rules": [ + { + "permission": r.permission, + "pattern": r.pattern, + "action": r.action, + } + for r in rules + ], + "always": patterns, + } + return build_lc_hitl_payload( + tool_name=tool_name, + args=args, + allowed_decisions=_PERMISSION_ASK_DECISIONS, + interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE, + context=context, + ) + + +__all__ = ["PERMISSION_ASK_INTERRUPT_TYPE", "build_permission_ask_payload"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/request.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py similarity index 54% rename from surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/request.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py index abd2871b8..42e47ef98 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/request.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py @@ -1,12 +1,12 @@ -"""Request a permission decision from the user (side-effectful entry point). +"""Side-effectful entry point: pause the graph and return the permission decision. -Wraps :func:`langgraph.types.interrupt` with the OTel spans that the -SurfSense dashboard expects, then normalises the resume value through -:func:`decision.normalize_permission_decision`. +Wraps :func:`langgraph.types.interrupt` with the OTel spans the SurfSense +dashboard expects, then projects the resume value through +:func:`normalize_permission_decision` so the middleware downstream only +sees the canonical permission-domain shape. -When ``emit_interrupt`` is ``False`` the call short-circuits to -``reject``; this is used by non-interactive deployments where ``ask`` must -not block. +When ``emit_interrupt`` is ``False`` the call short-circuits to ``reject``; +this is used by non-interactive deployments where ``ask`` must not block. """ from __future__ import annotations @@ -18,8 +18,8 @@ from langgraph.types import interrupt from app.agents.new_chat.permissions import Rule from app.observability import otel as ot -from ..decision import normalize_permission_decision -from .payload import build_permission_ask_payload +from .decision import normalize_permission_decision +from .payload import PERMISSION_ASK_INTERRUPT_TYPE, build_permission_ask_payload def request_permission_decision( @@ -30,6 +30,7 @@ def request_permission_decision( rules: list[Rule], emit_interrupt: bool, ) -> dict[str, Any]: + """Pause for an ``ask`` decision; return the canonical permission decision dict.""" if not emit_interrupt: return {"decision_type": "reject"} @@ -43,7 +44,7 @@ def request_permission_decision( pattern=patterns[0] if patterns else None, extra={"permission.patterns": list(patterns)}, ), - ot.interrupt_span(interrupt_type="permission_ask"), + ot.interrupt_span(interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE), ): decision = interrupt(payload) return normalize_permission_decision(decision) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/decision.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/decision.py deleted file mode 100644 index bb8f9ea25..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/decision.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Coerce inbound permission decisions to a canonical dict shape. - -Two wire formats are accepted: -- SurfSense legacy: ``{"decision_type": "once"|"always"|"reject", "feedback"?}``. -- LangChain HITL envelope: ``{"decisions": [{"type": "approve"|"edit"|"reject", ...}]}``. - -The middleware downstream only inspects the canonical shape returned here, -so adding a new envelope means changing this module alone. - -The middleware fails closed: any unrecognised payload becomes ``reject`` -(with a warning) so the agent never proceeds on ambiguous input. - -When the reply is an ``edit``, the result keeps ``decision_type="once"`` -(the call still goes through) and adds an ``edited_args`` key holding the -user-modified ``args`` dict. The orchestrator merges those into the -``tool_call`` before keeping it; see :mod:`interrupt.edit.merge`. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from .interrupt.edit import extract_edited_args - -logger = logging.getLogger(__name__) - - -# ``edit`` collapses to ``once``; any ``edited_args`` ride on the result. -_LC_TYPE_TO_PERMISSION_DECISION: dict[str, str] = { - "approve": "once", - "reject": "reject", - "edit": "once", -} - - -def normalize_permission_decision(decision: Any) -> dict[str, Any]: - """Return ``{"decision_type": ..., "feedback"?: str, "edited_args"?: dict}``.""" - if isinstance(decision, str): - return {"decision_type": decision} - if not isinstance(decision, dict): - logger.warning( - "Unrecognized permission resume value (%s); treating as reject", - type(decision).__name__, - ) - return {"decision_type": "reject"} - - if decision.get("decision_type"): - return decision - - payload: dict[str, Any] = decision - decisions = decision.get("decisions") - if isinstance(decisions, list) and decisions: - first = decisions[0] - if isinstance(first, dict): - payload = first - - raw_type = payload.get("type") or payload.get("decision_type") - if not raw_type: - logger.warning( - "Permission resume missing decision type (keys=%s); treating as reject", - list(payload.keys()), - ) - return {"decision_type": "reject"} - - raw_type = str(raw_type).lower() - mapped = _LC_TYPE_TO_PERMISSION_DECISION.get(raw_type) - if mapped is None: - # Tolerate legacy values arriving without ``decision_type`` wrapping. - if raw_type in {"once", "always", "reject"}: - mapped = raw_type - else: - logger.warning( - "Unknown permission decision type %r; treating as reject", raw_type - ) - mapped = "reject" - - out: dict[str, Any] = {"decision_type": mapped} - feedback = payload.get("feedback") or payload.get("message") - if isinstance(feedback, str) and feedback.strip(): - out["feedback"] = feedback - - if raw_type == "edit": - edited = extract_edited_args(payload) - if edited: - out["edited_args"] = edited - - return out - - -__all__ = ["normalize_permission_decision"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/__init__.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/__init__.py deleted file mode 100644 index 993bc50b9..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Apply ``edit`` permission decisions to tool calls (extract + merge).""" - -from .extract import extract_edited_args -from .merge import merge_edited_args - -__all__ = ["extract_edited_args", "merge_edited_args"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/extract.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/extract.py deleted file mode 100644 index 85d365ece..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/extract.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Extract edited args from a permission decision payload. - -Two shapes are accepted (mirrors :func:`app.agents.new_chat.tools.hitl._parse_decision`): - -- LangChain HITL envelope: ``{"edited_action": {"args": {...}}}``. -- Legacy flat shape: ``{"args": {...}}``. - -Returns ``None`` when no edited args are present. The orchestrator decides -whether to merge them (see :mod:`interrupt.edit.merge`); this module is pure parsing. -""" - -from __future__ import annotations - -from typing import Any - - -def extract_edited_args(decision_payload: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(decision_payload, dict): - return None - - edited_action = decision_payload.get("edited_action") - if isinstance(edited_action, dict): - edited_args = edited_action.get("args") - if isinstance(edited_args, dict): - return edited_args - - flat_args = decision_payload.get("args") - if isinstance(flat_args, dict): - return flat_args - - return None - - -__all__ = ["extract_edited_args"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/merge.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/merge.py deleted file mode 100644 index 6632c677c..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/edit/merge.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Apply edited args to a tool call. - -Semantics match :func:`app.agents.new_chat.tools.hitl.request_approval`'s -``final_params = {**params, **edited_params}`` — shallow merge, edited -values override originals. Keys absent from ``edited_args`` keep their -original values, so partial edits are safe. - -Returns a NEW ``tool_call`` dict (the input is not mutated) so the caller -can swap it into the ``AIMessage.tool_calls`` list without aliasing. -""" - -from __future__ import annotations - -from typing import Any - - -def merge_edited_args( - tool_call: dict[str, Any], edited_args: dict[str, Any] -) -> dict[str, Any]: - original_args = tool_call.get("args") or {} - merged_args = {**original_args, **edited_args} - return {**tool_call, "args": merged_args} - - -__all__ = ["merge_edited_args"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/payload.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/payload.py deleted file mode 100644 index d5de1c209..000000000 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/interrupt/payload.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Build the ``permission_ask`` interrupt payload (pure data). - -The frontend's streaming layer keys off ``type`` and renders the approval -card from ``action`` (the tool call being reviewed) and ``context`` -(the matched rules and patterns that prompted the ask). ``context.always`` -lists the patterns the user can promote to a permanent allow rule with a -single ``"always"`` reply. -""" - -from __future__ import annotations - -from typing import Any - -from app.agents.new_chat.permissions import Rule - - -def build_permission_ask_payload( - *, - tool_name: str, - args: dict[str, Any], - patterns: list[str], - rules: list[Rule], -) -> dict[str, Any]: - return { - "type": "permission_ask", - # ``params`` (not ``args``) is what SurfSense's streaming normalizer forwards. - "action": {"tool": tool_name, "params": args or {}}, - "context": { - "patterns": patterns, - "rules": [ - { - "permission": r.permission, - "pattern": r.pattern, - "action": r.action, - } - for r in rules - ], - "always": patterns, - }, - } - - -__all__ = ["build_permission_ask_payload"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py index d2370889c..a8bb24143 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py @@ -5,10 +5,10 @@ LangChain's :class:`HumanInTheLoopMiddleware` only supports a static allow/deny/ask, no glob patterns, no per-space/per-thread overrides, and no auto-deny synthesis. -This middleware layers OpenCode's wildcard-ruleset model on top of -SurfSense's ``interrupt({type, action, context})`` payload shape (see -:mod:`app.agents.new_chat.tools.hitl`) so the frontend keeps working -unchanged. +This middleware layers OpenCode's wildcard-ruleset model on top of the +unified langchain HITL wire format (see :mod:`hitl_wire`), so it sits +beside ``HumanInTheLoopMiddleware`` and self-gated approvals on a single +parallel-HITL routing layer in ``task_tool`` + ``resume_routing``. Per-tool-call flow inside :meth:`_process`: @@ -47,13 +47,13 @@ from langgraph.runtime import Runtime from app.agents.new_chat.errors import CorrectedError, RejectedError from app.agents.new_chat.permissions import Ruleset +from ..ask.edit import merge_edited_args +from ..ask.request import request_permission_decision from ..deny import build_deny_message -from ..interrupt.edit import merge_edited_args -from ..interrupt.request import request_permission_decision -from ..pattern_resolver import PatternResolver -from ..runtime_promote import persist_always from .evaluation import evaluate_tool_call +from .pattern_resolver import PatternResolver from .ruleset_view import all_rulesets +from .runtime_promote import persist_always logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py index 6777aa093..51531c4eb 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py @@ -24,7 +24,7 @@ from app.agents.new_chat.permissions import ( evaluate_many, ) -from ..pattern_resolver import PatternResolver, default_pattern_resolver +from .pattern_resolver import PatternResolver, default_pattern_resolver logger = logging.getLogger(__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 new file mode 100644 index 000000000..4b760ec6f --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py @@ -0,0 +1,68 @@ +"""Construction recipe for :class:`PermissionMiddleware` shared across stacks. + +Single source of truth used by both the main-agent stack and every subagent +stack. Rule layers are evaluated earliest-to-latest (last match wins, +matching OpenCode's ``permission/index.ts`` evaluation order): + +1. ``surfsense_defaults`` — single ``allow */*`` rule. Connector tools + already self-gate via :func:`request_approval`, so the rule engine only + 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 policies. The KB subagent contributes + its destructive-FS ``ask`` rules here; connectors will follow once + they migrate off ``interrupt_on``. + +Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules`` +is intentionally NOT replicated: the multi-agent orchestrator already +excludes entire subagents whose required connectors are missing +(``SUBAGENT_TO_REQUIRED_CONNECTOR_MAP``), so the per-tool deny pass is +redundant here. +""" + +from __future__ import annotations + +from app.agents.new_chat.feature_flags import AgentFeatureFlags +from app.agents.new_chat.permissions import Rule, Ruleset + +from .core import PermissionMiddleware + +_SURFSENSE_DEFAULTS = Ruleset( + rules=[Rule(permission="*", pattern="*", action="allow")], + origin="surfsense_defaults", +) + + +def build_permission_mw( + *, + flags: AgentFeatureFlags, + extra_rulesets: list[Ruleset] | None = None, +) -> PermissionMiddleware | None: + """Return a configured :class:`PermissionMiddleware` or ``None`` when no work is needed. + + Args: + flags: Feature toggles. ``enable_permission`` switches the engine on; + ``disable_new_agent_stack`` overrides everything for safety. + extra_rulesets: Caller-supplied rulesets layered after the defaults. + Subagents pass their own policy here so each subagent owns its + rules without aliasing a shared engine. Presence of any extra + ruleset forces the middleware on regardless of + ``enable_permission`` — an explicit ``ask`` rule always asks. + + Returns: + ``None`` when the engine has no rules to enforce + (``enable_permission=False`` and no extras); a configured middleware + otherwise. + """ + permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack + has_extras = bool(extra_rulesets) + if not (permission_enabled or has_extras): + return None + + rulesets: list[Ruleset] = [_SURFSENSE_DEFAULTS] + if extra_rulesets: + rulesets.extend(extra_rulesets) + return PermissionMiddleware(rulesets=rulesets) + + +__all__ = ["build_permission_mw"] diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/pattern_resolver.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/pattern_resolver.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/pattern_resolver.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/pattern_resolver.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/runtime_promote.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py similarity index 100% rename from surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/runtime_promote.py rename to surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/middleware_stack.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/middleware_stack.py index 9889e629a..aa6211fcc 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/middleware_stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/subagent/middleware_stack.py @@ -3,7 +3,8 @@ Mirrors ``middleware/stack.py`` (the orchestrator's middleware stack) but exposes its contents as a dict keyed by purpose so specialists can pick the entries they need and decide ordering. The default consumer -(``pack_subagent``) prepends every non-``None`` value in insertion order. +(:func:`pack_subagent`) prepends every non-``None`` value in insertion +order, so ``None`` slots are silently skipped. Registry subagents never touch the SurfSense filesystem — that capability belongs to ``knowledge_base`` — so no FS middleware is exposed here. @@ -13,6 +14,9 @@ from __future__ import annotations from typing import Any +from app.agents.new_chat.feature_flags import AgentFeatureFlags + +from ..shared.permissions import build_permission_mw from ..shared.resilience import ResilienceMiddlewares from ..shared.todos import build_todos_mw @@ -20,9 +24,24 @@ from ..shared.todos import build_todos_mw def build_subagent_middleware_stack( *, resilience: ResilienceMiddlewares, + flags: AgentFeatureFlags | None = None, ) -> dict[str, Any]: + """Assemble the dict of middlewares prepended to every subagent's stack. + + Args: + resilience: Pre-built retry / fallback / call-limit middlewares + (shared with the orchestrator stack to keep behaviour symmetric). + flags: Feature flags driving optional layers. ``None`` disables the + permission layer (used in tests that only need todos+resilience). + + Returns: + Insertion-ordered dict; ``None`` values are tolerated and dropped by + the consumer so callers can flip slots on/off without reshaping. + """ + permission = build_permission_mw(flags=flags) if flags is not None else None return { "todos": build_todos_mw(), + "permission": permission, "retry": resilience.retry, "fallback": resilience.fallback, "model_call_limit": resilience.model_call_limit,