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:
CREDO23 2026-05-15 14:47:32 +02:00
parent 6671c91841
commit c8b756ae8f
16 changed files with 85 additions and 75 deletions

View file

@ -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).
"""

View file

@ -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,
]

View file

@ -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:

View file

@ -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

View file

@ -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``)