oxicode_agent/agent_loop/
todo_policy.rs1use oxicode_ai::{Api, Message, ToolChoice, UserMessage};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
13pub enum TodoEagerMode {
14 #[default]
16 Off,
17 Preferred,
20 Always,
23}
24
25const QUESTION_PROMPT_PREFIXES: &[&str] = &[
26 "what", "which", "when", "where", "why", "how", "who", "whom", "whose", "do", "does", "did",
27 "can", "could", "would", "will", "should", "is", "are", "am", "may", "shall",
28];
29
30pub(crate) fn looks_like_a_question(text: &str) -> bool {
33 let trimmed = text.trim_end();
34 if !(trimmed.ends_with('?') || trimmed.ends_with('!')) {
35 return false;
36 }
37 if !trimmed.is_ascii() {
41 return true;
42 }
43 let first_word = trimmed
44 .split_whitespace()
45 .next()
46 .unwrap_or("")
47 .to_ascii_lowercase();
48 QUESTION_PROMPT_PREFIXES.contains(&first_word.as_str())
49}
50
51pub fn provider_supports_tool_choice(api: Api) -> bool {
55 matches!(
56 api,
57 Api::OpenAiCompletions
58 | Api::OpenAiResponses
59 | Api::AnthropicMessages
60 | Api::GoogleGenerativeAi
61 | Api::GoogleVertex
62 | Api::AzureOpenAiResponses
63 | Api::BedrockConverseStream
64 )
65}
66
67pub fn build_eager_todo_prelude(
71 prompt_text: Option<&str>,
72 mode: TodoEagerMode,
73 has_existing_phases: bool,
74 is_subagent: bool,
75 model_supports_forcing: bool,
76) -> Option<(Message, Option<ToolChoice>)> {
77 if mode == TodoEagerMode::Off || has_existing_phases || is_subagent {
78 return None;
79 }
80 if let Some(text) = prompt_text
81 && looks_like_a_question(text)
82 {
83 return None;
84 }
85 let text = "Before starting, create a todo list with the `todo` tool covering the \
86 full scope of this request, then begin working through it."
87 .to_string();
88 let message = Message::User(UserMessage::hidden(text));
89 let choice = if mode == TodoEagerMode::Always && model_supports_forcing {
90 Some(ToolChoice::Named("todo".to_string()))
91 } else {
92 None
93 };
94 Some((message, choice))
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn eager_prelude_none_when_mode_off() {
103 assert!(
104 build_eager_todo_prelude(
105 Some("build a login page"),
106 TodoEagerMode::Off,
107 false,
108 false,
109 true
110 )
111 .is_none()
112 );
113 }
114
115 #[test]
116 fn eager_prelude_none_when_phases_already_exist() {
117 assert!(
118 build_eager_todo_prelude(
119 Some("build a login page"),
120 TodoEagerMode::Always,
121 true,
122 false,
123 true
124 )
125 .is_none()
126 );
127 }
128
129 #[test]
130 fn eager_prelude_none_for_subagent() {
131 assert!(
132 build_eager_todo_prelude(
133 Some("build a login page"),
134 TodoEagerMode::Always,
135 false,
136 true,
137 true
138 )
139 .is_none()
140 );
141 }
142
143 #[test]
144 fn eager_prelude_none_when_prompt_looks_like_a_question() {
145 assert!(
146 build_eager_todo_prelude(
147 Some("what does this function do?"),
148 TodoEagerMode::Always,
149 false,
150 false,
151 true
152 )
153 .is_none()
154 );
155 }
156
157 #[test]
158 fn eager_prelude_preferred_never_forces_tool_choice() {
159 let (_, choice) = build_eager_todo_prelude(
160 Some("build a login page"),
161 TodoEagerMode::Preferred,
162 false,
163 false,
164 true,
165 )
166 .unwrap();
167 assert!(choice.is_none());
168 }
169
170 #[test]
171 fn eager_prelude_always_forces_tool_choice_when_model_supports_it() {
172 let (_, choice) = build_eager_todo_prelude(
173 Some("build a login page"),
174 TodoEagerMode::Always,
175 false,
176 false,
177 true,
178 )
179 .unwrap();
180 assert_eq!(choice, Some(oxicode_ai::ToolChoice::Named("todo".into())));
181 }
182
183 #[test]
184 fn eager_prelude_always_falls_back_to_reminder_only_when_model_cannot_force() {
185 let (_, choice) = build_eager_todo_prelude(
186 Some("build a login page"),
187 TodoEagerMode::Always,
188 false,
189 false,
190 false,
191 )
192 .unwrap();
193 assert!(choice.is_none());
194 }
195
196 #[test]
197 fn eager_prelude_message_is_hidden() {
198 let (msg, _) = build_eager_todo_prelude(
199 Some("build a login page"),
200 TodoEagerMode::Always,
201 false,
202 false,
203 true,
204 )
205 .unwrap();
206 match msg {
207 Message::User(u) => assert!(!u.visible),
208 _ => panic!("expected a hidden user message"),
209 }
210 }
211
212 #[test]
213 fn looks_like_a_question_detects_wh_words_and_non_ascii() {
214 assert!(looks_like_a_question("what does this do?"));
215 assert!(looks_like_a_question("Why is this failing?"));
216 assert!(looks_like_a_question("이 기능이 뭐야?"));
217 assert!(!looks_like_a_question("build a login page"));
218 assert!(!looks_like_a_question("add tests for the parser"));
219 }
220
221 #[test]
222 fn provider_supports_tool_choice_covers_builtin_json_providers() {
223 assert!(provider_supports_tool_choice(Api::OpenAiCompletions));
224 assert!(provider_supports_tool_choice(Api::AnthropicMessages));
225 assert!(provider_supports_tool_choice(Api::BedrockConverseStream));
226 assert!(!provider_supports_tool_choice(Api::OllamaChat));
227 }
228}