1use machi_agent::{Agent, CompletionRequirement};
4use machi_types::Message;
5
6use crate::state::ConversationState;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum GateDecision {
11 Complete,
13 Continue {
15 reminder: String,
17 },
18}
19
20pub trait StopGate: Send + Sync {
22 fn evaluate(
24 &self,
25 agent: &Agent,
26 state: &dyn ConversationState,
27 retries_used: u32,
28 ) -> GateDecision;
29}
30
31#[derive(Debug, Clone)]
33pub struct CompletionToolGate {
34 pub requirement: CompletionRequirement,
36}
37
38impl StopGate for CompletionToolGate {
39 fn evaluate(
40 &self,
41 _agent: &Agent,
42 state: &dyn ConversationState,
43 retries_used: u32,
44 ) -> GateDecision {
45 completion_gate(&self.requirement, state, retries_used).unwrap_or(GateDecision::Complete)
46 }
47}
48
49#[derive(Default)]
51pub struct GateChain {
52 gates: Vec<Box<dyn StopGate>>,
53}
54
55impl std::fmt::Debug for GateChain {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("GateChain")
58 .field("gates", &self.gates.len())
59 .finish()
60 }
61}
62
63impl GateChain {
64 #[must_use]
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 #[must_use]
72 pub fn push(mut self, gate: impl StopGate + 'static) -> Self {
73 self.gates.push(Box::new(gate));
74 self
75 }
76
77 #[must_use]
79 pub fn from_agent(agent: &Agent) -> Self {
80 let mut chain = Self::new();
81 if let Some(req) = agent.definition().completion.clone() {
82 chain = chain.push(CompletionToolGate { requirement: req });
83 }
84 chain
85 }
86
87 #[must_use]
89 pub fn evaluate(
90 &self,
91 agent: &Agent,
92 state: &dyn ConversationState,
93 retries_used: u32,
94 ) -> GateDecision {
95 for gate in &self.gates {
96 match gate.evaluate(agent, state, retries_used) {
97 GateDecision::Complete => {}
98 cont @ GateDecision::Continue { .. } => return cont,
99 }
100 }
101 GateDecision::Complete
102 }
103}
104
105#[must_use]
109pub fn evaluate_stop_gates(
110 agent: &Agent,
111 state: &dyn ConversationState,
112 completion_retries_used: u32,
113) -> GateDecision {
114 GateChain::from_agent(agent).evaluate(agent, state, completion_retries_used)
115}
116
117fn completion_gate(
118 req: &CompletionRequirement,
119 state: &dyn ConversationState,
120 retries_used: u32,
121) -> Option<GateDecision> {
122 if tool_was_called(state, &req.tool) {
123 return None;
124 }
125 if retries_used >= req.max_retries {
126 return None;
127 }
128 Some(GateDecision::Continue {
129 reminder: req.reminder.clone(),
130 })
131}
132
133fn tool_was_called(state: &dyn ConversationState, tool_name: &str) -> bool {
134 state
135 .messages()
136 .iter()
137 .any(|m| message_calls_tool(m, tool_name))
138}
139
140fn message_calls_tool(message: &Message, tool_name: &str) -> bool {
141 message.tool_calls.iter().any(|c| c.name == tool_name)
142}
143
144#[cfg(test)]
145mod tests {
146 use machi_agent::{AgentBuilder, CompletionRequirement};
147 use machi_types::{Message, ToolCall, ToolCallId};
148 use serde_json::json;
149
150 use super::*;
151 use crate::state::VecConversationState;
152
153 #[test]
154 fn requires_completion_tool() {
155 let agent = AgentBuilder::named("a")
156 .model("mock")
157 .completion(CompletionRequirement {
158 tool: "submit".into(),
159 reminder: "call submit".into(),
160 max_retries: 2,
161 })
162 .build()
163 .expect("agent");
164 let state = VecConversationState::from_messages(vec![Message::user("hi")]);
165 let d = evaluate_stop_gates(&agent, &state, 0);
166 assert_eq!(
167 d,
168 GateDecision::Continue {
169 reminder: "call submit".into()
170 }
171 );
172 }
173
174 #[test]
175 fn passes_when_tool_called() {
176 let agent = AgentBuilder::named("a")
177 .model("mock")
178 .completion(CompletionRequirement {
179 tool: "submit".into(),
180 reminder: "call submit".into(),
181 max_retries: 2,
182 })
183 .build()
184 .expect("agent");
185 let id = ToolCallId::new("t1").expect("id");
186 let mut state = VecConversationState::new();
187 state.append(Message::assistant_tools(vec![ToolCall {
188 id,
189 name: "submit".into(),
190 arguments: json!({}),
191 }]));
192 let d = evaluate_stop_gates(&agent, &state, 0);
193 assert_eq!(d, GateDecision::Complete);
194 }
195
196 #[test]
197 fn chain_from_agent() {
198 let agent = AgentBuilder::named("a")
199 .model("mock")
200 .completion(CompletionRequirement {
201 tool: "done".into(),
202 reminder: "use done".into(),
203 max_retries: 1,
204 })
205 .build()
206 .expect("agent");
207 let chain = GateChain::from_agent(&agent);
208 let state = VecConversationState::new();
209 assert!(matches!(
210 chain.evaluate(&agent, &state, 0),
211 GateDecision::Continue { .. }
212 ));
213 }
214
215 #[test]
216 fn max_retries_then_complete_without_tool() {
217 let agent = AgentBuilder::named("a")
218 .model("mock")
219 .completion(CompletionRequirement {
220 tool: "submit".into(),
221 reminder: "call submit".into(),
222 max_retries: 2,
223 })
224 .build()
225 .expect("agent");
226 let state = VecConversationState::from_messages(vec![Message::user("hi")]);
227 assert!(matches!(
228 evaluate_stop_gates(&agent, &state, 0),
229 GateDecision::Continue { .. }
230 ));
231 assert!(matches!(
232 evaluate_stop_gates(&agent, &state, 1),
233 GateDecision::Continue { .. }
234 ));
235 assert_eq!(
237 evaluate_stop_gates(&agent, &state, 2),
238 GateDecision::Complete
239 );
240 }
241
242 #[test]
243 fn custom_gate_in_chain_first_continue_wins() {
244 struct AlwaysContinue;
245 impl StopGate for AlwaysContinue {
246 fn evaluate(
247 &self,
248 _agent: &Agent,
249 _state: &dyn ConversationState,
250 _retries_used: u32,
251 ) -> GateDecision {
252 GateDecision::Continue {
253 reminder: "again".into(),
254 }
255 }
256 }
257
258 let agent = AgentBuilder::named("a").model("mock").build().expect("a");
259 let chain = GateChain::new().push(AlwaysContinue);
260 let state = VecConversationState::new();
261 assert_eq!(
262 chain.evaluate(&agent, &state, 0),
263 GateDecision::Continue {
264 reminder: "again".into()
265 }
266 );
267 }
268}