diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 2fb8f9458..3d163af35 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -56,6 +56,11 @@ impl CoworkMode { "WebSearch".to_string(), "WebFetch".to_string(), "ControlHub".to_string(), + // Recurring office work ("check these channels every 30 + // minutes") is squarely this mode's job, and ControlHub's + // `wait` sends schedules here rather than pinning a turn open + // for the interval. + "Cron".to_string(), "InitMiniApp".to_string(), "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), @@ -120,6 +125,15 @@ mod tests { } } + #[test] + fn cowork_mode_can_schedule_recurring_work() { + // Asked to sweep a set of channels every 30 minutes, this mode used to + // reply that it had no cron tool — accurately, because Cron was not in + // its list — and fall back to chaining long waits. + let tools = CoworkMode::new().default_tools(); + assert!(tools.contains(&"Cron".to_string())); + } + #[test] fn cowork_mode_includes_miniapp_lifecycle_tools_in_defaults() { let tools = CoworkMode::new().default_tools(); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs index 001147328..5eababbd8 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -39,6 +39,10 @@ impl DeepResearchMode { "WriteStdin".to_string(), "ExecControl".to_string(), "ControlHub".to_string(), + // Standing research ("re-check these sources every morning") + // belongs on a schedule, and ControlHub's `wait` points here + // rather than at an hour-long turn. + "Cron".to_string(), "TodoWrite".to_string(), "AskUserQuestion".to_string(), ], diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 78216eef6..293fa3d07 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -44,6 +44,10 @@ impl TeamMode { "AskUserQuestion".to_string(), "Git".to_string(), "ControlHub".to_string(), + // Every mode that carries ControlHub needs Cron: ControlHub's + // `wait` tells the agent to schedule long or repeating work + // here instead of holding the turn open. + "Cron".to_string(), "GetFileDiff".to_string(), ], } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 1354a6a15..8904b0e8c 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -145,6 +145,10 @@ pub fn shared_coding_mode_tools() -> Vec { "Git".to_string(), "ReviewPlatform".to_string(), "ControlHub".to_string(), + // Pairs with ControlHub: its `wait` sends anything repeating, or + // further out than an hour, to Cron rather than holding the turn open + // for the interval. + "Cron".to_string(), "InitMiniApp".to_string(), "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 462445ae7..c0636392b 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -340,6 +340,30 @@ fn every_builtin_primary_mode_defaults_to_the_thread_goal_lifecycle() { } } +#[test] +fn every_builtin_mode_with_control_hub_can_also_schedule_with_cron() { + // ControlHub's `wait` documentation tells the agent to schedule anything + // repeating — or further out than an hour — with Cron instead of holding + // the turn open. A mode that offers one without the other sends the agent + // after a tool that is not in its list; Cowork answered a "check every 30 + // minutes" request with "I have no cron tool" for exactly this reason. + for spec in builtin_agent_specs() + .iter() + .filter(|spec| spec.category == AgentCategory::Mode) + { + let mode = (spec.factory)(); + let default_tools = mode.default_tools(); + if !default_tools.iter().any(|tool| tool == "ControlHub") { + continue; + } + assert!( + default_tools.iter().any(|tool| tool == "Cron"), + "builtin mode {} offers ControlHub but cannot schedule with Cron", + mode.id() + ); + } +} + #[test] fn non_deep_review_builtin_subagents_default_to_primary() { for agent_type in [ diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index 66032288f..8e0bf97c9 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -271,7 +271,7 @@ Use this tool via `{ domain, action, params }` for browser automation, terminal * `wait { duration_ms }` — pause for a fixed time, up to 60 minutes (`ms` and `seconds` are accepted spellings). This is the action to use when you must idle between rounds of work, e.g. `{ "duration_ms": 1800000 }` to resume in 30 minutes. It needs no browser session, and the result reports the `ms` actually waited, so check that figure before assuming the full pause happened. * `wait { condition, timeout_ms? }` — wait on the page instead: 'load' | 'domcontentloaded' | 'networkidle' | a CSS/@ref selector, bounded by `timeout_ms` (default 15s). Requires a connected session. When a `condition` is present it always wins, and any duration you pass becomes its timeout rather than a separate sleep. * A `wait` carrying neither is rejected with `INVALID_PARAMS` — it never silently returns. - * `wait` holds the turn open for its whole duration, so it suits a one-off pause, not a schedule. For work that should repeat ("produce another round every 30 minutes") or resume more than an hour out, create a job with the `Cron` tool instead — it ends the turn and re-invokes you when the job fires, rather than idling with the context loaded. + * `wait` holds the turn open for its whole duration, so it suits a one-off pause, not a schedule. For work that should repeat ("produce another round every 30 minutes") or resume more than an hour out, create a job with the `Cron` tool instead — it ends the turn and re-invokes you when the job fires, rather than idling with the context loaded. Every built-in mode that has ControlHub also has `Cron`; if it is genuinely absent from your tool list, say so rather than substituting a chain of long `wait` calls. - Automation workflow: connect -> navigate -> snapshot (returns @e1, @e2 ... refs) -> click/fill with `{ "selector": "@e1" }` (the key `ref` is accepted too). - Take a fresh snapshot after any DOM mutation; a stale `@eN` ref returns `error.code = STALE_REF`, while a selector that matches nothing returns `NOT_FOUND`.