1use crate::core::agent::session::AgentSessionState;
2use crate::llm::provider::MessageRole;
3
4pub fn tracker_was_adopted(history: &[crate::llm::provider::Message]) -> bool {
7 let mut pending_tracker_calls = std::collections::HashMap::new();
8 for message in history {
9 if message.role == MessageRole::Assistant {
10 pending_tracker_calls.clear();
13 for call in message.tool_calls.iter().flatten() {
14 let Some(function) = call
15 .function
16 .as_ref()
17 .filter(|function| function.name == crate::config::constants::tools::TASK_TRACKER)
18 else {
19 continue;
20 };
21 let Ok(args) = serde_json::from_str::<serde_json::Value>(&function.arguments) else {
22 continue;
23 };
24 if matches!(args.get("action").and_then(serde_json::Value::as_str), Some("create" | "update" | "add")) {
25 pending_tracker_calls.insert(call.id.as_str(), args);
26 }
27 }
28 } else if message.role == MessageRole::Tool
29 && let Some(args) = message.tool_call_id.as_deref().and_then(|id| pending_tracker_calls.remove(id))
30 && let Ok(output) = serde_json::from_str::<serde_json::Value>(message.content.as_text().as_ref())
31 && tracker_adoption_succeeded(crate::config::constants::tools::TASK_TRACKER, &args, &output)
32 {
33 return true;
34 }
35 }
36 false
37}
38
39pub fn tracker_adoption_succeeded(tool_name: &str, args: &serde_json::Value, output: &serde_json::Value) -> bool {
41 use serde_json::Value;
42 tool_name == crate::config::constants::tools::TASK_TRACKER
43 && output.get("error").is_none()
44 && output.get("success").and_then(Value::as_bool) != Some(false)
45 && output.get("blocked").and_then(Value::as_bool) != Some(true)
46 && output.get("not_executed").and_then(Value::as_bool) != Some(true)
47 && matches!(
48 (args.get("action").and_then(Value::as_str), output.get("status").and_then(Value::as_str)),
49 (Some("create"), Some("created" | "replaced" | "unchanged"))
50 | (Some("update"), Some("updated" | "unchanged"))
51 | (Some("add"), Some("added"))
52 )
53}
54
55pub fn tracker_final_text_is_safety_handoff(text: &str) -> bool {
64 let lower = text.to_ascii_lowercase();
65 tracker_final_text_is_policy_handoff(text)
66 || lower.contains("verification is still pending")
67 || lower.contains("unverified assistant responses")
68 || lower.contains("anti-blind")
69 || lower.contains("verification gate")
70}
71
72pub fn tracker_final_text_is_policy_handoff(text: &str) -> bool {
76 let lower = text.to_ascii_lowercase();
77 if lower.trim().is_empty() {
78 return false;
79 }
80 if lower.contains("permission denied")
82 || lower.contains("access denied")
83 || lower.contains("safety fuse")
84 || lower.contains("tool-call safety fuse")
85 || lower.contains("policy block")
86 || lower.contains("blocked by policy")
87 || lower.contains("requires manual intervention")
88 || lower.contains("missing credentials")
89 || lower.contains("credentials are missing")
90 || lower.contains("denied by policy")
91 || lower.contains("denied by workspace tool policy")
92 || lower.contains("denied by tool policy")
93 || lower.contains("execution denied by policy")
94 || lower.contains("blocked by tool policy")
95 {
96 return true;
97 }
98 false
101}
102
103pub fn recoverable_status_recap_phrasing(lower: &str) -> bool {
109 lower.contains("turn budget")
110 || lower.contains("preview budget")
111 || lower.contains("tool preview budget")
112 || lower.contains("wall clock")
113 || lower.contains("safety cap")
114 || lower.contains("recovery fallback")
115 || lower.contains("recovery could not confirm")
116 || lower.contains("recovery exhausted")
117 || lower.contains("recovery was exhausted")
118 || lower.contains("tool budget")
119 || lower.contains("tool loop")
120 || lower.contains("tool-call budget")
121 || lower.contains("tool follow-up")
122 || lower.contains("read cap")
123 || lower.contains("work budget")
124 || lower.contains("max tool")
125 || lower.contains("per-turn tool")
126 || lower.contains("budget exhausted")
127 || lower.contains("budget ran out")
128}
129
130pub fn tracker_final_text_requires_user_input(text: &str) -> bool {
135 let trimmed = text.trim();
136 if trimmed.is_empty() {
137 return false;
138 }
139 if trimmed.ends_with('?') {
140 return true;
141 }
142 let lower = trimmed.to_ascii_lowercase();
143 const STRONG: &[&str] = &[
144 "please provide",
145 "please confirm",
146 "please approve",
147 "need your approval",
148 "need your permission",
149 "need your decision",
150 "need you to choose",
151 "need you to confirm",
152 "waiting for your",
153 "awaiting your",
154 "your choice",
155 "your decision",
156 "need approval",
157 "requires approval",
158 "approval is required",
159 "need permission",
160 "requires permission",
161 "permission is required",
162 "grant permission",
163 "authorize this",
164 "need a decision",
165 "need clarification",
166 "waiting for input",
167 "awaiting input",
168 "waiting on you",
169 "how should i proceed",
170 "what should i do",
171 "what would you like",
172 "how would you like",
173 "do you want me to",
174 ];
175 const CLAUSE_START: &[&str] = &["could you ", "can you ", "shall i ", "should i "];
176 if STRONG.iter().any(|p| lower.contains(p)) {
177 return true;
178 }
179 CLAUSE_START
180 .iter()
181 .any(|p| lower.trim_start().starts_with(p) || lower.contains(&format!("\n{p}")))
182}
183
184pub fn check_completion_candidate(response_text: &str) -> bool {
186 const COMPLETION_SENTENCES: &[&str] = &[
188 "the task is complete",
189 "task is complete",
190 "task has been completed",
191 "i have successfully completed the task",
192 "work is finished",
193 "operation successful",
194 "i am done",
195 "no more actions needed",
196 "successfully accomplished",
197 "task is now complete",
198 "everything is finished",
199 "i've finished the task",
200 "all requested changes have been applied",
201 "i have finished all the work",
202 ];
203
204 const SOFT_INDICATORS: &[&str] = &[
206 "task completed",
207 "task done",
208 "all done",
209 "finished.",
210 "complete.",
211 "done.",
212 ];
213
214 const UNRESOLVED_PHRASES: &[&str] = &[
215 "still need to",
216 "remaining step",
217 "remaining work",
218 "verification pending",
219 "verification still pending",
220 "tests not run",
221 "haven't run",
222 "have not run",
223 "blocked on",
224 "open questions remain",
225 "question remains",
226 "todo:",
227 "not complete yet",
228 "once verification",
229 "after verification",
230 ];
231
232 let response_lower = response_text.to_lowercase();
233
234 if UNRESOLVED_PHRASES.iter().any(|phrase| response_lower.contains(phrase))
235 || structured_contract_has_unresolved_sections(response_text)
236 {
237 return false;
238 }
239
240 if COMPLETION_SENTENCES.iter().any(|&s| response_lower.contains(s)) {
242 return true;
243 }
244
245 let trimmed = response_lower.trim();
247 for &indicator in SOFT_INDICATORS {
248 if trimmed.ends_with(indicator) || trimmed == indicator {
249 let sentences: Vec<_> = trimmed.split(['.', '!', '?']).collect();
252 if let Some(last_sentence) = sentences.last() {
253 let ls = last_sentence.trim();
254 if ls.contains(indicator)
255 && !ls.contains("will")
256 && !ls.contains("going to")
257 && !ls.contains("about to")
258 && !ls.contains("once")
259 && !ls.contains("after")
260 {
261 return true;
262 }
263 }
264 }
265 }
266
267 {
274 let mut has_summary_header = false;
275 let mut has_facts_header = false;
276 for line in response_text.lines() {
277 let line_lower = line.trim().to_lowercase();
278 if line_lower == "## summary" || line_lower == "# summary" {
279 has_summary_header = true;
280 }
281 if line_lower == "## facts" || line_lower == "# facts" {
282 has_facts_header = true;
283 }
284 if has_summary_header && has_facts_header {
285 return true;
286 }
287 }
288 }
289
290 false
291}
292
293fn structured_contract_has_unresolved_sections(response_text: &str) -> bool {
294 let mut in_open_questions = false;
295 let mut in_verification = false;
296
297 for line in response_text.lines() {
298 let line_lower = line.trim().to_lowercase();
299 if line_lower.starts_with('#') {
300 in_open_questions = line_lower == "## open questions" || line_lower == "# open questions";
301 in_verification = line_lower == "## verification" || line_lower == "# verification";
302 continue;
303 }
304
305 if line_lower.is_empty() {
306 continue;
307 }
308
309 if in_open_questions && !section_entry_is_none(&line_lower) {
310 return true;
311 }
312
313 if in_verification && section_entry_is_unresolved(&line_lower) {
314 return true;
315 }
316 }
317
318 false
319}
320
321fn section_entry_is_none(line: &str) -> bool {
322 let normalized = normalized_section_entry(line).trim_end_matches('.');
323 matches!(normalized, "none" | "n/a")
324}
325
326fn section_entry_is_unresolved(line: &str) -> bool {
327 let normalized = normalized_section_entry(line);
328 normalized.contains("pending")
329 || normalized.contains("not run")
330 || normalized.contains("failed")
331 || normalized.contains("blocked")
332}
333
334fn normalized_section_entry(line: &str) -> &str {
335 line.trim_start_matches(['-', '*']).trim()
336}
337
338pub fn check_for_response_loop(response_text: &str, session_state: &mut AgentSessionState) -> bool {
341 if response_text.len() < 10 {
342 return false;
343 }
344
345 let normalized_current = response_text.split_whitespace().collect::<Vec<_>>().join(" ");
347
348 let repeated = session_state
349 .messages
350 .iter()
351 .rev()
352 .filter(|m| m.role == MessageRole::Assistant)
353 .skip(1)
354 .take(2)
355 .any(|m| {
356 let normalized_prev = m.content.as_text().split_whitespace().collect::<Vec<_>>().join(" ");
357 normalized_prev == normalized_current
358 });
359
360 if repeated {
361 let warning = "Repetitive assistant response detected. Breaking potential loop.".to_string();
362 session_state.push_warning(warning);
363 session_state.consecutive_idle_turns = session_state.consecutive_idle_turns.saturating_add(1);
364 return true;
365 }
366
367 false
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::llm::provider::Message;
374
375 #[test]
376 fn policy_handoffs_are_distinct_from_pending_verification_recaps() {
377 for text in [
378 "Verification is still pending after the tool-call budget was exhausted.",
379 "Repeated unverified assistant responses reached the anti-blind verification gate.",
380 ] {
381 assert!(tracker_final_text_is_safety_handoff(text));
382 assert!(!tracker_final_text_is_policy_handoff(text));
383 }
384 for text in [
385 "Verification is still pending; permission denied.",
386 "Tool budget exhausted; blocked by tool policy.",
387 "Missing credentials for the verifier.",
388 "Tool-call safety fuse stopped verification.",
389 ] {
390 assert!(tracker_final_text_is_policy_handoff(text));
391 assert!(tracker_final_text_is_safety_handoff(text));
392 }
393 }
394
395 #[test]
396 fn tracker_final_text_is_safety_handoff_vocabulary() {
397 assert!(tracker_final_text_is_safety_handoff(
398 "Permission denied for exec_command. Next step: retry after access is granted."
399 ));
400 assert!(tracker_final_text_is_safety_handoff(
401 "I hit the tool-call safety fuse mid-verification; policy block."
402 ));
403 assert!(tracker_final_text_is_safety_handoff(
404 "Blocked action: exec_command is denied by workspace tool policy."
405 ));
406 assert!(tracker_final_text_is_safety_handoff(
407 "Turn blocked after repeated unverified assistant responses; verification is still pending."
408 ));
409 assert!(tracker_final_text_is_safety_handoff(
410 "Anti-blind checkpoint remains; verification is still pending."
411 ));
412 assert!(!tracker_final_text_is_safety_handoff(
413 "## Status\nBlocked by turn budget. Next step: read design/diff.rs."
414 ));
415 assert!(!tracker_final_text_is_safety_handoff(
416 "## Status\nBlocked by turn tool budget. Next step: patch the struct."
417 ));
418 assert!(!tracker_final_text_is_safety_handoff(
419 "Tool loop budget exhausted; continuing next turn with remaining tracker steps."
420 ));
421 assert!(!tracker_final_text_is_safety_handoff(
422 "Fix 1 partially applied, needs verification; next step is cargo check."
423 ));
424 assert!(!tracker_final_text_is_safety_handoff("Reviewed tool policy docs; next step is the tracker patch."));
426 assert!(tracker_final_text_is_safety_handoff(
427 "Blocked action: exec_command is denied by workspace tool policy."
428 ));
429 assert!(!tracker_final_text_is_safety_handoff("Implemented patch apply; verification passed."));
430 assert!(!tracker_final_text_is_safety_handoff(""));
431 assert!(tracker_final_text_is_safety_handoff("Tool budget exhausted; denied by policy for exec_command."));
433 assert!(tracker_final_text_is_safety_handoff("Turn budget hit, then permission denied for the write."));
434 }
435
436 #[test]
437 fn recoverable_status_recap_phrasing_covers_session_budget_recaps() {
438 for phrase in [
439 "blocked by the turn's preview budget",
440 "tool budget ran out",
441 "hit the per-file read cap",
442 "preview budget exhausted",
443 "tool loop budget exhausted",
444 "recovery fallback",
445 "reached the safety cap",
446 "work budget exhausted",
447 ] {
448 assert!(
449 recoverable_status_recap_phrasing(&phrase.to_ascii_lowercase()),
450 "must treat as recoverable: {phrase}"
451 );
452 }
453 for phrase in [
454 "permission denied for exec_command",
455 "verification is still pending",
456 "needs a user decision",
457 ] {
458 assert!(
459 !recoverable_status_recap_phrasing(&phrase.to_ascii_lowercase()),
460 "must not treat as recoverable budget phrasing: {phrase}"
461 );
462 }
463 }
464
465 #[test]
466 fn tracker_final_text_requires_user_input_vocabulary() {
467 assert!(tracker_final_text_requires_user_input("Step 1 done. Which branch should I use for step 2?"));
468 assert!(tracker_final_text_requires_user_input(
469 "## Status\nPlease confirm whether to use the stable schema."
470 ));
471 assert!(tracker_final_text_requires_user_input("Can you confirm the migration path before I edit."));
472 assert!(!tracker_final_text_requires_user_input(
473 "## Status\nNext we need your workspace path in CONFIG; patching the struct now."
474 ));
475 assert!(!tracker_final_text_requires_user_input("Blocked by turn budget. Next step: read helpers.rs."));
476 assert!(!tracker_final_text_requires_user_input(""));
477 }
478
479 #[test]
480 fn test_completion_candidates() {
481 assert!(check_completion_candidate("The task is complete"));
482 assert!(check_completion_candidate("Revision 1: task is complete."));
483 assert!(check_completion_candidate("I have successfully completed the task."));
484 assert!(check_completion_candidate("Task done"));
485 assert!(check_completion_candidate("All done"));
486
487 assert!(!check_completion_candidate("I will have the task done soon"));
489 assert!(!check_completion_candidate("Is the task done?"));
490 assert!(!check_completion_candidate("random text"));
491 assert!(!check_completion_candidate("The task is complete once verification finishes."));
492 assert!(!check_completion_candidate("All done. Verification pending."));
493 assert!(!check_completion_candidate("All done, but open questions remain."));
494 }
495
496 #[test]
497 fn subagent_markdown_contract_detected_as_complete() {
498 let contract = "## Summary\n- Background subprocess launched; PID 86065.\n\n## Facts\n- Script started at 2026-04-25T08:39:10Z.\n\n## Touched Files\n- None\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- None";
499 assert!(check_completion_candidate(contract));
500 }
501
502 #[test]
503 fn subagent_markdown_contract_with_crlf_detected_as_complete() {
504 let contract = "## Summary\r\n- Done.\r\n\r\n## Facts\r\n- Fact 1.\r\n";
505 assert!(check_completion_candidate(contract));
506 }
507
508 #[test]
509 fn subagent_markdown_contract_with_leading_whitespace_detected() {
510 let contract = "\n\n## Summary\n- item\n\n## Facts\n- fact\n";
511 assert!(check_completion_candidate(contract));
512 }
513
514 #[test]
515 fn document_with_only_summary_header_not_detected() {
516 let doc = "## Summary\n- This is a doc without a Facts section.\n";
517 assert!(!check_completion_candidate(doc));
518 }
519
520 #[test]
521 fn document_with_only_facts_header_not_detected() {
522 let doc = "## Facts\n- Fact without summary.\n";
523 assert!(!check_completion_candidate(doc));
524 }
525
526 #[test]
527 fn structured_contract_with_open_questions_is_not_complete() {
528 let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- Need to rerun the end-to-end flow.";
529 assert!(!check_completion_candidate(doc));
530 }
531
532 #[test]
533 fn structured_contract_with_unresolved_verification_is_not_complete() {
534 let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Verification pending.\n\n## Open Questions\n- None";
535 assert!(!check_completion_candidate(doc));
536 }
537
538 #[test]
539 fn structured_contract_with_none_punctuation_is_complete() {
540 let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- None.";
541 assert!(check_completion_candidate(doc));
542 }
543
544 #[test]
545 fn response_loop_ignores_current_assistant_message() {
546 let repeated_response = "The task is complete";
547 let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
548 state.messages_mut().push(Message::assistant(repeated_response.to_string()));
549
550 assert!(!check_for_response_loop(repeated_response, &mut state));
551 }
552
553 #[test]
554 fn response_loop_still_detects_prior_duplicate_assistant_message() {
555 let repeated_response = "The task is complete";
556 let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
557 state.messages_mut().push(Message::assistant(repeated_response.to_string()));
558 state.messages_mut().push(Message::assistant(repeated_response.to_string()));
559
560 assert!(check_for_response_loop(repeated_response, &mut state));
561 }
562}