From 7608d0bc17a3e90f97d8b60cbfd0650805495557 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 03:41:50 -0700 Subject: [PATCH] [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill --- backend/apps/agents/agent_manager.py | 77 ++++++++++++++++++---- backend/apps/agents/schedule_mcp_server.py | 42 ++++++++---- backend/apps/workflows/workflows.py | 11 +++- 3 files changed, 104 insertions(+), 26 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index cf458d60..7b24fc0b 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -612,6 +612,12 @@ class AgentManager: "mcp__openswarm-schedule__DeleteScheduledWorkflow", "mcp__openswarm-schedule__PauseAllWorkflows", } + _CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete") + + def _is_claude_schedule_skill(tool_name: str, tool_input) -> bool: + if tool_name != "Skill" or not isinstance(tool_input, dict): + return False + return str(tool_input.get("skill") or "").strip().lower() == "schedule" # OS-level scheduling across macOS/Linux/Windows. Agent must # not install cron entries, launchd plists, Windows scheduled @@ -732,6 +738,8 @@ class AgentManager: """ if tool_name == "Bash" and _looks_like_os_scheduling(tool_input): return "ask", None + if tool_name in _CLAUDE_INTERNAL_SCHEDULER_TOOLS: + return "deny", None # Committing or mutating a native recurring schedule is the in-app # twin of the crontab gate above: real, user-visible, hard-to-undo, # so it goes through ApprovalBar every time regardless of the @@ -907,6 +915,14 @@ class AgentManager: return decision async def can_use_tool(tool_name, input_data, context): + if _is_claude_schedule_skill(tool_name, input_data): + p_note_tool_used(tool_name, False) + return PermissionResultDeny( + message=( + "Use the openswarm-schedule MCP tools instead of " + "Claude's internal schedule skill." + ) + ) sensitive_pattern: str | None = None if tool_name != "AskUserQuestion": policy, sensitive_pattern = _maybe_override_policy( @@ -936,6 +952,18 @@ class AgentManager: if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) + if _is_claude_schedule_skill(tool_name, tool_input): + p_note_tool_used(tool_name, False) + return { + "hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "deny", + "permissionDecisionReason": ( + "Use the openswarm-schedule MCP tools instead " + "of Claude's internal schedule skill." + ), + } + } policy, sensitive_pattern = _maybe_override_policy( _get_effective_policy(tool_name), tool_name, tool_input ) @@ -1225,21 +1253,35 @@ class AgentManager: mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps) global_settings = load_settings() - # Nudge the agent to surface ScheduleWorkflow proactively when - # the user's ask looks recurring. The per-tool description - # carries the full protocol; this is just the "when to think - # about it" signal so the agent doesn't ignore the surface. + # Nudge the agent to surface workflow conversion proactively when + # the user's ask looks recurring. The actual conversion is owned + # by the UI prompt; chat should not turn this into a cadence Q&A. schedule_ctx = ( "\n" - "After completing a substantive task, if the work looks " - "repeatable (the user said 'every', 'each', 'daily', " - "'weekly', 'morning', 'before standup', or you just did " - "the same sequence twice in this session), offer to " - "schedule it. Use AskUserQuestion to confirm cadence, " - "then ScheduleWorkflow to create it. Never reach for " - "crontab, launchctl, or schtasks; always use the native " - "scheduler so the user can see, pause, and edit it. " - "Don't ask after trivial one-off requests.\n" + "When a normal chat produces work that would naturally be " + "useful again later, prefer the openswarm-schedule MCP " + "workflow path over Claude's internal scheduler. If the user " + "explicitly says 'daily', 'weekly', 'every', 'each', " + "'morning', 'before standup', 'monitor', 'alert me', or " + "'keep this updated', treat that as a strong recurring-work " + "signal. After completing that work, call " + "SuggestConvertToWorkflow with a short reason and cadence " + "hint so the UI can open its workflow-conversion prompt. You " + "may say one short sentence before the tool call, such as " + "'This is a good fit for built-in Workflows.' Do not repeat " + "that advice after the tool call, do not send a follow-up " + "assistant message like 'Done', do not say you are 'nudging' " + "the UI, and do not mention the tool. If the user " + "then says 'ok', 'yes', or 'do it', do not ask what time and " + "do not schedule from chat; the UI prompt/Convert button owns " + "the conversion flow. Only call ScheduleWorkflow when the user " + "explicitly asks you to create a live schedule and has already " + "given an exact cadence and time (for example, 'every weekday " + "at 8am'). If cadence or time is missing, use " + "SuggestConvertToWorkflow instead of asking a follow-up. Never " + "use Skill('schedule'), CronCreate, CronList, CronDelete, " + "crontab, launchctl, or schtasks for user-facing recurring " + "work.\n" "" ) composed_prompt = self._compose_system_prompt( @@ -1555,6 +1597,15 @@ class AgentManager: else: effective_allowed.append(f"mcp__{name}__*") + if "openswarm-schedule" in mcp_servers: + effective_allowed = [ + t for t in effective_allowed + if t not in _CLAUDE_INTERNAL_SCHEDULER_TOOLS + ] + for _bt in _CLAUDE_INTERNAL_SCHEDULER_TOOLS: + if _bt not in effective_disallowed: + effective_disallowed.append(_bt) + # If the openswarm-web MCP was registered, the CLI's built-in # WebSearch/WebFetch are guaranteed to fail (no Anthropic # backend). Suppress them so the model picks our MCP variants diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index fcaa9c1d..ac2ef86a 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -4,9 +4,9 @@ Why this exists: the agent should be able to schedule recurring work on the user's behalf, but ALWAYS through the native scheduler (visible, auditable, cost-capped) rather than `crontab`. Each tool is a thin -wrapper around /api/workflows/*. The descriptions are written to nudge -the agent toward AskUserQuestion-first behavior (confirm cadence with -the user before calling ScheduleWorkflow). +wrapper around /api/workflows/*. The descriptions are written to prefer +UI-owned workflow conversion for vague recurring asks, and to reserve +ScheduleWorkflow for exact, user-specified live schedules. """ import json @@ -52,8 +52,13 @@ TOOLS = [ "name": "ScheduleWorkflow", "description": ( "Create a recurring scheduled workflow for the user. Use this " - "ONLY after confirming cadence with the user via AskUserQuestion " - "(do not assume — the user must pick or accept the time). " + "ONLY when the user explicitly asks you to create a live schedule " + "and has already supplied an exact cadence and time. Do not use " + "this after a generic convert-to-workflow suggestion, and do not " + "ask follow-up questions like 'what time should it run' from a " + "normal chat. If cadence or time is missing, call " + "SuggestConvertToWorkflow instead so the UI can open the workflow " + "conversion prompt. " "The workflow runs the listed steps on the schedule and is " "visible in the user's Workflows hub. Never use crontab, " "launchctl, or schtasks to schedule recurring work; always use " @@ -240,13 +245,25 @@ TOOLS = [ { "name": "SuggestConvertToWorkflow", "description": ( - "Call this at the end of a response when you have just completed a task " - "the user is likely to want to repeat on a schedule (e.g. a daily report, " - "a weekly digest, a recurring data check, a monitoring ping). Do NOT call " - "it for one-off tasks, debugging sessions, creative work, or anything " - "where 'repeat it tomorrow' would be odd. Use sparingly — once per session " - "maximum, only with high confidence. This nudges the frontend to highlight " - "the 'Convert to Workflow' button and suggest a cadence." + "Call this at the end of a response when the completed task is a clear " + "candidate for repeatable scheduled work (e.g. a daily report, weekly " + "digest, recurring data check, monitoring ping, inbox triage, or status " + "briefing). Prefer this native workflow nudge over Claude's internal " + "schedule skill or CronCreate/CronList/CronDelete tools. Use it whenever " + "the user explicitly mentions daily, weekly, every, each, mornings, " + "standup, monitoring, alerts, or keeping something updated, and when you " + "have just done a sequence that would naturally be useful again later. Do " + "NOT call it for one-off tasks, debugging sessions, creative work, or " + "anything where 'repeat it tomorrow' would be odd. It is OK to call this " + "more than once per session for distinct workflow candidates, but avoid " + "repeated nudges for the same task. This nudges the frontend to highlight " + "the 'Convert to Workflow' button and open the workflow-conversion " + "prompt. In user-facing text, say at most one short sentence, such " + "as: 'This is a good fit for built-in Workflows.' Do not repeat the " + "advice after this tool returns, do not say you are nudging the UI, " + "and do not ask what time it should run. After this tool returns, " + "do not send another assistant message like 'Done'; the UI prompt " + "will handle the next step." ), "inputSchema": { "type": "object", @@ -324,6 +341,7 @@ def handle_schedule_workflow(args: dict) -> dict: "steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s], "schedule": schedule, "source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None, + "dashboard_id": DASHBOARD_ID or None, } r = _call("POST", "/create", body) if "_error" in r: diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 3c2f35d5..517cff95 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -282,7 +282,16 @@ async def create_workflow(body: WorkflowCreate): pass storage.save_workflow(wf) scheduler.kick() - return _enriched(wf) + enriched = _enriched(wf) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:updated", { + "workflow_id": wf.id, + "workflow": enriched, + }) + except Exception: + pass + return enriched async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]: