use super::{LoopDecision, PostTurnAssessment, RunFinalStatus, StopReason};
pub(super) trait LoopPolicy {
fn decide_after_turn(&self, assessment: &PostTurnAssessment) -> LoopDecision;
}
trait LoopPolicyRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision>;
}
#[derive(Debug, Default, Clone, Copy)]
pub(super) struct DefaultLoopPolicy;
impl LoopPolicy for DefaultLoopPolicy {
fn decide_after_turn(&self, assessment: &PostTurnAssessment) -> LoopDecision {
RepeatedActionRule
.decide(assessment)
.or_else(|| RuntimeStopRule.decide(assessment))
.or_else(|| WorkCompletedRule.decide(assessment))
.or_else(|| ManaStopRule.decide(assessment))
.or_else(|| TextFallbackStopRule.decide(assessment))
.or_else(|| ContinueRecommendationRule.decide(assessment))
.or_else(|| PlanningOnlyNoProgressRule.decide(assessment))
.unwrap_or_else(|| finish(StopReason::NoAutomaticFollowUp))
}
}
#[derive(Debug, Default, Clone, Copy)]
struct RepeatedActionRule;
impl LoopPolicyRule for RepeatedActionRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision> {
assessment
.runtime
.repeated_action
.then(|| finish(StopReason::RepeatedAction))
}
}
#[derive(Debug, Default, Clone, Copy)]
struct RuntimeStopRule;
impl LoopPolicyRule for RuntimeStopRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision> {
assessment.runtime.execution_stop_reason.map(finish)
}
}
#[derive(Debug, Default, Clone, Copy)]
struct WorkCompletedRule;
impl LoopPolicyRule for WorkCompletedRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision> {
assessment
.runtime
.work_completed
.then(|| finish(StopReason::WorkCompleted))
}
}
#[derive(Debug, Default, Clone, Copy)]
struct ManaStopRule;
impl LoopPolicyRule for ManaStopRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision> {
assessment.mana.stop_reason.map(finish)
}
}
#[derive(Debug, Default, Clone, Copy)]
struct TextFallbackStopRule;
impl LoopPolicyRule for TextFallbackStopRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision> {
assessment
.text_fallback
.planner_stop_reason
.or(assessment.text_fallback.execution_stop_reason)
.map(finish)
}
}
#[derive(Debug, Default, Clone, Copy)]
struct ContinueRecommendationRule;
impl LoopPolicyRule for ContinueRecommendationRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision> {
assessment
.continue_recommendation
.as_ref()
.map(|recommendation| LoopDecision::Continue {
prompt: recommendation.prompt.clone(),
reason: recommendation.reason,
})
}
}
#[derive(Debug, Default, Clone, Copy)]
struct PlanningOnlyNoProgressRule;
impl LoopPolicyRule for PlanningOnlyNoProgressRule {
fn decide(&self, assessment: &PostTurnAssessment) -> Option<LoopDecision> {
assessment
.runtime
.planning_only_progress
.then(|| finish(StopReason::NoProgress))
}
}
fn finish(reason: StopReason) -> LoopDecision {
LoopDecision::Finish {
status: RunFinalStatus::from_stop_reason(reason),
}
}
impl super::Agent {
pub(super) fn loop_decision_after_turn(&self, assessment: &PostTurnAssessment) -> LoopDecision {
DefaultLoopPolicy.decide_after_turn(assessment)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{ContinueReason, ManaEvidence, RuntimeEvidence, TextFallbackEvidence};
fn assessment() -> PostTurnAssessment {
PostTurnAssessment {
runtime: RuntimeEvidence {
repeated_action: false,
execution_stop_reason: None,
work_completed: false,
execution_debt: false,
execution_evidence: false,
planning_only_progress: false,
},
mana: ManaEvidence { stop_reason: None },
text_fallback: TextFallbackEvidence {
planner_stop_reason: None,
execution_stop_reason: None,
},
continue_recommendation: None,
}
}
fn final_reason(decision: LoopDecision) -> StopReason {
match decision {
LoopDecision::Finish {
status:
RunFinalStatus::Done { reason }
| RunFinalStatus::DoneWithConcerns { reason, .. }
| RunFinalStatus::Blocked { reason, .. },
} => reason,
other => panic!("expected finish decision, got {other:?}"),
}
}
#[test]
fn repeated_action_wins_over_other_policy_signals() {
let mut assessment = assessment();
assessment.runtime.repeated_action = true;
assessment.runtime.execution_stop_reason = Some(StopReason::ExecutionBlocked);
assessment.runtime.work_completed = true;
assessment.continue_recommendation = Some(super::super::ContinueRecommendation {
prompt: "continue".into(),
reason: ContinueReason::HighConfidenceVisibleNextStep,
});
assert_eq!(
final_reason(DefaultLoopPolicy.decide_after_turn(&assessment)),
StopReason::RepeatedAction
);
}
#[test]
fn runtime_execution_blocker_wins_over_work_completed() {
let mut assessment = assessment();
assessment.runtime.execution_stop_reason = Some(StopReason::ExecutionBlocked);
assessment.runtime.work_completed = true;
assert_eq!(
final_reason(DefaultLoopPolicy.decide_after_turn(&assessment)),
StopReason::ExecutionBlocked
);
}
#[test]
fn mana_stop_wins_over_text_fallback_and_continue() {
let mut assessment = assessment();
assessment.mana.stop_reason = Some(StopReason::UserBlocker);
assessment.text_fallback.execution_stop_reason = Some(StopReason::WorkCompleted);
assessment.continue_recommendation = Some(super::super::ContinueRecommendation {
prompt: "continue".into(),
reason: ContinueReason::HighConfidenceVisibleNextStep,
});
assert_eq!(
final_reason(DefaultLoopPolicy.decide_after_turn(&assessment)),
StopReason::UserBlocker
);
}
#[test]
fn continue_recommendation_runs_after_stop_reasons_are_absent() {
let mut assessment = assessment();
assessment.continue_recommendation = Some(super::super::ContinueRecommendation {
prompt: "continue".into(),
reason: ContinueReason::ExecutionDebt,
});
assert_eq!(
DefaultLoopPolicy.decide_after_turn(&assessment),
LoopDecision::Continue {
prompt: "continue".into(),
reason: ContinueReason::ExecutionDebt,
}
);
}
#[test]
fn planning_only_progress_maps_to_no_progress_without_continue_reason() {
let mut assessment = assessment();
assessment.runtime.planning_only_progress = true;
assert_eq!(
final_reason(DefaultLoopPolicy.decide_after_turn(&assessment)),
StopReason::NoProgress
);
}
#[test]
fn default_policy_stops_without_automatic_follow_up() {
assert_eq!(
final_reason(DefaultLoopPolicy.decide_after_turn(&assessment())),
StopReason::NoAutomaticFollowUp
);
}
}