[aidan] feat/schedule: inherit source-session tool surface for scheduled runs

This commit is contained in:
abccodes
2026-06-20 17:55:25 -07:00
parent 6c399038d6
commit c307eb31b4
3 changed files with 168 additions and 21 deletions
+4 -3
View File
@@ -55,9 +55,9 @@ def _resolve_system_prompt(wf: Workflow) -> Optional[str]:
return wf.system_prompt or None
def _resolve_allowed_tools(wf: Workflow) -> list[str]:
def _resolve_allowed_tools(wf: Workflow) -> Optional[list[str]]:
if not wf.actions.freeze:
return []
return None
return list(wf.actions.configured_sets)
@@ -221,13 +221,14 @@ async def execute(
if not steps:
raise ValueError("Workflow has no steps")
resolved_allowed_tools = _resolve_allowed_tools(wf)
config = AgentConfig(
name=wf.title or "Workflow",
model=wf.model or "sonnet",
mode=wf.mode or "agent",
provider=wf.provider or "anthropic",
system_prompt=_resolve_system_prompt(wf),
allowed_tools=_resolve_allowed_tools(wf) or [
allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [
"Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion",
],
dashboard_id=wf.dashboard_id,
+28 -15
View File
@@ -128,12 +128,13 @@ def _collect_tool_names_from_content(content, out: set[str]) -> None:
_collect_tool_names_from_content(nested, out)
def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], list[str]]:
def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], list[str], Optional[list[str]]]:
if not session_id:
return {}, []
return {}, [], None
try:
from backend.apps.agents.agent_manager import agent_manager
sess = agent_manager.sessions.get(session_id)
allowed_tools = list(getattr(sess, "allowed_tools", [])) if sess is not None else None
decisions = getattr(sess, "approval_decisions", None) if sess is not None else None
messages = getattr(sess, "messages", None) if sess is not None else None
tool_latencies = getattr(sess, "tool_latencies", None) if sess is not None else None
@@ -143,8 +144,10 @@ def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str],
decisions = data.get("approval_decisions") or []
messages = data.get("messages") or []
tool_latencies = data.get("tool_latencies") or {}
raw_allowed = data.get("allowed_tools")
allowed_tools = list(raw_allowed) if isinstance(raw_allowed, list) else None
except Exception:
return {}, []
return {}, [], None
approvals: dict[str, str] = {}
tools: set[str] = set()
for entry in decisions or []:
@@ -172,7 +175,7 @@ def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str],
if name:
tools.add(name)
_collect_tool_names_from_content(content, tools)
return approvals, sorted(tools)
return approvals, sorted(tools), allowed_tools
def p_prune_step_tool_usage(wf: Workflow) -> None:
@@ -196,11 +199,26 @@ async def list_workflows(dashboard_id: Optional[str] = None):
return {"workflows": [_enriched(w) for w in items]}
def _normalize_schedule_state(wf: Workflow) -> None:
def _normalize_schedule_state(wf: Workflow, source_allowed_tools: Optional[list[str]] = None) -> None:
if wf.schedule.timezone == "local" and wf.schedule.enabled:
wf.schedule.timezone = scheduler.host_timezone_name()
if wf.schedule.enabled and not scheduler.is_schedule_configured(wf.schedule):
wf.schedule.enabled = False
if wf.schedule.enabled and wf.schedule.repeat_unit == "month" and wf.schedule.day_of_month is None:
tz = scheduler._resolve_tz(wf.schedule.timezone)
wf.schedule.day_of_month = datetime.now(timezone.utc).astimezone(tz).day
if wf.schedule.enabled and scheduler.is_schedule_configured(wf.schedule) and not wf.actions.freeze:
if wf.source_session_id:
allowed = source_allowed_tools
if allowed is None:
_, _, allowed = p_source_session_memory(wf.source_session_id)
if allowed is not None:
wf.actions = wf.actions.model_copy(update={
"freeze": True,
"configured_sets": list(allowed),
})
else:
wf.actions = wf.actions.model_copy(update={"freeze": True})
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
@@ -226,13 +244,6 @@ async def create_workflow(body: WorkflowCreate):
if not body.unsaved and not _has_nonempty_steps(body.steps):
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
actions = body.actions
# Scheduled workflows default to freeze=on for safety. The user can
# flip "Full agent access" in the editor with an explicit confirm.
# Source-session creates inherit the chat's tool choices so we leave
# them alone there (the source session itself already vetted the
# blast radius).
if body.schedule.enabled and scheduler.is_schedule_configured(body.schedule) and not actions.freeze and not body.source_session_id:
actions = actions.model_copy(update={"freeze": True})
wf = Workflow(
title=body.title,
description=body.description,
@@ -252,7 +263,7 @@ async def create_workflow(body: WorkflowCreate):
auto_named=body.auto_named,
unsaved=body.unsaved,
)
source_approvals, source_tools = p_source_session_memory(body.source_session_id)
source_approvals, source_tools, source_allowed_tools = p_source_session_memory(body.source_session_id)
wf.remembered_approvals = source_approvals
wf.source_tools = source_tools
# Convert-from-chat passes the steps signature so the workflow counts as
@@ -261,7 +272,7 @@ async def create_workflow(body: WorkflowCreate):
wf.tested_signature = body.tested_signature
if not wf.icon:
wf.icon = _derive_icon(wf)
_normalize_schedule_state(wf)
_normalize_schedule_state(wf, source_allowed_tools=source_allowed_tools)
# Force-generate title + description + per-step labels from the steps
# in a single aux call. Previously we only filled missing description,
# leaving stale session names ("Inbox check") as titles. Step labels
@@ -1061,13 +1072,14 @@ async def test_run_workflow(workflow_id: str, body: dict):
)
from backend.apps.workflows import executor
resolved_allowed_tools = executor._resolve_allowed_tools(wf)
config = AgentConfig(
name=f"{wf.title or 'Workflow'} (test)",
model=wf.model or "sonnet",
mode=wf.mode or "agent",
provider=wf.provider or "anthropic",
system_prompt=executor._resolve_system_prompt(wf),
allowed_tools=executor._resolve_allowed_tools(wf) or [
allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [
"Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion",
],
dashboard_id=wf.dashboard_id,
@@ -1192,6 +1204,7 @@ async def schedule_agent_session(workflow_id: str):
" - repeat_every: the interval count (1 unless they say e.g. \"every other\"; "
"for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n"
" - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n"
" - day_of_month: 1-31 when repeat_unit=\"month\" (1 for \"first of the month\")\n"
f" - timezone: \"{local_tz}\" unless the user names a different specific zone\n\n"
"If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence "
"is genuinely ambiguous, ask ONE short clarifying question first; otherwise "
+136 -3
View File
@@ -26,6 +26,7 @@ import shutil
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
@@ -189,6 +190,110 @@ def test_month_repeat_no_longer_clamps_to_28():
assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date()
def test_month_repeat_can_pin_first_day():
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
tz = ZoneInfo("America/Los_Angeles")
sched = ScheduleConfig(
enabled=True,
repeat_unit="month",
repeat_every=1,
day_of_month=1,
hour=9,
minute=0,
timezone="America/Los_Angeles",
)
ref_local = datetime(2025, 6, 20, 10, 0, tzinfo=tz)
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
assert nxt.astimezone(tz).date() == datetime(2025, 7, 1).date()
def test_month_repeat_every_respects_interval_after_clamped_day():
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
sched = ScheduleConfig(
enabled=True,
repeat_unit="month",
repeat_every=2,
day_of_month=31,
hour=9,
minute=0,
timezone="UTC",
)
nxt = _next_fire_after(sched, datetime(2025, 1, 31, 9, 0, tzinfo=timezone.utc))
assert nxt == datetime(2025, 3, 31, 9, 0, tzinfo=timezone.utc)
def test_monthly_create_pins_missing_day_to_creation_day(monkeypatch):
from backend.apps.workflows import workflows as workflows_mod
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, WorkflowStep
from backend.apps.workflows.scheduler import _next_fire_after
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None):
base = datetime(2025, 3, 31, 10, 0, tzinfo=timezone.utc)
return base.astimezone(tz) if tz is not None else base.replace(tzinfo=None)
monkeypatch.setattr(workflows_mod, "datetime", FrozenDateTime)
body = WorkflowCreate(
title="monthly",
steps=[WorkflowStep(text="say hi")],
schedule=ScheduleConfig(
enabled=True,
repeat_unit="month",
repeat_every=1,
hour=9,
minute=0,
timezone="UTC",
),
)
result = asyncio.new_event_loop().run_until_complete(workflows_mod.create_workflow(body))
assert result["schedule"]["day_of_month"] == 31
sched = ScheduleConfig(**result["schedule"])
nxt = _next_fire_after(sched, datetime(2025, 4, 30, 9, 0, tzinfo=timezone.utc))
assert nxt == datetime(2025, 5, 31, 9, 0, tzinfo=timezone.utc)
def test_daily_repeat_every_skips_by_interval():
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
sched = ScheduleConfig(
enabled=True,
repeat_unit="day",
repeat_every=3,
hour=9,
minute=0,
timezone="UTC",
)
nxt = _next_fire_after(sched, datetime(2026, 6, 20, 9, 0, tzinfo=timezone.utc))
assert nxt == datetime(2026, 6, 23, 9, 0, tzinfo=timezone.utc)
def test_weekly_repeat_every_skips_inactive_weeks():
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
sched = ScheduleConfig(
enabled=True,
repeat_unit="week",
repeat_every=2,
on_days=[1],
hour=9,
minute=0,
timezone="UTC",
)
nxt = _next_fire_after(sched, datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc))
assert nxt == datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc)
def test_frozen_empty_tool_set_does_not_fall_back_to_defaults():
from backend.apps.workflows.executor import _resolve_allowed_tools
from backend.apps.workflows.models import ActionsConfig
wf = _make_wf(actions=ActionsConfig(freeze=True, configured_sets=[]))
assert _resolve_allowed_tools(wf) == []
def test_calendar_occurrences_use_schedule_timezone_not_viewer_timezone():
"""A 9am New York schedule returns UTC instants. The frontend can then
render those instants in the viewer's current timezone."""
@@ -335,9 +440,10 @@ def test_freeze_defaults_on_for_scheduled_create():
"""POST /workflows/create with schedule.enabled=true and no source
session should flip actions.freeze=True to keep blast radius small."""
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig, WorkflowStep
body = WorkflowCreate(
title="scheduled",
steps=[WorkflowStep(text="say hi")],
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0),
actions=ActionsConfig(freeze=False, configured_sets=[]),
)
@@ -348,10 +454,11 @@ def test_freeze_defaults_on_for_scheduled_create():
def test_freeze_not_forced_when_source_session_present():
"""Source-session creates inherit the chat's choices; we don't override."""
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig, WorkflowStep
body = WorkflowCreate(
title="from chat",
source_session_id="sess-1",
steps=[WorkflowStep(text="say hi")],
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0),
actions=ActionsConfig(freeze=False, configured_sets=[]),
)
@@ -359,14 +466,40 @@ def test_freeze_not_forced_when_source_session_present():
assert result["actions"]["freeze"] is False
def test_source_session_create_inherits_allowed_tools():
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig, WorkflowStep
agent_manager.sessions["sess-allowed"] = SimpleNamespace(
allowed_tools=["Read"],
approval_decisions=[],
messages=[],
tool_latencies={},
)
try:
body = WorkflowCreate(
title="from restricted chat",
source_session_id="sess-allowed",
steps=[WorkflowStep(text="say hi")],
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0),
actions=ActionsConfig(freeze=False, configured_sets=[]),
)
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
finally:
agent_manager.sessions.pop("sess-allowed", None)
assert result["actions"]["freeze"] is True
assert result["actions"]["configured_sets"] == ["Read"]
def test_create_enabled_schedule_normalizes_local_timezone(monkeypatch):
from backend.apps.workflows import scheduler
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, WorkflowStep
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Chicago")
monkeypatch.setattr(scheduler, "_host_tz_cache", None)
body = WorkflowCreate(
title="local-tz-create",
steps=[WorkflowStep(text="say hi")],
schedule=ScheduleConfig(
enabled=True,
repeat_unit="day",