[aidan] feat/scheduled-tasks: remember workflow tool permissions across runs

This commit is contained in:
abccodes
2026-06-16 22:37:53 -07:00
parent 5a5046d5a9
commit ae68c1289b
6 changed files with 298 additions and 6 deletions
+117 -3
View File
@@ -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:
+71 -3
View File
@@ -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:
+12
View File
@@ -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
+36
View File
@@ -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 {