mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 21:27:41 +02:00
[aidan] feat/schedule: inherit source-session tool surface for scheduled runs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user