Skip to main content

oxicode_agent/agent_loop/
todo_policy.rs

1//! Eager todo-list creation policy for the first agent turn. Ports the
2//! eager-prelude half of omp's `TodoTracker` (`todo-tracker.ts`); the
3//! reminders/mid-run-nudge half lives in `agent_loop/mod.rs` next to
4//! `build_stop_reminder`/`MidRunNudgeState` to avoid a second todo-state
5//! owner.
6
7use oxicode_ai::{Api, Message, ToolChoice, UserMessage};
8
9/// Mirrors `oxicode_cli`'s `Settings::TodoEagerMode` without a crate
10/// dependency in the other direction; `oxicode-cli` converts when building
11/// `AgentLoopConfig`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
13pub enum TodoEagerMode {
14    /// No automatic todo prelude (preserves today's behavior).
15    #[default]
16    Off,
17    /// Inject a hidden "create a todo plan" message, but never force the
18    /// `todo` tool call.
19    Preferred,
20    /// Inject the hidden message AND force the `todo` tool call when the
21    /// provider supports native forced tool choice.
22    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
30/// Whether `text` reads as a question rather than a task request. Ports
31/// omp's `QUESTION_PROMPT_RE` + non-ASCII fallback (`todo-tracker.ts:24-30`).
32pub(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    // Non-ASCII prose ending in "?"/"!" is treated as a genuine question
38    // regardless of the English word list (CJK, Spanish "¿…?", etc.) — the
39    // punctuation alone is the reliable signal there.
40    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
51/// Whether a provider's wire format supports native forced-tool-choice
52/// (`ToolChoice::Named`). The built-in JSON providers do; Ollama and owned
53/// (in-band XML) dialects don't, so a `Named` choice degrades to `Auto` there.
54pub 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
67/// Builds the first-turn eager-todo message + optional forced tool choice.
68/// Returns `None` when eager mode is off, a plan already exists, this is a
69/// sub-agent, or the prompt looks like a question rather than a task.
70pub 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}