1use serde::Serialize;
15
16use crate::task_state::{TaskClassification, TaskOperatingState, TaskStateInput};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20pub enum PlannedAction {
21 AnswerDirectly,
23 ContinueCentralized,
25 InspectMemory,
27 ComposeSkill,
29 ComposeSubagent,
31 DelegateToSpecialist,
33 ReturnBlocker,
35 NormalizationRetry,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct ActionCandidate {
42 pub action: PlannedAction,
43 pub confidence: f64,
45 pub rationale: String,
47}
48
49#[derive(Debug, Clone, Serialize)]
51pub struct TaskExecutionPlan {
52 pub candidates: Vec<ActionCandidate>,
54 pub selected: PlannedAction,
56 pub selected_rationale: String,
58}
59
60pub fn plan(state: &TaskOperatingState, input: &TaskStateInput) -> TaskExecutionPlan {
68 let mut candidates = Vec::new();
69
70 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 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 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 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 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 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 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 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 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 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 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 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
256fn 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
278fn 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#[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 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 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 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; 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 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 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 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 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 #[test]
579 fn named_tool_match_prevents_compose_subagent() {
580 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 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 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 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}