mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] feat/scheduled-tasks: remember workflow tool permissions across runs
This commit is contained in:
@@ -7,7 +7,7 @@ import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
from backend.apps.agents.core.models import (
|
||||
AgentConfig, AgentSession, Message, MessageBranch, ApprovalRequest, ToolGroupMeta,
|
||||
@@ -79,6 +79,59 @@ logger = logging.getLogger(__name__)
|
||||
os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000")
|
||||
|
||||
|
||||
# Per-session approval memory for workflow runs. 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 here (not in the workflows app) because
|
||||
# agent_manager must not import workflows; that would be an upward import cycle.
|
||||
class WorkflowApprovalMemory:
|
||||
def __init__(
|
||||
self,
|
||||
decisions: dict[str, str],
|
||||
step_usage: dict[str, dict[str, bool]],
|
||||
remember: Optional[Callable[[str, str], None]],
|
||||
ask_timeout: float,
|
||||
):
|
||||
self.decisions = decisions # workflow-level: tool -> "allow"/"deny"
|
||||
self.step_usage = step_usage # per-step record: step_id -> {tool: approved}
|
||||
self.remember = remember # persist a workflow-level decision to disk
|
||||
self.ask_timeout = ask_timeout
|
||||
# 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.
|
||||
self.current_step_id: Optional[str] = None
|
||||
|
||||
|
||||
p_approval_memory: dict[str, WorkflowApprovalMemory] = {}
|
||||
|
||||
|
||||
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, step_usage, remember, ask_timeout
|
||||
)
|
||||
|
||||
|
||||
def clear_workflow_approval_memory(session_id: str) -> None:
|
||||
p_approval_memory.pop(session_id, None)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
def _apply_context_window(session, settings=None) -> None:
|
||||
"""Set session.context_window from the registry for its (provider, model).
|
||||
|
||||
@@ -723,6 +776,7 @@ class AgentManager:
|
||||
tool_name: str,
|
||||
tool_input,
|
||||
sensitive_pattern: str | None = None,
|
||||
timeout: float = 600.0,
|
||||
) -> dict:
|
||||
"""Send an approval request via WebSocket and wait for the user's decision."""
|
||||
safe_input = tool_input if isinstance(tool_input, dict) else {}
|
||||
@@ -753,6 +807,7 @@ class AgentManager:
|
||||
|
||||
decision = await ws_manager.send_approval_request(
|
||||
session_id, request_id, tool_name, safe_input,
|
||||
timeout=timeout,
|
||||
sensitive_pattern=sensitive_pattern,
|
||||
sensitive_label=label,
|
||||
sensitive_why=why,
|
||||
@@ -782,6 +837,7 @@ class AgentManager:
|
||||
"tool": tool_name,
|
||||
"behavior": decision.get("behavior"),
|
||||
"decision_ms": approval_latency_ms,
|
||||
"sensitive_pattern": sensitive_pattern,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
@@ -796,6 +852,58 @@ class AgentManager:
|
||||
})
|
||||
return decision
|
||||
|
||||
def p_note_tool_used(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. No-op outside a workflow run or before a step is set.
|
||||
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
|
||||
|
||||
async def p_resolve_ask(tool_name, tool_input, sensitive_pattern) -> dict:
|
||||
"""Resolve an 'ask' policy. On a workflow run, reuse a remembered
|
||||
decision (this step first, then the workflow-level fallback that
|
||||
covers chat-seeded and cross-step approvals) 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(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 {"behavior": "allow"}
|
||||
if prior_step is False:
|
||||
return {"behavior": "deny", "message": "Denied by a remembered workflow permission"}
|
||||
prior = mem.decisions.get(tool_name)
|
||||
if prior == "allow":
|
||||
p_note_tool_used(tool_name, True)
|
||||
return {"behavior": "allow"}
|
||||
if prior == "deny":
|
||||
p_note_tool_used(tool_name, False)
|
||||
return {"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(
|
||||
tool_name, tool_input, sensitive_pattern=sensitive_pattern, timeout=timeout,
|
||||
)
|
||||
if rememberable and decision.get("behavior") in ("allow", "deny"):
|
||||
behavior = decision["behavior"]
|
||||
mem.decisions[tool_name] = behavior
|
||||
p_note_tool_used(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
|
||||
|
||||
async def can_use_tool(tool_name, input_data, context):
|
||||
sensitive_pattern: str | None = None
|
||||
if tool_name != "AskUserQuestion":
|
||||
@@ -803,11 +911,13 @@ class AgentManager:
|
||||
_get_effective_policy(tool_name), tool_name, input_data
|
||||
)
|
||||
if policy == "always_allow":
|
||||
p_note_tool_used(tool_name, True)
|
||||
return PermissionResultAllow(updated_input=input_data)
|
||||
if policy == "deny":
|
||||
p_note_tool_used(tool_name, False)
|
||||
return PermissionResultDeny(message="Tool denied by permission policy")
|
||||
|
||||
decision = await _request_user_approval(tool_name, input_data, sensitive_pattern=sensitive_pattern)
|
||||
decision = await p_resolve_ask(tool_name, input_data, sensitive_pattern)
|
||||
if decision.get("behavior") == "allow":
|
||||
return PermissionResultAllow(
|
||||
updated_input=decision.get("updated_input", input_data)
|
||||
@@ -828,7 +938,11 @@ class AgentManager:
|
||||
_get_effective_policy(tool_name), tool_name, tool_input
|
||||
)
|
||||
|
||||
if policy == "always_allow":
|
||||
p_note_tool_used(tool_name, True)
|
||||
|
||||
if policy == "deny":
|
||||
p_note_tool_used(tool_name, False)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": hook_event,
|
||||
@@ -838,7 +952,7 @@ class AgentManager:
|
||||
}
|
||||
|
||||
if policy == "ask":
|
||||
decision = await _request_user_approval(tool_name, tool_input, sensitive_pattern=sensitive_pattern)
|
||||
decision = await p_resolve_ask(tool_name, tool_input, sensitive_pattern)
|
||||
|
||||
if decision.get("behavior") == "allow":
|
||||
if tool_use_id:
|
||||
|
||||
@@ -37,6 +37,48 @@ def _resolve_allowed_tools(wf: Workflow) -> list[str]:
|
||||
return list(wf.actions.configured_sets)
|
||||
|
||||
|
||||
def p_make_remember_approval(workflow_id: str):
|
||||
def p_remember_approval(tool_name: str, behavior: str) -> None:
|
||||
fresh = storage.get_workflow(workflow_id)
|
||||
if fresh is None:
|
||||
return
|
||||
fresh.remembered_approvals = {**fresh.remembered_approvals, tool_name: behavior}
|
||||
storage.save_workflow(fresh)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
asyncio.get_running_loop().create_task(ws_manager.broadcast_global("workflow:updated", {
|
||||
"workflow_id": fresh.id,
|
||||
"workflow": fresh.model_dump(mode="json"),
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
return p_remember_approval
|
||||
|
||||
|
||||
def p_persist_step_tool_usage(workflow_id: str, step_usage: dict[str, dict[str, bool]]) -> None:
|
||||
fresh = storage.get_workflow(workflow_id)
|
||||
if fresh is None:
|
||||
return
|
||||
live_ids = {s.id for s in fresh.steps}
|
||||
draft = getattr(fresh, "draft_steps", None)
|
||||
if draft is not None:
|
||||
live_ids.update(s.id for s in draft)
|
||||
fresh.step_tool_usage = {
|
||||
sid: dict(tools)
|
||||
for sid, tools in (step_usage or {}).items()
|
||||
if sid in live_ids and isinstance(tools, dict)
|
||||
}
|
||||
storage.save_workflow(fresh)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
asyncio.get_running_loop().create_task(ws_manager.broadcast_global("workflow:updated", {
|
||||
"workflow_id": fresh.id,
|
||||
"workflow": fresh.model_dump(mode="json"),
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _persist_run_fields(wf: Workflow, run_fields: dict, schedule_runs_count_delta: int = 0) -> None:
|
||||
"""Merge run-side fields into the current on-disk workflow.
|
||||
|
||||
@@ -85,7 +127,13 @@ def _monthly_spend_so_far(wf: Workflow) -> float:
|
||||
|
||||
|
||||
async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.agent_manager import (
|
||||
agent_manager,
|
||||
clear_workflow_approval_memory,
|
||||
get_workflow_step_usage,
|
||||
set_workflow_approval_memory,
|
||||
set_workflow_approval_step,
|
||||
)
|
||||
|
||||
run = WorkflowRun(
|
||||
workflow_id=wf.id,
|
||||
@@ -134,7 +182,7 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
|
||||
session = None
|
||||
try:
|
||||
steps = [s.text for s in wf.steps if s.text and s.text.strip()]
|
||||
steps = [s for s in wf.steps if s.text and s.text.strip()]
|
||||
if not steps:
|
||||
raise ValueError("Workflow has no steps")
|
||||
|
||||
@@ -154,6 +202,19 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
run.session_id = session.id
|
||||
storage.record_run(run)
|
||||
|
||||
# Reuse the user's earlier allow/deny answers so an unattended fire
|
||||
# doesn't park on a permission prompt. Scheduled runs prompt for an
|
||||
# unseen tool only briefly (30s) before failing; manual/test runs are
|
||||
# attended, so keep the roomy window. Sensitive-path prompts are never
|
||||
# remembered (handled by the gate); they keep prompting every run.
|
||||
set_workflow_approval_memory(
|
||||
session.id,
|
||||
decisions=dict(wf.remembered_approvals),
|
||||
step_usage={sid: dict(tools) for sid, tools in wf.step_tool_usage.items()},
|
||||
remember=p_make_remember_approval(wf.id),
|
||||
ask_timeout=30.0 if triggered_by == "schedule" else 600.0,
|
||||
)
|
||||
|
||||
# Background poller: surface the latest tool-call name as a
|
||||
# live "what's the agent doing" subtitle on the workflow:run
|
||||
# ws event. Cheap enough to run at 1.5s cadence; nothing else
|
||||
@@ -214,6 +275,7 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
# the disc immediately, not after the agent finishes the step.
|
||||
run.active_step_idx = idx
|
||||
run.last_tool_label = None
|
||||
set_workflow_approval_step(session.id, step.id)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as _wsm
|
||||
await _wsm.broadcast_global("workflow:run", {
|
||||
@@ -222,7 +284,7 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await agent_manager.send_message(session.id, step)
|
||||
await agent_manager.send_message(session.id, step.text)
|
||||
await _await_session_idle(session.id)
|
||||
sess_state = agent_manager.sessions.get(session.id)
|
||||
if sess_state is not None and getattr(sess_state, "status", None) == "error":
|
||||
@@ -284,6 +346,12 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
# the first page). close_session also drops in-memory state and
|
||||
# persists the final snapshot to disk.
|
||||
if session is not None:
|
||||
try:
|
||||
p_persist_step_tool_usage(wf.id, get_workflow_step_usage(session.id))
|
||||
except Exception:
|
||||
logger.exception("persist step tool usage failed for workflow %s", wf.id)
|
||||
set_workflow_approval_step(session.id, None)
|
||||
clear_workflow_approval_memory(session.id)
|
||||
try:
|
||||
await agent_manager.close_session(session.id)
|
||||
except Exception:
|
||||
|
||||
@@ -110,6 +110,16 @@ class Workflow(BaseModel):
|
||||
# Sticky session id for the embedded scheduling agent (the chat that
|
||||
# turns "every Wednesday at 1pm" into a permission-gated tool call).
|
||||
schedule_agent_session_id: Optional[str] = None
|
||||
# Tool permissions the user answered once and we reuse on later runs so an
|
||||
# unattended scheduled fire doesn't stall waiting for someone to click.
|
||||
# tool_name -> decision. Only ordinary "ask" tools land here; sensitive
|
||||
# paths keep their own per-pattern trust and never auto-remember.
|
||||
remembered_approvals: dict[str, Literal["allow", "deny"]] = Field(default_factory=dict)
|
||||
# Behind-the-scenes record of which tools each step touched and whether each
|
||||
# was permitted, keyed by stable step id (not index, so reorders don't
|
||||
# scramble it). Auto-maintained on runs; enforcement stays workflow-level
|
||||
# via remembered_approvals, this is the finer per-step picture.
|
||||
step_tool_usage: dict[str, dict[str, bool]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowRun(BaseModel):
|
||||
@@ -165,3 +175,5 @@ class WorkflowUpdate(BaseModel):
|
||||
mode: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
cost_cap_usd_monthly: Optional[float] = None
|
||||
remembered_approvals: Optional[dict[str, Literal["allow", "deny"]]] = None
|
||||
step_tool_usage: Optional[dict[str, dict[str, bool]]] = None
|
||||
|
||||
@@ -95,6 +95,41 @@ def _derive_icon(wf: Workflow) -> str:
|
||||
return "W"
|
||||
|
||||
|
||||
def p_source_session_approvals(session_id: Optional[str]) -> dict[str, str]:
|
||||
if not session_id:
|
||||
return {}
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
sess = agent_manager.sessions.get(session_id)
|
||||
decisions = getattr(sess, "approval_decisions", None) if sess is not None else None
|
||||
if decisions is None:
|
||||
from backend.apps.agents.manager.session.session_store import _load_session_data
|
||||
data = _load_session_data(session_id) or {}
|
||||
decisions = data.get("approval_decisions") or []
|
||||
except Exception:
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for entry in decisions or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("sensitive_pattern"):
|
||||
continue
|
||||
tool = str(entry.get("tool") or "")
|
||||
behavior = entry.get("behavior")
|
||||
if tool and behavior in ("allow", "deny"):
|
||||
out[tool] = behavior
|
||||
return out
|
||||
|
||||
|
||||
def p_prune_step_tool_usage(wf: Workflow) -> None:
|
||||
live_ids = {s.id for s in wf.steps}
|
||||
wf.step_tool_usage = {
|
||||
sid: dict(tools)
|
||||
for sid, tools in (wf.step_tool_usage or {}).items()
|
||||
if sid in live_ids and isinstance(tools, dict)
|
||||
}
|
||||
|
||||
|
||||
@workflows.router.get("/list")
|
||||
async def list_workflows(dashboard_id: Optional[str] = None):
|
||||
items = storage.list_workflows()
|
||||
@@ -134,6 +169,7 @@ async def create_workflow(body: WorkflowCreate):
|
||||
provider=body.provider or "anthropic",
|
||||
cost_cap_usd_monthly=body.cost_cap_usd_monthly,
|
||||
)
|
||||
wf.remembered_approvals = p_source_session_approvals(body.source_session_id)
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
if wf.schedule.enabled:
|
||||
|
||||
@@ -75,6 +75,64 @@ export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; set
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<RememberedApprovals draft={draft} setDraft={setDraft} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Permissions this workflow learned on an earlier run and now reuses without
|
||||
// asking. A reused "allow" runs unattended, so the user has to be able to see
|
||||
// and take it back here.
|
||||
function RememberedApprovals({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const entries = Object.entries(draft.remembered_approvals || {});
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const prettyName = (tool: string) => (tool.includes('__') ? tool.split('__').pop() || tool : tool);
|
||||
const forget = (tool: string) => {
|
||||
const next = { ...(draft.remembered_approvals || {}) };
|
||||
const nextStepUsage = Object.fromEntries(
|
||||
Object.entries(draft.step_tool_usage || {}).map(([stepId, tools]) => {
|
||||
const copy = { ...(tools || {}) };
|
||||
delete copy[tool];
|
||||
return [stepId, copy];
|
||||
}),
|
||||
);
|
||||
delete next[tool];
|
||||
setDraft({ ...draft, remembered_approvals: next, step_tool_usage: nextStepUsage });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mt: 1, pt: 1.25, borderTop: `1px solid ${c.border.subtle}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
Saved permissions this workflow reuses on later runs.
|
||||
</Typography>
|
||||
<Box
|
||||
onClick={() => setDraft({ ...draft, remembered_approvals: {}, step_tool_usage: {} })}
|
||||
role="button"
|
||||
sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', whiteSpace: 'nowrap', ml: 1, '&:hover': { color: c.text.primary } }}>
|
||||
Clear all
|
||||
</Box>
|
||||
</Box>
|
||||
{entries.map(([tool, answer]) => (
|
||||
<Box key={tool} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.75, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated }}>
|
||||
<Typography sx={{ fontSize: LABEL_FS, color: c.text.primary, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{prettyName(tool)}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: LABEL_FS, fontWeight: 600, color: answer === 'allow' ? c.status.success : c.status.error }}>
|
||||
{answer === 'allow' ? 'Allowed' : 'Blocked'}
|
||||
</Typography>
|
||||
<Box
|
||||
onClick={() => forget(tool)}
|
||||
role="button"
|
||||
aria-label={`Forget ${prettyName(tool)}`}
|
||||
sx={{ display: 'inline-flex', fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', px: 0.4, '&:hover': { color: c.status.error } }}>
|
||||
✕
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ export interface Workflow {
|
||||
edit_agent_session_id?: string | null;
|
||||
/** Sticky session id for the embedded scheduling agent (cadence -> gated tool call). */
|
||||
schedule_agent_session_id?: string | null;
|
||||
/** Tool permissions the user answered once and we reuse on later runs so an
|
||||
* unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */
|
||||
remembered_approvals?: Record<string, 'allow' | 'deny'>;
|
||||
step_tool_usage?: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
|
||||
Reference in New Issue
Block a user