tirea_agentos/runtime/stop_policy/
conditions.rs

1use std::collections::VecDeque;
2
3use crate::contracts::thread::ToolCall;
4use crate::contracts::{RunContext, StoppedReason};
5
6/// Aggregated runtime stats consumed by stop policies.
7pub struct StopPolicyStats<'a> {
8    /// Number of completed steps.
9    pub step: usize,
10    /// Tool calls emitted by the current step.
11    pub step_tool_call_count: usize,
12    /// Total tool calls across the whole run.
13    pub total_tool_call_count: usize,
14    /// Cumulative input tokens across all LLM calls.
15    pub total_input_tokens: usize,
16    /// Cumulative output tokens across all LLM calls.
17    pub total_output_tokens: usize,
18    /// Number of consecutive rounds where all tools failed.
19    pub consecutive_errors: usize,
20    /// Time elapsed since the loop started.
21    pub elapsed: std::time::Duration,
22    /// Tool calls from the most recent LLM response.
23    pub last_tool_calls: &'a [ToolCall],
24    /// Text from the most recent LLM response.
25    pub last_text: &'a str,
26    /// History of tool call names per round (most recent last), for loop detection.
27    pub tool_call_history: &'a VecDeque<Vec<String>>,
28}
29
30/// Canonical stop-policy input.
31pub struct StopPolicyInput<'a> {
32    /// Current run context.
33    pub run_ctx: &'a RunContext,
34    /// Runtime stats.
35    pub stats: StopPolicyStats<'a>,
36}
37
38/// Stop-policy contract used by [`super::StopPolicyPlugin`].
39pub trait StopPolicy: Send + Sync {
40    /// Stable policy id.
41    fn id(&self) -> &str;
42
43    /// Evaluate stop decision. Return `Some(StoppedReason)` to terminate.
44    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason>;
45}
46
47// ---------------------------------------------------------------------------
48// Built-in stop conditions
49// ---------------------------------------------------------------------------
50
51/// Stop after a fixed number of tool-call rounds.
52pub struct MaxRounds(pub usize);
53
54impl StopPolicy for MaxRounds {
55    fn id(&self) -> &str {
56        "max_rounds"
57    }
58
59    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
60        if input.stats.step >= self.0 {
61            Some(StoppedReason::new("max_rounds_reached"))
62        } else {
63            None
64        }
65    }
66}
67
68/// Stop after a wall-clock duration elapses.
69pub struct Timeout(pub std::time::Duration);
70
71impl StopPolicy for Timeout {
72    fn id(&self) -> &str {
73        "timeout"
74    }
75
76    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
77        if input.stats.elapsed >= self.0 {
78            Some(StoppedReason::new("timeout_reached"))
79        } else {
80            None
81        }
82    }
83}
84
85/// Stop when cumulative token usage exceeds a budget.
86pub struct TokenBudget {
87    /// Maximum total tokens (input + output). 0 = unlimited.
88    pub max_total: usize,
89}
90
91impl StopPolicy for TokenBudget {
92    fn id(&self) -> &str {
93        "token_budget"
94    }
95
96    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
97        if self.max_total > 0
98            && (input.stats.total_input_tokens + input.stats.total_output_tokens) >= self.max_total
99        {
100            Some(StoppedReason::new("token_budget_exceeded"))
101        } else {
102            None
103        }
104    }
105}
106
107/// Stop after N consecutive rounds where all tool executions failed.
108pub struct ConsecutiveErrors(pub usize);
109
110impl StopPolicy for ConsecutiveErrors {
111    fn id(&self) -> &str {
112        "consecutive_errors"
113    }
114
115    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
116        if self.0 > 0 && input.stats.consecutive_errors >= self.0 {
117            Some(StoppedReason::new("consecutive_errors_exceeded"))
118        } else {
119            None
120        }
121    }
122}
123
124/// Stop when a specific tool is called by the LLM.
125pub struct StopOnTool(pub String);
126
127impl StopPolicy for StopOnTool {
128    fn id(&self) -> &str {
129        "stop_on_tool"
130    }
131
132    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
133        for call in input.stats.last_tool_calls {
134            if call.name == self.0 {
135                return Some(StoppedReason::with_detail("tool_called", self.0.clone()));
136            }
137        }
138        None
139    }
140}
141
142/// Stop when LLM output text contains a literal pattern.
143pub struct ContentMatch(pub String);
144
145impl StopPolicy for ContentMatch {
146    fn id(&self) -> &str {
147        "content_match"
148    }
149
150    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
151        if !self.0.is_empty() && input.stats.last_text.contains(&self.0) {
152            Some(StoppedReason::with_detail(
153                "content_matched",
154                self.0.clone(),
155            ))
156        } else {
157            None
158        }
159    }
160}
161
162/// Stop when the same tool call pattern repeats within a sliding window.
163///
164/// Compares the sorted tool names of the most recent round against previous
165/// rounds within `window` size. If the same set appears twice consecutively,
166/// the loop is considered stuck.
167pub struct LoopDetection {
168    /// Number of recent rounds to compare. Minimum 2.
169    pub window: usize,
170}
171
172impl StopPolicy for LoopDetection {
173    fn id(&self) -> &str {
174        "loop_detection"
175    }
176
177    fn evaluate(&self, input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
178        let window = self.window.max(2);
179        let history = input.stats.tool_call_history;
180        if history.len() < 2 {
181            return None;
182        }
183
184        let recent: Vec<_> = history.iter().rev().take(window).collect();
185        for pair in recent.windows(2) {
186            if pair[0] == pair[1] {
187                return Some(StoppedReason::new("loop_detected"));
188            }
189        }
190        None
191    }
192}