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