Skip to main content

machi_runtime/
gates.rs

1//! Stop gates evaluated when the model returns a non-tool final message.
2
3use machi_agent::{Agent, CompletionRequirement};
4use machi_types::Message;
5
6use crate::state::ConversationState;
7
8/// Result of evaluating stop gates after a final assistant message.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum GateDecision {
11    /// Turn may complete with the current assistant message.
12    Complete,
13    /// Inject a user/system reminder and force another sample.
14    Continue {
15        /// Reminder text appended to the conversation.
16        reminder: String,
17    },
18}
19
20/// Extensible stop-gate. Gates run in order; first non-[`GateDecision::Complete`] wins.
21pub trait StopGate: Send + Sync {
22    /// Evaluate after a final assistant message is appended.
23    fn evaluate(
24        &self,
25        agent: &Agent,
26        state: &dyn ConversationState,
27        retries_used: u32,
28    ) -> GateDecision;
29}
30
31/// Require a named tool to have been called in this conversation.
32#[derive(Debug, Clone)]
33pub struct CompletionToolGate {
34    /// Requirement from the agent definition (or override).
35    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/// Composite of ordered gates (first Continue wins).
50#[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    /// Empty chain → always complete.
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Push a gate.
71    #[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    /// Build default chain from agent definition (completion requirement if any).
78    #[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    /// Evaluate all gates.
88    #[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/// Evaluate configured gates for the agent against conversation history.
106///
107/// Convenience wrapper around [`GateChain::from_agent`].
108#[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        // Exhausted: complete anyway (fail-open after budgeted reminders).
236        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}