mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 12:12:22 +02:00
131 lines
5.3 KiB
Python
131 lines
5.3 KiB
Python
"""Per-session workflow approval memory + the gate's resolve-ask helper.
|
|
|
|
The workflow executor pushes context in (keyed by session id) so the permission
|
|
gates can reuse a prior allow/deny instead of prompting, and so an unattended
|
|
fire fails fast instead of parking for ten minutes. Lives down here (not in the
|
|
workflows app) so both the gate hooks and the executor import it downward;
|
|
agent_manager re-exports the setters for the executor's convenience.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Callable, Dict, Optional
|
|
|
|
from pydantic import BaseModel, ConfigDict
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.manager.permissions.ApprovalDecision import ApprovalDecision
|
|
from backend.apps.agents.manager.permissions.decision import request_user_approval
|
|
from backend.apps.agents.manager.streaming.HookContext import HookContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WorkflowApprovalMemory(BaseModel):
|
|
"""A workflow run's approval context, pushed in by the executor."""
|
|
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
decisions: Dict[str, str] # workflow-level: tool -> "allow"/"deny"
|
|
step_usage: Dict[str, Dict[str, bool]] # per-step record: step_id -> {tool: approved}
|
|
remember: Optional[Callable[[str, str], None]] # persist a workflow-level decision to disk
|
|
ask_timeout: float
|
|
# The executor bumps this as it advances steps so the gate can record which tools each step touched. None on test runs that don't thread it.
|
|
current_step_id: Optional[str] = None
|
|
|
|
|
|
p_approval_memory: Dict[str, WorkflowApprovalMemory] = {}
|
|
|
|
|
|
@typechecked
|
|
def set_workflow_approval_memory(
|
|
session_id: str,
|
|
*,
|
|
decisions: Dict[str, str],
|
|
step_usage: Dict[str, Dict[str, bool]],
|
|
remember: Optional[Callable[[str, str], None]],
|
|
ask_timeout: float,
|
|
) -> None:
|
|
p_approval_memory[session_id] = WorkflowApprovalMemory(
|
|
decisions=decisions, step_usage=step_usage, remember=remember, ask_timeout=ask_timeout
|
|
)
|
|
|
|
|
|
@typechecked
|
|
def clear_workflow_approval_memory(session_id: str) -> None:
|
|
p_approval_memory.pop(session_id, None)
|
|
|
|
|
|
@typechecked
|
|
def set_workflow_approval_step(session_id: str, step_id: Optional[str]) -> None:
|
|
mem = p_approval_memory.get(session_id)
|
|
if mem is not None:
|
|
mem.current_step_id = step_id
|
|
|
|
|
|
@typechecked
|
|
def get_workflow_step_usage(session_id: str) -> Dict[str, Dict[str, bool]]:
|
|
mem = p_approval_memory.get(session_id)
|
|
return mem.step_usage if mem is not None else {}
|
|
|
|
|
|
@typechecked
|
|
def is_claude_schedule_skill(tool_name: str, tool_input: object) -> bool:
|
|
if tool_name != "Skill" or not isinstance(tool_input, dict):
|
|
return False
|
|
return str(tool_input.get("skill") or "").strip().lower() == "schedule"
|
|
|
|
|
|
@typechecked
|
|
def note_tool_used(session_id: str, tool_name: str, approved: bool) -> None:
|
|
# Record which tools each step touched (in-memory; the executor/test path persists step_usage once at run end). Captures every tool the gate sees so a step's tool set is complete, not only the ones that prompted.
|
|
mem = p_approval_memory.get(session_id)
|
|
if mem is None or mem.current_step_id is None:
|
|
return
|
|
mem.step_usage.setdefault(mem.current_step_id, {})[tool_name] = approved
|
|
|
|
|
|
@typechecked
|
|
async def resolve_ask(
|
|
ctx: HookContext, tool_name: str, tool_input: object, sensitive_pattern: Optional[str]
|
|
) -> ApprovalDecision:
|
|
"""Resolve an 'ask' policy. On a workflow run, reuse a remembered decision
|
|
(this step first, then the workflow-level fallback) instead of prompting, and
|
|
persist any fresh non-sensitive answer so later fires don't re-ask. Shared by
|
|
both gates so they can't disagree (and so the first one's answer is reused by
|
|
the second within the same call)."""
|
|
mem = p_approval_memory.get(ctx.session_id)
|
|
rememberable = (
|
|
mem is not None
|
|
and sensitive_pattern is None
|
|
and tool_name != "AskUserQuestion"
|
|
)
|
|
if rememberable:
|
|
sid = mem.current_step_id
|
|
prior_step = mem.step_usage.get(sid, {}).get(tool_name) if sid is not None else None
|
|
if prior_step is True:
|
|
return ApprovalDecision(behavior="allow")
|
|
if prior_step is False:
|
|
return ApprovalDecision(behavior="deny", message="Denied by a remembered workflow permission")
|
|
prior = mem.decisions.get(tool_name)
|
|
if prior == "allow":
|
|
note_tool_used(ctx.session_id, tool_name, True)
|
|
return ApprovalDecision(behavior="allow")
|
|
if prior == "deny":
|
|
note_tool_used(ctx.session_id, tool_name, False)
|
|
return ApprovalDecision(behavior="deny", message="Denied by a remembered workflow permission")
|
|
timeout = mem.ask_timeout if mem is not None else 600.0
|
|
decision = await request_user_approval(
|
|
ctx.session, ctx.session_id, tool_name, tool_input, ctx.builtin_perms,
|
|
sensitive_pattern=sensitive_pattern, timeout=timeout,
|
|
)
|
|
if rememberable and decision.behavior in ("allow", "deny"):
|
|
behavior = decision.behavior
|
|
mem.decisions[tool_name] = behavior
|
|
note_tool_used(ctx.session_id, tool_name, behavior == "allow")
|
|
if mem.remember:
|
|
try:
|
|
mem.remember(tool_name, behavior)
|
|
except Exception:
|
|
logger.exception("Failed to persist remembered workflow approval")
|
|
return decision
|