| 1 | //! Automatic Workflow trigger and suppression heuristics (#4127). |
| 2 | //! |
| 3 | //! Soft-auto model: the **agent** decides to use Workflow without the operator |
| 4 | //! saying the word "workflow". Policy here answers "should we orchestrate?" — |
| 5 | //! the parent prompt still **tells the operator** the intended shape and may |
| 6 | //! ask setup questions via `request_user_input` (TUI modal) before calling |
| 7 | //! `workflow` / `plan`. |
| 8 | //! |
| 9 | //! This remains Act/Agent guidance rather than a prose classifier at the host |
| 10 | //! boundary. Operate sends ordinary work to direct background workers and |
| 11 | //! reaches for Workflow only when its stronger orchestration properties help. |
| 12 | |
| 13 | /// Signals the parent can supply without full conversation replay. |
| 14 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
| 15 | pub struct WorkflowTriggerSignals { |
| 16 | /// Approximate open file / edit scope count for the current ask. |
| 17 | pub distinct_file_scopes: usize, |
| 18 | /// True when the operator is mid interactive multi-turn design/chat. |
| 19 | pub highly_interactive: bool, |
| 20 | /// True when the ask requires writes but no clear phase/child decomposition. |
| 21 | pub risky_writes_unclear_decomposition: bool, |
| 22 | /// Estimated child count if Workflow launched now. |
| 23 | pub estimated_children: usize, |
| 24 | /// Soft cap from `[workflow].auto_start_child_limit` (default 16). |
| 25 | pub auto_start_child_limit: usize, |
| 26 | /// Approximate parent context tokens in use (for high-volume signal). |
| 27 | pub context_tokens: usize, |
| 28 | /// Threshold above which high context volume favors Workflow. |
| 29 | pub high_context_token_threshold: usize, |
| 30 | } |
| 31 | |
| 32 | impl WorkflowTriggerSignals { |
| 33 | #[must_use] |
| 34 | pub fn product_defaults() -> Self { |
| 35 | Self { |
| 36 | distinct_file_scopes: 0, |
| 37 | highly_interactive: false, |
| 38 | risky_writes_unclear_decomposition: false, |
| 39 | estimated_children: 0, |
| 40 | auto_start_child_limit: 16, |
| 41 | context_tokens: 0, |
| 42 | high_context_token_threshold: 80_000, |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// Decision for automatic Workflow launch / recommendation. |
| 48 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 49 | pub enum WorkflowTriggerDecision { |
| 50 | /// Launch or recommend Workflow. |
| 51 | Trigger { reason: &'static str }, |
| 52 | /// Suppress automatic Workflow; prefer direct tools / single agent. |
| 53 | Suppress { reason: &'static str }, |
| 54 | } |
| 55 | |
| 56 | impl WorkflowTriggerDecision { |
| 57 | #[must_use] |
| 58 | pub fn should_trigger(&self) -> bool { |
| 59 | matches!(self, Self::Trigger { .. }) |
| 60 | } |
| 61 | |
| 62 | #[cfg(test)] |
| 63 | #[must_use] |
| 64 | pub fn reason(&self) -> &'static str { |
| 65 | match self { |
| 66 | Self::Trigger { reason } | Self::Suppress { reason } => reason, |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// Evaluate whether automatic Workflow is appropriate for this user ask. |
| 72 | /// |
| 73 | /// Suppression wins over trigger when both could apply (noisy auto-orchestration |
| 74 | /// is worse than missing a fan-out). Act/Agent soft-auto guidance should stay |
| 75 | /// aligned with these rules. Operate dispatches direct workers unless stronger |
| 76 | /// Workflow properties are explicitly useful. |
| 77 | #[must_use] |
| 78 | pub fn evaluate_workflow_trigger( |
| 79 | user_text: &str, |
| 80 | signals: &WorkflowTriggerSignals, |
| 81 | ) -> WorkflowTriggerDecision { |
| 82 | let text = user_text.trim(); |
| 83 | let lower = text.to_ascii_lowercase(); |
| 84 | |
| 85 | // --- Hard suppressions (AC) --- |
| 86 | if signals.highly_interactive { |
| 87 | return WorkflowTriggerDecision::Suppress { |
| 88 | reason: "highly interactive task — keep turn-by-turn", |
| 89 | }; |
| 90 | } |
| 91 | if signals.risky_writes_unclear_decomposition { |
| 92 | return WorkflowTriggerDecision::Suppress { |
| 93 | reason: "risky writes without clear decomposition", |
| 94 | }; |
| 95 | } |
| 96 | if signals.estimated_children > 0 |
| 97 | && signals.auto_start_child_limit > 0 |
| 98 | && signals.estimated_children > signals.auto_start_child_limit |
| 99 | { |
| 100 | return WorkflowTriggerDecision::Suppress { |
| 101 | reason: "estimated children exceed auto_start_child_limit", |
| 102 | }; |
| 103 | } |
| 104 | if child_overhead_exceeds_benefit(&lower, signals) { |
| 105 | return WorkflowTriggerDecision::Suppress { |
| 106 | reason: "child overhead greater than benefit", |
| 107 | }; |
| 108 | } |
| 109 | if is_simple_command_or_factual_question(&lower, text) { |
| 110 | return WorkflowTriggerDecision::Suppress { |
| 111 | reason: "simple command or factual question", |
| 112 | }; |
| 113 | } |
| 114 | if is_one_file_edit(&lower, signals) { |
| 115 | return WorkflowTriggerDecision::Suppress { |
| 116 | reason: "one-file edit — use direct tools", |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | // --- Triggers (AC) --- |
| 121 | if signals.distinct_file_scopes >= 3 { |
| 122 | return WorkflowTriggerDecision::Trigger { |
| 123 | reason: "independent scopes across multiple files", |
| 124 | }; |
| 125 | } |
| 126 | if signals.context_tokens >= signals.high_context_token_threshold { |
| 127 | return WorkflowTriggerDecision::Trigger { |
| 128 | reason: "high context volume favors staged Workflow", |
| 129 | }; |
| 130 | } |
| 131 | if has_fanout_language(&lower) { |
| 132 | return WorkflowTriggerDecision::Trigger { |
| 133 | reason: "audit/sweep/compare/fan-out language", |
| 134 | }; |
| 135 | } |
| 136 | if has_staged_work_language(&lower) { |
| 137 | return WorkflowTriggerDecision::Trigger { |
| 138 | reason: "staged multi-phase work", |
| 139 | }; |
| 140 | } |
| 141 | if has_independent_verification_language(&lower) { |
| 142 | return WorkflowTriggerDecision::Trigger { |
| 143 | reason: "independent verification pass", |
| 144 | }; |
| 145 | } |
| 146 | |
| 147 | WorkflowTriggerDecision::Suppress { |
| 148 | reason: "no automatic Workflow trigger matched", |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | fn child_overhead_exceeds_benefit(lower: &str, signals: &WorkflowTriggerSignals) -> bool { |
| 153 | // Tiny asks or explicit single-step language — spawn cost dominates. |
| 154 | if signals.estimated_children == 1 { |
| 155 | return true; |
| 156 | } |
| 157 | if lower.len() < 24 && !has_fanout_language(lower) && !has_staged_work_language(lower) { |
| 158 | return true; |
| 159 | } |
| 160 | let tiny = [ |
| 161 | "fix typo", |
| 162 | "rename variable", |
| 163 | "one liner", |
| 164 | "one-liner", |
| 165 | "quick peek", |
| 166 | "just check", |
| 167 | ]; |
| 168 | tiny.iter().any(|needle| lower.contains(needle)) |
| 169 | } |
| 170 | |
| 171 | fn is_simple_command_or_factual_question(lower: &str, original: &str) -> bool { |
| 172 | if lower.starts_with('/') { |
| 173 | // Slash commands are UI routing, not orchestration. |
| 174 | return true; |
| 175 | } |
| 176 | let factual_prefixes = [ |
| 177 | "what is ", |
| 178 | "what's ", |
| 179 | "whats ", |
| 180 | "who is ", |
| 181 | "when is ", |
| 182 | "where is ", |
| 183 | "how many ", |
| 184 | "which ", |
| 185 | "define ", |
| 186 | "explain ", |
| 187 | ]; |
| 188 | if factual_prefixes.iter().any(|p| lower.starts_with(p)) && original.len() < 160 { |
| 189 | return true; |
| 190 | } |
| 191 | let simple_cmds = [ |
| 192 | "run tests", |
| 193 | "run the tests", |
| 194 | "cargo test", |
| 195 | "cargo check", |
| 196 | "git status", |
| 197 | "git log", |
| 198 | "git diff", |
| 199 | "ls", |
| 200 | "pwd", |
| 201 | "show version", |
| 202 | "print version", |
| 203 | ]; |
| 204 | if simple_cmds |
| 205 | .iter() |
| 206 | .any(|c| lower == *c || lower.starts_with(&format!("{c} "))) |
| 207 | { |
| 208 | return true; |
| 209 | } |
| 210 | // Short yes/no or status pings. |
| 211 | matches!( |
| 212 | lower.trim_end_matches(['?', '.', '!']), |
| 213 | "ok" | "thanks" | "thank you" | "status" | "ping" | "hello" | "hi" |
| 214 | ) |
| 215 | } |
| 216 | |
| 217 | fn is_one_file_edit(lower: &str, signals: &WorkflowTriggerSignals) -> bool { |
| 218 | if signals.distinct_file_scopes == 1 { |
| 219 | let editish = [ |
| 220 | "edit ", |
| 221 | "fix ", |
| 222 | "patch ", |
| 223 | "update ", |
| 224 | "change ", |
| 225 | "rewrite ", |
| 226 | "in this file", |
| 227 | "this file", |
| 228 | "only this file", |
| 229 | "single file", |
| 230 | "one file", |
| 231 | ]; |
| 232 | return editish.iter().any(|n| lower.contains(n)); |
| 233 | } |
| 234 | // Explicit single-file phrasing without scope signal. |
| 235 | lower.contains("only this file") |
| 236 | || lower.contains("just this file") |
| 237 | || lower.contains("single file") |
| 238 | || (lower.contains("one file") && !has_fanout_language(lower)) |
| 239 | } |
| 240 | |
| 241 | fn has_fanout_language(lower: &str) -> bool { |
| 242 | const NEEDLES: &[&str] = &[ |
| 243 | "audit", |
| 244 | "sweep", |
| 245 | "compare", |
| 246 | "fan-out", |
| 247 | "fan out", |
| 248 | "fanout", |
| 249 | "in parallel", |
| 250 | "parallel across", |
| 251 | "across the codebase", |
| 252 | "across packages", |
| 253 | "across crates", |
| 254 | "every crate", |
| 255 | "all packages", |
| 256 | "all modules", |
| 257 | "multi-repo", |
| 258 | "multi repo", |
| 259 | ]; |
| 260 | NEEDLES.iter().any(|n| lower.contains(n)) |
| 261 | } |
| 262 | |
| 263 | fn has_staged_work_language(lower: &str) -> bool { |
| 264 | const NEEDLES: &[&str] = &[ |
| 265 | "phase 1", |
| 266 | "phase 2", |
| 267 | "first implement", |
| 268 | "then verify", |
| 269 | "implement then", |
| 270 | "staged", |
| 271 | "multi-phase", |
| 272 | "multi phase", |
| 273 | "plan then execute", |
| 274 | "explore then implement", |
| 275 | "scout then", |
| 276 | ]; |
| 277 | NEEDLES.iter().any(|n| lower.contains(n)) |
| 278 | } |
| 279 | |
| 280 | fn has_independent_verification_language(lower: &str) -> bool { |
| 281 | const NEEDLES: &[&str] = &[ |
| 282 | "independent verification", |
| 283 | "verify independently", |
| 284 | "separate verifier", |
| 285 | "second pair of eyes", |
| 286 | "review in parallel", |
| 287 | "verify in parallel", |
| 288 | "independent review", |
| 289 | ]; |
| 290 | NEEDLES.iter().any(|n| lower.contains(n)) |
| 291 | } |
| 292 | |
| 293 | /// Reachability probe so the soft-auto surface stays linked in release builds. |
| 294 | /// |
| 295 | /// Returns `true` when a canonical fan-out ask would trigger Workflow under |
| 296 | /// product defaults (used by registry/tool wiring smoke tests). |
| 297 | #[must_use] |
| 298 | pub fn soft_auto_policy_is_linked() -> bool { |
| 299 | evaluate_workflow_trigger( |
| 300 | "audit every crate for unsafe blocks", |
| 301 | &WorkflowTriggerSignals::product_defaults(), |
| 302 | ) |
| 303 | .should_trigger() |
| 304 | } |
| 305 | |
| 306 | #[cfg(test)] |
| 307 | mod tests { |
| 308 | use super::*; |
| 309 | |
| 310 | fn signals() -> WorkflowTriggerSignals { |
| 311 | WorkflowTriggerSignals::product_defaults() |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn suppresses_one_file_edits() { |
| 316 | let mut s = signals(); |
| 317 | s.distinct_file_scopes = 1; |
| 318 | let d = evaluate_workflow_trigger("fix the typo in this file", &s); |
| 319 | assert!(!d.should_trigger(), "{d:?}"); |
| 320 | assert!(d.reason().contains("one-file")); |
| 321 | } |
| 322 | |
| 323 | #[test] |
| 324 | fn suppresses_simple_commands_and_factual_questions() { |
| 325 | let s = signals(); |
| 326 | for ask in [ |
| 327 | "cargo test", |
| 328 | "git status", |
| 329 | "what is a worktree?", |
| 330 | "how many crates are there?", |
| 331 | "/help", |
| 332 | "thanks", |
| 333 | ] { |
| 334 | let d = evaluate_workflow_trigger(ask, &s); |
| 335 | assert!(!d.should_trigger(), "expected suppress for {ask:?}: {d:?}"); |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | #[test] |
| 340 | fn product_defaults_match_workflow_child_limit() { |
| 341 | assert_eq!(signals().auto_start_child_limit, 16); |
| 342 | } |
| 343 | |
| 344 | #[test] |
| 345 | fn suppresses_highly_interactive_and_unclear_risky_writes() { |
| 346 | let mut s = signals(); |
| 347 | s.highly_interactive = true; |
| 348 | assert!(!evaluate_workflow_trigger("redesign the product with me", &s).should_trigger()); |
| 349 | |
| 350 | s = signals(); |
| 351 | s.risky_writes_unclear_decomposition = true; |
| 352 | assert!(!evaluate_workflow_trigger("make it better somehow", &s).should_trigger()); |
| 353 | } |
| 354 | |
| 355 | #[test] |
| 356 | fn suppresses_when_child_overhead_dominates() { |
| 357 | let mut s = signals(); |
| 358 | s.estimated_children = 1; |
| 359 | assert!(!evaluate_workflow_trigger("quick peek at main.rs", &s).should_trigger()); |
| 360 | |
| 361 | s = signals(); |
| 362 | s.estimated_children = 20; |
| 363 | s.auto_start_child_limit = 8; |
| 364 | let d = evaluate_workflow_trigger("audit the whole monorepo", &s); |
| 365 | assert!(!d.should_trigger(), "{d:?}"); |
| 366 | assert!(d.reason().contains("auto_start_child_limit")); |
| 367 | } |
| 368 | |
| 369 | #[test] |
| 370 | fn triggers_on_fanout_and_staged_language() { |
| 371 | let s = signals(); |
| 372 | for ask in [ |
| 373 | "audit every crate for unsafe blocks", |
| 374 | "sweep the codebase for TODO debt", |
| 375 | "compare the two provider implementations in parallel", |
| 376 | "phase 1 explore then phase 2 implement", |
| 377 | "run an independent verification of the release notes", |
| 378 | ] { |
| 379 | let d = evaluate_workflow_trigger(ask, &s); |
| 380 | assert!(d.should_trigger(), "expected trigger for {ask:?}: {d:?}"); |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | #[test] |
| 385 | fn triggers_on_independent_scopes_and_high_context() { |
| 386 | let mut s = signals(); |
| 387 | s.distinct_file_scopes = 5; |
| 388 | assert!( |
| 389 | evaluate_workflow_trigger("touch the related modules carefully", &s).should_trigger() |
| 390 | ); |
| 391 | |
| 392 | s = signals(); |
| 393 | s.context_tokens = 120_000; |
| 394 | assert!(evaluate_workflow_trigger("continue the migration plan", &s).should_trigger()); |
| 395 | } |
| 396 | |
| 397 | #[test] |
| 398 | fn suppression_wins_over_fanout_language_when_interactive() { |
| 399 | let mut s = signals(); |
| 400 | s.highly_interactive = true; |
| 401 | let d = evaluate_workflow_trigger("let's design an audit sweep together", &s); |
| 402 | assert!(!d.should_trigger(), "{d:?}"); |
| 403 | assert!(d.reason().contains("interactive")); |
| 404 | } |
| 405 | } |
| 406 |