mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
multi_agent_chat/permissions: restructure slice + simplify factory
This commit is contained in:
parent
a36b15b834
commit
8eaab12971
18 changed files with 299 additions and 231 deletions
|
|
@ -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
|
Public surface (one entry point only — every other symbol is an internal of
|
||||||
:func:`normalize_permission_decision` for the streaming layer and the
|
the rule engine and stays inside ``middleware/``, ``ask/``, or ``deny.py``):
|
||||||
:data:`PatternResolver` type for callers that register per-tool resolvers.
|
|
||||||
|
- :func:`build_permission_mw` — construction recipe shared by every stack.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .decision import normalize_permission_decision
|
from .middleware.factory import build_permission_mw
|
||||||
from .middleware import PermissionMiddleware
|
|
||||||
from .pattern_resolver import PatternResolver
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["build_permission_mw"]
|
||||||
"PatternResolver",
|
|
||||||
"PermissionMiddleware",
|
|
||||||
"normalize_permission_decision",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
Wraps :func:`langgraph.types.interrupt` with the OTel spans the SurfSense
|
||||||
SurfSense dashboard expects, then normalises the resume value through
|
dashboard expects, then projects the resume value through
|
||||||
:func:`decision.normalize_permission_decision`.
|
: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
|
When ``emit_interrupt`` is ``False`` the call short-circuits to ``reject``;
|
||||||
``reject``; this is used by non-interactive deployments where ``ask`` must
|
this is used by non-interactive deployments where ``ask`` must not block.
|
||||||
not block.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -18,8 +18,8 @@ from langgraph.types import interrupt
|
||||||
from app.agents.new_chat.permissions import Rule
|
from app.agents.new_chat.permissions import Rule
|
||||||
from app.observability import otel as ot
|
from app.observability import otel as ot
|
||||||
|
|
||||||
from ..decision import normalize_permission_decision
|
from .decision import normalize_permission_decision
|
||||||
from .payload import build_permission_ask_payload
|
from .payload import PERMISSION_ASK_INTERRUPT_TYPE, build_permission_ask_payload
|
||||||
|
|
||||||
|
|
||||||
def request_permission_decision(
|
def request_permission_decision(
|
||||||
|
|
@ -30,6 +30,7 @@ def request_permission_decision(
|
||||||
rules: list[Rule],
|
rules: list[Rule],
|
||||||
emit_interrupt: bool,
|
emit_interrupt: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Pause for an ``ask`` decision; return the canonical permission decision dict."""
|
||||||
if not emit_interrupt:
|
if not emit_interrupt:
|
||||||
return {"decision_type": "reject"}
|
return {"decision_type": "reject"}
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ def request_permission_decision(
|
||||||
pattern=patterns[0] if patterns else None,
|
pattern=patterns[0] if patterns else None,
|
||||||
extra={"permission.patterns": list(patterns)},
|
extra={"permission.patterns": list(patterns)},
|
||||||
),
|
),
|
||||||
ot.interrupt_span(interrupt_type="permission_ask"),
|
ot.interrupt_span(interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE),
|
||||||
):
|
):
|
||||||
decision = interrupt(payload)
|
decision = interrupt(payload)
|
||||||
return normalize_permission_decision(decision)
|
return normalize_permission_decision(decision)
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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
|
allow/deny/ask, no glob patterns, no per-space/per-thread overrides, and
|
||||||
no auto-deny synthesis.
|
no auto-deny synthesis.
|
||||||
|
|
||||||
This middleware layers OpenCode's wildcard-ruleset model on top of
|
This middleware layers OpenCode's wildcard-ruleset model on top of the
|
||||||
SurfSense's ``interrupt({type, action, context})`` payload shape (see
|
unified langchain HITL wire format (see :mod:`hitl_wire`), so it sits
|
||||||
:mod:`app.agents.new_chat.tools.hitl`) so the frontend keeps working
|
beside ``HumanInTheLoopMiddleware`` and self-gated approvals on a single
|
||||||
unchanged.
|
parallel-HITL routing layer in ``task_tool`` + ``resume_routing``.
|
||||||
|
|
||||||
Per-tool-call flow inside :meth:`_process`:
|
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.errors import CorrectedError, RejectedError
|
||||||
from app.agents.new_chat.permissions import Ruleset
|
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 ..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 .evaluation import evaluate_tool_call
|
||||||
|
from .pattern_resolver import PatternResolver
|
||||||
from .ruleset_view import all_rulesets
|
from .ruleset_view import all_rulesets
|
||||||
|
from .runtime_promote import persist_always
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from app.agents.new_chat.permissions import (
|
||||||
evaluate_many,
|
evaluate_many,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..pattern_resolver import PatternResolver, default_pattern_resolver
|
from .pattern_resolver import PatternResolver, default_pattern_resolver
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
Mirrors ``middleware/stack.py`` (the orchestrator's middleware stack) but
|
Mirrors ``middleware/stack.py`` (the orchestrator's middleware stack) but
|
||||||
exposes its contents as a dict keyed by purpose so specialists can pick
|
exposes its contents as a dict keyed by purpose so specialists can pick
|
||||||
the entries they need and decide ordering. The default consumer
|
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
|
Registry subagents never touch the SurfSense filesystem — that capability
|
||||||
belongs to ``knowledge_base`` — so no FS middleware is exposed here.
|
belongs to ``knowledge_base`` — so no FS middleware is exposed here.
|
||||||
|
|
@ -13,6 +14,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
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.resilience import ResilienceMiddlewares
|
||||||
from ..shared.todos import build_todos_mw
|
from ..shared.todos import build_todos_mw
|
||||||
|
|
||||||
|
|
@ -20,9 +24,24 @@ from ..shared.todos import build_todos_mw
|
||||||
def build_subagent_middleware_stack(
|
def build_subagent_middleware_stack(
|
||||||
*,
|
*,
|
||||||
resilience: ResilienceMiddlewares,
|
resilience: ResilienceMiddlewares,
|
||||||
|
flags: AgentFeatureFlags | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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 {
|
return {
|
||||||
"todos": build_todos_mw(),
|
"todos": build_todos_mw(),
|
||||||
|
"permission": permission,
|
||||||
"retry": resilience.retry,
|
"retry": resilience.retry,
|
||||||
"fallback": resilience.fallback,
|
"fallback": resilience.fallback,
|
||||||
"model_call_limit": resilience.model_call_limit,
|
"model_call_limit": resilience.model_call_limit,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue