Skip to main content

roboticus_agent/
action_planner.rs

1//! Deterministic action planner for task-oriented turns.
2//!
3//! Maps a [`TaskOperatingState`] + [`TaskStateInput`] to a ranked list of
4//! [`ActionCandidate`]s and selects the best one. No LLM call — this is
5//! pure heuristic scoring.
6//!
7//! ## Invariants
8//!
9//! - All task-oriented next-action selection flows through [`plan`].
10//! - Direct execution, memory inspection, and blocker surfacing are
11//!   equally first-class — not afterthoughts to delegation.
12//! - The decomposition gate is a scored input, not an authority.
13
14use serde::Serialize;
15
16use crate::task_state::{TaskClassification, TaskOperatingState, TaskStateInput};
17
18/// The set of valid next actions the planner can select.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20pub enum PlannedAction {
21    /// Answer directly — conversational turn, no task routing.
22    AnswerDirectly,
23    /// Proceed with centralized (non-delegated) inference.
24    ContinueCentralized,
25    /// Inspect memory deeper — recall gap detected, deeper retrieval warranted.
26    InspectMemory,
27    /// Compose a new skill to fill a capability gap.
28    ComposeSkill,
29    /// Compose a new specialist subagent.
30    ComposeSubagent,
31    /// Delegate to an existing specialist with matching capabilities.
32    DelegateToSpecialist,
33    /// Cannot proceed — surface a real blocker to the user.
34    ReturnBlocker,
35    /// Malformed tool output detected — retry with preserved state.
36    NormalizationRetry,
37}
38
39/// A candidate next action with its confidence score and rationale.
40#[derive(Debug, Clone, Serialize)]
41pub struct ActionCandidate {
42    pub action: PlannedAction,
43    /// Confidence score (0.0–1.0). Higher = more confident this is the right action.
44    pub confidence: f64,
45    /// Human-readable explanation of WHY this action was chosen.
46    pub rationale: String,
47}
48
49/// The planner's output: ranked candidates and the selected action.
50#[derive(Debug, Clone, Serialize)]
51pub struct TaskExecutionPlan {
52    /// All considered candidates, sorted by confidence descending.
53    pub candidates: Vec<ActionCandidate>,
54    /// The selected action (first candidate).
55    pub selected: PlannedAction,
56    /// Rationale for the selected action.
57    pub selected_rationale: String,
58}
59
60/// Plan the next action for a turn based on the operating state.
61///
62/// This is the **single authority** on task-oriented next-action selection.
63/// No shortcut, guard, or pipeline stage may independently choose
64/// delegation, composition, or memory inspection for task turns.
65///
66/// The planner is deterministic — same inputs always produce the same output.
67pub fn plan(state: &TaskOperatingState, input: &TaskStateInput) -> TaskExecutionPlan {
68    let mut candidates = Vec::new();
69
70    // ── Rule 1: Conversation => AnswerDirectly (short-circuit) ──
71    if state.classification == TaskClassification::Conversation {
72        candidates.push(ActionCandidate {
73            action: PlannedAction::AnswerDirectly,
74            confidence: 0.95,
75            rationale: "Turn classified as conversation, not task".into(),
76        });
77        return finalize(candidates);
78    }
79
80    // ── Rule 2: Provider breaker open => ReturnBlocker ──
81    if state.runtime_constraints.provider_breaker_open {
82        candidates.push(ActionCandidate {
83            action: PlannedAction::ReturnBlocker,
84            confidence: 0.8,
85            rationale: "Provider circuit breaker open; cannot proceed with inference".into(),
86        });
87    }
88
89    // ── Rule 3: Explicit workflow + matching roster => Delegate ──
90    // Only fires when the user EXPLICITLY requested delegation (semantic match at
91    // 0.80 threshold). Conversational turns with incidental specialist fit do NOT
92    // trigger auto-delegation — the agent responds directly and can delegate via
93    // tool call during inference if it decides to.
94    if state.roster_fit.explicit_workflow && state.roster_fit.fit_count > 0 {
95        candidates.push(ActionCandidate {
96            action: PlannedAction::DelegateToSpecialist,
97            confidence: 0.9,
98            rationale: format!(
99                "Explicit delegation requested; {} specialist(s) fit: {}",
100                state.roster_fit.fit_count,
101                state.roster_fit.fit_names.join(", ")
102            ),
103        });
104    }
105
106    // ── Rule 3b: Explicit workflow + named plugin tool match => ContinueCentralized ──
107    // When the user says "relay to Claude Code" (or any named plugin tool), and
108    // that tool exists in the registry, the agent should use it during inference
109    // rather than creating a new specialist subagent.
110    // Confidence 0.88: beats ComposeSubagent (0.85) but defers to a fitting
111    // specialist (0.9) when one exists — the specialist is the more specific match.
112    if state.roster_fit.explicit_workflow
113        && input.named_tool_match
114        && state.roster_fit.fit_count == 0
115    {
116        candidates.push(ActionCandidate {
117            action: PlannedAction::ContinueCentralized,
118            confidence: 0.88,
119            rationale: "Explicit delegation requested for a named plugin tool that exists in the tool registry; routing to centralized inference for tool-call dispatch".into(),
120        });
121    }
122
123    // ── Rule 4: Explicit workflow + empty roster + creator => Compose ──
124    // Only fires when no named tool match was found (Rule 3b above).
125    if state.roster_fit.explicit_workflow
126        && state.roster_fit.taskable_count == 0
127        && !input.named_tool_match
128        && is_creator_authority(&input.authority)
129    {
130        candidates.push(ActionCandidate {
131            action: PlannedAction::ComposeSubagent,
132            confidence: 0.85,
133            rationale: "Explicit delegation requested but roster empty and no matching tool/plugin; composing specialist"
134                .into(),
135        });
136    }
137
138    // ── Rule 5: Decomposition gate recommends delegation + fit exists ──
139    // Only fires when BOTH the gate recommends delegation AND the user's turn
140    // is NOT a direct conversational address. If the user is talking TO the agent
141    // (not requesting task dispatch), the agent should respond directly — delegation
142    // happens during inference via tool calls, not pre-inference routing.
143    if let Some(ref proposal) = input.decomposition_proposal
144        && proposal.should_delegate
145        && state.roster_fit.fit_count > 0
146        && state.roster_fit.explicit_workflow
147    {
148        candidates.push(ActionCandidate {
149            action: PlannedAction::DelegateToSpecialist,
150            confidence: 0.75,
151            rationale: format!(
152                "Decomposition gate recommends delegation (utility margin {:.2}); {} specialist(s) fit",
153                proposal.utility_margin, state.roster_fit.fit_count
154            ),
155        });
156    }
157
158    // ── Rule 6: Memory recall gap + low similarity => InspectMemory ──
159    // Only for task turns — conversational turns get passive memory.
160    if state.memory_confidence.recall_gap
161        && state.memory_confidence.avg_similarity < 0.5
162        && !state.runtime_constraints.budget_pressured
163    {
164        candidates.push(ActionCandidate {
165            action: PlannedAction::InspectMemory,
166            confidence: 0.7,
167            rationale: format!(
168                "Memory recall gap detected ({} empty tier(s), avg similarity {:.2}); deeper inspection warranted",
169                state.memory_confidence.empty_tiers.len(),
170                state.memory_confidence.avg_similarity
171            ),
172        });
173    }
174
175    // ── Rule 7: Missing skills + creator authority => ComposeSkill ──
176    if !state.skill_fit.missing_skills.is_empty() && is_creator_authority(&input.authority) {
177        candidates.push(ActionCandidate {
178            action: PlannedAction::ComposeSkill,
179            confidence: 0.65,
180            rationale: format!(
181                "Missing skills: {}",
182                state.skill_fit.missing_skills.join(", ")
183            ),
184        });
185    }
186
187    // ── Rule 8: Previous turn had protocol issues => NormalizationRetry ──
188    // When the LLM produced malformed tool protocol or narrated tool calls
189    // instead of executing them, inject a correction instruction before
190    // the next inference so the model doesn't repeat the pattern.
191    // Confidence escalates with streak length — persistent failures are
192    // a stronger signal that the model needs explicit correction.
193    if input.previous_turn_had_protocol_issues {
194        let streak_boost = (input.normalization_retry_streak as f64 * 0.02).min(0.1);
195        candidates.push(ActionCandidate {
196            action: PlannedAction::NormalizationRetry,
197            confidence: 0.75 + streak_boost,
198            rationale: format!(
199                "Previous turn contained malformed tool protocol (streak: {}); \
200                 injecting correction instruction",
201                input.normalization_retry_streak
202            ),
203        });
204    }
205
206    // ── Rule 9: Structural repetition detected => inject variation hint ──
207    // When the agent has produced structurally identical responses 3+ times in a
208    // row, the planner annotates ContinueCentralized with a variation directive so
209    // the inference layer knows to break the pattern. This does NOT change the
210    // selected action — it enriches the rationale that is surfaced to the prompt.
211    if state.behavioral_history.structural_repetition {
212        let pattern = state
213            .behavioral_history
214            .repeated_pattern
215            .as_deref()
216            .unwrap_or("unknown");
217        candidates.push(ActionCandidate {
218            action: PlannedAction::ContinueCentralized,
219            confidence: 0.55,
220            rationale: format!(
221                "Pattern-locked: {} consecutive responses with skeleton \"{}\". \
222                 Vary response structure before proceeding.",
223                state.behavioral_history.repetition_streak, pattern
224            ),
225        });
226    }
227
228    // ── Rule 10: User engagement declining => flag strategy change ──
229    // When user message lengths are monotonically decreasing over 3+ turns and
230    // the most recent message is short, the planner signals that the current
231    // approach may not be meeting the user's needs.
232    if state.behavioral_history.engagement_declining {
233        candidates.push(ActionCandidate {
234            action: PlannedAction::ContinueCentralized,
235            confidence: 0.5,
236            rationale: "User engagement declining: messages are getting shorter and more \
237                        directive. Consider changing strategy or asking a focused question."
238                .into(),
239        });
240    }
241
242    // ── Fallback: ContinueCentralized ──
243    if candidates.is_empty() || candidates.iter().all(|c| c.confidence < 0.5) {
244        candidates.push(ActionCandidate {
245            action: PlannedAction::ContinueCentralized,
246            confidence: 0.6,
247            rationale:
248                "No strong delegation/composition signal; proceeding with centralized inference"
249                    .into(),
250        });
251    }
252
253    finalize(candidates)
254}
255
256/// Sort candidates by confidence descending and select the best one.
257fn finalize(mut candidates: Vec<ActionCandidate>) -> TaskExecutionPlan {
258    candidates.sort_by(|a, b| {
259        b.confidence
260            .partial_cmp(&a.confidence)
261            .unwrap_or(std::cmp::Ordering::Equal)
262    });
263    let selected = candidates
264        .first()
265        .map(|c| c.action)
266        .unwrap_or(PlannedAction::ContinueCentralized);
267    let selected_rationale = candidates
268        .first()
269        .map(|c| c.rationale.clone())
270        .unwrap_or_else(|| "No candidates generated".into());
271    TaskExecutionPlan {
272        candidates,
273        selected,
274        selected_rationale,
275    }
276}
277
278/// Check whether the authority level is sufficient for composition actions.
279fn is_creator_authority(authority: &str) -> bool {
280    let lower = authority.to_ascii_lowercase();
281    lower.contains("creator")
282        || lower.contains("selfgenerated")
283        || lower.contains("self_generated")
284        || lower.contains("admin")
285}
286
287// ── Tests ────────────────────────────────────────────────────────────
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::task_state::{MemoryConfidence, RosterFit, RuntimeConstraints, SkillFit, ToolFit};
293
294    fn base_input() -> TaskStateInput {
295        TaskStateInput {
296            user_content: "do something".into(),
297            intents: vec!["Execution".into()],
298            authority: "Creator".into(),
299            retrieval_metrics: None,
300            tool_search_stats: None,
301            mcp_tools_available: false,
302            taskable_agent_count: 0,
303            fit_agent_count: 0,
304            fit_agent_names: vec![],
305            enabled_skill_count: 5,
306            matching_skill_count: 0,
307            missing_skills: vec![],
308            remaining_budget_tokens: 8000,
309            provider_breaker_open: false,
310            inference_mode: "standard".into(),
311            decomposition_proposal: None,
312            explicit_specialist_workflow: false,
313            named_tool_match: false,
314            recent_response_skeletons: vec![],
315            recent_user_message_lengths: vec![],
316            self_echo_fragments: vec![],
317            declared_action: None,
318            previous_turn_had_protocol_issues: false,
319            normalization_retry_streak: 0,
320        }
321    }
322
323    fn task_state(classification: TaskClassification) -> TaskOperatingState {
324        TaskOperatingState {
325            classification,
326            memory_confidence: MemoryConfidence {
327                avg_similarity: 0.7,
328                budget_utilization: 0.5,
329                retrieval_count: 5,
330                recall_gap: false,
331                empty_tiers: vec![],
332            },
333            runtime_constraints: RuntimeConstraints {
334                remaining_budget_tokens: 8000,
335                budget_pressured: false,
336                provider_breaker_open: false,
337                inference_mode: "standard".into(),
338            },
339            tool_fit: ToolFit {
340                available_count: 10,
341                high_relevance_count: 3,
342                token_savings: 2000,
343                mcp_available: false,
344            },
345            roster_fit: RosterFit {
346                taskable_count: 0,
347                fit_count: 0,
348                fit_names: vec![],
349                explicit_workflow: false,
350            },
351            skill_fit: SkillFit {
352                enabled_count: 5,
353                matching_count: 0,
354                missing_skills: vec![],
355            },
356            behavioral_history: crate::task_state::BehavioralHistory {
357                structural_repetition: false,
358                repetition_streak: 0,
359                repeated_pattern: None,
360                engagement_declining: false,
361                self_echo_risk: 0.0,
362                echo_fragment: None,
363                variation_hint: None,
364            },
365            declared_action: crate::task_state::DeclaredActionState {
366                action_declared: false,
367                action: None,
368                high_consequence: false,
369            },
370        }
371    }
372
373    #[test]
374    fn conversation_short_circuits_to_answer_directly() {
375        let state = task_state(TaskClassification::Conversation);
376        let input = base_input();
377        let plan = plan(&state, &input);
378        assert_eq!(plan.selected, PlannedAction::AnswerDirectly);
379        assert_eq!(plan.candidates.len(), 1);
380        assert!(plan.candidates[0].confidence >= 0.9);
381    }
382
383    #[test]
384    fn provider_breaker_open_returns_blocker() {
385        let mut state = task_state(TaskClassification::Task);
386        state.runtime_constraints.provider_breaker_open = true;
387        let input = base_input();
388        let plan = plan(&state, &input);
389        assert_eq!(plan.selected, PlannedAction::ReturnBlocker);
390    }
391
392    #[test]
393    fn explicit_workflow_with_fit_delegates() {
394        let mut state = task_state(TaskClassification::Task);
395        state.roster_fit.explicit_workflow = true;
396        state.roster_fit.fit_count = 2;
397        state.roster_fit.fit_names = vec!["research-specialist".into()];
398        let mut input = base_input();
399        input.explicit_specialist_workflow = true;
400        let plan = plan(&state, &input);
401        assert_eq!(plan.selected, PlannedAction::DelegateToSpecialist);
402    }
403
404    #[test]
405    fn explicit_workflow_empty_roster_composes() {
406        let mut state = task_state(TaskClassification::Task);
407        state.roster_fit.explicit_workflow = true;
408        state.roster_fit.taskable_count = 0;
409        let mut input = base_input();
410        input.explicit_specialist_workflow = true;
411        let plan = plan(&state, &input);
412        assert_eq!(plan.selected, PlannedAction::ComposeSubagent);
413    }
414
415    #[test]
416    fn memory_gap_triggers_inspect() {
417        let mut state = task_state(TaskClassification::Task);
418        state.memory_confidence.recall_gap = true;
419        state.memory_confidence.avg_similarity = 0.3;
420        state.memory_confidence.empty_tiers = vec!["semantic".into(), "procedural".into()];
421        let input = base_input();
422        let plan = plan(&state, &input);
423        // InspectMemory should be a candidate
424        assert!(
425            plan.candidates
426                .iter()
427                .any(|c| c.action == PlannedAction::InspectMemory)
428        );
429    }
430
431    #[test]
432    fn missing_skills_triggers_compose_skill() {
433        let mut state = task_state(TaskClassification::Task);
434        state.skill_fit.missing_skills = vec!["dnd-rules".into()];
435        let input = base_input();
436        let plan = plan(&state, &input);
437        assert!(
438            plan.candidates
439                .iter()
440                .any(|c| c.action == PlannedAction::ComposeSkill)
441        );
442    }
443
444    #[test]
445    fn fallback_is_continue_centralized() {
446        let state = task_state(TaskClassification::Task);
447        let input = base_input();
448        let plan = plan(&state, &input);
449        assert_eq!(plan.selected, PlannedAction::ContinueCentralized);
450    }
451
452    #[test]
453    fn non_creator_cannot_compose() {
454        let mut state = task_state(TaskClassification::Task);
455        state.roster_fit.explicit_workflow = true;
456        state.roster_fit.taskable_count = 0;
457        let mut input = base_input();
458        input.authority = "Peer".into();
459        input.explicit_specialist_workflow = true;
460        let plan = plan(&state, &input);
461        // Should NOT propose ComposeSubagent for non-creator
462        assert!(
463            !plan
464                .candidates
465                .iter()
466                .any(|c| c.action == PlannedAction::ComposeSubagent)
467        );
468    }
469
470    #[test]
471    fn candidates_sorted_by_confidence() {
472        let mut state = task_state(TaskClassification::Task);
473        state.roster_fit.explicit_workflow = true;
474        state.roster_fit.fit_count = 1;
475        state.roster_fit.fit_names = vec!["specialist".into()];
476        state.memory_confidence.recall_gap = true;
477        state.memory_confidence.avg_similarity = 0.3;
478        state.memory_confidence.empty_tiers = vec!["semantic".into()];
479        let mut input = base_input();
480        input.explicit_specialist_workflow = true;
481        let plan = plan(&state, &input);
482        // Verify candidates are sorted descending
483        for w in plan.candidates.windows(2) {
484            assert!(w[0].confidence >= w[1].confidence);
485        }
486    }
487
488    #[test]
489    fn decomposition_gate_as_scored_input() {
490        let mut state = task_state(TaskClassification::Task);
491        state.roster_fit.fit_count = 1;
492        state.roster_fit.fit_names = vec!["specialist".into()];
493        state.roster_fit.explicit_workflow = true; // Rule 5 now requires explicit workflow
494        let mut input = base_input();
495        input.decomposition_proposal = Some(crate::task_state::DecompositionProposal {
496            should_delegate: true,
497            rationale: "task complexity warrants delegation".into(),
498            utility_margin: 0.7,
499        });
500        let plan = plan(&state, &input);
501        // Delegation should appear as a candidate from the gate signal
502        assert!(
503            plan.candidates
504                .iter()
505                .any(|c| c.action == PlannedAction::DelegateToSpecialist)
506        );
507    }
508
509    #[test]
510    fn pattern_locked_injects_variation_hint_into_candidates() {
511        let mut state = task_state(TaskClassification::Task);
512        state.behavioral_history.structural_repetition = true;
513        state.behavioral_history.repetition_streak = 3;
514        state.behavioral_history.repeated_pattern = Some("narrative+question+options".into());
515        let input = base_input();
516        let plan = plan(&state, &input);
517        // ContinueCentralized should be present with a variation-directive rationale
518        let variation_candidate = plan
519            .candidates
520            .iter()
521            .find(|c| c.action == PlannedAction::ContinueCentralized);
522        assert!(
523            variation_candidate.is_some(),
524            "expected ContinueCentralized candidate for pattern-locked state"
525        );
526        let rationale = &variation_candidate.unwrap().rationale;
527        assert!(
528            rationale.contains("Pattern-locked"),
529            "rationale should contain 'Pattern-locked': {rationale}"
530        );
531        assert!(
532            rationale.contains("narrative+question+options"),
533            "rationale should name the repeated pattern: {rationale}"
534        );
535    }
536
537    #[test]
538    fn user_engagement_declining_injects_strategy_change_hint() {
539        let mut state = task_state(TaskClassification::Task);
540        state.behavioral_history.engagement_declining = true;
541        let input = base_input();
542        let plan = plan(&state, &input);
543        // ContinueCentralized should be present with an engagement-declining rationale
544        let engagement_candidate = plan
545            .candidates
546            .iter()
547            .find(|c| c.action == PlannedAction::ContinueCentralized);
548        assert!(
549            engagement_candidate.is_some(),
550            "expected ContinueCentralized candidate for engagement-declining state"
551        );
552        let rationale = &engagement_candidate.unwrap().rationale;
553        assert!(
554            rationale.contains("engagement declining"),
555            "rationale should mention engagement: {rationale}"
556        );
557    }
558
559    #[test]
560    fn pattern_locked_does_not_override_higher_priority_actions() {
561        // Even with pattern_locked, a provider breaker open should win (confidence 0.8 > 0.55)
562        let mut state = task_state(TaskClassification::Task);
563        state.behavioral_history.structural_repetition = true;
564        state.behavioral_history.repetition_streak = 3;
565        state.behavioral_history.repeated_pattern = Some("narrative+question".into());
566        state.runtime_constraints.provider_breaker_open = true;
567        let input = base_input();
568        let plan = plan(&state, &input);
569        assert_eq!(
570            plan.selected,
571            PlannedAction::ReturnBlocker,
572            "ReturnBlocker (conf 0.8) must win over pattern-locked ContinueCentralized (conf 0.55)"
573        );
574    }
575
576    // ── Named tool match: delegation routing ─────────────────────
577
578    #[test]
579    fn named_tool_match_prevents_compose_subagent() {
580        // When the user says "relay to Claude Code" and the tool exists in
581        // the registry, the planner should route to ContinueCentralized (so
582        // the tool is invoked during inference) — NOT ComposeSubagent.
583        let mut state = task_state(TaskClassification::Task);
584        state.roster_fit = RosterFit {
585            taskable_count: 0,
586            fit_count: 0,
587            fit_names: vec![],
588            explicit_workflow: true,
589        };
590        let mut input = base_input();
591        input.user_content = "relay that question to the claude code instance".into();
592        input.explicit_specialist_workflow = true;
593        input.named_tool_match = true;
594
595        let plan = plan(&state, &input);
596        assert_eq!(
597            plan.selected,
598            PlannedAction::ContinueCentralized,
599            "Named tool match must route to ContinueCentralized, not ComposeSubagent"
600        );
601    }
602
603    #[test]
604    fn explicit_delegation_without_tool_match_composes_specialist() {
605        // When the user explicitly requests delegation but names NO existing
606        // tool and the roster is empty, ComposeSubagent is correct.
607        let mut state = task_state(TaskClassification::Task);
608        state.roster_fit = RosterFit {
609            taskable_count: 0,
610            fit_count: 0,
611            fit_names: vec![],
612            explicit_workflow: true,
613        };
614        let mut input = base_input();
615        input.user_content = "compose a specialist for this analysis".into();
616        input.explicit_specialist_workflow = true;
617        input.named_tool_match = false;
618
619        let plan = plan(&state, &input);
620        assert_eq!(
621            plan.selected,
622            PlannedAction::ComposeSubagent,
623            "Without tool match, explicit workflow + empty roster should compose specialist"
624        );
625    }
626
627    #[test]
628    fn named_tool_match_outranks_compose_subagent_confidence() {
629        // Rule 3b (named plugin tool, conf 0.88) must beat Rule 4 (compose,
630        // conf 0.85). Rule 4 is also gated by !named_tool_match so it won't
631        // fire, but the confidence ordering is still verified.
632        let mut state = task_state(TaskClassification::Task);
633        state.roster_fit = RosterFit {
634            taskable_count: 0,
635            fit_count: 0,
636            fit_names: vec![],
637            explicit_workflow: true,
638        };
639        let mut input = base_input();
640        input.explicit_specialist_workflow = true;
641        input.named_tool_match = true;
642
643        let plan = plan(&state, &input);
644        assert_eq!(
645            plan.selected,
646            PlannedAction::ContinueCentralized,
647            "Named plugin tool match must win over ComposeSubagent"
648        );
649        let centralized = plan
650            .candidates
651            .iter()
652            .find(|c| c.action == PlannedAction::ContinueCentralized)
653            .expect("ContinueCentralized candidate must exist");
654        assert!(
655            (centralized.confidence - 0.88).abs() < 0.01,
656            "ContinueCentralized confidence should be 0.88, got {}",
657            centralized.confidence
658        );
659    }
660
661    #[test]
662    fn existing_specialist_fit_delegates_despite_tool_match() {
663        // When both a named plugin tool AND a fit specialist exist, the
664        // specialist wins (Rule 3 at 0.9) because it's the more specific
665        // match. Rule 3b only fires when fit_count == 0.
666        let mut state = task_state(TaskClassification::Task);
667        state.roster_fit = RosterFit {
668            taskable_count: 1,
669            fit_count: 1,
670            fit_names: vec!["code-analyst".into()],
671            explicit_workflow: true,
672        };
673        let mut input = base_input();
674        input.explicit_specialist_workflow = true;
675        input.named_tool_match = true;
676
677        let plan = plan(&state, &input);
678        assert_eq!(
679            plan.selected,
680            PlannedAction::DelegateToSpecialist,
681            "Fitting specialist (0.9) must win over named tool match when both exist"
682        );
683    }
684}