mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
hitl/wire: rename 'always' decision-type to 'approve_always'
Renames the SurfSense HITL extension decision-type from "always" to "approve_always" so it sits in the same verb-first family as "approve", "reject", and "edit". The Python constant is now SURFSENSE_DECISION_APPROVE_ALWAYS; the wire value, the permission-domain decision_type, and the FE union members all match (no wire/internal mismatch). Both the multi_agent_chat permission middleware and the legacy new_chat one accept the new wire value; the FE types.ts union is updated accordingly. The "context.always" payload key is intentionally left untouched - it's the patterns-to-promote field, semantically distinct from the decision type.
This commit is contained in:
parent
6671c91841
commit
c8b756ae8f
16 changed files with 85 additions and 75 deletions
|
|
@ -1,11 +1,12 @@
|
|||
"""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}``.
|
||||
``{decision_type: "once" | "approve_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.
|
||||
domain mapping (``approve|edit`` → ``once``, ``approve_always`` →
|
||||
``approve_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.
|
||||
|
|
@ -20,7 +21,7 @@ from app.agents.multi_agent_chat.subagents.shared.hitl.wire import (
|
|||
LC_DECISION_APPROVE,
|
||||
LC_DECISION_EDIT,
|
||||
LC_DECISION_REJECT,
|
||||
SURFSENSE_DECISION_ALWAYS,
|
||||
SURFSENSE_DECISION_APPROVE_ALWAYS,
|
||||
parse_lc_envelope,
|
||||
)
|
||||
|
||||
|
|
@ -28,15 +29,15 @@ 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``)
|
||||
# legacy SurfSense bare-scalar values (``once`` / ``approve_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",
|
||||
SURFSENSE_DECISION_APPROVE_ALWAYS: "approve_always",
|
||||
LC_DECISION_REJECT: "reject",
|
||||
"once": "once",
|
||||
"always": "always",
|
||||
"approve_always": "approve_always",
|
||||
"reject": "reject",
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ def normalize_permission_decision(envelope: Any) -> dict[str, Any]:
|
|||
bare scalar string, or a pre-canonical dict).
|
||||
|
||||
Returns:
|
||||
``{"decision_type": "once"|"always"|"reject"}`` plus optional
|
||||
``{"decision_type": "once"|"approve_always"|"reject"}`` plus optional
|
||||
``feedback`` (``reject`` with a user message) and ``edited_args``
|
||||
(``edit`` reply with non-empty arg overrides).
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from app.agents.multi_agent_chat.subagents.shared.hitl.wire import (
|
|||
LC_DECISION_APPROVE,
|
||||
LC_DECISION_EDIT,
|
||||
LC_DECISION_REJECT,
|
||||
SURFSENSE_DECISION_ALWAYS,
|
||||
SURFSENSE_DECISION_APPROVE_ALWAYS,
|
||||
build_lc_hitl_payload,
|
||||
)
|
||||
from app.agents.new_chat.permissions import Rule
|
||||
|
|
@ -21,7 +21,7 @@ _PERMISSION_ASK_DECISIONS: list[str] = [
|
|||
LC_DECISION_APPROVE,
|
||||
LC_DECISION_REJECT,
|
||||
LC_DECISION_EDIT,
|
||||
SURFSENSE_DECISION_ALWAYS,
|
||||
SURFSENSE_DECISION_APPROVE_ALWAYS,
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class _AlwaysPromotion:
|
||||
"""A pending request to save an ``always`` decision to the user's trust list."""
|
||||
"""A pending request to save an ``approve_always`` decision to the user's trust list."""
|
||||
|
||||
connector_id: int
|
||||
tool_name: str
|
||||
|
|
@ -59,15 +59,15 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
to wildcard patterns. Tools without an entry use the bare
|
||||
tool name as the only pattern.
|
||||
runtime_ruleset: Mutable :class:`Ruleset` extended in-place when
|
||||
the user replies ``"always"``. Reused across calls in the
|
||||
same agent instance so newly-allowed rules apply downstream.
|
||||
the user replies ``"approve_always"``. Reused across calls in
|
||||
the same agent instance so newly-allowed rules apply downstream.
|
||||
always_emit_interrupt_payload: Set ``False`` to make ``ask``
|
||||
collapse to ``deny`` (for non-interactive deployments).
|
||||
tools_by_name: Map from tool name to :class:`BaseTool`, used to
|
||||
decorate ``ask`` interrupts with the tool's description and
|
||||
MCP metadata for the FE card.
|
||||
trusted_tool_saver: Async callback invoked on ``always`` decisions
|
||||
for MCP tools (those whose ``metadata`` carries an
|
||||
trusted_tool_saver: Async callback invoked on ``approve_always``
|
||||
decisions for MCP tools (those whose ``metadata`` carries an
|
||||
``mcp_connector_id``). Without it the promotion only lives
|
||||
in-memory for the current agent instance.
|
||||
"""
|
||||
|
|
@ -104,8 +104,9 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
"""Pure decision pass: returns ``(state_update, pending_promotions)``.
|
||||
|
||||
Side effects performed here are in-memory only (rule promotion
|
||||
into ``runtime_ruleset``). DB writes for ``always`` decisions
|
||||
are queued as ``_AlwaysPromotion`` and flushed by the async hook.
|
||||
into ``runtime_ruleset``). DB writes for ``approve_always``
|
||||
decisions are queued as ``_AlwaysPromotion`` and flushed by the
|
||||
async hook.
|
||||
"""
|
||||
del runtime
|
||||
messages = state.get("messages") or []
|
||||
|
|
@ -155,7 +156,7 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
)
|
||||
kind = str(decision.get("decision_type") or "reject").lower()
|
||||
edited_args = decision.get("edited_args")
|
||||
if kind in ("once", "always"):
|
||||
if kind in ("once", "approve_always"):
|
||||
final_call = (
|
||||
merge_edited_args(call, edited_args)
|
||||
if isinstance(edited_args, dict) and edited_args
|
||||
|
|
@ -163,7 +164,7 @@ class PermissionMiddleware(AgentMiddleware): # type: ignore[type-arg]
|
|||
)
|
||||
if final_call is not call:
|
||||
any_change = True
|
||||
if kind == "always":
|
||||
if kind == "approve_always":
|
||||
persist_always(self._runtime_ruleset, name, patterns)
|
||||
promotion = self._build_always_promotion(name)
|
||||
if promotion is not None:
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
Static rulesets come from the agent factory (defaults, space-scoped,
|
||||
thread-scoped, etc.). The runtime ruleset is the in-memory one that
|
||||
:func:`runtime_promote.persist_always` extends when the user replies
|
||||
``"always"``. Evaluators always see them merged in this order so newly-
|
||||
promoted rules apply to subsequent calls.
|
||||
``"approve_always"``. Evaluators always see them merged in this order so
|
||||
newly-promoted rules apply to subsequent calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Promote an ``"always"`` reply into in-memory allow rules.
|
||||
"""Promote an ``"approve_always"`` reply into in-memory allow rules.
|
||||
|
||||
Subsequent calls within the same agent instance match these new rules and
|
||||
proceed without prompting. Durable persistence (to ``agent_permission_rules``)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from .payload import (
|
|||
LC_DECISION_APPROVE,
|
||||
LC_DECISION_EDIT,
|
||||
LC_DECISION_REJECT,
|
||||
SURFSENSE_DECISION_ALWAYS,
|
||||
SURFSENSE_DECISION_APPROVE_ALWAYS,
|
||||
build_lc_hitl_payload,
|
||||
)
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ __all__ = [
|
|||
"LC_DECISION_APPROVE",
|
||||
"LC_DECISION_EDIT",
|
||||
"LC_DECISION_REJECT",
|
||||
"SURFSENSE_DECISION_ALWAYS",
|
||||
"SURFSENSE_DECISION_APPROVE_ALWAYS",
|
||||
"ParsedLcDecision",
|
||||
"build_lc_hitl_payload",
|
||||
"parse_lc_envelope",
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ class ParsedLcDecision:
|
|||
|
||||
Attributes:
|
||||
decision_type: Lower-cased decision identifier — ``"approve"``,
|
||||
``"reject"``, ``"edit"``, ``"always"``, or any custom value the
|
||||
FE may emit. Callers map this to their own domain semantics.
|
||||
``"reject"``, ``"edit"``, ``"approve_always"``, or any custom value
|
||||
the FE may emit. Callers map this to their own domain semantics.
|
||||
edited_args: Populated only on ``"edit"`` replies that actually carry
|
||||
args; ``None`` otherwise so callers can use truthiness directly.
|
||||
message: Free-form user feedback (typically attached to ``"reject"``).
|
||||
|
|
@ -48,9 +48,9 @@ def parse_lc_envelope(envelope: Any) -> ParsedLcDecision:
|
|||
|
||||
- ``{"decisions": [{"type": "approve" | "reject" | "edit", ...}]}`` — the
|
||||
langchain HITL standard envelope.
|
||||
- A bare scalar string (``"once"``, ``"always"``, ``"reject"``) — used by
|
||||
the legacy SurfSense permission wire. We tolerate it so the parser can
|
||||
sit behind both call sites without a second adapter.
|
||||
- A bare scalar string (``"once"``, ``"approve_always"``, ``"reject"``) —
|
||||
used by the legacy SurfSense permission wire. We tolerate it so the
|
||||
parser can sit behind both call sites without a second adapter.
|
||||
|
||||
Edit args are read from the standard ``edited_action.args`` first, then
|
||||
fall back to a flat ``args`` field for legacy compatibility — both shapes
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ LC_DECISION_APPROVE = "approve"
|
|||
LC_DECISION_REJECT = "reject"
|
||||
LC_DECISION_EDIT = "edit"
|
||||
|
||||
# ``always`` is a SurfSense extension surfaced by ``PermissionMiddleware`` so a
|
||||
# single click can promote the matched pattern to a runtime allow rule. The FE
|
||||
# renders an extra button when it appears in ``allowed_decisions``.
|
||||
SURFSENSE_DECISION_ALWAYS = "always"
|
||||
# ``approve_always`` is a SurfSense extension surfaced by ``PermissionMiddleware``
|
||||
# so a single click can promote the matched pattern to a runtime allow rule and
|
||||
# (for MCP tools) save it to the user's trusted-tools list. The FE renders an
|
||||
# extra button when it appears in ``allowed_decisions``.
|
||||
SURFSENSE_DECISION_APPROVE_ALWAYS = "approve_always"
|
||||
|
||||
|
||||
def build_lc_hitl_payload(
|
||||
|
|
@ -41,8 +42,8 @@ def build_lc_hitl_payload(
|
|||
an empty dict so the FE always has a stable shape to render.
|
||||
allowed_decisions: Subset of
|
||||
``[LC_DECISION_APPROVE, LC_DECISION_REJECT, LC_DECISION_EDIT,
|
||||
SURFSENSE_DECISION_ALWAYS]``. Other values are passed through but
|
||||
the FE may not render a control for them.
|
||||
SURFSENSE_DECISION_APPROVE_ALWAYS]``. Other values are passed through
|
||||
but the FE may not render a control for them.
|
||||
interrupt_type: SurfSense card discriminator (``"gmail_email_send"``,
|
||||
``"permission_ask"``, etc.); the FE keys off this to mount the
|
||||
right card.
|
||||
|
|
@ -80,6 +81,6 @@ __all__ = [
|
|||
"LC_DECISION_APPROVE",
|
||||
"LC_DECISION_EDIT",
|
||||
"LC_DECISION_REJECT",
|
||||
"SURFSENSE_DECISION_ALWAYS",
|
||||
"SURFSENSE_DECISION_APPROVE_ALWAYS",
|
||||
"build_lc_hitl_payload",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue