Skip to main content

ai_agent/utils/
inspector.rs

1// Inspector — final review of unfinished tasks at the end of a query loop.
2//
3// Roles and Jobs:
4//
5// **Role:** The Inspector acts as a silent gatekeeper between the agent's "done" and the
6// real world. It catches the gap between what the LLM thinks is complete and what
7// actually remains unfinished — ensuring the LLM sees its own blind spots before
8// returning control to the user.
9//
10// **Jobs:**
11//
12// 1. **Scavenge** — Query every task store (TodoWrite, Task V2 both in-process and disk-backed)
13//    for items whose status is not `"completed"` or `"deleted"`.
14//
15// 2. **Deduplicate** — The same task may exist in multiple stores with different IDs.
16//    Merge entries by subject/ID to avoid reporting the same work twice.
17//
18// 3. **Summarize** — Format the incomplete items into a concise, actionable list grouped
19//    by tool system (TODOs vs Tasks), including status, owner, and ACTIVE_FORM where available.
20//
21// 4. **Nudge** — Return a single system message that the LLM can read and act on.
22//    If nothing is unfinished, return `None` so the query loop exits cleanly
23//    without injecting an empty or redundant message into the conversation.
24//
25// **When it runs:** At every "graceful exit" point in the query loop
26// (`streaming_result.tool_calls.is_empty()`), *before* returning `ExitReason::Completed`.
27// If any unfinished items are found, the message is injected as a System message
28// and the loop continues (consuming one turn) so the LLM can address them.
29
30/// Information about an unfinished todo item
31#[derive(Debug, Clone)]
32pub struct TodoItemInfo {
33    pub content: String,
34    pub status: String,
35    pub active_form: Option<String>,
36}
37
38/// Information about an unfinished task
39#[derive(Debug, Clone)]
40pub struct TaskInfo {
41    pub id: String,
42    pub subject: String,
43    pub status: String,
44    pub owner: Option<String>,
45}
46
47/// Inspect all task stores for unfinished items and return a nudge message if any exist.
48///
49/// The entry point called from `query_engine.rs` at the end of a query turn.
50/// Returns `Some(message)` if there is work left to do, `None` if everything is complete.
51pub fn check() -> Option<String> {
52    let incomplete_todos = collect_unfinished_todos();
53    let incomplete_tasks = collect_unfinished_tasks();
54
55    if incomplete_todos.is_empty() && incomplete_tasks.is_empty() {
56        return None;
57    }
58
59    Some(format_nudge(&incomplete_todos, &incomplete_tasks))
60}
61
62fn collect_unfinished_todos() -> Vec<TodoItemInfo> {
63    let session_key = "default_session";
64    crate::tools::todo::get_unfinished_todos(session_key)
65        .into_iter()
66        .map(|t| TodoItemInfo {
67            content: t.content,
68            status: t.status,
69            active_form: t.active_form,
70        })
71        .collect()
72}
73
74fn collect_unfinished_tasks() -> Vec<TaskInfo> {
75    let mut tasks = crate::tools::tasks::get_unfinished_tasks()
76        .into_iter()
77        .map(|t| TaskInfo {
78            id: t.id,
79            subject: t.subject,
80            status: t.status,
81            owner: t.owner,
82        })
83        .collect::<Vec<_>>();
84
85    // Also check the V2 task_list store (different key space, numeric IDs)
86    for t in crate::utils::task_list::get_unfinished_tasks() {
87        // Avoid duplicates by ID
88        if !tasks.iter().any(|ti| ti.id == t.id) {
89            tasks.push(TaskInfo {
90                id: t.id,
91                subject: t.subject,
92                status: t.status.to_string(),
93                owner: t.owner,
94            });
95        }
96    }
97
98    tasks
99}
100
101/// Format a nudge message listing unfinished tasks.
102pub fn format_nudge(incomplete_todos: &[TodoItemInfo], incomplete_tasks: &[TaskInfo]) -> String {
103    let mut lines = Vec::new();
104
105    lines.push(
106        "You have unfinished items that may not be complete. Review and address them:".to_string(),
107    );
108
109    if !incomplete_todos.is_empty() {
110        lines.push(String::new());
111        lines.push("**TODOs:**".to_string());
112        for todo in incomplete_todos {
113            let status_tag = format!("[{}]", todo.status);
114            let active = todo
115                .active_form
116                .as_deref()
117                .map(|a| format!(" ({})", a))
118                .unwrap_or_default();
119            lines.push(format!("  - {} {}{}", status_tag, todo.content, active));
120        }
121    }
122
123    if !incomplete_tasks.is_empty() {
124        lines.push(String::new());
125        lines.push("**Tasks:**".to_string());
126        for task in incomplete_tasks {
127            let owner = task
128                .owner
129                .as_deref()
130                .map(|o| format!(" ({})", o))
131                .unwrap_or_default();
132            lines.push(format!(
133                "  - {} [{}]{}{}",
134                task.id, task.status, task.subject, owner
135            ));
136        }
137    }
138
139    lines.push(String::new());
140    lines.push(
141        "Please continue working on these unfinished items before considering the task complete."
142            .to_string(),
143    );
144
145    lines.join("\n")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_format_empty_nudge() {
154        let msg = format_nudge(&[], &[]);
155        assert!(msg.contains("unfinished items"));
156        assert!(msg.contains("continue working"));
157    }
158
159    #[test]
160    fn test_format_nudge_with_todos() {
161        let todos = vec![
162            TodoItemInfo {
163                content: "Implement feature X".to_string(),
164                status: "in_progress".to_string(),
165                active_form: Some("Implementing feature X".to_string()),
166            },
167            TodoItemInfo {
168                content: "Write tests".to_string(),
169                status: "pending".to_string(),
170                active_form: None,
171            },
172        ];
173        let msg = format_nudge(&todos, &[]);
174        assert!(msg.contains("in_progress"));
175        assert!(msg.contains("Implement feature X"));
176        assert!(msg.contains("pending"));
177        assert!(msg.contains("Write tests"));
178    }
179
180    #[test]
181    fn test_format_nudge_with_tasks() {
182        let tasks = vec![TaskInfo {
183            id: "task-1".to_string(),
184            subject: "Add error handling".to_string(),
185            status: "pending".to_string(),
186            owner: Some("agent-1".to_string()),
187        }];
188        let msg = format_nudge(&[], &tasks);
189        assert!(msg.contains("task-1"));
190        assert!(msg.contains("[pending]"));
191        assert!(msg.contains("Add error handling"));
192        assert!(msg.contains("(agent-1)"));
193    }
194
195    #[test]
196    fn test_format_nudge_combined() {
197        let todos = vec![TodoItemInfo {
198            content: "Fix bug".to_string(),
199            status: "in_progress".to_string(),
200            active_form: Some("Fixing bug".to_string()),
201        }];
202        let tasks = vec![TaskInfo {
203            id: "task-2".to_string(),
204            subject: "Update docs".to_string(),
205            status: "pending".to_string(),
206            owner: None,
207        }];
208        let msg = format_nudge(&todos, &tasks);
209        assert!(msg.contains("**TODOs:**"));
210        assert!(msg.contains("**Tasks:**"));
211        assert!(msg.contains("Fix bug"));
212        assert!(msg.contains("task-2"));
213    }
214
215    #[test]
216    fn test_check_no_stores() {
217        // With no todos/tasks stored, should return None
218        // Note: this test may be affected by other tests that modify global state
219        let todos = collect_unfinished_todos();
220        let tasks = collect_unfinished_tasks();
221        // Just verify the function doesn't panic
222        let _ = check();
223    }
224}