返回 CodeWhale
workflow_trigger.rs
根目录 / crates / tui / src / tools / workflow_trigger.rs
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
406 lines RUST