1mod budget;
17mod claim_check;
20pub(crate) mod compress;
21mod crew_attest;
22mod crew_tool;
23mod display;
24mod git_tool;
25pub(crate) mod scratchpad;
27pub(crate) mod semantic;
29pub(crate) mod experiential;
31pub(crate) mod scheduled;
33pub(crate) mod plan_exec;
36pub(crate) mod spill;
37pub(crate) mod tool_recovery;
40mod driver;
45#[cfg(feature = "markdown")]
49mod markdown;
50#[cfg(not(feature = "markdown"))]
51mod markdown {
52 use std::io::{self, Write};
58
59 #[derive(Debug, Clone, Copy)]
60 pub struct RenderOpts {
61 pub color: bool,
62 pub cols: usize,
63 }
64 pub fn render_markdown(src: &str, _opts: RenderOpts) -> String {
65 src.to_string()
66 }
67
68 pub struct MarkdownStreamWriter<W: Write> {
72 out: W,
73 wrote: bool,
74 ended_nl: bool,
75 }
76 impl<W: Write> MarkdownStreamWriter<W> {
77 pub fn new(out: W, _opts: RenderOpts) -> Self {
78 Self {
79 out,
80 wrote: false,
81 ended_nl: true,
82 }
83 }
84 pub fn push(&mut self, delta: &str) -> io::Result<()> {
85 if let Some(&last) = delta.as_bytes().last() {
86 self.wrote = true;
87 self.ended_nl = last == b'\n';
88 }
89 self.out.write_all(delta.as_bytes())
90 }
91 pub fn finish(&mut self) -> io::Result<()> {
92 if self.wrote && !self.ended_nl {
93 self.out.write_all(b"\n")?;
94 }
95 self.out.flush()
96 }
97 }
98}
99mod mcp;
100mod memory_fetch;
101mod note_sink;
102mod observation;
103mod permissions;
104mod recall;
105mod report;
109mod resume;
112mod deny;
116mod routing;
120mod tool_search;
124mod tools;
125mod transcript;
126mod trim;
127mod untrusted;
131mod warmup;
132
133pub use compress::{
134 compress_user_initiated, CompressCounters, CompressState, ManualCompressOutcome, SummarizeFn,
135 SummarizeFuture, Summarizer, CONTINUATION_PREFIX, SUMMARY_END_MARKER, SUMMARY_PREFIX,
136};
137pub use crew_attest::{crew_authz, crew_step_up_policy, CrewAuthz, Presence};
138pub use crew_tool::{compose_roster_tool_definition, crew_tool_definition, CrewRunner};
139pub use display::{
140 fmt_token_gauge, fmt_tokens_compact, gauge_level, print_harness_notice, print_list_item,
141 print_newt, GaugeLevel, NEWT_ORANGE_CT,
142};
143pub use driver::{
144 TurnDriver, TurnDriverConfig, TurnDriverError, TurnOutcome, TurnStatus,
145 VISIBLE_TRANSCRIPT_ROLES,
146};
147pub use experiential::{
148 experience_block, ExperienceStore, SessionExperienceStore, EXPERIENCE_TOP_K,
149};
150pub use git_tool::{git_tool_definition, GitTool};
151pub use markdown::{render_markdown, MarkdownStreamWriter, RenderOpts};
152pub use mcp::{McpTools, NoMcp};
153pub use plan_exec::{run_plan, run_plan_with_reground, NoReground, PlanRun, Reground};
154pub use scheduled::{
155 plan_block, plan_reseat_pointer, PlanSnapshot, SessionStepLedger, Step, StepLedger, StepStatus,
156};
157pub use scratchpad::{scratchpad_state_block, ScratchpadStore, SessionScratchpadStore};
158pub use semantic::{
159 chunk_source, code_evidence_block, code_search_tool_definition, cosine, gather_code_files,
160 index_files, retrieve_evidence, CodeChunk, CodeSearch, Embedder, EmbeddingsClient,
161 SemanticIndex, SessionSemanticIndex,
162};
163pub use spill::{SessionSpillStore, SpillStore};
164
165#[cfg(feature = "markdown-table-formatter")]
172pub fn tidy_markdown_tables(src: &str) -> String {
173 markdown_table_formatter::format_tables(src)
174}
175
176#[cfg(not(feature = "markdown-table-formatter"))]
178pub fn tidy_markdown_tables(src: &str) -> String {
179 src.to_string()
180}
181
182#[cfg(test)]
183mod tidy_tables_tests {
184 #[cfg(not(feature = "markdown-table-formatter"))]
185 #[test]
186 fn identity_without_the_feature() {
187 let ragged = "| a | bb |\n|---|---|\n| ccc | d |";
190 assert_eq!(super::tidy_markdown_tables(ragged), ragged);
191 }
192
193 #[cfg(feature = "markdown-table-formatter")]
194 #[test]
195 fn aligns_pipes_with_the_feature() {
196 let ragged = "| a | bb |\n| --- | --- |\n| ccc | d |\n";
197 let tidy = super::tidy_markdown_tables(ragged);
198 assert_ne!(tidy, ragged, "the table should be reformatted");
199 assert!(tidy.contains("ccc"), "content preserved");
200 let pipe_cols = |s: &str| {
202 s.char_indices()
203 .filter(|(_, c)| *c == '|')
204 .map(|(i, _)| i)
205 .collect::<Vec<_>>()
206 };
207 let rows: Vec<&str> = tidy.lines().filter(|l| l.contains('|')).collect();
208 let first = pipe_cols(rows[0]);
209 for r in &rows {
210 assert_eq!(pipe_cols(r), first, "pipes aligned across rows: {r:?}");
211 }
212 }
213}
214pub use budget::get_context_remaining_tool_definition;
215pub use memory_fetch::{
216 memory_fetch_tool_definition, MemAddr, MemPayload, MemorySource, StoreMemorySource,
217};
218pub use note_sink::{save_note_tool_definition, NoteNudge, NoteSink};
219pub use observation::{ShellObservation, SHELL_OBSERVATION_PREFIX};
220pub use permissions::{
221 append_denial, load_denials, widen_caveats, DenialKind, PermissionDecision, PermissionGate,
222 PermissionRecord, PermissionRequest, PersistentDenial,
223};
224pub use recall::{recall_tool_definition, RecallSource, StoreRecallSource};
225pub use resume::resume_context_tool_definition;
226pub use tools::{
227 execute_tool, execute_tool_with_offload, filter_advertised_tools, full_access_requested,
228 ocap_disabled, persona_tool_allowed, set_max_output_tokens, set_output_head_tokens,
229 tool_definitions, venv_cmd_prefix,
230};
231pub use transcript::{
232 transcript_lines, transcript_lines_styled, TranscriptLine, TranscriptRole, TranscriptStyle,
233};
234pub use trim::trim_for_summary;
235pub use untrusted::wrap_untrusted;
236pub use warmup::warmup_if_cold;
237
238use crate::retry::{with_backoff_notify, RetryPolicy};
239use compress::{compress, compression_trigger, CompressAction, CompressRequest};
240use crossterm::{
241 execute,
242 style::{Color as CtColor, Print, ResetColor, SetForegroundColor},
243};
244use display::{
245 emit_compression_notice, emit_overflow_notice, print_debug, print_retry_indicator, print_trace,
246};
247use std::io::{self, Write as _};
248use tools::{is_hallucination, merged_tool_definitions};
249use trim::{
250 estimate_request_tokens, estimate_tokens, estimate_value_tokens, merge_round_usage,
251 ollama_usage, openai_usage, PromptTracker,
252};
253
254fn tui_retry_policy() -> RetryPolicy {
259 RetryPolicy::for_local_inference()
260}
261
262pub type RecoverCw400 = fn(&anyhow::Error, &str, &str) -> Option<u32>;
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum RoundObservation {
274 Accepted {
281 prompt_tokens: u32,
282 estimated_tokens: usize,
283 },
284 SuspectedOverflow { prompt_tokens: u32 },
287 ThinkingOnly,
290}
291
292fn num_ctx_input_ceiling(num_ctx: Option<u32>, input_ceiling_pct: u32) -> Option<usize> {
301 num_ctx
302 .map(|c| (c as usize) * (input_ceiling_pct as usize) / 100)
303 .filter(|&c| c > 0)
304}
305
306fn initial_send_budget(
328 max_ok_input: Option<u32>,
329 safe_context: Option<u32>,
330 num_ctx: Option<u32>,
331 input_ceiling_pct: u32,
332) -> Option<usize> {
333 let cached = match (max_ok_input, safe_context) {
334 (Some(m), Some(s)) => Some(m.max(s) as usize),
335 (m, s) => m.or(s).map(|c| c as usize),
336 };
337 match (cached, num_ctx_input_ceiling(num_ctx, input_ceiling_pct)) {
338 (Some(budget), Some(ceiling)) => Some(budget.min(ceiling)),
339 (budget, ceiling) => budget.or(ceiling),
340 }
341}
342
343fn calibrate_up(est: usize, ratio: f32) -> usize {
348 (est as f32 * ratio).ceil() as usize
349}
350
351fn calibrate_down(real: usize, ratio: f32) -> usize {
355 (real as f32 / ratio).floor() as usize
356}
357
358fn sanitize_estimate_ratio(estimate_ratio: Option<f32>) -> f32 {
363 estimate_ratio
364 .filter(|r| r.is_finite() && (0.5..=3.0).contains(r))
365 .unwrap_or(1.0)
366}
367
368fn emit_accepted(
374 hook: &mut Option<&mut dyn FnMut(RoundObservation)>,
375 round_usage: Option<crate::TokenUsage>,
376 truncation_suspect: bool,
377 estimated_tokens: usize,
378) {
379 if truncation_suspect {
380 return;
381 }
382 if let (Some(hook), Some(u)) = (hook.as_deref_mut(), round_usage) {
383 hook(RoundObservation::Accepted {
384 prompt_tokens: u.input_tokens,
385 estimated_tokens,
386 });
387 }
388}
389
390#[cfg(test)]
395mod send_budget_tests {
396 use super::compress::compression_trigger;
397 use super::{initial_send_budget, num_ctx_input_ceiling};
398
399 #[test]
404 fn first_turn_fresh_cache_trigger_sees_the_num_ctx_ceiling() {
405 let budget = initial_send_budget(None, None, Some(4096), 80);
406 assert_eq!(budget, Some(3276), "80% of 4096 — reply headroom reserved");
407 let trigger = compression_trigger(3, 41_355, 39_900, 40, None, budget, 1_432)
411 .expect("the ceiling must fire the trigger on the first turn");
412 assert!(trigger.hard_budget, "a real token budget, not a soft halve");
413 assert_eq!(
414 trigger.budget,
415 3_276 - 1_432,
416 "budget lands in message space: ceiling minus tool-schema tokens"
417 );
418 assert_eq!(trigger.max_messages, None, "no count firing here");
419 }
420
421 #[test]
427 fn absent_num_ctx_leaves_the_budget_unchanged() {
428 assert_eq!(initial_send_budget(None, None, None, 80), None);
429 assert_eq!(
430 initial_send_budget(Some(2_000), None, None, 80),
431 Some(2_000)
432 );
433 assert_eq!(
434 initial_send_budget(None, Some(5_000), None, 80),
435 Some(5_000)
436 );
437 assert_eq!(
438 initial_send_budget(Some(2_000), Some(5_000), None, 80),
439 Some(5_000),
440 "an HWM below safe_context is a floor, not a cap — safe_context wins"
441 );
442 assert_eq!(
444 compression_trigger(3, 41_355, 39_900, 40, None, None, 1_432),
445 None
446 );
447 }
448
449 #[test]
454 fn cached_budget_is_max_of_proven_and_believed() {
455 assert_eq!(
459 initial_send_budget(Some(6_068), Some(26_214), None, 80),
460 Some(26_214),
461 "HWM below safe_context → safe_context"
462 );
463 assert_eq!(
466 initial_send_budget(Some(8_734), Some(6_553), None, 80),
467 Some(8_734),
468 "HWM above safe_context (proven beyond the claim) → HWM"
469 );
470 assert_eq!(
475 initial_send_budget(Some(800_000), Some(64_000), None, 80),
476 Some(800_000),
477 "post-cw-400: max_ok_input is the authoritative cap"
478 );
479 assert_eq!(
480 initial_send_budget(Some(800_000), Some(800_000), None, 80),
481 Some(800_000)
482 );
483 }
484
485 #[test]
489 fn calibration_helpers_round_in_the_safe_direction() {
490 use super::{calibrate_down, calibrate_up, sanitize_estimate_ratio};
491 assert_eq!(calibrate_up(6_068, 1.0), 6_068);
493 assert_eq!(calibrate_down(6_068, 1.0), 6_068);
494 assert_eq!(calibrate_up(1_000, 1.3), 1_300);
496 assert_eq!(calibrate_down(1_000, 1.3), 769, "floor, never round up");
497 assert_eq!(calibrate_up(3, 1.5), 5, "4.5 ceils to 5");
499 assert_eq!(calibrate_down(3, 2.0), 1, "1.5 floors to 1");
500 assert_eq!(sanitize_estimate_ratio(None), 1.0);
502 assert_eq!(sanitize_estimate_ratio(Some(f32::NAN)), 1.0);
503 assert_eq!(sanitize_estimate_ratio(Some(0.1)), 1.0);
504 assert_eq!(sanitize_estimate_ratio(Some(5.0)), 1.0);
505 assert_eq!(sanitize_estimate_ratio(Some(1.29)), 1.29);
506 assert_eq!(sanitize_estimate_ratio(Some(0.5)), 0.5, "clamp inclusive");
507 assert_eq!(sanitize_estimate_ratio(Some(3.0)), 3.0, "clamp inclusive");
508 }
509
510 #[test]
516 fn calibration_composes_across_the_trigger_boundary() {
517 use super::{calibrate_down, calibrate_up};
518 let cal = 1.3_f32;
519 let send_budget = 8_734_usize; let tool_tokens_est = 1_000_usize; let tool_tokens_real = calibrate_up(tool_tokens_est, cal); let current_real = calibrate_up(9_000, cal); let trigger = compression_trigger(
524 3,
525 current_real,
526 9_000,
527 40,
528 None,
529 Some(send_budget),
530 tool_tokens_real,
531 )
532 .expect("over-budget context fires the guard");
533 assert!(trigger.hard_budget);
534 assert_eq!(trigger.budget, send_budget - tool_tokens_real);
537 let pipeline_budget = calibrate_down(trigger.budget, cal);
538 assert_eq!(pipeline_budget, calibrate_down(8_734 - 1_300, cal));
539 assert!(
540 pipeline_budget < trigger.budget,
541 "ratio > 1: the estimate-space target is tighter than the real one"
542 );
543 }
544
545 #[test]
548 fn ceiling_composes_with_cached_budgets_via_min() {
549 assert_eq!(
552 initial_send_budget(Some(2_135), None, Some(4_096), 80),
553 Some(2_135)
554 );
555 assert_eq!(
558 initial_send_budget(None, Some(104_857), Some(4_096), 80),
559 Some(3_276)
560 );
561 assert_eq!(
562 initial_send_budget(Some(104_857), Some(104_857), Some(4_096), 80),
563 Some(3_276)
564 );
565 }
566
567 #[test]
570 fn zero_or_tiny_num_ctx_is_no_budget_at_all() {
571 assert_eq!(num_ctx_input_ceiling(None, 80), None);
572 assert_eq!(num_ctx_input_ceiling(Some(0), 80), None);
573 assert_eq!(
574 num_ctx_input_ceiling(Some(1), 80),
575 None,
576 "80% rounds to zero"
577 );
578 assert_eq!(initial_send_budget(None, None, Some(0), 80), None);
579 assert_eq!(
581 initial_send_budget(Some(2_000), None, Some(0), 80),
582 Some(2_000)
583 );
584 }
585}
586
587pub struct ChatCtx<'a> {
591 pub url: &'a str,
592 pub model: &'a str,
593 pub kind: crate::BackendKind,
595 pub api_key: Option<&'a str>,
597 pub messages: &'a [crate::MemMessage],
599 pub task: &'a str,
600 pub workspace: &'a str,
601 pub color: bool,
602 pub markdown: bool,
606 pub tool_offload: bool,
610 pub spill_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
614 pub compaction_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
620 pub scratchpad: bool,
623 pub scratchpad_store: Option<&'a dyn crate::agentic::scratchpad::ScratchpadStore>,
626 pub code_search: Option<crate::agentic::semantic::CodeSearch<'a>>,
629 pub experience_store: Option<&'a dyn crate::agentic::experiential::ExperienceStore>,
632 pub step_ledger: Option<&'a dyn crate::agentic::scheduled::StepLedger>,
635 pub caveats: &'a crate::caveats::Caveats,
636 pub persona_tools: Option<&'a [String]>,
644 pub max_tool_rounds: usize,
647 pub workflow_grace_rounds: usize,
651 pub narration_nudge_cap: usize,
658 pub tool_output_lines: usize,
662 pub debug: bool,
665 pub trace: bool,
668 pub num_ctx: Option<u32>,
675 pub connect_timeout_secs: u64,
678 pub inference_timeout_secs: u64,
681 pub mid_loop_trim_threshold: usize,
684 pub mid_loop_trim_tokens: Option<usize>,
689 pub max_ok_input: Option<u32>,
694 pub build_check_cmd: Option<String>,
698 pub safe_context: Option<u32>,
703 pub recover_cw_400: Option<RecoverCw400>,
710 pub note_sink: Option<&'a mut dyn NoteSink>,
716 pub note_nudge: Option<&'a mut NoteNudge>,
720 pub recall_source: Option<&'a dyn RecallSource>,
726 pub memory_source: Option<&'a dyn MemorySource>,
735 pub summarizer: Option<&'a SummarizeFn>,
742 pub compress_state: Option<&'a mut CompressState>,
746 pub tool_events: Option<&'a mut Vec<crate::ToolEvent>>,
754 pub phantom_reaches: Option<&'a mut Vec<crate::PhantomReach>>,
760 pub end_reason: Option<&'a mut Option<crate::TurnEndReason>>,
766 pub permission_gate: Option<&'a mut dyn PermissionGate>,
773 pub on_round_usage: Option<&'a mut dyn FnMut(RoundObservation)>,
784 pub estimate_ratio: Option<f32>,
790 pub estimation: crate::tokens::TokenEstimation,
794 pub summary_input_cap_floor_chars: usize,
797 pub input_ceiling_pct: u32,
802 pub low_budget_pct: usize,
806 pub exec_floor: Option<&'a crate::caveats::Scope<String>>,
815 pub write_ledger: Option<&'a std::cell::RefCell<crate::verify_gate::WriteLedger>>,
823 pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
831 pub git_tool: Option<&'a dyn GitTool>,
838 pub crew_runner: Option<&'a dyn CrewRunner>,
844}
845
846fn ledger_note_write(
854 write_ledger: Option<&std::cell::RefCell<crate::verify_gate::WriteLedger>>,
855 name: &str,
856 args: &serde_json::Value,
857 workspace: &str,
858) {
859 let Some(led) = write_ledger else {
860 return;
861 };
862 if name != "write_file" && name != "edit_file" {
863 return;
864 }
865 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
866 let abs = lexical_normalize(&std::path::Path::new(workspace).join(p));
871 led.borrow_mut().note_before_write(abs);
872 }
873}
874
875fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
881 use std::path::Component;
882 let mut out = std::path::PathBuf::new();
883 for comp in path.components() {
884 match comp {
885 Component::ParentDir => {
886 if matches!(out.components().next_back(), Some(Component::Normal(_))) {
888 out.pop();
889 } else {
890 out.push("..");
891 }
892 }
893 Component::CurDir => {}
894 other => out.push(other.as_os_str()),
895 }
896 }
897 out
898}
899
900#[cfg(test)]
901mod retry_ledger_tests {
902 use super::*;
903
904 #[test]
905 fn lexical_normalize_collapses_dot_and_parent() {
906 assert_eq!(
907 lexical_normalize(std::path::Path::new("/ws/examples/../foo.py")),
908 std::path::PathBuf::from("/ws/foo.py")
909 );
910 assert_eq!(
911 lexical_normalize(std::path::Path::new("/ws/./a//b/foo.py")),
912 std::path::PathBuf::from("/ws/a/b/foo.py")
913 );
914 assert_eq!(
916 lexical_normalize(std::path::Path::new("../x.py")),
917 std::path::PathBuf::from("../x.py")
918 );
919 }
920
921 #[test]
922 fn ledger_note_write_keys_on_the_normalized_path() {
923 let tmp = tempfile::tempdir().unwrap();
924 std::fs::write(tmp.path().join("foo.py"), "real\n").unwrap();
925 let led = std::cell::RefCell::new(crate::verify_gate::WriteLedger::new());
926 let args = serde_json::json!({ "path": "examples/../foo.py" });
928 ledger_note_write(
929 Some(&led),
930 "write_file",
931 &args,
932 tmp.path().to_str().unwrap(),
933 );
934 assert!(
937 led.borrow().revert(&tmp.path().join("foo.py")).unwrap(),
938 "the normalized key matches the gate's path"
939 );
940 ledger_note_write(
942 Some(&led),
943 "read_file",
944 &serde_json::json!({ "path": "foo.py" }),
945 tmp.path().to_str().unwrap(),
946 );
947 assert_eq!(led.borrow().len(), 1, "only write tools are tracked");
948 }
949}
950
951fn is_tools_unsupported_error(e: &anyhow::Error) -> bool {
956 let s = e.to_string().to_lowercase();
957 s.contains("does not support tools") || s.contains("not support tools")
958}
959
960fn is_ollama_tool_xml_error(e: &anyhow::Error) -> bool {
966 let s = e.to_string().to_lowercase();
967 s.contains("ollama") && s.contains("xml syntax error")
968}
969
970fn ollama_tool_xml_retry_nudge() -> &'static str {
971 "The previous assistant turn failed inside Ollama's XML tool-call parser. \
972 Keep using tools, but emit exactly one valid native tool call with well-formed \
973 arguments now. Do not answer in prose or wrap the call in explanatory XML."
974}
975
976fn is_cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) -> bool {
989 cancel.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
990}
991
992async fn cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) {
997 match cancel {
998 None => std::future::pending().await,
999 Some(flag) => {
1000 while !flag.load(std::sync::atomic::Ordering::Relaxed) {
1004 tokio::time::sleep(std::time::Duration::from_millis(15)).await;
1005 }
1006 }
1007 }
1008}
1009
1010async fn cancellable<F: std::future::Future>(
1014 cancel: Option<&std::sync::atomic::AtomicBool>,
1015 fut: F,
1016) -> Option<F::Output> {
1017 tokio::select! {
1018 biased;
1019 _ = cancelled(cancel) => None,
1020 v = fut => Some(v),
1021 }
1022}
1023
1024async fn with_thinking_spinner<F: std::future::Future>(
1030 animate: bool,
1031 label: &str,
1032 fut: F,
1033) -> F::Output {
1034 if !animate {
1035 return fut.await;
1036 }
1037 tokio::pin!(fut);
1038 let start = std::time::Instant::now();
1039 let mut frame = 0usize;
1040 let mut ticker = tokio::time::interval(std::time::Duration::from_millis(120));
1041 loop {
1042 tokio::select! {
1043 biased;
1044 out = &mut fut => {
1045 let _ = write!(io::stdout(), "\r\x1b[K");
1046 let _ = io::stdout().flush();
1047 return out;
1048 }
1049 _ = ticker.tick() => {
1050 let line = format_spinner(frame, start.elapsed().as_secs_f32(), label, 0);
1051 let mut out = io::stdout();
1052 let _ = execute!(
1053 out,
1054 Print("\r\x1b[K"),
1055 SetForegroundColor(CtColor::DarkGrey),
1056 Print(&line),
1057 ResetColor,
1058 );
1059 let _ = out.flush();
1060 frame = frame.wrapping_add(1);
1061 }
1062 }
1063 }
1064}
1065
1066pub async fn chat_complete(
1067 ctx: ChatCtx<'_>,
1068 mcp: &mut dyn McpTools,
1069) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
1070 if ctx.kind == crate::BackendKind::Openai {
1073 if responses_api_selected() {
1077 return openai_responses_complete(ctx, mcp).await;
1078 }
1079 return openai_chat_complete(ctx, mcp).await;
1080 }
1081 let markdown = ctx.markdown;
1084 let ChatCtx {
1085 url,
1086 model,
1087 kind: _,
1088 api_key: _,
1089 messages: mem_messages,
1090 task,
1091 workspace,
1092 color,
1093 markdown: _,
1094 tool_offload,
1095 spill_store,
1096 compaction_store,
1097 scratchpad,
1098 scratchpad_store,
1099 code_search,
1100 experience_store,
1101 step_ledger,
1102 caveats,
1103 persona_tools,
1104 max_tool_rounds,
1105 workflow_grace_rounds,
1106 narration_nudge_cap,
1107 tool_output_lines,
1108 debug,
1109 trace,
1110 num_ctx,
1111 connect_timeout_secs,
1112 inference_timeout_secs,
1113 mid_loop_trim_threshold,
1114 mid_loop_trim_tokens,
1115 max_ok_input,
1116 build_check_cmd,
1117 safe_context,
1118 recover_cw_400,
1119 mut note_sink,
1120 mut note_nudge,
1121 recall_source,
1122 memory_source,
1123 summarizer,
1124 compress_state,
1125 mut tool_events,
1126 mut phantom_reaches,
1127 mut end_reason,
1128 mut permission_gate,
1129 mut on_round_usage,
1130 estimate_ratio,
1131 estimation,
1132 summary_input_cap_floor_chars,
1133 input_ceiling_pct,
1134 low_budget_pct,
1135 exec_floor,
1136 write_ledger,
1137 cancel,
1138 git_tool,
1139 crew_runner,
1140 } = ctx;
1141 let mut local_compress_state = CompressState::new();
1144 let compress_state = match compress_state {
1145 Some(s) => s,
1146 None => &mut local_compress_state,
1147 };
1148 let client = reqwest::Client::builder()
1149 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1150 .timeout(std::time::Duration::from_secs(inference_timeout_secs))
1151 .build()?;
1152 let stream_client = reqwest::Client::builder()
1163 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1164 .read_timeout(std::time::Duration::from_secs(inference_timeout_secs))
1165 .build()?;
1166 let chat_url = format!("{}/api/chat", url.trim_end_matches('/'));
1167 let retry = tui_retry_policy();
1168 let advertise_save_note = note_sink.is_some();
1172 let advertise_recall = recall_source.is_some();
1173 let advertise_memory_fetch = memory_source.is_some();
1174 let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
1176 let advertise_code_search = code_search.is_some();
1178 let advertise_experiential = experience_store.is_some();
1180 let advertise_scheduled = step_ledger.is_some();
1182 let advertise_git = git_tool.is_some();
1183 let advertise_team = crew_runner.is_some();
1184
1185 let mut messages: Vec<serde_json::Value> = mem_messages
1188 .iter()
1189 .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
1190 .collect();
1191
1192 if note_sink.is_some() {
1197 if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
1198 append_nudge_line(&mut messages, &line);
1199 }
1200 }
1201
1202 let mut accumulated_usage: Option<crate::TokenUsage> = None;
1203 let mut hallucination_count: u32 = 0;
1204 let mut repeat_calls = RepeatCallGuard::default();
1206 let mut overflow_retries: u32 = 0;
1207 let mut suspicious_empty_retries: u32 = 0;
1208 let mut cw_retries: u32 = 0;
1210 let mut tools_supported = true;
1214 let mut tools_unsupported_notified = false;
1215 let num_ctx_ceiling = num_ctx_input_ceiling(num_ctx, input_ceiling_pct);
1223 let mut send_budget: Option<usize> =
1224 initial_send_budget(max_ok_input, safe_context, num_ctx, input_ceiling_pct);
1225 let mut send_budget_authoritative = safe_context.is_some() || num_ctx_ceiling.is_some();
1232 let tools = merged_tool_definitions(
1236 mcp,
1237 advertise_save_note,
1238 advertise_recall,
1239 advertise_memory_fetch,
1240 advertise_git,
1241 advertise_team,
1242 advertise_scratchpad,
1243 advertise_code_search,
1244 advertise_experiential,
1245 advertise_scheduled,
1246 );
1247 let tools = filter_advertised_tools(tools, persona_tools);
1251 let tool_tokens = estimate_value_tokens(&tools, estimation);
1252 let cal = sanitize_estimate_ratio(estimate_ratio);
1257 let tool_tokens_real = calibrate_up(tool_tokens, cal);
1258 let animate = color && thinking_stream_enabled();
1262 let mut prompt_tracker = PromptTracker::new();
1265 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1266 let mut read_only_rounds: usize = 0;
1270 let mut narration_nudges: usize = 0;
1278 let mut pending_plan_nudges: usize = 0;
1281 let mut stale_file_nudges: usize = 0;
1285 let nudge_classifier = crate::NudgeClassifier::load_default();
1286 let workflow_steerer = crate::WorkflowSteerer::load_default();
1287 let mut workflow_runtime = WorkflowRuntimeState::default();
1288 workflow_runtime.set_progress_horizon(
1293 workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
1294 );
1295 let mut ollama_xml_retry_nudges: usize = 0;
1296 let mut thinking_only_reported = false;
1299 let mut observed_paths = claim_check::ObservedPaths::default();
1302 let observed_resolver = claim_check::workspace_resolver(workspace);
1303
1304 let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
1308 let mut workflow_grace_active = false;
1309 let mut current_tool_round_limit = max_tool_rounds;
1310 'round_loop: for round in 0..hard_tool_rounds {
1311 if round >= current_tool_round_limit {
1312 if workflow_grace_active {
1313 break;
1314 }
1315 let Some(nudge) = workflow_runtime.cap_grace_nudge(
1316 step_ledger,
1317 max_tool_rounds,
1318 workflow_grace_rounds,
1319 ) else {
1320 break;
1321 };
1322 workflow_grace_active = true;
1323 current_tool_round_limit = hard_tool_rounds;
1324 if debug {
1325 print_debug(
1326 "workflow progress at soft round cap — granting configured grace window",
1327 color,
1328 );
1329 }
1330 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1331 }
1332 if is_cancelled(cancel) {
1336 return Ok((String::new(), false, accumulated_usage, hallucination_count));
1337 }
1338 if round > 0 {
1339 if color {
1341 execute!(
1342 io::stdout(),
1343 SetForegroundColor(CtColor::DarkGrey),
1344 Print("…\n"),
1345 ResetColor
1346 )
1347 .ok();
1348 }
1349 }
1350
1351 if round > 0 {
1358 if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
1359 messages.push(serde_json::json!({ "role": "user", "content": ptr }));
1360 }
1361 if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
1362 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1363 }
1364 }
1365
1366 const READ_ONLY_NUDGE_AFTER: usize = 3;
1373 if read_only_rounds >= READ_ONLY_NUDGE_AFTER {
1374 let remaining = current_tool_round_limit.saturating_sub(round + 1);
1375 let delegate_hint = workflow_steerer
1381 .delegate_hint(&workflow_classifier_text(&messages, ""), advertise_team);
1382 messages.push(serde_json::json!({
1383 "role": "user",
1384 "content": read_only_action_nudge(
1385 read_only_rounds,
1386 remaining,
1387 step_ledger,
1388 delegate_hint.as_deref(),
1389 )
1390 }));
1391 read_only_rounds = 0;
1392 }
1393
1394 {
1403 let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
1407 let message_tokens = estimate_tokens(&messages, estimation);
1412 if let Some(trigger) = compression_trigger(
1413 messages.len(),
1414 current,
1415 message_tokens,
1416 mid_loop_trim_threshold,
1417 mid_loop_trim_tokens,
1418 send_budget,
1419 tool_tokens_real,
1420 ) {
1421 let pipeline_budget = if trigger.hard_budget {
1426 calibrate_down(trigger.budget, cal)
1427 } else {
1428 trigger.budget
1429 };
1430 let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
1435 let outcome = match with_thinking_spinner(
1440 animate,
1441 "compressing context…",
1442 cancellable(
1443 cancel,
1444 compress(
1445 CompressRequest {
1446 messages: &messages,
1447 budget: pipeline_budget,
1448 max_messages: trigger.max_messages,
1449 task,
1450 hard_budget: trigger.hard_budget,
1451 authoritative: token_fired || send_budget_authoritative,
1452 focus: None,
1453 est: estimation,
1454 summary_input_cap_floor_chars,
1455 compaction_store,
1456 },
1457 summarizer,
1458 compress_state,
1459 ),
1460 ),
1461 )
1462 .await
1463 {
1464 Some(o) => o,
1465 None => {
1466 return Ok((String::new(), false, accumulated_usage, hallucination_count))
1467 }
1468 };
1469 if let Some(notice) = outcome.notice {
1470 print_harness_notice(¬ice, color);
1471 }
1472 if outcome.action == CompressAction::Refused {
1473 anyhow::bail!(
1479 "context (~{current} tokens) exceeds the model's input budget and \
1480 auto-compression is disabled after repeated ineffective passes — \
1481 start a new conversation or ask a more focused question, or run \
1482 `newt tunings reset {model}` if this model's learned budget looks wrong"
1483 );
1484 }
1485 if outcome.fired {
1486 let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
1492 ", still over budget"
1493 } else {
1494 ""
1495 };
1496 emit_compression_notice(
1497 color,
1498 outcome.tokens_before,
1499 outcome.tokens_after,
1500 outcome.action,
1501 suffix,
1502 );
1503 if debug {
1504 print_debug(
1505 &format!(
1506 "compression: {} → {} messages (budget ~{} tokens, \
1507 +~{tool_tokens} tool-schema tokens ride along)",
1508 messages.len(),
1509 outcome.messages.len(),
1510 pipeline_budget,
1511 ),
1512 color,
1513 );
1514 }
1515 messages = outcome.messages;
1516 prompt_tracker.invalidate();
1517 apply_post_compaction_continuation(
1518 &mut messages,
1519 &mut narration_nudges,
1520 outcome.action,
1521 step_ledger,
1522 round > 0,
1523 );
1524 }
1525 }
1526 }
1527
1528 let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
1533
1534 let mut body_no_stream = if let Some(ctx_size) = num_ctx {
1539 serde_json::json!({
1540 "model": model,
1541 "messages": messages,
1542 "stream": false,
1543 "tools": tools.clone(),
1544 "options": { "num_ctx": ctx_size },
1545 })
1546 } else {
1547 serde_json::json!({
1548 "model": model,
1549 "messages": messages,
1550 "stream": false,
1551 "tools": tools.clone(),
1552 })
1553 };
1554 if !tools_supported {
1558 if let Some(o) = body_no_stream.as_object_mut() {
1559 o.remove("tools");
1560 }
1561 }
1562
1563 let dispatch = match with_thinking_spinner(
1570 animate,
1571 "thinking…",
1572 cancellable(
1573 cancel,
1574 with_backoff_notify(
1575 &retry,
1576 || async {
1577 let resp = client
1578 .post(&chat_url)
1579 .json(&body_no_stream)
1580 .send()
1581 .await
1582 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
1583 if !resp.status().is_success() {
1584 let status = resp.status();
1585 let text = resp.text().await.unwrap_or_default();
1586 anyhow::bail!("Ollama {status}: {text}");
1587 }
1588 resp.json::<serde_json::Value>()
1589 .await
1590 .map_err(anyhow::Error::from)
1591 },
1592 |attempt, delay| print_retry_indicator(attempt, delay, color),
1593 ),
1594 ),
1595 )
1596 .await
1597 {
1598 Some(d) => d,
1599 None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
1601 };
1602 let json: serde_json::Value = match dispatch {
1603 Ok(j) => j,
1604 Err(e) => {
1605 let tools_unsupported = is_tools_unsupported_error(&e);
1613 let malformed_xml_tool_call = is_ollama_tool_xml_error(&e);
1614 if tools_supported && tools_unsupported {
1615 tools_supported = false;
1616 if !tools_unsupported_notified {
1617 tools_unsupported_notified = true;
1618 let notice = format!(
1619 "{model} does not support tools — tools disabled for this turn"
1620 );
1621 print_newt(¬ice, color, false);
1622 }
1623 continue 'round_loop;
1624 }
1625 if tools_supported && malformed_xml_tool_call && ollama_xml_retry_nudges < 2 {
1626 ollama_xml_retry_nudges += 1;
1627 print_newt(
1628 &format!(
1629 "{model} produced malformed Ollama XML tool-call syntax — \
1630 retrying with a stricter tool-call nudge"
1631 ),
1632 color,
1633 false,
1634 );
1635 messages.push(serde_json::json!({
1636 "role": "user",
1637 "content": ollama_tool_xml_retry_nudge()
1638 }));
1639 continue 'round_loop;
1640 }
1641 if cw_retries < 2 {
1645 if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
1646 emit_overflow_notice(
1647 color,
1648 accumulated_usage.as_ref(),
1649 Some(new_cap),
1650 model,
1651 cw_retries + 1,
1652 );
1653 let new_budget =
1656 num_ctx_ceiling.map_or(new_cap as usize, |c| (new_cap as usize).min(c));
1657 send_budget = Some(new_budget);
1658 send_budget_authoritative = true;
1661 let outcome = compress(
1662 CompressRequest {
1663 messages: &messages,
1667 budget: calibrate_down(
1668 new_budget.saturating_sub(tool_tokens_real),
1669 cal,
1670 ),
1671 max_messages: None,
1672 task,
1673 hard_budget: true,
1674 authoritative: true,
1675 focus: None,
1676 est: estimation,
1677 summary_input_cap_floor_chars,
1678 compaction_store,
1679 },
1680 summarizer,
1681 compress_state,
1682 )
1683 .await;
1684 if let Some(notice) = outcome.notice {
1685 print_harness_notice(¬ice, color);
1686 }
1687 if outcome.action == CompressAction::Refused {
1688 return Err(e);
1690 }
1691 if outcome.fired {
1692 messages = outcome.messages;
1693 prompt_tracker.invalidate();
1694 apply_post_compaction_continuation(
1695 &mut messages,
1696 &mut narration_nudges,
1697 outcome.action,
1698 step_ledger,
1699 round > 0,
1700 );
1701 }
1702 cw_retries += 1;
1703 continue 'round_loop;
1704 }
1705 }
1706 return Err(e);
1707 }
1708 };
1709
1710 let round_usage = ollama_usage(&json);
1714 if let Some(u) = round_usage {
1715 prompt_tracker.record(u.input_tokens, messages.len());
1716 }
1717 accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
1718
1719 let truncation_suspect = round_usage
1724 .is_some_and(|u| num_ctx.is_some_and(|c| u.input_tokens >= c.saturating_mul(95) / 100));
1725 if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
1731 let raised = (u.input_tokens as usize).min(num_ctx_ceiling.unwrap_or(usize::MAX));
1732 if raised > budget {
1733 send_budget = Some(raised);
1734 if debug {
1735 print_debug(
1736 &format!(
1737 "send budget raised to ~{raised} tokens (backend accepted \
1738 {}-token prompt)",
1739 u.input_tokens
1740 ),
1741 color,
1742 );
1743 }
1744 }
1745 }
1746
1747 let message = &json["message"];
1748 let probe_content = message["content"].as_str().unwrap_or("").to_string();
1751
1752 let native_calls = message["tool_calls"].as_array();
1753 let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
1759 tool_recovery::recover_tool_calls(&probe_content)
1760 } else {
1761 tool_recovery::Recovery::default()
1762 };
1763 let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
1764 Some(t) if !t.is_empty() => Some(t),
1765 _ if !recovered.calls.is_empty() => Some(&recovered.calls),
1766 _ => None,
1767 };
1768 let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
1769 if debug && !recovered.calls.is_empty() {
1770 print_debug(
1771 &format!(
1772 "recovered {} tool call(s) from content (non-native emission)",
1773 recovered.calls.len()
1774 ),
1775 color,
1776 );
1777 }
1778
1779 if debug {
1780 let content_excerpt = if probe_content.is_empty() {
1781 "(empty)".to_string()
1782 } else {
1783 let chars: String = probe_content.chars().take(80).collect();
1784 if probe_content.len() > 80 {
1785 format!("{chars}…")
1786 } else {
1787 chars
1788 }
1789 };
1790 let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
1791 let usage_str = match round_usage {
1792 Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
1793 None => "no usage".into(),
1794 };
1795 print_debug(
1796 &format!(
1797 "round {round} probe: tool_calls={tc_count} usage=[{usage_str}] content={content_excerpt:?}"
1798 ),
1799 color,
1800 );
1801 }
1802
1803 if !has_tools {
1804 if recovered.tool_shaped {
1808 hallucination_count += 1;
1809 if debug {
1810 print_debug(
1811 "format-hallucination: tool call emitted as unrecoverable text",
1812 color,
1813 );
1814 }
1815 }
1816 macro_rules! maybe_nudge_no_tool_content {
1821 ($content:expr, $usage:expr) => {{
1822 let content = $content;
1823 if !content.is_empty() {
1824 let nudge_classification = nudge_classifier.classify(content);
1825 let workflow_classifier_text =
1826 workflow_classifier_text(&messages, content);
1827 let workflow_hint = nudge_classification
1828 .is_plan_update()
1829 .then(|| workflow_steerer.plan_update_hint(&workflow_classifier_text))
1830 .flatten();
1831 let classifier_plan_direction = nudge_classification
1832 .is_plan_update()
1833 .then(|| {
1834 nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate)
1835 })
1836 .flatten();
1837 let plan_nudge_hint =
1838 combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
1839 if round + 1 < current_tool_round_limit {
1840 if let Some(nudge) = workflow_runtime.rediscovery_nudge(
1841 Some(&nudge_classification),
1842 content,
1843 step_ledger,
1844 ) {
1845 if debug {
1846 print_debug(
1847 "workflow evidence rediscovery — nudging toward active repair",
1848 color,
1849 );
1850 }
1851 messages.push(serde_json::json!({
1852 "role": "assistant",
1853 "content": content
1854 }));
1855 messages.push(serde_json::json!({
1856 "role": "user",
1857 "content": format!(
1858 "{} {}",
1859 compress::LOOP_GUIDANCE_PREFIX, nudge
1860 )
1861 }));
1862 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1863 continue 'round_loop;
1864 }
1865 }
1866 if pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
1867 && round + 1 < current_tool_round_limit
1868 {
1869 if let Some(nudge) = pending_plan_completion_nudge(
1870 step_ledger,
1871 nudge_classification.is_plan_update(),
1872 plan_nudge_hint.as_deref(),
1873 ) {
1874 if debug {
1875 print_debug(
1876 "active plan has unfinished steps — nudging before final answer",
1877 color,
1878 );
1879 }
1880 messages.push(serde_json::json!({
1881 "role": "assistant",
1882 "content": content
1883 }));
1884 messages.push(serde_json::json!({
1885 "role": "user",
1886 "content": format!(
1887 "{} {}",
1888 compress::LOOP_GUIDANCE_PREFIX, nudge
1889 )
1890 }));
1891 pending_plan_nudges += 1;
1892 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1893 continue 'round_loop;
1894 }
1895 }
1896 if stale_file_nudges < STALE_FILE_NUDGE_CAP
1897 && round + 1 < current_tool_round_limit
1898 && looks_like_unverified_stale_file_blocker(content)
1899 {
1900 if debug {
1901 print_debug(
1902 "unverified stale-file blocker — nudging to check ground truth",
1903 color,
1904 );
1905 }
1906 messages.push(serde_json::json!({
1907 "role": "assistant",
1908 "content": content
1909 }));
1910 messages.push(serde_json::json!({
1911 "role": "user",
1912 "content": format!(
1913 "{} {}",
1914 compress::LOOP_GUIDANCE_PREFIX,
1915 stale_file_ground_truth_nudge()
1916 ),
1917 }));
1918 stale_file_nudges += 1;
1919 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1920 continue 'round_loop;
1921 }
1922 if narration_nudges < narration_nudge_cap
1923 && round + 1 < current_tool_round_limit
1924 && nudge_classification.is_pending_action()
1925 {
1926 if debug {
1927 print_debug(
1928 "narrated intent with no tool call — nudging to act and continuing",
1929 color,
1930 );
1931 }
1932 messages.push(serde_json::json!({
1936 "role": "assistant",
1937 "content": content
1938 }));
1939 let direction = if narration_nudges == 0 {
1945 nudge_classifier
1946 .direction_for(nudge_classification.class)
1947 .map(str::to_string)
1948 .unwrap_or_else(narration_action_nudge)
1949 } else {
1950 escalated_narration_action_nudge(
1951 narration_nudges + 1,
1952 narration_nudge_cap,
1953 step_ledger,
1954 )
1955 };
1956 messages.push(serde_json::json!({
1957 "role": "user",
1958 "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, direction),
1959 }));
1960 narration_nudges += 1;
1961 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1962 continue 'round_loop;
1963 }
1964 let accepted_reason = if nudge_classification.is_pending_action() {
1970 if round + 1 >= current_tool_round_limit {
1971 crate::TurnEndReason::NarrationFinalRound
1972 } else {
1973 crate::TurnEndReason::NarrationCapExhausted
1974 }
1975 } else {
1976 crate::TurnEndReason::Completed
1977 };
1978 if debug && accepted_reason != crate::TurnEndReason::Completed {
1979 print_debug(
1980 &format!(
1981 "no-tool narration accepted as final answer ({accepted_reason:?})"
1982 ),
1983 color,
1984 );
1985 }
1986 if let Some(slot) = &mut end_reason {
1987 **slot = Some(accepted_reason);
1988 }
1989 }
1990 }};
1991 }
1992 let mut body_stream = if let Some(ctx_size) = num_ctx {
2001 serde_json::json!({
2002 "model": model,
2003 "messages": &messages,
2004 "stream": true,
2005 "tools": tools.clone(),
2006 "options": { "num_ctx": ctx_size },
2007 })
2008 } else {
2009 serde_json::json!({
2010 "model": model,
2011 "messages": &messages,
2012 "stream": true,
2013 "tools": tools.clone(),
2014 })
2015 };
2016 if !tools_supported {
2019 if let Some(o) = body_stream.as_object_mut() {
2020 o.remove("tools");
2021 }
2022 }
2023 let sresp = match cancellable(
2027 cancel,
2028 with_backoff_notify(
2029 &retry,
2030 || async {
2031 stream_client
2032 .post(&chat_url)
2033 .json(&body_stream)
2034 .send()
2035 .await
2036 .map_err(|e| anyhow::anyhow!("stream request failed: {e}"))
2037 },
2038 |attempt, delay| print_retry_indicator(attempt, delay, color),
2039 ),
2040 )
2041 .await
2042 {
2043 Some(r) => r?,
2044 None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
2045 };
2046
2047 if !sresp.status().is_success() {
2048 if debug {
2049 print_debug("stream request non-2xx — using probe content", color);
2050 }
2051 maybe_nudge_no_tool_content!(probe_content.as_str(), None);
2052 if !probe_content.is_empty() {
2055 emit_accepted(
2056 &mut on_round_usage,
2057 round_usage,
2058 truncation_suspect,
2059 round_est_raw,
2060 );
2061 }
2062 if probe_content.is_empty() {
2063 if let Some(slot) = &mut end_reason {
2064 **slot = Some(crate::TurnEndReason::Empty);
2065 }
2066 }
2067 return Ok((probe_content, false, accumulated_usage, hallucination_count));
2068 }
2069 let show_thinking = color && thinking_stream_enabled();
2072 let leading_reasoning = crate::reasoning::emits_leading_reasoning(model);
2076 let (streamed, stream_usage) = match stream_response(
2080 sresp,
2081 color,
2082 show_thinking,
2083 leading_reasoning,
2084 cancel,
2085 markdown,
2086 )
2087 .await
2088 {
2089 Ok(v) => v,
2090 Err(e) => {
2091 print_harness_notice(
2103 &format!(
2104 "stream broke mid-response ({e}) — recovered the answer \
2105 from the non-streamed probe"
2106 ),
2107 color,
2108 );
2109 if !probe_content.is_empty() {
2110 maybe_nudge_no_tool_content!(probe_content.as_str(), None);
2111 emit_accepted(
2112 &mut on_round_usage,
2113 round_usage,
2114 truncation_suspect,
2115 round_est_raw,
2116 );
2117 }
2118 if probe_content.is_empty() {
2119 if let Some(slot) = &mut end_reason {
2120 **slot = Some(crate::TurnEndReason::Empty);
2121 }
2122 }
2123 return Ok((probe_content, false, accumulated_usage, hallucination_count));
2124 }
2125 };
2126
2127 if streamed.is_empty() {
2128 if debug {
2131 print_debug(
2132 &format!(
2133 "stream returned empty — falling back to probe content ({} chars)",
2134 probe_content.len()
2135 ),
2136 color,
2137 );
2138 }
2139 if probe_content.is_empty() {
2140 let merged = merge_round_usage(accumulated_usage, stream_usage);
2141 let empty_round_usage = merge_round_usage(round_usage, stream_usage);
2142 let generated_unusable_output = empty_round_usage
2143 .as_ref()
2144 .map(|u| u.output_tokens > 0)
2145 .unwrap_or(false);
2146
2147 if generated_unusable_output
2148 && suspicious_empty_retries < SUSPICIOUS_EMPTY_RETRY_CAP
2149 {
2150 if trace {
2151 print_trace(&ollama_response_shape(&json), color);
2152 }
2153 if debug {
2154 let fields = ollama_non_content_fields(&json);
2155 let field_note = if fields.is_empty() {
2156 "no known non-content fields".to_string()
2157 } else {
2158 format!("non-content fields: {}", fields.join(", "))
2159 };
2160 print_debug(
2161 &format!(
2162 "empty assistant content with generated tokens — retrying ({}/{SUSPICIOUS_EMPTY_RETRY_CAP}; {field_note})",
2163 suspicious_empty_retries + 1
2164 ),
2165 color,
2166 );
2167 }
2168 if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2174 thinking_only_reported = true;
2175 if let Some(hook) = on_round_usage.as_deref_mut() {
2176 hook(RoundObservation::ThinkingOnly);
2177 }
2178 }
2179 messages.push(serde_json::json!({
2180 "role": "user",
2181 "content": suspicious_empty_retry_nudge(suspicious_empty_retries, &json)
2182 }));
2183 accumulated_usage = merged;
2184 suspicious_empty_retries += 1;
2185 continue 'round_loop;
2186 }
2187
2188 let overflow_likely = merged
2194 .as_ref()
2195 .zip(safe_context)
2196 .map(|(u, safe)| u.input_tokens >= safe * 85 / 100)
2197 .unwrap_or(false);
2198 if overflow_likely && overflow_retries < 2 {
2199 emit_overflow_notice(
2200 color,
2201 merged.as_ref(),
2202 safe_context,
2203 model,
2204 overflow_retries + 1,
2205 );
2206 let target = calibrate_down(
2215 safe_context
2216 .map(|s| (s as usize).saturating_mul(3) / 4)
2217 .unwrap_or(0)
2218 .saturating_sub(tool_tokens_real),
2219 cal,
2220 );
2221 let outcome = compress(
2222 CompressRequest {
2223 messages: &messages,
2224 budget: target,
2225 max_messages: None,
2226 task,
2227 hard_budget: true,
2228 authoritative: true,
2231 focus: None,
2232 est: estimation,
2233 summary_input_cap_floor_chars,
2234 compaction_store,
2235 },
2236 summarizer,
2237 compress_state,
2238 )
2239 .await;
2240 if let Some(notice) = outcome.notice {
2241 print_harness_notice(¬ice, color);
2242 }
2243 if outcome.fired {
2244 messages = outcome.messages;
2245 prompt_tracker.invalidate();
2246 apply_post_compaction_continuation(
2247 &mut messages,
2248 &mut narration_nudges,
2249 outcome.action,
2250 step_ledger,
2251 round > 0,
2252 );
2253 } else {
2254 let fallback = crate::prune::prune(
2259 &messages,
2260 &crate::prune::PruneConfig {
2261 keep_last: 2,
2262 ..Default::default()
2263 },
2264 );
2265 if fallback.chars_reclaimed > 0 {
2266 messages = fallback.messages;
2267 prompt_tracker.invalidate();
2268 }
2269 }
2270 accumulated_usage = merged;
2271 overflow_retries += 1;
2272 continue 'round_loop;
2273 }
2274 if overflow_likely {
2279 if let (Some(hook), Some(u)) =
2280 (on_round_usage.as_deref_mut(), merged.as_ref())
2281 {
2282 hook(RoundObservation::SuspectedOverflow {
2283 prompt_tokens: u.input_tokens,
2284 });
2285 }
2286 }
2287 if generated_unusable_output {
2288 if trace {
2289 print_trace(&ollama_response_shape(&json), color);
2290 }
2291 if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2296 if let Some(hook) = on_round_usage.as_deref_mut() {
2297 hook(RoundObservation::ThinkingOnly);
2298 }
2299 }
2300 if let Some(slot) = &mut end_reason {
2301 **slot = Some(crate::TurnEndReason::Empty);
2302 }
2303 return Ok((
2304 suspicious_empty_ollama_diagnostic(&json),
2305 false,
2306 merged,
2307 hallucination_count,
2308 ));
2309 }
2310 let msg = "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)";
2311 if let Some(slot) = &mut end_reason {
2312 **slot = Some(crate::TurnEndReason::Empty);
2313 }
2314 return Ok((msg.to_string(), false, merged, hallucination_count));
2315 }
2316 maybe_nudge_no_tool_content!(probe_content.as_str(), stream_usage);
2318 emit_accepted(
2320 &mut on_round_usage,
2321 round_usage,
2322 truncation_suspect,
2323 round_est_raw,
2324 );
2325 return Ok((
2326 probe_content,
2327 false,
2328 merge_round_usage(accumulated_usage, stream_usage),
2329 hallucination_count,
2330 ));
2331 }
2332
2333 maybe_nudge_no_tool_content!(streamed.as_str(), stream_usage);
2342 emit_accepted(
2344 &mut on_round_usage,
2345 round_usage,
2346 truncation_suspect,
2347 round_est_raw,
2348 );
2349 return Ok((
2350 streamed,
2351 true,
2352 merge_round_usage(accumulated_usage, stream_usage),
2353 hallucination_count,
2354 ));
2355 }
2356
2357 emit_accepted(
2361 &mut on_round_usage,
2362 round_usage,
2363 truncation_suspect,
2364 round_est_raw,
2365 );
2366 messages.push(message.clone());
2367 let mut round_wrote = false;
2368 let mut round_modified_workspace = false;
2369 let mut round_progress = false;
2370 for tc in tool_calls.unwrap() {
2371 let anthropic_native = tc["function"].is_null();
2372 let name = if anthropic_native {
2373 tc["name"].as_str().unwrap_or("unknown")
2374 } else {
2375 tc["function"]["name"].as_str().unwrap_or("unknown")
2376 };
2377 let args = if anthropic_native {
2378 tc["input"].clone()
2379 } else {
2380 match &tc["function"]["arguments"] {
2381 serde_json::Value::String(s) => {
2382 serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
2383 }
2384 v => v.clone(),
2385 }
2386 };
2387 if is_hallucination(name, &args) {
2388 hallucination_count += 1;
2389 }
2390 if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
2394 if let Some(rec) = tool_events.as_deref_mut() {
2395 rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
2396 }
2397 messages.push(serde_json::json!({ "role": "tool", "content": steer }));
2398 continue;
2399 }
2400 if !is_read_only_call(name, &args) {
2401 round_wrote = true;
2402 }
2403 if name == "save_note" && note_sink.is_some() {
2406 if let Some(n) = note_nudge.as_deref_mut() {
2407 n.note_saved();
2408 }
2409 }
2410 ledger_note_write(write_ledger, name, &args, workspace);
2413 let tool_t0 = std::time::Instant::now();
2414 let result = if tools::is_context_remaining_call(name) {
2422 let report = budget::render_context_budget(
2423 prompt_tracker.current(&messages, Some(&tools), cal, estimation),
2424 num_ctx_input_ceiling(num_ctx, input_ceiling_pct),
2425 num_ctx,
2426 input_ceiling_pct as usize,
2427 low_budget_pct,
2428 );
2429 display::print_tool_call("get_context_remaining", "", color);
2430 display::print_tool_output(&report, tool_output_lines, color);
2431 report
2432 } else {
2433 let dispatch = execute_tool_with_offload(
2443 name,
2444 &args,
2445 workspace,
2446 color,
2447 tool_output_lines,
2448 caveats,
2449 mcp,
2450 build_check_cmd.as_deref(),
2451 note_sink
2455 .as_deref_mut()
2456 .map(|s| &mut *s as &mut dyn NoteSink),
2457 recall_source,
2458 memory_source,
2459 permission_gate
2461 .as_deref_mut()
2462 .map(|g| &mut *g as &mut dyn PermissionGate),
2463 exec_floor,
2465 git_tool,
2467 crew_runner,
2469 scratchpad_store,
2470 code_search,
2471 experience_store,
2472 step_ledger,
2473 tool_offload,
2474 spill_store,
2475 persona_tools,
2476 );
2477 match cancellable(cancel, dispatch).await {
2483 Some(r) => r,
2484 None => format!("error: {name} interrupted — tool cancelled before completion"),
2485 }
2486 };
2487 let ok = tools::tool_result_ok(&result);
2492 if ok && is_workspace_write_call(name) {
2493 round_modified_workspace = true;
2494 }
2495 if ok && meaningful_workflow_progress(name, &result) {
2496 round_progress = true;
2497 }
2498 repeat_calls.record(name, &args, ok, &result);
2499 if workflow_runtime.record_tool_result(&result) {
2500 round_progress = true;
2501 }
2502 if let Some(rec) = tool_events.as_deref_mut() {
2503 rec.push(crate::ToolEvent::from_call(
2504 name,
2505 &args,
2506 ok,
2507 u64::try_from(tool_t0.elapsed().as_millis()).ok(),
2508 ));
2509 }
2510 if let Some(pr) = phantom_reaches.as_deref_mut() {
2517 if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
2518 .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
2519 {
2520 pr.push(crate::PhantomReach {
2521 name_as_called: name.to_string(),
2522 resolution,
2523 active_context_features: Vec::new(),
2524 });
2525 }
2526 }
2527 observed_paths.record(&result, &observed_resolver);
2530 messages.push(serde_json::json!({
2531 "role": "tool",
2532 "content": maybe_offload_tool_result(name, result, tool_offload, spill_store)
2535 }));
2536 }
2537 if round_wrote {
2538 read_only_rounds = 0;
2539 } else {
2540 read_only_rounds = read_only_rounds.saturating_add(1);
2541 }
2542 workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
2543 }
2544
2545 let trimmed = trim_for_summary(&messages, 2, 6);
2549 let progress = cap_exit_progress(step_ledger, scratchpad_store);
2552 let (text, streamed, usage) = final_summary_ollama(
2553 &client,
2554 &chat_url,
2555 model,
2556 trimmed,
2557 CapExit {
2558 max_tool_rounds,
2559 accumulated: accumulated_usage,
2560 wasted_calls: repeat_calls.total_failures(),
2561 progress,
2562 observed: observed_paths.into_vec(),
2563 },
2564 )
2565 .await?;
2566 let text = claim_check::annotate_against_workspace(text, workspace);
2571 if let Some(slot) = &mut end_reason {
2572 **slot = Some(crate::TurnEndReason::RoundCap);
2573 }
2574 Ok((text, streamed, usage, hallucination_count))
2575}
2576
2577fn first_line(s: &str) -> String {
2585 s.lines().next().unwrap_or("").chars().take(200).collect()
2586}
2587
2588#[derive(Debug, Clone, PartialEq, Eq)]
2606enum RepeatMemo {
2607 Failure {
2608 first_line: String,
2609 },
2610 NoResult {
2611 reason: String,
2612 },
2613 EvidenceObserved {
2614 subject: String,
2615 advice: &'static str,
2616 },
2617}
2618
2619#[derive(Default)]
2620struct RepeatCallGuard {
2621 repeat_memos: std::collections::HashMap<String, RepeatMemo>,
2623 fails_by_tool: std::collections::HashMap<String, usize>,
2625}
2626
2627impl RepeatCallGuard {
2628 const ESCALATE_AFTER: usize = 2;
2631
2632 fn key(name: &str, args: &serde_json::Value) -> String {
2633 format!("{name}\u{1}{args}")
2636 }
2637
2638 fn repeat_steer(&self, name: &str, args: &serde_json::Value) -> Option<String> {
2641 let key = Self::key(name, args);
2642 match self.repeat_memos.get(&key)? {
2643 RepeatMemo::Failure { first_line: prev } => {
2644 let mut msg = format!(
2645 "You already called `{name}` with these exact arguments and it failed: {prev}. \
2646 Do NOT repeat the same call — use a different tool or different arguments."
2647 );
2648 if self.fails_by_tool.get(name).copied().unwrap_or(0) >= Self::ESCALATE_AFTER {
2649 msg.push_str(&format!(
2650 " `{name}` has failed repeatedly this session; stop using it and prefer \
2651 the embedded tools (read_file, edit_file, write_file, find, git)."
2652 ));
2653 }
2654 Some(msg)
2655 }
2656 RepeatMemo::NoResult { reason } => Some(format!(
2657 "You already ran `{name}` with these exact arguments this turn and {reason}. \
2658 Don't repeat the identical call — create or update the missing state, change \
2659 the arguments when the tool accepts them, or use a different tool."
2660 )),
2661 RepeatMemo::EvidenceObserved { subject, advice } => Some(format!(
2662 "You already observed {subject} with `{name}` and received output. Do NOT repeat \
2663 the identical call — {advice}"
2664 )),
2665 }
2666 }
2667
2668 fn successful_fetch_url(name: &str, args: &serde_json::Value, result: &str) -> Option<String> {
2669 if name != "web_fetch" {
2670 return None;
2671 }
2672 let url = args.get("url")?.as_str()?.trim();
2673 if url.is_empty() || result.trim().is_empty() {
2674 None
2675 } else {
2676 Some(url.chars().take(200).collect())
2677 }
2678 }
2679
2680 fn successful_read_only_shell_command(
2681 name: &str,
2682 args: &serde_json::Value,
2683 result: &str,
2684 ) -> Option<String> {
2685 if name != "run_command" || result.trim().is_empty() {
2686 return None;
2687 }
2688 let command = args.get("command")?.as_str()?.trim();
2689 if is_read_only_shell_probe(command) {
2690 Some(command.chars().take(200).collect())
2691 } else {
2692 None
2693 }
2694 }
2695
2696 fn no_result_reason(name: &str, result: &str) -> Option<&'static str> {
2703 match name {
2704 "recall" if result.starts_with("no matches in past conversations") => Some(
2705 "it returned no matches (and recall cannot see the current conversation \
2706 — use resume_context for THIS conversation)",
2707 ),
2708 "state_get" if result.starts_with("no such key") => {
2709 Some("the key is not set (state_get returned \"no such key\")")
2710 }
2711 "plan_get" if result.starts_with("no active plan") => Some(
2712 "it found no active plan; call update_plan now with a short ordered plan if the \
2713 work has more than one step",
2714 ),
2715 _ => None,
2716 }
2717 }
2718
2719 fn classify_repeat_memo(
2724 name: &str,
2725 args: &serde_json::Value,
2726 ok: bool,
2727 result: &str,
2728 ) -> Option<RepeatMemo> {
2729 if !ok {
2730 return Some(RepeatMemo::Failure {
2731 first_line: first_line(result),
2732 });
2733 }
2734 if let Some(reason) = Self::no_result_reason(name, result) {
2735 return Some(RepeatMemo::NoResult {
2736 reason: reason.to_string(),
2737 });
2738 }
2739 if let Some(url) = Self::successful_fetch_url(name, args, result) {
2740 return Some(RepeatMemo::EvidenceObserved {
2741 subject: format!("`{url}`"),
2742 advice: "use the fetched content above, fetch a different URL, inspect local \
2743 files, or answer the user.",
2744 });
2745 }
2746 if let Some(command) = Self::successful_read_only_shell_command(name, args, result) {
2747 return Some(RepeatMemo::EvidenceObserved {
2748 subject: format!("read-only shell probe `{command}`"),
2749 advice: "use the observed output above, change the query, inspect a different \
2750 file, or make the next edit/test decision.",
2751 });
2752 }
2753 None
2754 }
2755
2756 fn record(&mut self, name: &str, args: &serde_json::Value, ok: bool, result: &str) {
2760 if !ok {
2761 *self.fails_by_tool.entry(name.to_string()).or_default() += 1;
2762 }
2763 if let Some(memo) = Self::classify_repeat_memo(name, args, ok, result) {
2764 self.repeat_memos.insert(Self::key(name, args), memo);
2765 }
2766 }
2767
2768 fn total_failures(&self) -> usize {
2771 self.fails_by_tool.values().sum()
2772 }
2773}
2774
2775#[derive(Debug, Clone, PartialEq, Eq)]
2776struct WorkflowErrorEvidence {
2777 fingerprint: String,
2778 observations: usize,
2779}
2780
2781#[derive(Debug, Default)]
2782struct WorkflowRuntimeState {
2783 error_evidence: Option<WorkflowErrorEvidence>,
2784 read_only_rounds_after_evidence: usize,
2785 writes_after_evidence: usize,
2786 rounds_since_progress: Option<usize>,
2787 progress_horizon_rounds: Option<usize>,
2793 step_lock_nudges: usize,
2794 rediscovery_nudges: usize,
2795}
2796
2797impl WorkflowRuntimeState {
2798 const STEP_LOCK_NUDGE_CAP: usize = 3;
2799 const REDISCOVERY_NUDGE_CAP: usize = 2;
2800
2801 fn set_progress_horizon(&mut self, rounds: Option<usize>) {
2804 self.progress_horizon_rounds = rounds;
2805 }
2806
2807 fn progress_horizon(&self) -> usize {
2808 self.progress_horizon_rounds
2809 .unwrap_or(WORKFLOW_RECENT_PROGRESS_ROUNDS)
2810 }
2811
2812 fn record_tool_result(&mut self, result: &str) -> bool {
2813 let Some(fingerprint) = workflow_error_fingerprint(result) else {
2814 return false;
2815 };
2816 match self.error_evidence.as_mut() {
2817 Some(evidence) if evidence.fingerprint == fingerprint => {
2818 evidence.observations = evidence.observations.saturating_add(1);
2819 false
2820 }
2821 _ => {
2822 self.error_evidence = Some(WorkflowErrorEvidence {
2823 fingerprint,
2824 observations: 1,
2825 });
2826 self.read_only_rounds_after_evidence = 0;
2827 self.writes_after_evidence = 0;
2828 self.step_lock_nudges = 0;
2829 self.rediscovery_nudges = 0;
2830 true
2831 }
2832 }
2833 }
2834
2835 fn record_round_outcome(&mut self, round_wrote: bool, round_progress: bool) {
2836 if round_progress {
2837 self.rounds_since_progress = Some(0);
2838 } else if let Some(rounds) = self.rounds_since_progress.as_mut() {
2839 *rounds = rounds.saturating_add(1);
2840 }
2841 if self.error_evidence.is_none() {
2842 return;
2843 }
2844 if round_wrote {
2845 self.writes_after_evidence = self.writes_after_evidence.saturating_add(1);
2846 self.read_only_rounds_after_evidence = 0;
2847 } else {
2848 self.read_only_rounds_after_evidence =
2849 self.read_only_rounds_after_evidence.saturating_add(1);
2850 }
2851 }
2852
2853 fn round_start_nudge(
2854 &mut self,
2855 step_ledger: Option<&dyn scheduled::StepLedger>,
2856 ) -> Option<String> {
2857 let evidence = self.error_evidence.as_ref()?;
2858 if self.writes_after_evidence > 0 || self.read_only_rounds_after_evidence == 0 {
2859 return None;
2860 }
2861 if self.step_lock_nudges >= Self::STEP_LOCK_NUDGE_CAP {
2862 return None;
2863 }
2864 self.step_lock_nudges += 1;
2865 Some(workflow_step_lock_nudge(
2866 &evidence.fingerprint,
2867 evidence.observations,
2868 active_step_description(step_ledger).as_deref(),
2869 ))
2870 }
2871
2872 fn rediscovery_nudge(
2873 &mut self,
2874 classification: Option<&crate::NudgeClassification>,
2875 content: &str,
2876 step_ledger: Option<&dyn scheduled::StepLedger>,
2877 ) -> Option<String> {
2878 let evidence = self.error_evidence.as_ref()?;
2879 if self.writes_after_evidence > 0 {
2880 return None;
2881 }
2882 if self.rediscovery_nudges >= Self::REDISCOVERY_NUDGE_CAP {
2883 return None;
2884 }
2885 let classified_stall = classification.is_some_and(|c| {
2886 matches!(
2887 c.class,
2888 crate::NudgeClass::PendingAction | crate::NudgeClass::PlanUpdate
2889 )
2890 });
2891 if !classified_stall && !looks_like_error_rediscovery(content) {
2892 return None;
2893 }
2894 self.rediscovery_nudges += 1;
2895 Some(workflow_rediscovery_nudge(
2896 &evidence.fingerprint,
2897 active_step_description(step_ledger).as_deref(),
2898 ))
2899 }
2900
2901 fn cap_grace_nudge(
2902 &mut self,
2903 step_ledger: Option<&dyn scheduled::StepLedger>,
2904 max_tool_rounds: usize,
2905 workflow_grace_rounds: usize,
2906 ) -> Option<String> {
2907 if workflow_grace_rounds == 0 {
2908 return None;
2909 }
2910 let active_step = active_step_description(step_ledger);
2911 let recent_progress = self
2912 .rounds_since_progress
2913 .is_some_and(|rounds| rounds <= self.progress_horizon());
2914 if let Some(evidence) = self.error_evidence.as_ref() {
2915 if self.writes_after_evidence > 0 {
2916 return Some(workflow_post_write_grace_nudge(
2917 &evidence.fingerprint,
2918 active_step.as_deref(),
2919 max_tool_rounds,
2920 workflow_grace_rounds,
2921 ));
2922 }
2923 if self.read_only_rounds_after_evidence > 0 || recent_progress {
2924 return Some(workflow_cap_grace_nudge(
2925 &evidence.fingerprint,
2926 active_step.as_deref(),
2927 max_tool_rounds,
2928 workflow_grace_rounds,
2929 ));
2930 }
2931 }
2932 if active_step.is_some() && recent_progress {
2933 return Some(workflow_progress_grace_nudge(
2934 active_step.as_deref(),
2935 max_tool_rounds,
2936 workflow_grace_rounds,
2937 ));
2938 }
2939 None
2940 }
2941}
2942
2943fn workflow_error_fingerprint(result: &str) -> Option<String> {
2944 build_error_fingerprint(result).or_else(|| edit_miss_fingerprint(result))
2945}
2946
2947fn build_error_fingerprint(result: &str) -> Option<String> {
2948 let mut pending_error: Option<String> = None;
2949 let mut fingerprints = Vec::new();
2950 for line in result.lines() {
2951 let trimmed = line.trim();
2952 if trimmed.starts_with("error[") || trimmed.starts_with("error:") {
2953 pending_error = Some(normalize_error_line(trimmed));
2954 continue;
2955 }
2956 if let Some(rest) = trimmed.strip_prefix("-->") {
2957 if let Some(error) = pending_error.take() {
2958 let location = rest
2959 .split_whitespace()
2960 .next()
2961 .unwrap_or("")
2962 .trim()
2963 .trim_start_matches("./");
2964 if location.is_empty() {
2965 fingerprints.push(error);
2966 } else {
2967 fingerprints.push(format!("{location} {error}"));
2968 }
2969 if fingerprints.len() >= 3 {
2970 break;
2971 }
2972 }
2973 }
2974 }
2975 if fingerprints.is_empty() {
2976 pending_error.map(|e| e.chars().take(240).collect())
2977 } else {
2978 Some(fingerprints.join(" | ").chars().take(500).collect())
2979 }
2980}
2981
2982fn edit_miss_fingerprint(result: &str) -> Option<String> {
2983 let lc = result.to_ascii_lowercase();
2984 if !(lc.contains("old_string")
2985 && (lc.contains("not found")
2986 || lc.contains("old string not found")
2987 || lc.contains("matches 0")
2988 || lc.contains("no match")))
2989 {
2990 return None;
2991 }
2992 let line = result
2993 .lines()
2994 .find(|line| {
2995 let l = line.to_ascii_lowercase();
2996 l.contains("old_string")
2997 && (l.contains("not found") || l.contains("matches 0") || l.contains("no match"))
2998 })
2999 .unwrap_or("edit_file old_string not found");
3000 Some(format!("edit_file {}", normalize_error_line(line)))
3001}
3002
3003fn normalize_error_line(line: &str) -> String {
3004 let mut out = String::new();
3005 let mut in_ws = false;
3006 for c in line.chars() {
3007 if c.is_whitespace() {
3008 if !in_ws {
3009 out.push(' ');
3010 in_ws = true;
3011 }
3012 } else if c != '`' {
3013 out.push(c);
3014 in_ws = false;
3015 }
3016 }
3017 out.chars().take(240).collect()
3018}
3019
3020fn active_step_description(step_ledger: Option<&dyn scheduled::StepLedger>) -> Option<String> {
3021 let snapshot = step_ledger?.snapshot();
3022 snapshot
3023 .steps
3024 .iter()
3025 .find(|step| step.status == StepStatus::Active)
3026 .or_else(|| {
3027 snapshot
3028 .steps
3029 .iter()
3030 .find(|step| step.status != StepStatus::Done)
3031 })
3032 .map(|step| step.description.clone())
3033}
3034
3035fn workflow_step_lock_nudge(
3036 fingerprint: &str,
3037 observations: usize,
3038 active_step: Option<&str>,
3039) -> String {
3040 let active = active_step
3041 .map(|step| format!(" Active step: '{step}'."))
3042 .unwrap_or_default();
3043 format!(
3044 "<workflow_state>\nactive_step = \"repair the current tool/build error\"\nlast_error_fingerprint = \"{fingerprint}\"\nobservations = {observations}\nnext_allowed_actions = \"use the latest file evidence, then edit_file/write_file for the active repair, then run the focused verification\"\ndisallowed_actions = \"re-reading the same evidence, re-deriving the same plan, or restating findings without editing\"\n</workflow_state>\n{active} You already have the error evidence above. Do not re-read or summarize it again unless you need one exact replacement span. Make the smallest edit that addresses this exact fingerprint, then run the focused check."
3045 )
3046}
3047
3048fn workflow_rediscovery_nudge(fingerprint: &str, active_step: Option<&str>) -> String {
3049 let active = active_step
3050 .map(|step| format!(" Active step: '{step}'."))
3051 .unwrap_or_default();
3052 format!(
3053 "You are rediscovering an error that is already recorded: {fingerprint}.{active} Do not restate findings, update the same plan, or claim handoff. Call the concrete edit tool for this repair now. After the edit, run one focused verification command and use its new output as ground truth."
3054 )
3055}
3056
3057fn workflow_cap_grace_nudge(
3058 fingerprint: &str,
3059 active_step: Option<&str>,
3060 max_tool_rounds: usize,
3061 workflow_grace_rounds: usize,
3062) -> String {
3063 let active = active_step
3064 .map(|step| format!(" Active step: '{step}'."))
3065 .unwrap_or_default();
3066 format!(
3067 "<workflow_state>\nnormal_tool_round_cap = {max_tool_rounds}\nconfigured_workflow_grace_rounds = {workflow_grace_rounds}\nlast_error_fingerprint = \"{fingerprint}\"\nnext_allowed_actions = \"call edit_file or write_file now using the latest observed file contents; then run the focused verification\"\ndisallowed_actions = \"summary of findings, handoff, plan rediscovery, or another broad read-only pass\"\n</workflow_state>\nThe normal tool-call cap was reached immediately after repair evidence without a successful workspace edit.{active} This is a bounded grace window, not a final-answer round. Use the latest observed contents and call the concrete edit tool now. If one exact replacement span is still missing, read only that minimal span, then edit in the grace window."
3068 )
3069}
3070
3071fn workflow_post_write_grace_nudge(
3072 fingerprint: &str,
3073 active_step: Option<&str>,
3074 max_tool_rounds: usize,
3075 workflow_grace_rounds: usize,
3076) -> String {
3077 let active = active_step
3078 .map(|step| format!(" Active step: '{step}'."))
3079 .unwrap_or_default();
3080 format!(
3081 "<workflow_state>\nnormal_tool_round_cap = {max_tool_rounds}\nconfigured_workflow_grace_rounds = {workflow_grace_rounds}\nlast_error_fingerprint = \"{fingerprint}\"\nnext_allowed_actions = \"run the focused verification for the edit you just made, or continue the active implementation step with one concrete tool call\"\ndisallowed_actions = \"summary of findings, handoff, or broad rediscovery\"\n</workflow_state>\nThe normal tool-call cap was reached immediately after a workspace edit related to recorded repair evidence.{active} This is a bounded verification window. Do not summarize or stop because of the normal cap; run the focused check or the next concrete implementation tool now."
3082 )
3083}
3084
3085fn workflow_progress_grace_nudge(
3086 active_step: Option<&str>,
3087 max_tool_rounds: usize,
3088 workflow_grace_rounds: usize,
3089) -> String {
3090 let active = active_step
3091 .map(|step| format!(" Active step: '{step}'."))
3092 .unwrap_or_default();
3093 format!(
3094 "<workflow_state>\nnormal_tool_round_cap = {max_tool_rounds}\nconfigured_workflow_grace_rounds = {workflow_grace_rounds}\nnext_allowed_actions = \"continue the active workflow step with one concrete tool call, then update the plan or verify\"\ndisallowed_actions = \"summary of findings, handoff, or broad rediscovery\"\n</workflow_state>\nThe normal tool-call cap was reached while the active workflow was still making concrete progress.{active} This is a bounded grace window, not a final-answer round. Continue with the next concrete implementation or verification tool now."
3095 )
3096}
3097
3098fn looks_like_error_rediscovery(content: &str) -> bool {
3099 let lc = content.to_ascii_lowercase();
3100 (lc.contains("summary of findings")
3101 || lc.contains("root cause")
3102 || lc.contains("current state")
3103 || lc.contains("remaining work")
3104 || lc.contains("build failure"))
3105 && (lc.contains("error") || lc.contains("build") || lc.contains("compile"))
3106}
3107
3108fn cap_exit_progress(
3112 step_ledger: Option<&dyn scheduled::StepLedger>,
3113 scratchpad_store: Option<&dyn scratchpad::ScratchpadStore>,
3114) -> Option<String> {
3115 let plan = step_ledger.and_then(scheduled::plan_block);
3116 let state = scratchpad_store.and_then(scratchpad::scratchpad_state_block);
3117 let parts: Vec<String> = [plan, state].into_iter().flatten().collect();
3118 (!parts.is_empty()).then(|| parts.join("\n\n"))
3119}
3120
3121fn is_read_only_tool(name: &str) -> bool {
3122 matches!(
3123 name,
3124 "list_dir"
3125 | "read_file"
3126 | "find"
3127 | "search"
3128 | "web_fetch"
3129 | "use_skill"
3130 | "save_note"
3131 | "recall"
3132 )
3133}
3134
3135fn is_read_only_call(name: &str, args: &serde_json::Value) -> bool {
3136 is_read_only_tool(name)
3137 || (name == "run_command"
3138 && args
3139 .get("command")
3140 .and_then(|v| v.as_str())
3141 .is_some_and(is_read_only_shell_probe))
3142}
3143
3144fn is_workspace_write_call(name: &str) -> bool {
3145 matches!(name, "write_file" | "edit_file")
3146}
3147
3148fn maybe_offload_tool_result(
3149 name: &str,
3150 result: String,
3151 tool_offload: bool,
3152 spill_store: Option<&dyn spill::SpillStore>,
3153) -> String {
3154 if matches!(name, "run_command" | "lifecycle") {
3155 result
3156 } else {
3157 spill::maybe_offload(result, tool_offload, spill_store)
3158 }
3159}
3160
3161fn meaningful_workflow_progress(name: &str, result: &str) -> bool {
3162 match name {
3163 "update_plan" => true,
3164 "write_file" => result.starts_with("wrote ") || result.starts_with("✓ wrote "),
3165 "edit_file" => edit_result_changed_file(result),
3166 _ => false,
3167 }
3168}
3169
3170fn edit_result_changed_file(result: &str) -> bool {
3171 result.starts_with("edited ") || result.starts_with("✓ edited ")
3172}
3173
3174fn is_read_only_shell_probe(command: &str) -> bool {
3175 let command = command.trim();
3176 if command.is_empty() {
3177 return false;
3178 }
3179 const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
3180 if command.contains(SHELL_META) {
3181 return false;
3182 }
3183 let mut tokens = command.split_ascii_whitespace();
3184 let Some(program) = tokens.next() else {
3185 return false;
3186 };
3187 match program {
3188 "grep" | "rg" | "head" | "tail" | "wc" | "pwd" => true,
3189 "sed" => !tokens.any(|t| t == "-i" || t.starts_with("-i")),
3190 _ => false,
3191 }
3192}
3193
3194const PENDING_PLAN_NUDGE_CAP: usize = 1;
3201const SUSPICIOUS_EMPTY_RETRY_CAP: u32 = 2;
3206const STALE_FILE_NUDGE_CAP: usize = 1;
3210const WORKFLOW_RECENT_PROGRESS_ROUNDS: usize = 3;
3213
3214fn tail_on_char_boundary(s: &str, max_bytes: usize) -> &str {
3215 let cut = s.len().saturating_sub(max_bytes);
3216 let start = (cut..=s.len())
3217 .find(|&i| s.is_char_boundary(i))
3218 .unwrap_or(0);
3219 &s[start..]
3220}
3221
3222#[cfg(test)]
3226fn looks_like_intent_to_act(content: &str) -> bool {
3227 crate::NudgeClassifier::builtin().is_pending_action(content)
3228}
3229
3230fn looks_like_unverified_stale_file_blocker(content: &str) -> bool {
3236 const FILE_CUES: &[&str] = &[
3237 "file",
3238 "line reference",
3239 "line references",
3240 "old_string",
3241 "edit_file",
3242 ".rs",
3243 ".toml",
3244 ".md",
3245 ];
3246 const STALE_CUES: &[&str] = &[
3247 "modified out from under",
3248 "changed out from under",
3249 "edited out from under",
3250 "modified concurrently",
3251 "changed concurrently",
3252 "stale context",
3253 "contexts are stale",
3254 "context is stale",
3255 "old line references",
3256 "line references are invalid",
3257 "file grew from",
3258 "grew from",
3259 ];
3260 const BLOCKER_CUES: &[&str] = &[
3261 "blocked",
3262 "cannot safely",
3263 "can't safely",
3264 "could land in the wrong place",
3265 "corrupt the code",
3266 "restore",
3267 "git checkout",
3268 "revert",
3269 "operator should",
3270 "human should",
3271 "recommendation",
3272 ];
3273
3274 let lc = content.to_lowercase();
3275 let tail = tail_on_char_boundary(&lc, 1_200);
3276 FILE_CUES.iter().any(|c| tail.contains(c))
3277 && STALE_CUES.iter().any(|c| tail.contains(c))
3278 && BLOCKER_CUES.iter().any(|c| tail.contains(c))
3279}
3280
3281fn narration_action_nudge() -> String {
3284 "You described what you were about to do but did not call any tool, so \
3285 nothing actually happened. If you intended to act, emit the tool call now \
3286 (for example edit_file or write_file with the real arguments) — do not just \
3287 describe it. If you are genuinely finished, say so explicitly in one \
3288 sentence."
3289 .to_string()
3290}
3291
3292fn post_compaction_continuation(step_ledger: Option<&dyn scheduled::StepLedger>) -> String {
3302 let step_clause = active_step_description(step_ledger)
3303 .map(|step| format!(" Active step: '{step}'."))
3304 .unwrap_or_default();
3305 format!(
3306 "{} You are mid-task: the context above was just compacted, not \
3307 completed.{step_clause} Continue working — your next output should be \
3308 the next concrete tool call (re-read any file you are about to edit \
3309 first, since full file contents were not preserved). Do not summarize \
3310 what happened and do not re-plan.",
3311 compress::CONTINUATION_PREFIX
3312 )
3313}
3314
3315fn apply_post_compaction_continuation(
3332 messages: &mut Vec<serde_json::Value>,
3333 narration_nudges: &mut usize,
3334 action: CompressAction,
3335 step_ledger: Option<&dyn scheduled::StepLedger>,
3336 mid_turn: bool,
3337) {
3338 if !mid_turn
3339 || !matches!(
3340 action,
3341 CompressAction::Summarized | CompressAction::StaticFallback
3342 )
3343 {
3344 return;
3345 }
3346 *narration_nudges = 0;
3347 messages.retain(|m| !compress::is_continuation_message(m));
3349 messages.push(serde_json::json!({
3350 "role": "user",
3351 "content": post_compaction_continuation(step_ledger),
3352 }));
3353}
3354
3355fn escalated_narration_action_nudge(
3361 attempt: usize,
3362 cap: usize,
3363 step_ledger: Option<&dyn scheduled::StepLedger>,
3364) -> String {
3365 let step_clause = active_step_description(step_ledger)
3366 .map(|step| format!(" Active step: '{step}'."))
3367 .unwrap_or_default();
3368 format!(
3369 "Reminder {attempt}/{cap}: you again described an action without calling \
3370 a tool, so nothing has happened.{step_clause} Your NEXT output must be \
3371 exactly one tool call that starts that action (for example read_file, \
3372 edit_file, or run_command with real arguments) — no prose before it. \
3373 If you are blocked, state the one concrete blocker in a single \
3374 sentence instead of announcing more intentions."
3375 )
3376}
3377
3378fn stale_file_ground_truth_nudge() -> String {
3379 "You claimed the file changed under you or that your edit context is stale, \
3380 but you did not prove that with ground truth. Before stopping or asking the \
3381 operator to restore/revert anything, run read-only verification: git status \
3382 --short, git diff -- <file>, wc -l <file>, and re-read the exact target \
3383 range. If those checks do not prove an actual concurrent change, continue \
3384 from the verified file contents. Never recommend git checkout/revert unless \
3385 git diff proves unwanted changes and the operator approves."
3386 .to_string()
3387}
3388
3389fn workflow_classifier_text(messages: &[serde_json::Value], current_content: &str) -> String {
3390 let mut parts = Vec::new();
3391 let start = messages.len().saturating_sub(12);
3392 for message in &messages[start..] {
3393 if let Some(text) = message_text(message) {
3394 if !text.trim().is_empty() {
3395 parts.push(text);
3396 }
3397 }
3398 }
3399 if !current_content.trim().is_empty() {
3400 parts.push(current_content.to_string());
3401 }
3402 parts.join("\n")
3403}
3404
3405fn combine_nudge_hints(first: Option<&str>, second: Option<&str>) -> Option<String> {
3406 let text = [first, second]
3407 .into_iter()
3408 .flatten()
3409 .map(str::trim)
3410 .filter(|hint| !hint.is_empty())
3411 .collect::<Vec<_>>()
3412 .join("\n\n");
3413 (!text.is_empty()).then_some(text)
3414}
3415
3416fn message_text(message: &serde_json::Value) -> Option<String> {
3417 match message.get("content")? {
3418 serde_json::Value::String(s) => Some(s.clone()),
3419 serde_json::Value::Array(parts) => {
3420 let text = parts
3421 .iter()
3422 .filter_map(|part| part.get("text").and_then(|text| text.as_str()))
3423 .collect::<Vec<_>>()
3424 .join("\n");
3425 (!text.is_empty()).then_some(text)
3426 }
3427 _ => None,
3428 }
3429}
3430
3431fn pending_plan_completion_nudge(
3432 step_ledger: Option<&dyn scheduled::StepLedger>,
3433 needs_plan_update: bool,
3434 workflow_hint: Option<&str>,
3435) -> Option<String> {
3436 let snapshot = step_ledger?.snapshot();
3437 let total = snapshot.steps.len();
3438 if total == 0 {
3439 return None;
3440 }
3441 let unfinished = snapshot
3442 .steps
3443 .iter()
3444 .filter(|s| s.status != StepStatus::Done)
3445 .count();
3446 if unfinished == 0 {
3447 return None;
3448 }
3449 let active = snapshot
3450 .steps
3451 .iter()
3452 .find(|s| s.status == StepStatus::Active)
3453 .or_else(|| snapshot.steps.iter().find(|s| s.status != StepStatus::Done));
3454 let active_clause = active
3455 .map(|s| format!(" Active step: '{}'.", s.description))
3456 .unwrap_or_default();
3457 let step_word = if unfinished == 1 { "step" } else { "steps" };
3458 let workflow_clause = workflow_hint
3459 .map(str::trim)
3460 .filter(|hint| !hint.is_empty())
3461 .map(|hint| format!("\n\n{hint}"))
3462 .unwrap_or_default();
3463 if needs_plan_update {
3464 Some(format!(
3465 "You ended with a findings/next-steps summary while the active plan still has \
3466 {unfinished}/{total} unfinished {step_word}.{active_clause} Your summary says \
3467 immediate prerequisite repair work now blocks the active step. Call update_plan now \
3468 with the full ordered plan: mark completed steps completed, make the immediate \
3469 blocker repair the active step, and keep later feature work pending. Then call the \
3470 next concrete tool for that active repair. Do not repeat the findings summary or \
3471 claim a tool-call limit while this nudge is giving you another round.{workflow_clause}"
3472 ))
3473 } else {
3474 Some(format!(
3475 "You ended the turn while the active plan still has {unfinished}/{total} unfinished \
3476 {step_word}.{active_clause} Either call update_plan with completed steps marked \
3477 completed, call the next tool for the active step, or state the concrete blocker. \
3478 Do not hand off by only describing remaining work."
3479 ))
3480 }
3481}
3482
3483fn read_only_action_nudge(
3484 read_only_rounds: usize,
3485 remaining_rounds: usize,
3486 step_ledger: Option<&dyn scheduled::StepLedger>,
3487 delegate_hint: Option<&str>,
3488) -> String {
3489 let plan_clause = if step_ledger.and_then(plan_reseat_pointer).is_some() {
3490 " You have an active multi-step plan; keep working the ACTIVE step instead of \
3491 restarting or re-planning."
3492 } else {
3493 ""
3494 };
3495 let delegate_clause = delegate_hint
3496 .map(|hint| format!(" {hint}"))
3497 .unwrap_or_default();
3498 format!(
3499 "[{read_only_rounds} read-only rounds so far. Stop AIMLESS exploring and start \
3500 making the change. This is a nudge, not a limit — you may still read, but if \
3501 you have enough context, call edit_file or write_file now. If a capability \
3502 denial blocks you, call request_permissions with the exact capability and \
3503 target, or take a different approach. If you truly cannot edit yet, state the \
3504 exact blocker. Before edit_file, read the ONE file you are about to change so \
3505 old_string matches exact text; never guess old_string or repeat a failed edit.\
3506 {plan_clause}{delegate_clause} ~{remaining_rounds} round(s) left.]"
3507 )
3508}
3509
3510fn append_nudge_line(messages: &mut Vec<serde_json::Value>, line: &str) {
3515 match messages.last_mut() {
3516 Some(last) if last["role"] == "user" => {
3517 let cur = last["content"].as_str().unwrap_or_default();
3518 last["content"] = serde_json::Value::String(format!("{cur}\n\n{line}"));
3519 }
3520 _ => messages.push(serde_json::json!({"role": "user", "content": line})),
3521 }
3522}
3523
3524fn ollama_non_content_fields(json: &serde_json::Value) -> Vec<&'static str> {
3525 let message = &json["message"];
3526 ["reasoning", "reasoning_content", "thinking"]
3527 .into_iter()
3528 .filter(|field| {
3529 message[*field]
3530 .as_str()
3531 .map(|value| !value.trim().is_empty())
3532 .unwrap_or(false)
3533 })
3534 .collect()
3535}
3536
3537fn ollama_response_shape(json: &serde_json::Value) -> String {
3538 let message = &json["message"];
3539 let message_keys = message
3540 .as_object()
3541 .map(|obj| obj.keys().cloned().collect::<Vec<_>>().join(","))
3542 .unwrap_or_else(|| "<missing>".to_string());
3543 let content_chars = message["content"]
3544 .as_str()
3545 .map(|content| content.chars().count())
3546 .unwrap_or(0);
3547 let tool_calls = message["tool_calls"]
3548 .as_array()
3549 .map(|calls| calls.len())
3550 .unwrap_or(0);
3551 let non_content = ollama_non_content_fields(json);
3552 let non_content = if non_content.is_empty() {
3553 "none".to_string()
3554 } else {
3555 non_content.join(",")
3556 };
3557 format!(
3558 "ollama response shape: message_keys=[{message_keys}] content_chars={content_chars} tool_calls={tool_calls} non_content_fields=[{non_content}] prompt_eval_count={} eval_count={}",
3559 json["prompt_eval_count"]
3560 .as_u64()
3561 .map_or("missing".to_string(), |n| n.to_string()),
3562 json["eval_count"]
3563 .as_u64()
3564 .map_or("missing".to_string(), |n| n.to_string())
3565 )
3566}
3567
3568fn suspicious_empty_retry_nudge(retry_index: u32, json: &serde_json::Value) -> String {
3569 if retry_index == 0 {
3570 return "Your previous response produced generated tokens but no assistant-visible content \
3571 and no tool call. Reply with either a tool call or final assistant content."
3572 .to_string();
3573 }
3574 let fields = ollama_non_content_fields(json);
3575 let field_note = if fields.is_empty() {
3576 "hidden/non-content fields".to_string()
3577 } else {
3578 format!("hidden/non-content field(s): {}", fields.join(", "))
3579 };
3580 format!(
3581 "Your previous response again produced generated tokens only in {field_note}, \
3582 with no assistant-visible content and no tool call. Hidden thinking is not an \
3583 action. If you intend to act, emit the exact tool call now; otherwise reply \
3584 with final assistant-visible content. Do not continue with hidden-only reasoning."
3585 )
3586}
3587
3588fn suspicious_empty_ollama_diagnostic(json: &serde_json::Value) -> String {
3589 let fields = ollama_non_content_fields(json);
3590 let field_note = if fields.is_empty() {
3591 "no known non-content fields were present".to_string()
3592 } else {
3593 format!("non-content field(s) present: {}", fields.join(", "))
3594 };
3595 format!(
3596 "(model generated output tokens but returned no assistant-visible content or tool calls; {field_note}; rerun with `newt --trace` to capture the response shape)"
3597 )
3598}
3599
3600fn cap_exit_nudge(max_tool_rounds: usize, progress: Option<&str>, observed: &[String]) -> String {
3605 let mut nudge = format!(
3611 "You have reached the tool-call limit ({max_tool_rounds} rounds). \
3612 Do NOT call any more tools. Summarize what you found across the tool \
3613 calls above and give your best final answer now. Cite only file paths \
3614 that appear verbatim in the messages above — if the evidence you need \
3615 was in the omitted messages, say so plainly instead of reconstructing \
3616 file names or line numbers from memory. Do not answer with an intention \
3617 to keep working (for example, \"let me read/edit/verify\"); if work remains, \
3618 list it as remaining work and state that the round cap stopped further tool calls."
3619 );
3620 if !observed.is_empty() {
3623 nudge.push_str(
3624 "\n\nFile paths actually observed in tool results this run \
3625 (these exist — cite from this list):",
3626 );
3627 for p in observed {
3628 nudge.push_str("\n- ");
3629 nudge.push_str(p);
3630 }
3631 }
3632 if let Some(p) = progress {
3633 nudge.push_str(&format!("\n\nYour progress so far:\n{p}"));
3634 }
3635 nudge
3636}
3637
3638fn cap_exit_tokens_hint(max_tool_rounds: usize, accumulated: Option<crate::TokenUsage>) -> String {
3644 match accumulated {
3645 Some(u) => format!(
3646 " ({} in / {} out tokens consumed across {max_tool_rounds} rounds)",
3647 u.input_tokens, u.output_tokens,
3648 ),
3649 None => String::new(),
3650 }
3651}
3652
3653fn cap_exit_advice(max_tool_rounds: usize, wasted_calls: usize) -> &'static str {
3654 if wasted_calls >= max_tool_rounds.max(1) {
3657 "most of those rounds were spent on tool calls that failed — the model \
3658 could not find a working edit/shell path, which is usually a tooling or \
3659 permissions issue rather than too few rounds; check `newt doctor`"
3660 } else {
3661 "raise [tui].max_tool_rounds in your config, or ask a more focused question"
3662 }
3663}
3664
3665fn cap_exit_progress_block(label: &str, progress: Option<&str>) -> String {
3666 match progress {
3667 Some(p) => format!("\n\n{label}:\n{p}"),
3668 None => String::new(),
3669 }
3670}
3671
3672fn cap_exit_fallback(
3673 max_tool_rounds: usize,
3674 accumulated: Option<crate::TokenUsage>,
3675 wasted_calls: usize,
3676 progress: Option<&str>,
3677) -> String {
3678 let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3679 let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3680 let salvaged = cap_exit_progress_block("Progress captured before the summary failed", progress);
3681 format!(
3682 "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}, \
3683 and the final summarization request also failed — {advice}){salvaged}"
3684 )
3685}
3686
3687fn cap_exit_action_handoff_fallback(
3688 max_tool_rounds: usize,
3689 accumulated: Option<crate::TokenUsage>,
3690 wasted_calls: usize,
3691 progress: Option<&str>,
3692) -> String {
3693 let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3694 let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3695 let salvaged = cap_exit_progress_block("Progress captured at the tool-call limit", progress);
3696 format!(
3697 "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}; \
3698 the final tools-disabled summary described future tool actions instead \
3699 of final state, so Newt preserved the verified progress instead of \
3700 accepting that handoff — {advice}){salvaged}"
3701 )
3702}
3703
3704fn cap_exit_summary_is_action_handoff(content: &str) -> bool {
3705 crate::NudgeClassifier::load_default()
3706 .classify(content)
3707 .class
3708 == crate::NudgeClass::PendingAction
3709 || looks_like_unverified_stale_file_blocker(content)
3710}
3711
3712struct CapExit {
3718 max_tool_rounds: usize,
3719 accumulated: Option<crate::TokenUsage>,
3720 wasted_calls: usize,
3721 progress: Option<String>,
3722 observed: Vec<String>,
3723}
3724
3725async fn final_summary_ollama(
3731 client: &reqwest::Client,
3732 chat_url: &str,
3733 model: &str,
3734 mut messages: Vec<serde_json::Value>,
3735 cap: CapExit,
3736) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3737 let CapExit {
3738 max_tool_rounds,
3739 accumulated,
3740 wasted_calls,
3741 progress,
3742 observed,
3743 } = cap;
3744 messages.push(serde_json::json!({
3745 "role": "user",
3746 "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3747 }));
3748 let body = serde_json::json!({
3750 "model": model,
3751 "messages": &messages,
3752 "stream": false,
3753 });
3754 let retry = tui_retry_policy();
3755 let result = with_backoff_notify(
3756 &retry,
3757 || async {
3758 let resp = client
3759 .post(chat_url)
3760 .json(&body)
3761 .send()
3762 .await
3763 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3764 if !resp.status().is_success() {
3765 let status = resp.status();
3766 let text = resp.text().await.unwrap_or_default();
3767 anyhow::bail!("Ollama {status}: {text}");
3768 }
3769 resp.json::<serde_json::Value>()
3770 .await
3771 .map_err(anyhow::Error::from)
3772 },
3773 |_, _| {}, )
3775 .await;
3776 match result {
3777 Ok(json) => {
3778 let (content, _reasoning) = crate::reasoning::split_reasoning(
3782 json["message"]["content"].as_str().unwrap_or(""),
3783 );
3784 let total = merge_round_usage(accumulated, ollama_usage(&json));
3785 if content.is_empty() {
3786 Ok((
3787 cap_exit_fallback(
3788 max_tool_rounds,
3789 accumulated,
3790 wasted_calls,
3791 progress.as_deref(),
3792 ),
3793 false,
3794 accumulated,
3795 ))
3796 } else if cap_exit_summary_is_action_handoff(&content) {
3797 Ok((
3798 cap_exit_action_handoff_fallback(
3799 max_tool_rounds,
3800 accumulated,
3801 wasted_calls,
3802 progress.as_deref(),
3803 ),
3804 false,
3805 total,
3806 ))
3807 } else {
3808 Ok((content, false, total))
3809 }
3810 }
3811 Err(_) => Ok((
3814 cap_exit_fallback(
3815 max_tool_rounds,
3816 accumulated,
3817 wasted_calls,
3818 progress.as_deref(),
3819 ),
3820 false,
3821 accumulated,
3822 )),
3823 }
3824}
3825
3826async fn final_summary_openai(
3831 client: &reqwest::Client,
3832 chat_url: &str,
3833 model: &str,
3834 api_key: Option<&str>,
3835 mut messages: Vec<serde_json::Value>,
3836 cap: CapExit,
3837) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3838 let CapExit {
3839 max_tool_rounds,
3840 accumulated,
3841 wasted_calls,
3842 progress,
3843 observed,
3844 } = cap;
3845 messages.push(serde_json::json!({
3846 "role": "user",
3847 "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3848 }));
3849 let body = serde_json::json!({
3851 "model": model,
3852 "messages": &messages,
3853 "stream": false,
3854 });
3855 let retry = tui_retry_policy();
3856 let result = with_backoff_notify(
3857 &retry,
3858 || async {
3859 let mut req = client.post(chat_url).json(&body);
3860 if let Some(key) = api_key {
3861 req = req.bearer_auth(key);
3862 }
3863 let resp = req
3864 .send()
3865 .await
3866 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3867 if !resp.status().is_success() {
3868 let status = resp.status();
3869 let text = resp.text().await.unwrap_or_default();
3870 anyhow::bail!("inference endpoint {status}: {text}");
3871 }
3872 resp.json::<serde_json::Value>()
3873 .await
3874 .map_err(anyhow::Error::from)
3875 },
3876 |_, _| {},
3877 )
3878 .await;
3879 match result {
3880 Ok(json) => {
3881 let (content, _reasoning) = crate::reasoning::split_reasoning(
3883 json["choices"][0]["message"]["content"]
3884 .as_str()
3885 .unwrap_or(""),
3886 );
3887 let total = merge_round_usage(accumulated, openai_usage(&json["usage"]));
3888 if content.is_empty() {
3889 Ok((
3890 cap_exit_fallback(
3891 max_tool_rounds,
3892 accumulated,
3893 wasted_calls,
3894 progress.as_deref(),
3895 ),
3896 false,
3897 accumulated,
3898 ))
3899 } else if cap_exit_summary_is_action_handoff(&content) {
3900 Ok((
3901 cap_exit_action_handoff_fallback(
3902 max_tool_rounds,
3903 accumulated,
3904 wasted_calls,
3905 progress.as_deref(),
3906 ),
3907 false,
3908 total,
3909 ))
3910 } else {
3911 Ok((content, false, total))
3912 }
3913 }
3914 Err(_) => Ok((
3915 cap_exit_fallback(
3916 max_tool_rounds,
3917 accumulated,
3918 wasted_calls,
3919 progress.as_deref(),
3920 ),
3921 false,
3922 accumulated,
3923 )),
3924 }
3925}
3926
3927pub async fn openai_chat_complete(
3935 ctx: ChatCtx<'_>,
3936 mcp: &mut dyn McpTools,
3937) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
3938 let ChatCtx {
3939 url,
3940 model,
3941 kind: _,
3942 api_key,
3943 messages: mem_messages,
3944 task,
3945 workspace,
3946 color,
3947 markdown: _,
3948 tool_offload,
3949 spill_store,
3950 compaction_store,
3951 scratchpad,
3952 scratchpad_store,
3953 code_search,
3954 experience_store,
3955 step_ledger,
3956 caveats,
3957 persona_tools,
3958 max_tool_rounds,
3959 workflow_grace_rounds,
3960 narration_nudge_cap,
3961 tool_output_lines,
3962 debug,
3963 trace,
3964 num_ctx,
3965 connect_timeout_secs,
3966 inference_timeout_secs,
3967 mid_loop_trim_threshold,
3968 mid_loop_trim_tokens,
3969 max_ok_input,
3970 build_check_cmd,
3971 safe_context,
3972 recover_cw_400,
3973 mut note_sink,
3974 mut note_nudge,
3975 recall_source,
3976 memory_source,
3977 summarizer,
3978 compress_state,
3979 mut tool_events,
3980 mut phantom_reaches,
3981 mut end_reason,
3982 mut permission_gate,
3983 mut on_round_usage,
3984 estimate_ratio,
3985 estimation,
3986 summary_input_cap_floor_chars,
3987 input_ceiling_pct,
3988 low_budget_pct,
3989 exec_floor,
3990 write_ledger,
3991 cancel,
3992 git_tool,
3993 crew_runner,
3994 } = ctx;
3995 let mut local_compress_state = CompressState::new();
3997 let compress_state = match compress_state {
3998 Some(s) => s,
3999 None => &mut local_compress_state,
4000 };
4001 let client = reqwest::Client::builder()
4002 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
4003 .timeout(std::time::Duration::from_secs(inference_timeout_secs))
4004 .build()?;
4005 let chat_url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
4006 let retry = tui_retry_policy();
4007 let advertise_save_note = note_sink.is_some();
4011 let advertise_recall = recall_source.is_some();
4012 let advertise_memory_fetch = memory_source.is_some();
4013 let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
4015 let advertise_code_search = code_search.is_some();
4017 let advertise_experiential = experience_store.is_some();
4019 let advertise_scheduled = step_ledger.is_some();
4021 let advertise_git = git_tool.is_some();
4022 let advertise_team = crew_runner.is_some();
4023
4024 let mut messages: Vec<serde_json::Value> = mem_messages
4025 .iter()
4026 .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
4027 .collect();
4028
4029 if note_sink.is_some() {
4031 if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
4032 append_nudge_line(&mut messages, &line);
4033 }
4034 }
4035
4036 let mut accumulated_usage: Option<crate::TokenUsage> = None;
4037 let mut hallucination_count: u32 = 0;
4038 let mut repeat_calls = RepeatCallGuard::default();
4040 let mut cw_retries: u32 = 0;
4042 let mut tools_supported = true;
4045 let mut tools_unsupported_notified = false;
4046 let mut send_budget: Option<usize> =
4051 initial_send_budget(max_ok_input, safe_context, None, input_ceiling_pct);
4052 let mut send_budget_authoritative = safe_context.is_some();
4058 let tools = merged_tool_definitions(
4060 mcp,
4061 advertise_save_note,
4062 advertise_recall,
4063 advertise_memory_fetch,
4064 advertise_git,
4065 advertise_team,
4066 advertise_scratchpad,
4067 advertise_code_search,
4068 advertise_experiential,
4069 advertise_scheduled,
4070 );
4071 let tools = filter_advertised_tools(tools, persona_tools);
4075 let tool_tokens = estimate_value_tokens(&tools, estimation);
4076 let cal = sanitize_estimate_ratio(estimate_ratio);
4079 let tool_tokens_real = calibrate_up(tool_tokens, cal);
4080 let mut prompt_tracker = PromptTracker::new();
4082 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
4083
4084 let mut observed_paths = claim_check::ObservedPaths::default();
4086 let observed_resolver = claim_check::workspace_resolver(workspace);
4087
4088 let mut narration_nudges: usize = 0;
4090 let mut pending_plan_nudges: usize = 0;
4092 let mut stale_file_nudges: usize = 0;
4094 let nudge_classifier = crate::NudgeClassifier::load_default();
4095 let workflow_steerer = crate::WorkflowSteerer::load_default();
4096 let mut workflow_runtime = WorkflowRuntimeState::default();
4097 workflow_runtime.set_progress_horizon(
4099 workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
4100 );
4101
4102 let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
4106 let mut workflow_grace_active = false;
4107 let mut current_tool_round_limit = max_tool_rounds;
4108 'round_loop: for round in 0..hard_tool_rounds {
4109 if round >= current_tool_round_limit {
4110 if workflow_grace_active {
4111 break;
4112 }
4113 let Some(nudge) = workflow_runtime.cap_grace_nudge(
4114 step_ledger,
4115 max_tool_rounds,
4116 workflow_grace_rounds,
4117 ) else {
4118 break;
4119 };
4120 workflow_grace_active = true;
4121 current_tool_round_limit = hard_tool_rounds;
4122 if debug {
4123 print_debug(
4124 "workflow progress at soft round cap — granting configured grace window",
4125 color,
4126 );
4127 }
4128 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4129 }
4130 if is_cancelled(cancel) {
4136 return Ok((String::new(), false, accumulated_usage, hallucination_count));
4137 }
4138 if round > 0 && color {
4139 execute!(
4140 io::stdout(),
4141 SetForegroundColor(CtColor::DarkGrey),
4142 Print("…\n"),
4143 ResetColor
4144 )
4145 .ok();
4146 }
4147
4148 if round > 0 {
4151 if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
4152 messages.push(serde_json::json!({ "role": "user", "content": ptr }));
4153 }
4154 if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
4155 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4156 }
4157 }
4158
4159 {
4163 let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
4166 let message_tokens = estimate_tokens(&messages, estimation);
4169 if let Some(trigger) = compression_trigger(
4170 messages.len(),
4171 current,
4172 message_tokens,
4173 mid_loop_trim_threshold,
4174 mid_loop_trim_tokens,
4175 send_budget,
4176 tool_tokens_real,
4177 ) {
4178 let pipeline_budget = if trigger.hard_budget {
4181 calibrate_down(trigger.budget, cal)
4182 } else {
4183 trigger.budget
4184 };
4185 let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
4189 let outcome = compress(
4190 CompressRequest {
4191 messages: &messages,
4192 budget: pipeline_budget,
4193 max_messages: trigger.max_messages,
4194 task,
4195 hard_budget: trigger.hard_budget,
4196 authoritative: token_fired || send_budget_authoritative,
4197 focus: None,
4198 est: estimation,
4199 summary_input_cap_floor_chars,
4200 compaction_store,
4201 },
4202 summarizer,
4203 compress_state,
4204 )
4205 .await;
4206 if let Some(notice) = outcome.notice {
4207 print_harness_notice(¬ice, color);
4208 }
4209 if outcome.action == CompressAction::Refused {
4210 anyhow::bail!(
4211 "context (~{current} tokens) exceeds the model's input budget and \
4212 auto-compression is disabled after repeated ineffective passes — \
4213 start a new conversation or ask a more focused question, or run \
4214 `newt tunings reset {model}` if this model's learned budget looks wrong"
4215 );
4216 }
4217 if outcome.fired {
4218 let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
4222 ", still over budget"
4223 } else {
4224 ""
4225 };
4226 emit_compression_notice(
4227 color,
4228 outcome.tokens_before,
4229 outcome.tokens_after,
4230 outcome.action,
4231 suffix,
4232 );
4233 if debug {
4234 print_debug(
4235 &format!(
4236 "compression: {} → {} messages (budget ~{} tokens, \
4237 +~{tool_tokens} tool-schema tokens ride along)",
4238 messages.len(),
4239 outcome.messages.len(),
4240 pipeline_budget,
4241 ),
4242 color,
4243 );
4244 }
4245 messages = outcome.messages;
4246 prompt_tracker.invalidate();
4247 apply_post_compaction_continuation(
4248 &mut messages,
4249 &mut narration_nudges,
4250 outcome.action,
4251 step_ledger,
4252 round > 0,
4253 );
4254 }
4255 }
4256 }
4257
4258 let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
4261
4262 let mut body = serde_json::json!({
4265 "model": model,
4266 "messages": messages,
4267 "tools": tools.clone(),
4268 "tool_choice": "auto",
4269 "stream": false,
4270 });
4271 if !tools_supported {
4274 if let Some(o) = body.as_object_mut() {
4275 o.remove("tools");
4276 o.remove("tool_choice");
4277 }
4278 }
4279 let _ = num_ctx; let dispatch = with_backoff_notify(
4286 &retry,
4287 || async {
4288 let mut req = client.post(&chat_url).json(&body);
4289 if let Some(key) = api_key {
4290 req = req.bearer_auth(key);
4291 }
4292 let resp = req
4293 .send()
4294 .await
4295 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
4296 if !resp.status().is_success() {
4297 let status = resp.status();
4298 let text = resp.text().await.unwrap_or_default();
4299 anyhow::bail!("inference endpoint {status}: {text}");
4300 }
4301 resp.json::<serde_json::Value>()
4302 .await
4303 .map_err(anyhow::Error::from)
4304 },
4305 |attempt, delay| print_retry_indicator(attempt, delay, color),
4306 )
4307 .await;
4308 let json: serde_json::Value = match dispatch {
4309 Ok(j) => j,
4310 Err(e) => {
4311 if tools_supported && is_tools_unsupported_error(&e) {
4316 tools_supported = false;
4317 if !tools_unsupported_notified {
4318 tools_unsupported_notified = true;
4319 print_newt(
4320 &format!(
4321 "{model} does not support tools — tools disabled for this session"
4322 ),
4323 color,
4324 false,
4325 );
4326 }
4327 continue 'round_loop;
4328 }
4329 if cw_retries < 2 {
4333 if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
4334 emit_overflow_notice(
4335 color,
4336 accumulated_usage.as_ref(),
4337 Some(new_cap),
4338 model,
4339 cw_retries + 1,
4340 );
4341 send_budget = Some(new_cap as usize);
4342 send_budget_authoritative = true;
4345 let outcome = compress(
4346 CompressRequest {
4347 messages: &messages,
4351 budget: calibrate_down(
4352 (new_cap as usize).saturating_sub(tool_tokens_real),
4353 cal,
4354 ),
4355 max_messages: None,
4356 task,
4357 hard_budget: true,
4358 authoritative: true,
4359 focus: None,
4360 est: estimation,
4361 summary_input_cap_floor_chars,
4362 compaction_store,
4363 },
4364 summarizer,
4365 compress_state,
4366 )
4367 .await;
4368 if let Some(notice) = outcome.notice {
4369 print_harness_notice(¬ice, color);
4370 }
4371 if outcome.action == CompressAction::Refused {
4372 return Err(e);
4374 }
4375 if outcome.fired {
4376 messages = outcome.messages;
4377 prompt_tracker.invalidate();
4378 apply_post_compaction_continuation(
4379 &mut messages,
4380 &mut narration_nudges,
4381 outcome.action,
4382 step_ledger,
4383 round > 0,
4384 );
4385 }
4386 cw_retries += 1;
4387 continue 'round_loop;
4388 }
4389 }
4390 return Err(e);
4391 }
4392 };
4393 let round_usage = openai_usage(&json["usage"]);
4396 if let Some(u) = round_usage {
4397 prompt_tracker.record(u.input_tokens, messages.len());
4398 }
4399 accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
4400
4401 let truncation_suspect = false;
4405 if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
4406 let raised = u.input_tokens as usize;
4407 if raised > budget {
4408 send_budget = Some(raised);
4409 if debug {
4410 print_debug(
4411 &format!(
4412 "send budget raised to ~{raised} tokens (backend accepted \
4413 {}-token prompt)",
4414 u.input_tokens
4415 ),
4416 color,
4417 );
4418 }
4419 }
4420 }
4421
4422 let message = &json["choices"][0]["message"];
4423
4424 let (oa_content, inline_reasoning) =
4430 crate::reasoning::split_reasoning(message["content"].as_str().unwrap_or(""));
4431 let separate_reasoning = message["reasoning_content"]
4432 .as_str()
4433 .map(str::trim)
4434 .filter(|s| !s.is_empty());
4435 if debug && (separate_reasoning.is_some() || inline_reasoning.is_some()) {
4436 let n = separate_reasoning
4437 .map(str::len)
4438 .or_else(|| inline_reasoning.as_deref().map(str::len))
4439 .unwrap_or(0);
4440 print_debug(
4441 &format!("reasoning ({n} chars) surfaced to the trace, not the answer"),
4442 color,
4443 );
4444 }
4445 let native_calls = message["tool_calls"].as_array();
4446 let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
4452 tool_recovery::recover_tool_calls(&oa_content)
4453 } else {
4454 tool_recovery::Recovery::default()
4455 };
4456 let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
4457 Some(t) if !t.is_empty() => Some(t),
4458 _ if !recovered.calls.is_empty() => Some(&recovered.calls),
4459 _ => None,
4460 };
4461 let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
4462 if debug && !recovered.calls.is_empty() {
4463 print_debug(
4464 &format!(
4465 "recovered {} tool call(s) from content (non-native emission)",
4466 recovered.calls.len()
4467 ),
4468 color,
4469 );
4470 }
4471
4472 if debug {
4473 let excerpt: String = oa_content.chars().take(80).collect();
4474 let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
4475 let usage_str = match round_usage {
4476 Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
4477 None => "no usage".into(),
4478 };
4479 print_debug(
4480 &format!(
4481 "round {round}: tool_calls={tc_count} usage=[{usage_str}] content={excerpt:?}"
4482 ),
4483 color,
4484 );
4485 }
4486
4487 if !has_tools {
4488 if recovered.tool_shaped {
4491 hallucination_count += 1;
4492 if debug {
4493 print_debug(
4494 "format-hallucination: tool call emitted as unrecoverable text",
4495 color,
4496 );
4497 }
4498 }
4499 let content = oa_content.clone();
4500 if content.is_empty() && debug {
4501 print_debug(
4502 "empty content with no tool calls — model produced nothing",
4503 color,
4504 );
4505 }
4506 let nudge_classification =
4510 (!content.is_empty()).then(|| nudge_classifier.classify(&content));
4511 let workflow_classifier_text = workflow_classifier_text(&messages, &content);
4512 let workflow_hint = nudge_classification
4513 .as_ref()
4514 .filter(|classification| classification.is_plan_update())
4515 .and_then(|_| workflow_steerer.plan_update_hint(&workflow_classifier_text));
4516 let classifier_plan_direction = nudge_classification
4517 .as_ref()
4518 .filter(|classification| classification.is_plan_update())
4519 .and_then(|_| nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate));
4520 let plan_nudge_hint =
4521 combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
4522 if !content.is_empty() && round + 1 < current_tool_round_limit {
4523 if let Some(nudge) = workflow_runtime.rediscovery_nudge(
4524 nudge_classification.as_ref(),
4525 &content,
4526 step_ledger,
4527 ) {
4528 if debug {
4529 print_debug(
4530 "workflow evidence rediscovery — nudging toward active repair",
4531 color,
4532 );
4533 }
4534 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4535 messages.push(serde_json::json!({
4536 "role": "user",
4537 "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, nudge)
4538 }));
4539 continue 'round_loop;
4540 }
4541 }
4542 if !content.is_empty()
4543 && pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
4544 && round + 1 < current_tool_round_limit
4545 {
4546 let needs_plan_update = nudge_classification
4547 .as_ref()
4548 .is_some_and(|c| c.is_plan_update());
4549 if let Some(nudge) = pending_plan_completion_nudge(
4550 step_ledger,
4551 needs_plan_update,
4552 plan_nudge_hint.as_deref(),
4553 ) {
4554 if debug {
4555 print_debug(
4556 "active plan has unfinished steps — nudging before final answer",
4557 color,
4558 );
4559 }
4560 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4561 messages.push(serde_json::json!({
4562 "role": "user",
4563 "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, nudge)
4564 }));
4565 pending_plan_nudges += 1;
4566 continue 'round_loop;
4567 }
4568 }
4569 if !content.is_empty()
4570 && stale_file_nudges < STALE_FILE_NUDGE_CAP
4571 && round + 1 < current_tool_round_limit
4572 && looks_like_unverified_stale_file_blocker(&content)
4573 {
4574 if debug {
4575 print_debug(
4576 "unverified stale-file blocker — nudging to check ground truth",
4577 color,
4578 );
4579 }
4580 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4581 messages.push(serde_json::json!({
4582 "role": "user",
4583 "content": format!(
4584 "{} {}",
4585 compress::LOOP_GUIDANCE_PREFIX,
4586 stale_file_ground_truth_nudge()
4587 ),
4588 }));
4589 stale_file_nudges += 1;
4590 continue 'round_loop;
4591 }
4592 if !content.is_empty()
4593 && narration_nudges < narration_nudge_cap
4594 && round + 1 < current_tool_round_limit
4595 && nudge_classification
4596 .as_ref()
4597 .is_some_and(|c| c.is_pending_action())
4598 {
4599 if debug {
4600 print_debug(
4601 "narrated intent with no tool call — nudging to act and continuing",
4602 color,
4603 );
4604 }
4605 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4606 let direction = if narration_nudges == 0 {
4610 nudge_classification
4611 .as_ref()
4612 .and_then(|classification| {
4613 nudge_classifier.direction_for(classification.class)
4614 })
4615 .map(str::to_string)
4616 .unwrap_or_else(narration_action_nudge)
4617 } else {
4618 escalated_narration_action_nudge(
4619 narration_nudges + 1,
4620 narration_nudge_cap,
4621 step_ledger,
4622 )
4623 };
4624 messages.push(serde_json::json!({
4625 "role": "user",
4626 "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, direction),
4627 }));
4628 narration_nudges += 1;
4629 continue 'round_loop;
4630 }
4631 if !content.is_empty() {
4634 emit_accepted(
4635 &mut on_round_usage,
4636 round_usage,
4637 truncation_suspect,
4638 round_est_raw,
4639 );
4640 }
4641 let accepted_reason = if content.is_empty() {
4644 crate::TurnEndReason::Empty
4645 } else if nudge_classification
4646 .as_ref()
4647 .is_some_and(|c| c.is_pending_action())
4648 {
4649 if round + 1 >= current_tool_round_limit {
4650 crate::TurnEndReason::NarrationFinalRound
4651 } else {
4652 crate::TurnEndReason::NarrationCapExhausted
4653 }
4654 } else {
4655 crate::TurnEndReason::Completed
4656 };
4657 if debug && accepted_reason != crate::TurnEndReason::Completed {
4658 print_debug(
4659 &format!("no-tool reply accepted as final answer ({accepted_reason:?})"),
4660 color,
4661 );
4662 }
4663 if let Some(slot) = &mut end_reason {
4664 **slot = Some(accepted_reason);
4665 }
4666 let out = if content.is_empty() {
4667 "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
4668 } else {
4669 content
4670 };
4671 return Ok((out, false, accumulated_usage, hallucination_count));
4672 }
4673
4674 emit_accepted(
4678 &mut on_round_usage,
4679 round_usage,
4680 truncation_suspect,
4681 round_est_raw,
4682 );
4683 let mut assistant_turn = message.clone();
4687 assistant_turn["content"] = serde_json::Value::String(oa_content.clone());
4688 if let Some(obj) = assistant_turn.as_object_mut() {
4689 obj.remove("reasoning_content");
4690 }
4691 messages.push(assistant_turn);
4692 let mut round_modified_workspace = false;
4693 let mut round_progress = false;
4694 for tc in tool_calls.unwrap() {
4695 let id = tc["id"].as_str().unwrap_or("");
4696 let anthropic_native = tc["function"].is_null();
4703 if anthropic_native && debug {
4704 let raw_name = tc["name"].as_str().unwrap_or("<missing>");
4705 print_debug(
4706 &format!(
4707 "tool call in Anthropic-native format inside tool_calls array \
4708 (no `function` key) — name={raw_name:?}"
4709 ),
4710 color,
4711 );
4712 }
4713 let name = if anthropic_native {
4714 tc["name"].as_str().unwrap_or("unknown")
4715 } else {
4716 tc["function"]["name"].as_str().unwrap_or("unknown")
4717 };
4718 let args = if anthropic_native {
4719 tc["input"].clone()
4720 } else {
4721 match &tc["function"]["arguments"] {
4722 serde_json::Value::String(s) => {
4723 serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
4724 }
4725 v => v.clone(),
4726 }
4727 };
4728 if trace {
4729 print_trace(
4730 &format!(
4731 "raw tool_call element: {}",
4732 serde_json::to_string(tc).unwrap_or_else(|_| "?".into())
4733 ),
4734 color,
4735 );
4736 }
4737 let mcp_handles = mcp.handles(name);
4738 if debug {
4739 print_debug(
4740 &format!("dispatching tool name={name:?} mcp_handles={mcp_handles}"),
4741 color,
4742 );
4743 }
4744 if is_hallucination(name, &args) {
4745 hallucination_count += 1;
4746 }
4747 if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
4751 if let Some(rec) = tool_events.as_deref_mut() {
4752 rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
4753 }
4754 messages.push(serde_json::json!({
4755 "role": "tool",
4756 "tool_call_id": id,
4757 "content": steer,
4758 }));
4759 continue;
4760 }
4761 if name == "save_note" && note_sink.is_some() {
4764 if let Some(n) = note_nudge.as_deref_mut() {
4765 n.note_saved();
4766 }
4767 }
4768 ledger_note_write(write_ledger, name, &args, workspace);
4771 let tool_t0 = std::time::Instant::now();
4772 let result = if tools::is_context_remaining_call(name) {
4777 let report = budget::render_context_budget(
4778 prompt_tracker.current(&messages, Some(&tools), cal, estimation),
4779 num_ctx_input_ceiling(num_ctx, input_ceiling_pct),
4780 num_ctx,
4781 input_ceiling_pct as usize,
4782 low_budget_pct,
4783 );
4784 display::print_tool_call("get_context_remaining", "", color);
4785 display::print_tool_output(&report, tool_output_lines, color);
4786 report
4787 } else {
4788 execute_tool_with_offload(
4789 name,
4790 &args,
4791 workspace,
4792 color,
4793 tool_output_lines,
4794 caveats,
4795 mcp,
4796 build_check_cmd.as_deref(),
4797 note_sink
4801 .as_deref_mut()
4802 .map(|s| &mut *s as &mut dyn NoteSink),
4803 recall_source,
4804 memory_source,
4805 permission_gate
4807 .as_deref_mut()
4808 .map(|g| &mut *g as &mut dyn PermissionGate),
4809 exec_floor,
4811 git_tool,
4813 crew_runner,
4815 scratchpad_store,
4816 code_search,
4817 experience_store,
4818 step_ledger,
4819 tool_offload,
4820 spill_store,
4821 persona_tools,
4822 )
4823 .await
4824 };
4825 if debug {
4826 let excerpt: String = result.chars().take(120).collect();
4827 print_debug(&format!("tool result: {excerpt:?}"), color);
4828 }
4829 let ok = tools::tool_result_ok(&result);
4834 if ok && is_workspace_write_call(name) {
4835 round_modified_workspace = true;
4836 }
4837 if ok && meaningful_workflow_progress(name, &result) {
4838 round_progress = true;
4839 }
4840 repeat_calls.record(name, &args, ok, &result);
4841 if workflow_runtime.record_tool_result(&result) {
4842 round_progress = true;
4843 }
4844 if let Some(rec) = tool_events.as_deref_mut() {
4845 rec.push(crate::ToolEvent::from_call(
4846 name,
4847 &args,
4848 ok,
4849 u64::try_from(tool_t0.elapsed().as_millis()).ok(),
4850 ));
4851 }
4852 if let Some(pr) = phantom_reaches.as_deref_mut() {
4859 if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
4860 .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
4861 {
4862 pr.push(crate::PhantomReach {
4863 name_as_called: name.to_string(),
4864 resolution,
4865 active_context_features: Vec::new(),
4866 });
4867 }
4868 }
4869 observed_paths.record(&result, &observed_resolver);
4871 messages.push(serde_json::json!({
4872 "role": "tool",
4873 "tool_call_id": id,
4874 "content": maybe_offload_tool_result(name, result, tool_offload, spill_store),
4876 }));
4877 }
4878 workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
4879 }
4880
4881 let trimmed = trim_for_summary(&messages, 2, 6);
4884 let progress = cap_exit_progress(step_ledger, scratchpad_store);
4886 let (text, streamed, usage) = final_summary_openai(
4887 &client,
4888 &chat_url,
4889 model,
4890 api_key,
4891 trimmed,
4892 CapExit {
4893 max_tool_rounds,
4894 accumulated: accumulated_usage,
4895 wasted_calls: repeat_calls.total_failures(),
4896 progress,
4897 observed: observed_paths.into_vec(),
4898 },
4899 )
4900 .await?;
4901 let text = claim_check::annotate_against_workspace(text, workspace);
4903 if let Some(slot) = &mut end_reason {
4904 **slot = Some(crate::TurnEndReason::RoundCap);
4905 }
4906 Ok((text, streamed, usage, hallucination_count))
4907}
4908
4909fn responses_api_selected() -> bool {
4923 std::env::var("NEWT_OPENAI_API")
4924 .ok()
4925 .is_some_and(|v| v.eq_ignore_ascii_case("responses"))
4926}
4927
4928fn build_responses_input(
4934 messages: &[serde_json::Value],
4935) -> (Option<String>, Vec<serde_json::Value>) {
4936 let mut instructions: Vec<String> = Vec::new();
4937 let mut input: Vec<serde_json::Value> = Vec::new();
4938 for m in messages {
4939 if m.get("type").is_some() {
4940 input.push(m.clone());
4941 continue;
4942 }
4943 let role = m["role"].as_str().unwrap_or("user");
4944 let content = m["content"].as_str().unwrap_or("");
4945 match role {
4946 "system" | "developer" => instructions.push(content.to_string()),
4947 _ => input.push(serde_json::json!({ "role": role, "content": content })),
4948 }
4949 }
4950 let ins = (!instructions.is_empty()).then(|| instructions.join("\n\n"));
4951 (ins, input)
4952}
4953
4954fn tools_to_responses(tools: &serde_json::Value) -> Vec<serde_json::Value> {
4959 tools
4960 .as_array()
4961 .map(|arr| {
4962 arr.iter()
4963 .map(|t| {
4964 let f = &t["function"];
4965 if f.is_object() {
4966 serde_json::json!({
4967 "type": "function",
4968 "name": f["name"],
4969 "description": f["description"],
4970 "parameters": f["parameters"],
4971 })
4972 } else {
4973 t.clone()
4974 }
4975 })
4976 .collect()
4977 })
4978 .unwrap_or_default()
4979}
4980
4981fn parse_responses_output(json: &serde_json::Value) -> (String, Vec<serde_json::Value>) {
4988 let mut text = String::new();
4989 let mut calls = Vec::new();
4990 if let Some(items) = json["output"].as_array() {
4991 for item in items {
4992 match item["type"].as_str() {
4993 Some("message") => {
4994 if let Some(parts) = item["content"].as_array() {
4995 for p in parts {
4996 if let Some(t) = p["text"].as_str() {
4997 text.push_str(t);
4998 }
4999 }
5000 }
5001 }
5002 Some("function_call") => calls.push(item.clone()),
5003 _ => {}
5004 }
5005 }
5006 }
5007 if text.is_empty() {
5008 if let Some(t) = json["output_text"].as_str() {
5009 text.push_str(t);
5010 }
5011 }
5012 (text, calls)
5013}
5014
5015fn responses_usage(v: &serde_json::Value) -> Option<crate::TokenUsage> {
5018 let input = v["input_tokens"].as_u64().map(|n| n as u32);
5019 let output = v["output_tokens"].as_u64().map(|n| n as u32);
5020 input.zip(output).map(|(i, o)| crate::TokenUsage {
5021 input_tokens: i,
5022 output_tokens: o,
5023 })
5024}
5025
5026pub async fn openai_responses_complete(
5032 ctx: ChatCtx<'_>,
5033 mcp: &mut dyn McpTools,
5034) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
5035 let ChatCtx {
5036 url,
5037 model,
5038 kind: _,
5039 api_key,
5040 messages: mem_messages,
5041 task: _,
5042 workspace,
5043 color,
5044 markdown: _,
5045 tool_offload,
5046 spill_store,
5047 compaction_store,
5048 scratchpad,
5049 scratchpad_store,
5050 code_search,
5051 experience_store,
5052 step_ledger,
5053 caveats,
5054 persona_tools,
5055 max_tool_rounds,
5056 workflow_grace_rounds: _,
5057 narration_nudge_cap: _,
5058 tool_output_lines,
5059 debug,
5060 trace,
5061 num_ctx,
5065 connect_timeout_secs,
5066 inference_timeout_secs,
5067 mid_loop_trim_threshold: _,
5068 mid_loop_trim_tokens: _,
5069 max_ok_input: _,
5070 build_check_cmd,
5071 safe_context: _,
5072 recover_cw_400: _,
5073 mut note_sink,
5074 mut note_nudge,
5075 recall_source,
5076 memory_source,
5077 summarizer: _,
5078 compress_state: _,
5079 mut tool_events,
5080 mut phantom_reaches,
5081 end_reason: _,
5082 mut permission_gate,
5083 on_round_usage: _,
5084 estimate_ratio: _,
5085 estimation,
5087 summary_input_cap_floor_chars: _,
5088 input_ceiling_pct,
5089 low_budget_pct,
5090 exec_floor,
5091 write_ledger,
5092 cancel,
5093 git_tool,
5094 crew_runner,
5095 } = ctx;
5096 let _ = compaction_store;
5099
5100 let client = reqwest::Client::builder()
5101 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
5102 .timeout(std::time::Duration::from_secs(inference_timeout_secs))
5103 .build()?;
5104 let responses_url = format!("{}/v1/responses", url.trim_end_matches('/'));
5105 let retry = tui_retry_policy();
5106 let advertise_save_note = note_sink.is_some();
5107 let advertise_recall = recall_source.is_some();
5108 let advertise_memory_fetch = memory_source.is_some();
5109 let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
5111 let advertise_code_search = code_search.is_some();
5113 let advertise_experiential = experience_store.is_some();
5115 let advertise_scheduled = step_ledger.is_some();
5117 let advertise_git = git_tool.is_some();
5118 let advertise_team = crew_runner.is_some();
5119
5120 let msgs_json: Vec<serde_json::Value> = mem_messages
5121 .iter()
5122 .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
5123 .collect();
5124 let (instructions, mut input) = build_responses_input(&msgs_json);
5125 let tools_chat = merged_tool_definitions(
5126 mcp,
5127 advertise_save_note,
5128 advertise_recall,
5129 advertise_memory_fetch,
5130 advertise_git,
5131 advertise_team,
5132 advertise_scratchpad,
5133 advertise_code_search,
5134 advertise_experiential,
5135 advertise_scheduled,
5136 );
5137 let tools_chat = filter_advertised_tools(tools_chat, persona_tools);
5140 let tools = tools_to_responses(&tools_chat);
5141
5142 let mut accumulated_usage: Option<crate::TokenUsage> = None;
5143 let mut hallucination_count: u32 = 0;
5144 let mut repeat_calls = RepeatCallGuard::default();
5146 let mut tools_supported = true;
5147 let mut tools_unsupported_notified = false;
5148
5149 let build_body = |input: &[serde_json::Value], with_tools: bool| {
5150 let mut body = serde_json::json!({ "model": model, "input": input, "stream": false });
5151 if let Some(ins) = &instructions {
5152 body["instructions"] = serde_json::json!(ins);
5153 }
5154 if with_tools && !tools.is_empty() {
5155 body["tools"] = serde_json::json!(tools);
5156 body["tool_choice"] = serde_json::json!("auto");
5157 }
5158 body
5159 };
5160
5161 for round in 0..max_tool_rounds {
5162 if is_cancelled(cancel) {
5163 return Ok((String::new(), false, accumulated_usage, hallucination_count));
5164 }
5165 let body = build_body(&input, tools_supported);
5166 let dispatch = with_backoff_notify(
5167 &retry,
5168 || async {
5169 let mut req = client.post(&responses_url).json(&body);
5170 if let Some(key) = api_key {
5171 req = req.bearer_auth(key);
5172 }
5173 let resp = req
5174 .send()
5175 .await
5176 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
5177 if !resp.status().is_success() {
5178 let status = resp.status();
5179 let text = resp.text().await.unwrap_or_default();
5180 anyhow::bail!("inference endpoint {status}: {text}");
5181 }
5182 resp.json::<serde_json::Value>()
5183 .await
5184 .map_err(anyhow::Error::from)
5185 },
5186 |attempt, delay| print_retry_indicator(attempt, delay, color),
5187 )
5188 .await;
5189
5190 let json = match dispatch {
5191 Ok(j) => j,
5192 Err(e) => {
5193 if tools_supported && is_tools_unsupported_error(&e) {
5194 tools_supported = false;
5195 if !tools_unsupported_notified {
5196 tools_unsupported_notified = true;
5197 print_newt(
5198 &format!(
5199 "{model} does not support tools — tools disabled for this session"
5200 ),
5201 color,
5202 false,
5203 );
5204 }
5205 continue;
5206 }
5207 return Err(e);
5208 }
5209 };
5210
5211 let round_usage = responses_usage(&json["usage"]);
5212 accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
5213 let (text, calls) = parse_responses_output(&json);
5214
5215 if debug {
5216 let excerpt: String = text.chars().take(80).collect();
5217 print_debug(
5218 &format!(
5219 "responses round {round}: function_calls={} content={excerpt:?}",
5220 calls.len()
5221 ),
5222 color,
5223 );
5224 }
5225
5226 if calls.is_empty() {
5227 let out = if text.is_empty() {
5228 "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
5229 } else {
5230 text
5231 };
5232 return Ok((out, false, accumulated_usage, hallucination_count));
5233 }
5234
5235 for call in &calls {
5238 input.push(call.clone());
5239 }
5240 for call in &calls {
5241 let call_id = call["call_id"]
5242 .as_str()
5243 .or_else(|| call["id"].as_str())
5244 .unwrap_or("");
5245 let name = call["name"].as_str().unwrap_or("unknown");
5246 let args = match &call["arguments"] {
5247 serde_json::Value::String(s) => {
5248 serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
5249 }
5250 v => v.clone(),
5251 };
5252 if trace {
5253 print_trace(
5254 &format!(
5255 "raw function_call: {}",
5256 serde_json::to_string(call).unwrap_or_else(|_| "?".into())
5257 ),
5258 color,
5259 );
5260 }
5261 if is_hallucination(name, &args) {
5262 hallucination_count += 1;
5263 }
5264 if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
5268 if let Some(rec) = tool_events.as_deref_mut() {
5269 rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
5270 }
5271 input.push(serde_json::json!({
5272 "type": "function_call_output",
5273 "call_id": call_id,
5274 "output": steer,
5275 }));
5276 continue;
5277 }
5278 if name == "save_note" && note_sink.is_some() {
5279 if let Some(n) = note_nudge.as_deref_mut() {
5280 n.note_saved();
5281 }
5282 }
5283 ledger_note_write(write_ledger, name, &args, workspace);
5284 let tool_t0 = std::time::Instant::now();
5285 let result = if tools::is_context_remaining_call(name) {
5290 let report = budget::render_context_budget(
5291 estimate_request_tokens(&input, Some(&tools_chat), estimation),
5292 num_ctx_input_ceiling(num_ctx, input_ceiling_pct),
5293 num_ctx,
5294 input_ceiling_pct as usize,
5295 low_budget_pct,
5296 );
5297 display::print_tool_call("get_context_remaining", "", color);
5298 display::print_tool_output(&report, tool_output_lines, color);
5299 report
5300 } else {
5301 execute_tool_with_offload(
5302 name,
5303 &args,
5304 workspace,
5305 color,
5306 tool_output_lines,
5307 caveats,
5308 mcp,
5309 build_check_cmd.as_deref(),
5310 note_sink
5311 .as_deref_mut()
5312 .map(|s| &mut *s as &mut dyn NoteSink),
5313 recall_source,
5314 memory_source,
5315 permission_gate
5316 .as_deref_mut()
5317 .map(|g| &mut *g as &mut dyn PermissionGate),
5318 exec_floor,
5319 git_tool,
5320 crew_runner,
5321 scratchpad_store,
5322 code_search,
5323 experience_store,
5324 step_ledger,
5325 tool_offload,
5326 spill_store,
5327 persona_tools,
5328 )
5329 .await
5330 };
5331 if debug {
5332 let excerpt: String = result.chars().take(120).collect();
5333 print_debug(&format!("tool result: {excerpt:?}"), color);
5334 }
5335 let ok = tools::tool_result_ok(&result);
5338 repeat_calls.record(name, &args, ok, &result);
5339 if let Some(rec) = tool_events.as_deref_mut() {
5340 rec.push(crate::ToolEvent::from_call(
5341 name,
5342 &args,
5343 ok,
5344 u64::try_from(tool_t0.elapsed().as_millis()).ok(),
5345 ));
5346 }
5347 if let Some(pr) = phantom_reaches.as_deref_mut() {
5354 if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
5355 .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
5356 {
5357 pr.push(crate::PhantomReach {
5358 name_as_called: name.to_string(),
5359 resolution,
5360 active_context_features: Vec::new(),
5361 });
5362 }
5363 }
5364 input.push(serde_json::json!({
5365 "type": "function_call_output",
5366 "call_id": call_id,
5367 "output": maybe_offload_tool_result(name, result, tool_offload, spill_store),
5369 }));
5370 }
5371 }
5372
5373 let body = build_body(&input, false);
5376 let mut req = client.post(&responses_url).json(&body);
5377 if let Some(key) = api_key {
5378 req = req.bearer_auth(key);
5379 }
5380 let resp = req.send().await?;
5381 if !resp.status().is_success() {
5382 let status = resp.status();
5383 let text = resp.text().await.unwrap_or_default();
5384 anyhow::bail!("inference endpoint {status}: {text}");
5385 }
5386 let json: serde_json::Value = resp.json().await?;
5387 accumulated_usage = merge_round_usage(accumulated_usage, responses_usage(&json["usage"]));
5388 let (text, _) = parse_responses_output(&json);
5389 Ok((text, false, accumulated_usage, hallucination_count))
5390}
5391
5392fn thinking_stream_enabled() -> bool {
5395 match std::env::var("NEWT_THINKING").ok().as_deref() {
5396 Some("off") => return false,
5397 Some("on" | "stream") => return true,
5398 _ => {}
5399 }
5400 crate::Config::resolve()
5401 .ok()
5402 .and_then(|c| c.tui)
5403 .map(|t| t.thinking == crate::ThinkingMode::Stream)
5404 .unwrap_or(true)
5405}
5406
5407const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5409
5410fn format_spinner(frame: usize, secs: f32, label: &str, chars: usize) -> String {
5417 let braille = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()];
5418 if chars == 0 {
5419 format!("{braille} {label} {secs:.1}s")
5420 } else {
5421 format!("{braille} {label} {secs:.1}s · {chars} chars")
5422 }
5423}
5424
5425struct ThinkingSpinner {
5432 color: bool,
5433 frame: usize,
5434 start: std::time::Instant,
5435 chars: usize,
5436 line_buf: String,
5437 spinner_drawn: bool,
5438}
5439
5440impl ThinkingSpinner {
5441 fn new(color: bool) -> Self {
5442 Self {
5443 color,
5444 frame: 0,
5445 start: std::time::Instant::now(),
5446 chars: 0,
5447 line_buf: String::new(),
5448 spinner_drawn: false,
5449 }
5450 }
5451
5452 fn reasoning(&mut self, chunk: &str) {
5455 self.chars += chunk.chars().count();
5456 self.line_buf.push_str(chunk);
5457 while let Some(nl) = self.line_buf.find('\n') {
5458 let line: String = self.line_buf.drain(..=nl).collect();
5459 self.print_dim_line(line.trim_end_matches(['\n', '\r']));
5460 }
5461 self.redraw_spinner();
5462 }
5463
5464 fn print_dim_line(&mut self, line: &str) {
5465 self.erase_spinner();
5466 if line.trim().is_empty() {
5467 return;
5468 }
5469 let mut out = io::stdout();
5470 if self.color {
5471 let _ = execute!(
5472 out,
5473 SetForegroundColor(CtColor::DarkGrey),
5474 Print(" "),
5475 Print(line),
5476 ResetColor,
5477 Print("\n"),
5478 );
5479 } else {
5480 let _ = writeln!(out, " {line}");
5481 }
5482 }
5483
5484 fn redraw_spinner(&mut self) {
5485 self.frame = self.frame.wrapping_add(1);
5486 let line = format_spinner(
5487 self.frame,
5488 self.start.elapsed().as_secs_f32(),
5489 "thinking…",
5490 self.chars,
5491 );
5492 let fitted = display::fit_line(&line, display::term_cols());
5496 let mut out = io::stdout();
5497 if self.color {
5498 let _ = execute!(
5499 out,
5500 Print("\r\x1b[K"),
5501 SetForegroundColor(CtColor::DarkGrey),
5502 Print(&fitted.head),
5503 SetForegroundColor(display::FADE_CT),
5504 Print(&fitted.fade),
5505 Print(fitted.ellipsis),
5506 ResetColor,
5507 );
5508 } else {
5509 let _ = write!(out, "\r{}{}{}", fitted.head, fitted.fade, fitted.ellipsis);
5510 }
5511 let _ = out.flush();
5512 self.spinner_drawn = true;
5513 }
5514
5515 fn erase_spinner(&mut self) {
5516 if self.spinner_drawn {
5517 let _ = write!(io::stdout(), "\r\x1b[K");
5518 let _ = io::stdout().flush();
5519 self.spinner_drawn = false;
5520 }
5521 }
5522
5523 fn finish(&mut self) {
5526 if !self.line_buf.is_empty() {
5527 let tail = std::mem::take(&mut self.line_buf);
5528 self.print_dim_line(tail.trim_end_matches(['\n', '\r']));
5529 }
5530 self.erase_spinner();
5531 }
5532}
5533
5534async fn stream_response(
5539 resp: reqwest::Response,
5540 color: bool,
5541 show_thinking: bool,
5542 leading_reasoning: bool,
5543 cancel: Option<&std::sync::atomic::AtomicBool>,
5544 markdown: bool,
5545) -> anyhow::Result<(String, Option<crate::TokenUsage>)> {
5546 let mut spinner = show_thinking.then(|| ThinkingSpinner::new(color));
5547 let mut full = String::new();
5548 let mut started = false;
5549 let mut usage: Option<crate::TokenUsage> = None;
5550 let cols = display::term_cols();
5556 let mut md =
5557 markdown.then(|| MarkdownStreamWriter::new(io::stdout(), RenderOpts { color: true, cols }));
5558 let mut think = if leading_reasoning {
5563 crate::reasoning::ThinkFilter::with_leading_reasoning()
5564 } else {
5565 crate::reasoning::ThinkFilter::new()
5566 };
5567
5568 let mut resp = resp;
5569 while let Some(chunk) = match cancellable(cancel, resp.chunk()).await {
5572 Some(c) => c?,
5573 None => None,
5574 } {
5575 let text = String::from_utf8_lossy(&chunk);
5576 for line in text.lines() {
5577 if line.is_empty() {
5578 continue;
5579 }
5580 let Ok(json) = serde_json::from_str::<serde_json::Value>(line) else {
5581 continue;
5582 };
5583 let raw = json["message"]["content"].as_str().unwrap_or("");
5584 let (token, reasoning) = think.feed_split(raw);
5585 if let Some(sp) = spinner.as_mut() {
5588 if !reasoning.is_empty() {
5589 sp.reasoning(&reasoning);
5590 }
5591 if let Some(t) = json["message"]["thinking"].as_str() {
5592 if !t.is_empty() {
5593 sp.reasoning(t);
5594 }
5595 }
5596 }
5597 let token = token.as_str();
5598 if !token.is_empty() {
5599 if !started {
5600 if let Some(sp) = spinner.as_mut() {
5602 sp.finish();
5603 }
5604 if color {
5605 execute!(
5606 io::stdout(),
5607 SetForegroundColor(NEWT_ORANGE_CT),
5608 Print("▸ "),
5609 ResetColor,
5610 )
5611 .ok();
5612 } else {
5613 print!("▸ ");
5614 }
5615 started = true;
5616 }
5617 if let Some(w) = md.as_mut() {
5618 w.push(token).ok();
5619 } else {
5620 print!("{token}");
5621 io::stdout().flush().ok();
5622 }
5623 full.push_str(token);
5624 }
5625 if json["done"].as_bool().unwrap_or(false) {
5626 let input = json["prompt_eval_count"].as_u64().map(|n| n as u32);
5628 let output = json["eval_count"].as_u64().map(|n| n as u32);
5629 usage = input.zip(output).map(|(i, o)| crate::TokenUsage {
5630 input_tokens: i,
5631 output_tokens: o,
5632 });
5633 break;
5634 }
5635 }
5636 }
5637 let tail = think.finish();
5640 if !tail.is_empty() {
5641 if !started {
5642 if let Some(sp) = spinner.as_mut() {
5643 sp.finish();
5644 }
5645 print!("▸ ");
5646 started = true;
5647 }
5648 if let Some(w) = md.as_mut() {
5649 w.push(&tail).ok();
5650 } else {
5651 print!("{tail}");
5652 io::stdout().flush().ok();
5653 }
5654 full.push_str(&tail);
5655 }
5656 if let Some(sp) = spinner.as_mut() {
5659 sp.finish();
5660 }
5661 if let Some(w) = md.as_mut() {
5664 w.finish().ok();
5665 }
5666 if started && md.is_none() {
5667 println!();
5668 }
5669 Ok((full, usage))
5670}
5671
5672#[cfg(test)]
5673mod repeat_call_guard_tests {
5674 use super::*;
5675
5676 #[test]
5677 fn short_circuits_exact_repeat_and_escalates() {
5678 let mut g = RepeatCallGuard::default();
5679 let args = serde_json::json!({"command": "mkdir x"});
5680 assert!(g.repeat_steer("run_command", &args).is_none());
5682 g.record("run_command", &args, false, "error: shell unavailable");
5684 let s = g.repeat_steer("run_command", &args).expect("repeat steers");
5685 assert!(s.contains("already called"), "{s}");
5686 assert!(s.contains("error: shell unavailable"), "{s}");
5687 assert!(
5688 !s.contains("stop using"),
5689 "one failure → no escalation yet: {s}"
5690 );
5691 g.record(
5693 "run_command",
5694 &serde_json::json!({"command": "ls"}),
5695 false,
5696 "error: denied",
5697 );
5698 let s2 = g.repeat_steer("run_command", &args).expect("still steers");
5699 assert!(s2.contains("stop using"), "escalates: {s2}");
5700 }
5701
5702 #[test]
5703 fn ignores_successes_and_distinct_calls() {
5704 let mut g = RepeatCallGuard::default();
5705 let a = serde_json::json!({"path": "f.rs"});
5706 g.record("read_file", &a, true, "file contents"); assert!(g.repeat_steer("read_file", &a).is_none());
5708 let b = serde_json::json!({"path": "g.rs"});
5710 g.record("read_file", &b, false, "error reading g.rs");
5711 assert!(
5712 g.repeat_steer("read_file", &a).is_none(),
5713 "distinct args still run"
5714 );
5715 assert!(g.repeat_steer("read_file", &b).is_some());
5716 }
5717
5718 #[test]
5719 fn steers_no_result_repeats_on_second_issuance() {
5720 let mut g = RepeatCallGuard::default();
5724
5725 let q = serde_json::json!({"query": "newt-tui PyO3 bindings"});
5728 assert!(
5729 g.repeat_steer("recall", &q).is_none(),
5730 "first recall must run"
5731 );
5732 g.record(
5733 "recall",
5734 &q,
5735 true,
5736 "no matches in past conversations for \"newt-tui PyO3 bindings\" — try different keywords.",
5737 );
5738 let s = g
5739 .repeat_steer("recall", &q)
5740 .expect("2nd identical recall steers");
5741 assert!(s.contains("no matches"), "{s}");
5742 assert!(
5743 s.contains("resume_context"),
5744 "recall steer points at resume_context: {s}"
5745 );
5746 assert!(
5747 !s.contains("stop using"),
5748 "a no-result is not a hard failure — no escalation: {s}"
5749 );
5750
5751 let k = serde_json::json!({"key": "current_task"});
5753 assert!(g.repeat_steer("state_get", &k).is_none());
5754 g.record("state_get", &k, true, "no such key: current_task");
5755 assert!(
5756 g.repeat_steer("state_get", &k).is_some(),
5757 "2nd identical state_get steers"
5758 );
5759
5760 let empty_plan_args = serde_json::json!({});
5763 assert!(g.repeat_steer("plan_get", &empty_plan_args).is_none());
5764 g.record(
5765 "plan_get",
5766 &empty_plan_args,
5767 true,
5768 "no active plan — if this is multi-step work, call update_plan next",
5769 );
5770 let plan_steer = g
5771 .repeat_steer("plan_get", &empty_plan_args)
5772 .expect("2nd identical empty plan_get steers");
5773 assert!(plan_steer.contains("update_plan"), "{plan_steer}");
5774
5775 let f = serde_json::json!({"path": "f.rs"});
5777 g.record("read_file", &f, true, "file contents");
5778 assert!(g.repeat_steer("read_file", &f).is_none());
5779
5780 let q2 = serde_json::json!({"query": "something else entirely"});
5782 assert!(
5783 g.repeat_steer("recall", &q2).is_none(),
5784 "distinct recall args still run"
5785 );
5786 }
5787
5788 #[test]
5789 fn steers_duplicate_successful_web_fetch() {
5790 let mut g = RepeatCallGuard::default();
5791 let issue = serde_json::json!({
5792 "url": "https://github.com/Gilamonster-Foundation/newt-agent/issues/771"
5793 });
5794
5795 assert!(
5796 g.repeat_steer("web_fetch", &issue).is_none(),
5797 "first fetch must run"
5798 );
5799 g.record("web_fetch", &issue, true, "# Issue\n\nbody");
5800 let steer = g
5801 .repeat_steer("web_fetch", &issue)
5802 .expect("2nd identical successful fetch steers");
5803 assert!(steer.contains("already observed"), "{steer}");
5804 assert!(steer.contains("`web_fetch`"), "{steer}");
5805 assert!(
5806 steer.contains("https://github.com/Gilamonster-Foundation/newt-agent/issues/771"),
5807 "{steer}"
5808 );
5809 assert!(
5810 g.repeat_steer(
5811 "web_fetch",
5812 &serde_json::json!({"url": "https://github.com/hartsock/scrybe"})
5813 )
5814 .is_none(),
5815 "distinct URLs still run"
5816 );
5817
5818 let file = serde_json::json!({"path": "src/lib.rs"});
5819 g.record("read_file", &file, true, "file contents");
5820 assert!(
5821 g.repeat_steer("read_file", &file).is_none(),
5822 "ordinary successful reads are still not steered"
5823 );
5824 }
5825
5826 #[test]
5827 fn steers_duplicate_successful_read_only_run_command() {
5828 let mut g = RepeatCallGuard::default();
5829 let args = serde_json::json!({
5830 "command": "grep -n 'help_lines' /Users/shawnhartsock/workspaces/newt-agent/newt-tui/src/lib.rs"
5831 });
5832
5833 assert!(
5834 g.repeat_steer("run_command", &args).is_none(),
5835 "first grep should run"
5836 );
5837 g.record(
5838 "run_command",
5839 &args,
5840 true,
5841 "9439:fn help_lines() -> &'static [&'static str] {",
5842 );
5843
5844 let steer = g
5845 .repeat_steer("run_command", &args)
5846 .expect("second identical grep should steer");
5847 assert!(steer.contains("already observed"), "{steer}");
5848 assert!(steer.contains("read-only shell probe"), "{steer}");
5849 assert!(steer.contains("`run_command`"), "{steer}");
5850 assert!(steer.contains("grep -n"), "{steer}");
5851 assert!(steer.contains("Do NOT repeat"), "{steer}");
5852 }
5853
5854 #[test]
5855 fn does_not_steer_successful_write_capable_run_command() {
5856 let mut g = RepeatCallGuard::default();
5857 let args = serde_json::json!({"command": "cargo test -p newt-tui"});
5858
5859 g.record("run_command", &args, true, "test result: ok");
5860
5861 assert!(
5862 g.repeat_steer("run_command", &args).is_none(),
5863 "successful build/test commands are still repeatable"
5864 );
5865 }
5866
5867 #[test]
5868 fn classifier_leaves_ordinary_successes_repeatable() {
5869 let file = serde_json::json!({"path": "src/lib.rs"});
5870 assert_eq!(
5871 RepeatCallGuard::classify_repeat_memo("read_file", &file, true, "file contents"),
5872 None
5873 );
5874
5875 let tests = serde_json::json!({"command": "cargo test -p newt-core"});
5876 assert_eq!(
5877 RepeatCallGuard::classify_repeat_memo("run_command", &tests, true, "test result: ok"),
5878 None
5879 );
5880
5881 let mut g = RepeatCallGuard::default();
5882 g.record("read_file", &file, true, "file contents");
5883 g.record("run_command", &tests, true, "test result: ok");
5884 assert!(
5885 g.repeat_memos.is_empty(),
5886 "ordinary successful calls must stay repeatable"
5887 );
5888 }
5889
5890 #[test]
5891 fn workflow_error_fingerprint_captures_cargo_location() {
5892 let output = r#"
5893error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5894 --> newt-tui/src/help_sections.rs:523:22
5895 |
5896523 | lines: SECTION_PROMPT_TOKENS,
5897 | ^^^^^^^^^^^^^^^^^^^^^ help: a static with a similar name exists: `SECTION_PROMPT`
5898"#;
5899
5900 let fp = build_error_fingerprint(output).expect("cargo error should fingerprint");
5901
5902 assert!(fp.contains("newt-tui/src/help_sections.rs:523:22"), "{fp}");
5903 assert!(fp.contains("error[E0425]"), "{fp}");
5904 assert!(fp.contains("SECTION_PROMPT_TOKENS"), "{fp}");
5905 }
5906
5907 #[test]
5908 fn workflow_runtime_nudges_after_error_without_writes() {
5909 let output = r#"
5910error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5911 --> newt-tui/src/help_sections.rs:523:22
5912"#;
5913 let mut state = WorkflowRuntimeState::default();
5914
5915 state.record_tool_result(output);
5916 state.record_round_outcome(false, false);
5917
5918 let nudge = state
5919 .round_start_nudge(None)
5920 .expect("read-only round after evidence should lock the active repair");
5921 assert!(nudge.contains("<workflow_state>"), "{nudge}");
5922 assert!(
5923 nudge.contains("newt-tui/src/help_sections.rs:523:22"),
5924 "{nudge}"
5925 );
5926 assert!(nudge.contains("next_allowed_actions"), "{nudge}");
5927 assert!(nudge.contains("disallowed_actions"), "{nudge}");
5928
5929 let classification = crate::NudgeClassification {
5930 class: crate::NudgeClass::PlanUpdate,
5931 score: 1.0,
5932 };
5933 let rediscovery = state
5934 .rediscovery_nudge(
5935 Some(&classification),
5936 "Summary of Findings\nRoot Cause: the build failure is still present.",
5937 None,
5938 )
5939 .expect("classified summary should be steered toward action");
5940 assert!(
5941 rediscovery.contains("Do not restate findings"),
5942 "{rediscovery}"
5943 );
5944 assert!(
5945 rediscovery.contains("newt-tui/src/help_sections.rs:523:22"),
5946 "{rediscovery}"
5947 );
5948 }
5949
5950 #[test]
5951 fn workflow_runtime_tracks_failed_edit_as_unresolved_evidence() {
5952 let output = "error: old_string not found in newt-tui/src/help_sections.rs";
5953 let mut state = WorkflowRuntimeState::default();
5954
5955 state.record_tool_result(output);
5956 state.record_round_outcome(false, false);
5957
5958 let nudge = state
5959 .round_start_nudge(None)
5960 .expect("failed edit should remain unresolved repair evidence");
5961 assert!(nudge.contains("old_string not found"), "{nudge}");
5962
5963 let grace = state
5964 .cap_grace_nudge(None, 25, 5)
5965 .expect("cap after failed edit/read-only recovery should grant an action round");
5966 assert!(
5967 grace.contains("configured_workflow_grace_rounds = 5"),
5968 "{grace}"
5969 );
5970 assert!(
5971 grace.contains("call edit_file or write_file now"),
5972 "{grace}"
5973 );
5974 assert!(
5975 state.cap_grace_nudge(None, 25, 0).is_none(),
5976 "configured zero grace disables soft cap extension"
5977 );
5978
5979 state.record_round_outcome(true, true);
5980 let verify = state
5981 .cap_grace_nudge(None, 25, 3)
5982 .expect("a successful edit at the cap should get a verification window");
5983 assert!(verify.contains("focused verification"), "{verify}");
5984 assert!(
5985 verify.contains("configured_workflow_grace_rounds = 3"),
5986 "{verify}"
5987 );
5988 }
5989
5990 #[test]
5991 fn workflow_runtime_grants_configured_grace_for_recent_plan_progress() {
5992 let ledger = SessionStepLedger::default();
5993 ledger.set_plan(&["finish round-cap grace".to_string(), "verify".to_string()]);
5994 let mut state = WorkflowRuntimeState::default();
5995
5996 state.record_round_outcome(false, true);
5997
5998 let nudge = state
5999 .cap_grace_nudge(Some(&ledger), 2, 4)
6000 .expect("recent active-plan progress should activate configured grace");
6001 assert!(
6002 nudge.contains("configured_workflow_grace_rounds = 4"),
6003 "{nudge}"
6004 );
6005 assert!(nudge.contains("finish round-cap grace"), "{nudge}");
6006 assert!(
6007 state.cap_grace_nudge(Some(&ledger), 2, 0).is_none(),
6008 "zero configured grace keeps the cap hard"
6009 );
6010 }
6011
6012 #[test]
6021 fn progress_horizon_override_widens_the_recent_progress_window() {
6022 let ledger = SessionStepLedger::default();
6023 ledger.set_plan(&["diagnose the failure".to_string(), "fix it".to_string()]);
6024
6025 let mut default_horizon = WorkflowRuntimeState::default();
6026 default_horizon.record_round_outcome(false, true); for _ in 0..4 {
6028 default_horizon.record_round_outcome(false, false); }
6030 assert!(
6031 default_horizon
6032 .cap_grace_nudge(Some(&ledger), 2, 4)
6033 .is_none(),
6034 "4 rounds since the last checkpoint exceeds the default 3-round horizon"
6035 );
6036
6037 let mut widened = WorkflowRuntimeState::default();
6038 widened.set_progress_horizon(Some(6));
6039 widened.record_round_outcome(false, true);
6040 for _ in 0..4 {
6041 widened.record_round_outcome(false, false);
6042 }
6043 assert!(
6044 widened.cap_grace_nudge(Some(&ledger), 2, 4).is_some(),
6045 "a widened 6-round horizon still treats 4-rounds-stale as recent progress"
6046 );
6047 }
6048
6049 #[test]
6050 fn workspace_write_classifier_is_narrow() {
6051 assert!(is_workspace_write_call("edit_file"));
6052 assert!(is_workspace_write_call("write_file"));
6053 assert!(!is_workspace_write_call("run_command"));
6054 assert!(!is_workspace_write_call("read_file"));
6055 }
6056
6057 #[test]
6058 fn no_result_reason_classifies_and_routes() {
6059 assert!(RepeatCallGuard::no_result_reason(
6061 "recall",
6062 "no matches in past conversations for \"x\" — try different keywords."
6063 )
6064 .is_some_and(|r| r.contains("no matches") && r.contains("resume_context")));
6065 assert!(
6066 RepeatCallGuard::no_result_reason("state_get", "no such key: current_task")
6067 .is_some_and(|r| r.contains("not set"))
6068 );
6069 assert!(
6070 RepeatCallGuard::no_result_reason("plan_get", "no active plan — call update_plan")
6071 .is_some_and(|r| r.contains("update_plan"))
6072 );
6073 assert!(
6075 RepeatCallGuard::no_result_reason("recall", "3 match(es) in past conversations")
6076 .is_none()
6077 );
6078 assert!(RepeatCallGuard::no_result_reason("read_file", "file contents").is_none());
6079
6080 let mut g = RepeatCallGuard::default();
6083 let q = serde_json::json!({"query": "x"});
6084 g.record("recall", &q, false, "error: index unavailable");
6085 assert!(matches!(
6086 g.repeat_memos.get(&RepeatCallGuard::key("recall", &q)),
6087 Some(RepeatMemo::Failure { first_line }) if first_line == "error: index unavailable"
6088 ));
6089 }
6090
6091 #[test]
6092 fn first_line_caps_and_takes_first() {
6093 assert_eq!(first_line("one\ntwo\nthree"), "one");
6094 assert_eq!(first_line(""), "");
6095 assert_eq!(first_line(&"x".repeat(500)).chars().count(), 200);
6096 }
6097}
6098
6099#[cfg(test)]
6100mod cap_exit_unit_tests {
6101 use super::*;
6102
6103 #[test]
6104 fn cap_exit_nudge_names_the_limit_and_folds_in_progress() {
6105 let nudge = cap_exit_nudge(5, None, &[]);
6106 assert!(nudge.contains("5 rounds"), "got: {nudge}");
6107 assert!(nudge.contains("Do NOT call any more tools"));
6108 assert!(
6111 nudge.contains("Cite only file paths that appear verbatim"),
6112 "got: {nudge}"
6113 );
6114 assert!(nudge.contains("say so plainly"), "got: {nudge}");
6115 assert!(
6116 !nudge.contains("progress so far"),
6117 "no block when None: {nudge}"
6118 );
6119 assert!(
6120 !nudge.contains("actually observed"),
6121 "no manifest block when the ledger is empty: {nudge}"
6122 );
6123 let with = cap_exit_nudge(5, Some("<plan>1. [x] foo</plan>"), &[]);
6125 assert!(with.contains("Your progress so far"), "got: {with}");
6126 assert!(with.contains("<plan>1. [x] foo</plan>"), "got: {with}");
6127 }
6128
6129 #[test]
6132 fn cap_exit_nudge_folds_in_the_observed_paths_manifest() {
6133 let observed = vec![
6134 "newt-tui/src/lib.rs".to_string(),
6135 "newt-core/src/agentic/mod.rs".to_string(),
6136 ];
6137 let nudge = cap_exit_nudge(5, Some("<state>k=v</state>"), &observed);
6138 assert!(
6139 nudge.contains("File paths actually observed in tool results"),
6140 "got: {nudge}"
6141 );
6142 assert!(nudge.contains("- newt-tui/src/lib.rs"), "got: {nudge}");
6143 assert!(
6144 nudge.contains("- newt-core/src/agentic/mod.rs"),
6145 "got: {nudge}"
6146 );
6147 let manifest_at = nudge.find("actually observed").unwrap();
6149 let progress_at = nudge.find("Your progress so far").unwrap();
6150 assert!(manifest_at < progress_at, "got: {nudge}");
6151 }
6152
6153 #[test]
6154 fn cap_exit_progress_renders_plan_and_state_or_none() {
6155 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6156 use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
6157 let ledger = SessionStepLedger::default();
6158 let pad = SessionScratchpadStore::default();
6159 assert!(cap_exit_progress(Some(&ledger), Some(&pad)).is_none());
6161 assert!(cap_exit_progress(None, None).is_none());
6162 ledger.set_plan(&["build it".to_string(), "test it".to_string()]);
6164 pad.set("cwd", "/work".to_string());
6165 let p = cap_exit_progress(
6166 Some(&ledger as &dyn StepLedger),
6167 Some(&pad as &dyn ScratchpadStore),
6168 )
6169 .expect("non-empty progress");
6170 assert!(p.contains("build it"), "{p}");
6171 assert!(p.contains("cwd"), "{p}");
6172 }
6173
6174 #[test]
6175 fn spinner_line_formats_and_frame_wraps() {
6176 assert_eq!(
6179 format_spinner(0, 1.23, "thinking…", 340),
6180 "⠋ thinking… 1.2s · 340 chars"
6181 );
6182 assert_eq!(
6184 format_spinner(1, 0.5, "compressing context…", 0),
6185 "⠙ compressing context… 0.5s"
6186 );
6187 assert!(
6189 format_spinner(SPINNER_FRAMES.len(), 0.0, "thinking…", 0).contains(SPINNER_FRAMES[0])
6190 );
6191 }
6192
6193 #[test]
6194 fn cap_exit_fallback_usage_advice_and_salvage() {
6195 let with = cap_exit_fallback(
6197 4,
6198 Some(crate::TokenUsage {
6199 input_tokens: 12,
6200 output_tokens: 34,
6201 }),
6202 0,
6203 None,
6204 );
6205 assert!(with.contains("12 in / 34 out tokens"), "got: {with}");
6206 assert!(with.contains("max_tool_rounds"), "got: {with}");
6207
6208 let without = cap_exit_fallback(4, None, 0, None);
6209 assert!(!without.contains("tokens consumed"), "got: {without}");
6210 assert!(without.contains("tool-call limit of 4"), "got: {without}");
6211
6212 let thrash = cap_exit_fallback(4, None, 6, None);
6215 assert!(thrash.contains("tool calls that failed"), "got: {thrash}");
6216 assert!(
6217 !thrash.contains("raise [tui].max_tool_rounds"),
6218 "thrash advice must not blame the cap: {thrash}"
6219 );
6220
6221 let salvaged = cap_exit_fallback(4, None, 0, Some("<state>cwd=/x</state>"));
6223 assert!(salvaged.contains("Progress captured"), "got: {salvaged}");
6224 assert!(
6225 salvaged.contains("<state>cwd=/x</state>"),
6226 "got: {salvaged}"
6227 );
6228 }
6229
6230 #[test]
6231 fn cap_exit_summary_action_handoff_is_rejected() {
6232 let handoff = "I have two issues: duplicate topic_has_rollups and a stray brace. Let me fix both — read around 490 to see what needs removing, then verify with a build check.";
6233 assert!(cap_exit_summary_is_action_handoff(handoff));
6234 assert!(!cap_exit_summary_is_action_handoff(
6235 "The duplicate helper definitions and stray brace were removed, and the build check passed."
6236 ));
6237
6238 let fallback = cap_exit_action_handoff_fallback(
6239 25,
6240 None,
6241 2,
6242 Some("<plan>1. [ ] fix duplicate helper definitions</plan>"),
6243 );
6244 assert!(fallback.contains("tool-call limit of 25"), "{fallback}");
6245 assert!(
6246 fallback.contains("described future tool actions"),
6247 "{fallback}"
6248 );
6249 assert!(
6250 fallback.contains("preserved the verified progress"),
6251 "{fallback}"
6252 );
6253 assert!(
6254 !fallback.contains("final summarization request also failed"),
6255 "{fallback}"
6256 );
6257 assert!(
6258 fallback.contains("Progress captured at the tool-call limit"),
6259 "{fallback}"
6260 );
6261 }
6262
6263 #[test]
6264 fn read_only_tools_classified_correctly() {
6265 for name in &[
6268 "list_dir",
6269 "read_file",
6270 "find",
6271 "search",
6272 "web_fetch",
6273 "use_skill",
6274 "save_note",
6275 ] {
6276 assert!(is_read_only_tool(name), "{name} should be read-only");
6277 }
6278 }
6279
6280 #[test]
6281 fn write_tools_not_read_only() {
6282 for name in &["edit_file", "write_file", "run_command"] {
6283 assert!(!is_read_only_tool(name), "{name} should NOT be read-only");
6284 }
6285 }
6286
6287 #[test]
6288 fn read_only_call_classifies_simple_shell_probes() {
6289 assert!(is_read_only_call(
6290 "run_command",
6291 &serde_json::json!({"command": "grep -n 'help_lines' newt-tui/src/lib.rs"})
6292 ));
6293 assert!(is_read_only_call(
6294 "run_command",
6295 &serde_json::json!({"command": "rg -n format_help newt-tui/src"})
6296 ));
6297 assert!(is_read_only_call(
6298 "run_command",
6299 &serde_json::json!({"command": "sed -n '1,20p' newt-tui/src/lib.rs"})
6300 ));
6301
6302 assert!(!is_read_only_call(
6303 "run_command",
6304 &serde_json::json!({"command": "cargo test -p newt-tui"})
6305 ));
6306 assert!(!is_read_only_call(
6307 "run_command",
6308 &serde_json::json!({"command": "sed -i 's/a/b/' file.txt"})
6309 ));
6310 assert!(!is_read_only_call(
6311 "run_command",
6312 &serde_json::json!({"command": "grep x file > out.txt"})
6313 ));
6314 }
6315
6316 #[test]
6317 fn read_only_action_nudge_names_edit_permission_and_blocker_paths() {
6318 let nudge = read_only_action_nudge(3, 4, None, None);
6319 assert!(nudge.contains("read-only rounds so far"), "{nudge}");
6320 assert!(nudge.contains("edit_file"), "{nudge}");
6321 assert!(nudge.contains("write_file"), "{nudge}");
6322 assert!(nudge.contains("request_permissions"), "{nudge}");
6323 assert!(nudge.contains("exact blocker"), "{nudge}");
6324 }
6325
6326 #[test]
6327 fn read_only_action_nudge_mentions_active_plan_when_present() {
6328 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6329
6330 let ledger = SessionStepLedger::default();
6331 ledger.restore(&PlanSnapshot {
6332 steps: vec![
6333 Step {
6334 description: "inspect".to_string(),
6335 status: StepStatus::Done,
6336 },
6337 Step {
6338 description: "edit".to_string(),
6339 status: StepStatus::Active,
6340 },
6341 ],
6342 });
6343 let nudge = read_only_action_nudge(3, 2, Some(&ledger as &dyn StepLedger), None);
6344 assert!(nudge.contains("active multi-step plan"), "{nudge}");
6345 assert!(nudge.contains("ACTIVE step"), "{nudge}");
6346 }
6347
6348 #[test]
6354 fn read_only_action_nudge_includes_a_delegate_hint_when_offered() {
6355 let nudge = read_only_action_nudge(3, 4, None, Some("consider calling crew or team"));
6356 assert!(nudge.contains("consider calling crew or team"), "{nudge}");
6357 assert!(nudge.contains("edit_file"), "{nudge}");
6360 }
6361
6362 #[test]
6363 fn read_only_action_nudge_omits_delegate_clause_when_none_offered() {
6364 let nudge = read_only_action_nudge(3, 4, None, None);
6365 assert!(!nudge.contains("crew"), "{nudge}");
6366 assert!(!nudge.contains("team"), "{nudge}");
6367 }
6368
6369 #[test]
6370 fn pending_plan_completion_nudge_is_state_driven() {
6371 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6372
6373 assert!(pending_plan_completion_nudge(None, false, None).is_none());
6374
6375 let ledger = SessionStepLedger::default();
6376 ledger.restore(&PlanSnapshot {
6377 steps: vec![
6378 Step {
6379 description: "already done".to_string(),
6380 status: StepStatus::Done,
6381 },
6382 Step {
6383 description: "keep working".to_string(),
6384 status: StepStatus::Active,
6385 },
6386 ],
6387 });
6388 let nudge = pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None)
6389 .expect("open plan produces a nudge");
6390 assert!(nudge.contains("1/2 unfinished step"), "{nudge}");
6391 assert!(nudge.contains("Active step: 'keep working'"), "{nudge}");
6392 assert!(nudge.contains("update_plan"), "{nudge}");
6393 assert!(nudge.contains("call the next tool"), "{nudge}");
6394 assert!(nudge.contains("concrete blocker"), "{nudge}");
6395
6396 let plan_update_nudge = pending_plan_completion_nudge(
6397 Some(&ledger as &dyn StepLedger),
6398 true,
6399 Some(
6400 "Configured workflow 'github_pr' is active. Workflow steps:\n- commit_step: Commit the verified step",
6401 ),
6402 )
6403 .expect("open plan produces a plan-update nudge");
6404 assert!(
6405 plan_update_nudge.contains("findings/next-steps summary"),
6406 "{plan_update_nudge}"
6407 );
6408 assert!(
6409 plan_update_nudge.contains("Call update_plan now"),
6410 "{plan_update_nudge}"
6411 );
6412 assert!(
6413 plan_update_nudge.contains("make the immediate blocker repair the active step"),
6414 "{plan_update_nudge}"
6415 );
6416 assert!(
6417 plan_update_nudge.contains("Do not repeat the findings summary"),
6418 "{plan_update_nudge}"
6419 );
6420 assert!(
6421 plan_update_nudge.contains("github_pr"),
6422 "{plan_update_nudge}"
6423 );
6424 assert!(
6425 plan_update_nudge.contains("commit_step"),
6426 "{plan_update_nudge}"
6427 );
6428
6429 ledger.restore(&PlanSnapshot {
6430 steps: vec![Step {
6431 description: "complete".to_string(),
6432 status: StepStatus::Done,
6433 }],
6434 });
6435 assert!(
6436 pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None).is_none()
6437 );
6438 }
6439
6440 #[test]
6441 fn workflow_classifier_text_keeps_recent_user_issue_context() {
6442 let messages = vec![
6443 serde_json::json!({
6444 "role": "user",
6445 "content": "Take a look at https://github.com/Gilamonster-Foundation/newt-agent/issues/548 and get me a PR."
6446 }),
6447 serde_json::json!({
6448 "role": "assistant",
6449 "content": "I will inspect the issue and repo state."
6450 }),
6451 ];
6452 let text = workflow_classifier_text(
6453 &messages,
6454 "Summary of Findings\n\nCurrent Status: the build is broken. Next Steps Required: update the plan.",
6455 );
6456 let hint = crate::WorkflowSteerer::builtin()
6457 .plan_update_hint(&text)
6458 .expect("GitHub issue context should select the PR workflow");
6459 assert!(hint.contains("github_pr"), "{hint}");
6460 assert!(hint.contains("read_issue"), "{hint}");
6461 assert!(hint.contains("open_pr"), "{hint}");
6462 }
6463}
6464
6465#[cfg(test)]
6481mod tool_round_cap_tests {
6482 use super::*;
6483 use crate::caveats::Caveats;
6484 use crate::{BackendKind, MemMessage};
6485 use std::sync::atomic::{AtomicUsize, Ordering};
6486 use std::sync::Arc;
6487 use wiremock::matchers::{method, path};
6488 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
6489
6490 fn request_has_tools(req: &Request) -> bool {
6492 serde_json::from_slice::<serde_json::Value>(&req.body)
6493 .ok()
6494 .map(|v| v.get("tools").is_some())
6495 .unwrap_or(false)
6496 }
6497
6498 struct OllamaResponder {
6502 tool_rounds_served: Arc<AtomicUsize>,
6503 final_answer: String,
6504 }
6505
6506 impl Respond for OllamaResponder {
6507 fn respond(&self, req: &Request) -> ResponseTemplate {
6508 if request_has_tools(req) {
6509 self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6510 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6511 "message": {
6512 "content": "",
6513 "tool_calls": [{
6514 "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
6515 }]
6516 }
6517 }))
6518 } else {
6519 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6520 "message": { "content": self.final_answer }
6521 }))
6522 }
6523 }
6524 }
6525
6526 struct OpenAiResponder {
6528 tool_rounds_served: Arc<AtomicUsize>,
6529 final_answer: String,
6530 }
6531
6532 impl Respond for OpenAiResponder {
6533 fn respond(&self, req: &Request) -> ResponseTemplate {
6534 if request_has_tools(req) {
6535 self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6536 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6537 "choices": [{ "message": {
6538 "content": null,
6539 "tool_calls": [{
6540 "id": "call_1",
6541 "type": "function",
6542 "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
6543 }]
6544 }}]
6545 }))
6546 } else {
6547 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6548 "choices": [{ "message": { "content": self.final_answer } }]
6549 }))
6550 }
6551 }
6552 }
6553
6554 fn msgs() -> Vec<MemMessage> {
6555 vec![
6556 MemMessage::system("you are a test"),
6557 MemMessage::user("do the thing"),
6558 ]
6559 }
6560
6561 #[tokio::test]
6562 async fn ollama_loop_honors_configured_cap_and_returns_real_final_answer() {
6563 let server = MockServer::start().await;
6564 let served = Arc::new(AtomicUsize::new(0));
6565 Mock::given(method("POST"))
6566 .and(path("/api/chat"))
6567 .respond_with(OllamaResponder {
6568 tool_rounds_served: served.clone(),
6569 final_answer: "here is my partial summary".into(),
6570 })
6571 .mount(&server)
6572 .await;
6573
6574 let messages = msgs();
6575 let caveats = Caveats::top();
6576 let cap = 3;
6577 let mut end_reason: Option<crate::TurnEndReason> = None;
6578 let (reply, streamed, _usage, _hallu) = chat_complete(
6579 ChatCtx {
6580 url: &server.uri(),
6581 model: "test-model",
6582 kind: BackendKind::Ollama,
6583 api_key: None,
6584 messages: &messages,
6585 task: "do the thing",
6586 workspace: ".",
6587 color: false,
6588 markdown: false,
6589 tool_offload: false,
6590 spill_store: None,
6591 compaction_store: None,
6592 scratchpad: false,
6593 scratchpad_store: None,
6594 code_search: None,
6595 experience_store: None,
6596 step_ledger: None,
6597 caveats: &caveats,
6598 persona_tools: None,
6599 max_tool_rounds: cap,
6600 narration_nudge_cap: 1,
6601 workflow_grace_rounds: 0,
6602 tool_output_lines: 20,
6603 debug: false,
6604 trace: false,
6605 num_ctx: None,
6606 input_ceiling_pct: 80,
6607 low_budget_pct: 15,
6608 connect_timeout_secs: 5,
6609 inference_timeout_secs: 120,
6610 mid_loop_trim_threshold: 40,
6611 mid_loop_trim_tokens: None,
6612 max_ok_input: None,
6613 build_check_cmd: None,
6614 safe_context: None,
6615 recover_cw_400: None,
6616 note_sink: None,
6617 note_nudge: None,
6618 recall_source: None,
6619 memory_source: None,
6620 summarizer: None,
6621 compress_state: None,
6622 tool_events: None,
6623 phantom_reaches: None,
6624 end_reason: Some(&mut end_reason),
6625 permission_gate: None,
6626 on_round_usage: None,
6627 estimate_ratio: None,
6628 estimation: crate::tokens::TokenEstimation::default(),
6629 summary_input_cap_floor_chars: 8_192,
6630 exec_floor: None,
6631 write_ledger: None,
6632 cancel: None,
6633 git_tool: None,
6634 crew_runner: None,
6635 },
6636 &mut NoMcp,
6637 )
6638 .await
6639 .expect("chat_complete should succeed");
6640
6641 assert_eq!(served.load(Ordering::SeqCst), cap);
6643 assert_eq!(reply, "here is my partial summary");
6646 assert_ne!(reply, "(reached tool-call limit)");
6647 assert!(!streamed);
6648 assert_eq!(end_reason, Some(crate::TurnEndReason::RoundCap));
6650 }
6651
6652 #[tokio::test]
6653 async fn ollama_cap_exit_rejects_action_intent_summary() {
6654 let server = MockServer::start().await;
6655 Mock::given(method("POST"))
6656 .and(path("/api/chat"))
6657 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6658 "message": {
6659 "content": "I have two issues: duplicate topic_has_rollups and a stray brace. Let me fix both — read around 490 to see what needs removing, then verify with a build check."
6660 }
6661 })))
6662 .mount(&server)
6663 .await;
6664
6665 let client = reqwest::Client::new();
6666 let chat_url = format!("{}/api/chat", server.uri());
6667 let (reply, streamed, _usage) = final_summary_ollama(
6668 &client,
6669 &chat_url,
6670 "test-model",
6671 Vec::new(),
6672 CapExit {
6673 max_tool_rounds: 25,
6674 accumulated: None,
6675 wasted_calls: 0,
6676 progress: Some("<plan>1. [ ] fix duplicate helper definitions</plan>".to_string()),
6677 observed: Vec::new(),
6678 },
6679 )
6680 .await
6681 .expect("final summary helper should return a fallback");
6682
6683 assert!(!streamed);
6684 assert!(reply.contains("tool-call limit of 25"), "{reply}");
6685 assert!(reply.contains("described future tool actions"), "{reply}");
6686 assert!(reply.contains("preserved the verified progress"), "{reply}");
6687 assert!(
6688 !reply.contains("Let me fix both"),
6689 "must not accept action-intent cap summary: {reply}"
6690 );
6691 assert!(
6692 !reply.contains("final summarization request also failed"),
6693 "{reply}"
6694 );
6695 assert!(
6696 reply.contains("Progress captured at the tool-call limit"),
6697 "{reply}"
6698 );
6699 }
6700
6701 struct ThrashResponder {
6706 round: AtomicUsize,
6707 }
6708
6709 impl Respond for ThrashResponder {
6710 fn respond(&self, req: &Request) -> ResponseTemplate {
6711 if request_has_tools(req) {
6712 let n = self.round.fetch_add(1, Ordering::SeqCst);
6713 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6717 "message": {
6718 "content": "",
6719 "tool_calls": [{
6720 "function": { "name": format!("bogus_tool_{n}"), "arguments": {} }
6721 }]
6722 }
6723 }))
6724 } else {
6725 ResponseTemplate::new(500).set_body_string("model exploded")
6728 }
6729 }
6730 }
6731
6732 #[tokio::test]
6733 async fn uat_thrash_run_gets_honest_cap_exit_not_raise_the_limit() {
6734 let server = MockServer::start().await;
6735 Mock::given(method("POST"))
6736 .and(path("/api/chat"))
6737 .respond_with(ThrashResponder {
6738 round: AtomicUsize::new(0),
6739 })
6740 .mount(&server)
6741 .await;
6742
6743 let messages = msgs();
6744 let caveats = Caveats::top();
6745 let cap = 3;
6746 let (reply, _streamed, _usage, hallu) = chat_complete(
6747 ChatCtx {
6748 url: &server.uri(),
6749 model: "test-model",
6750 kind: BackendKind::Ollama,
6751 api_key: None,
6752 messages: &messages,
6753 task: "do the thing",
6754 workspace: ".",
6755 color: false,
6756 markdown: false,
6757 tool_offload: false,
6758 spill_store: None,
6759 compaction_store: None,
6760 scratchpad: false,
6761 scratchpad_store: None,
6762 code_search: None,
6763 experience_store: None,
6764 step_ledger: None,
6765 caveats: &caveats,
6766 persona_tools: None,
6767 max_tool_rounds: cap,
6768 narration_nudge_cap: 1,
6769 workflow_grace_rounds: 0,
6770 tool_output_lines: 20,
6771 debug: false,
6772 trace: false,
6773 num_ctx: None,
6774 input_ceiling_pct: 80,
6775 low_budget_pct: 15,
6776 connect_timeout_secs: 5,
6777 inference_timeout_secs: 120,
6778 mid_loop_trim_threshold: 40,
6779 mid_loop_trim_tokens: None,
6780 max_ok_input: None,
6781 build_check_cmd: None,
6782 safe_context: None,
6783 recover_cw_400: None,
6784 note_sink: None,
6785 note_nudge: None,
6786 recall_source: None,
6787 memory_source: None,
6788 summarizer: None,
6789 compress_state: None,
6790 tool_events: None,
6791 phantom_reaches: None,
6792 end_reason: None,
6793 permission_gate: None,
6794 on_round_usage: None,
6795 estimate_ratio: None,
6796 estimation: crate::tokens::TokenEstimation::default(),
6797 summary_input_cap_floor_chars: 8_192,
6798 exec_floor: None,
6799 write_ledger: None,
6800 cancel: None,
6801 git_tool: None,
6802 crew_runner: None,
6803 },
6804 &mut NoMcp,
6805 )
6806 .await
6807 .expect("chat_complete should succeed even when the summary fails");
6808
6809 assert_eq!(hallu, cap as u32, "each round hallucinated a tool");
6811 assert!(
6813 reply.contains("tool calls that failed"),
6814 "honest advice expected, got: {reply}"
6815 );
6816 assert!(
6817 !reply.contains("raise [tui].max_tool_rounds"),
6818 "must not blame the round cap on a thrash run: {reply}"
6819 );
6820 }
6821
6822 #[tokio::test]
6823 async fn a_set_cancel_flag_abandons_the_turn_before_any_network_call() {
6824 let messages = msgs();
6829 let caveats = Caveats::top();
6830 let flag = std::sync::atomic::AtomicBool::new(true);
6831 let (reply, streamed, usage, hallu) = chat_complete(
6832 ChatCtx {
6833 url: "http://127.0.0.1:1",
6834 model: "test-model",
6835 kind: BackendKind::Ollama,
6836 api_key: None,
6837 messages: &messages,
6838 task: "do the thing",
6839 workspace: ".",
6840 color: false,
6841 markdown: false,
6842 tool_offload: false,
6843 spill_store: None,
6844 compaction_store: None,
6845 scratchpad: false,
6846 scratchpad_store: None,
6847 code_search: None,
6848 experience_store: None,
6849 step_ledger: None,
6850 caveats: &caveats,
6851 persona_tools: None,
6852 max_tool_rounds: 5,
6853 narration_nudge_cap: 1,
6854 workflow_grace_rounds: 0,
6855 tool_output_lines: 20,
6856 debug: false,
6857 trace: false,
6858 num_ctx: None,
6859 input_ceiling_pct: 80,
6860 low_budget_pct: 15,
6861 connect_timeout_secs: 5,
6862 inference_timeout_secs: 120,
6863 mid_loop_trim_threshold: 40,
6864 mid_loop_trim_tokens: None,
6865 max_ok_input: None,
6866 build_check_cmd: None,
6867 safe_context: None,
6868 recover_cw_400: None,
6869 note_sink: None,
6870 note_nudge: None,
6871 recall_source: None,
6872 memory_source: None,
6873 summarizer: None,
6874 compress_state: None,
6875 tool_events: None,
6876 phantom_reaches: None,
6877 end_reason: None,
6878 permission_gate: None,
6879 on_round_usage: None,
6880 estimate_ratio: None,
6881 estimation: crate::tokens::TokenEstimation::default(),
6882 summary_input_cap_floor_chars: 8_192,
6883 exec_floor: None,
6884 write_ledger: None,
6885 cancel: Some(&flag),
6886 git_tool: None,
6887 crew_runner: None,
6888 },
6889 &mut NoMcp,
6890 )
6891 .await
6892 .expect("an interrupted turn still returns Ok, just empty");
6893 assert!(reply.is_empty(), "interrupted before any model output");
6894 assert!(!streamed);
6895 assert!(usage.is_none());
6896 assert_eq!(hallu, 0);
6897 }
6898
6899 #[test]
6900 fn responses_input_splits_system_to_instructions_and_passes_typed_items() {
6901 let msgs = vec![
6902 serde_json::json!({"role": "system", "content": "be terse"}),
6903 serde_json::json!({"role": "user", "content": "hi"}),
6904 serde_json::json!({"role": "assistant", "content": "hello"}),
6905 serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "ok"}),
6907 ];
6908 let (instructions, input) = build_responses_input(&msgs);
6909 assert_eq!(instructions.as_deref(), Some("be terse"));
6910 assert_eq!(input.len(), 3);
6911 assert_eq!(input[0]["role"], "user");
6912 assert_eq!(input[0]["content"], "hi");
6913 assert_eq!(input[2]["type"], "function_call_output");
6914 }
6915
6916 #[test]
6917 fn tools_flatten_to_responses_shape() {
6918 let chat = serde_json::json!([{
6919 "type": "function",
6920 "function": {
6921 "name": "git",
6922 "description": "run git",
6923 "parameters": {"type": "object"}
6924 }
6925 }]);
6926 let out = tools_to_responses(&chat);
6927 assert_eq!(out.len(), 1);
6928 assert_eq!(out[0]["type"], "function");
6929 assert_eq!(
6930 out[0]["name"], "git",
6931 "name hoisted out of the function wrapper"
6932 );
6933 assert_eq!(out[0]["description"], "run git");
6934 assert!(out[0]["function"].is_null(), "no nested function wrapper");
6935 }
6936
6937 #[test]
6938 fn parse_responses_output_extracts_text_calls_and_usage() {
6939 let json = serde_json::json!({
6940 "output": [
6941 {"type": "reasoning", "summary": "…"},
6942 {"type": "message", "role": "assistant",
6943 "content": [{"type": "output_text", "text": "the answer"}]},
6944 {"type": "function_call", "call_id": "call_1", "name": "git",
6945 "arguments": "{\"op\":\"status\"}"}
6946 ],
6947 "usage": {"input_tokens": 100, "output_tokens": 20}
6948 });
6949 let (text, calls) = parse_responses_output(&json);
6950 assert_eq!(text, "the answer");
6951 assert_eq!(calls.len(), 1);
6952 assert_eq!(calls[0]["call_id"], "call_1");
6953 let usage = responses_usage(&json["usage"]).unwrap();
6954 assert_eq!(usage.input_tokens, 100);
6955 assert_eq!(usage.output_tokens, 20);
6956 }
6957
6958 #[tokio::test]
6959 async fn responses_loop_returns_message_text_from_v1_responses() {
6960 let server = MockServer::start().await;
6961 Mock::given(method("POST"))
6962 .and(path("/v1/responses"))
6963 .respond_with(
6964 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6965 "output": [{
6966 "type": "message", "role": "assistant",
6967 "content": [{"type": "output_text", "text": "hello from responses"}]
6968 }],
6969 "usage": {"input_tokens": 12, "output_tokens": 4}
6970 })),
6971 )
6972 .mount(&server)
6973 .await;
6974
6975 let messages = msgs();
6976 let caveats = Caveats::top();
6977 let (reply, streamed, usage, _hallu) = openai_responses_complete(
6978 ChatCtx {
6979 url: &server.uri(),
6980 model: "gpt-5-codex",
6981 kind: BackendKind::Openai,
6982 api_key: Some("sk-test"),
6983 messages: &messages,
6984 task: "do the thing",
6985 workspace: ".",
6986 color: false,
6987 markdown: false,
6988 tool_offload: false,
6989 spill_store: None,
6990 compaction_store: None,
6991 scratchpad: false,
6992 scratchpad_store: None,
6993 code_search: None,
6994 experience_store: None,
6995 step_ledger: None,
6996 caveats: &caveats,
6997 persona_tools: None,
6998 max_tool_rounds: 5,
6999 narration_nudge_cap: 1,
7000 workflow_grace_rounds: 0,
7001 tool_output_lines: 20,
7002 debug: false,
7003 trace: false,
7004 num_ctx: None,
7005 input_ceiling_pct: 80,
7006 low_budget_pct: 15,
7007 connect_timeout_secs: 5,
7008 inference_timeout_secs: 120,
7009 mid_loop_trim_threshold: 40,
7010 mid_loop_trim_tokens: None,
7011 max_ok_input: None,
7012 build_check_cmd: None,
7013 safe_context: None,
7014 recover_cw_400: None,
7015 note_sink: None,
7016 note_nudge: None,
7017 recall_source: None,
7018 memory_source: None,
7019 summarizer: None,
7020 compress_state: None,
7021 tool_events: None,
7022 phantom_reaches: None,
7023 end_reason: None,
7024 permission_gate: None,
7025 on_round_usage: None,
7026 estimate_ratio: None,
7027 estimation: crate::tokens::TokenEstimation::default(),
7028 summary_input_cap_floor_chars: 8_192,
7029 exec_floor: None,
7030 write_ledger: None,
7031 cancel: None,
7032 git_tool: None,
7033 crew_runner: None,
7034 },
7035 &mut NoMcp,
7036 )
7037 .await
7038 .expect("responses loop returns the message text");
7039 assert_eq!(reply, "hello from responses");
7040 assert!(!streamed);
7041 assert_eq!(usage.map(|u| u.input_tokens), Some(12));
7042 }
7043
7044 #[tokio::test]
7045 async fn openai_loop_honors_configured_cap_and_returns_real_final_answer() {
7046 let server = MockServer::start().await;
7047 let served = Arc::new(AtomicUsize::new(0));
7048 Mock::given(method("POST"))
7049 .and(path("/v1/chat/completions"))
7050 .respond_with(OpenAiResponder {
7051 tool_rounds_served: served.clone(),
7052 final_answer: "openai partial answer".into(),
7053 })
7054 .mount(&server)
7055 .await;
7056
7057 let messages = msgs();
7058 let caveats = Caveats::top();
7059 let cap = 2;
7060 let (reply, streamed, _usage, _hallu) = openai_chat_complete(
7061 ChatCtx {
7062 url: &server.uri(),
7063 model: "test-model",
7064 kind: BackendKind::Openai,
7065 api_key: Some("sk-test"),
7066 messages: &messages,
7067 task: "do the thing",
7068 workspace: ".",
7069 color: false,
7070 markdown: false,
7071 tool_offload: false,
7072 spill_store: None,
7073 compaction_store: None,
7074 scratchpad: false,
7075 scratchpad_store: None,
7076 code_search: None,
7077 experience_store: None,
7078 step_ledger: None,
7079 caveats: &caveats,
7080 persona_tools: None,
7081 max_tool_rounds: cap,
7082 narration_nudge_cap: 1,
7083 workflow_grace_rounds: 0,
7084 tool_output_lines: 20,
7085 debug: false,
7086 trace: false,
7087 num_ctx: None,
7088 input_ceiling_pct: 80,
7089 low_budget_pct: 15,
7090 connect_timeout_secs: 5,
7091 inference_timeout_secs: 120,
7092 mid_loop_trim_threshold: 40,
7093 mid_loop_trim_tokens: None,
7094 max_ok_input: None,
7095 build_check_cmd: None,
7096 safe_context: None,
7097 recover_cw_400: None,
7098 note_sink: None,
7099 note_nudge: None,
7100 recall_source: None,
7101 memory_source: None,
7102 summarizer: None,
7103 compress_state: None,
7104 tool_events: None,
7105 phantom_reaches: None,
7106 end_reason: None,
7107 permission_gate: None,
7108 on_round_usage: None,
7109 estimate_ratio: None,
7110 estimation: crate::tokens::TokenEstimation::default(),
7111 summary_input_cap_floor_chars: 8_192,
7112 exec_floor: None,
7113 write_ledger: None,
7114 cancel: None,
7115 git_tool: None,
7116 crew_runner: None,
7117 },
7118 &mut NoMcp,
7119 )
7120 .await
7121 .expect("openai_chat_complete should succeed");
7122
7123 assert_eq!(served.load(Ordering::SeqCst), cap);
7124 assert_eq!(reply, "openai partial answer");
7125 assert_ne!(reply, "(reached tool-call limit)");
7126 assert!(!streamed);
7127 }
7128
7129 #[tokio::test]
7134 async fn ollama_loop_records_tool_events_with_digested_args() {
7135 let server = MockServer::start().await;
7136 struct TwoToolResponder;
7137 impl Respond for TwoToolResponder {
7138 fn respond(&self, req: &Request) -> ResponseTemplate {
7139 if request_has_tools(req) {
7140 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7141 "message": { "content": "", "tool_calls": [
7142 { "function": { "name": "list_dir",
7143 "arguments": {"path": "."} } },
7144 { "function": { "name": "definitely_not_a_real_tool",
7145 "arguments": {"token": "tippy-top-secret"} } }
7146 ]}
7147 }))
7148 } else {
7149 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7150 "message": { "content": "done" }
7151 }))
7152 }
7153 }
7154 }
7155 Mock::given(method("POST"))
7156 .and(path("/api/chat"))
7157 .respond_with(TwoToolResponder)
7158 .mount(&server)
7159 .await;
7160
7161 let ws = tempfile::TempDir::new().unwrap();
7162 let workspace = ws.path().to_string_lossy().into_owned();
7163 let messages = msgs();
7164 let caveats = Caveats::top();
7165 let mut events: Vec<crate::ToolEvent> = Vec::new();
7166 chat_complete(
7167 ChatCtx {
7168 url: &server.uri(),
7169 model: "test-model",
7170 kind: BackendKind::Ollama,
7171 api_key: None,
7172 messages: &messages,
7173 task: "do the thing",
7174 workspace: &workspace,
7175 color: false,
7176 markdown: false,
7177 tool_offload: false,
7178 spill_store: None,
7179 compaction_store: None,
7180 scratchpad: false,
7181 scratchpad_store: None,
7182 code_search: None,
7183 experience_store: None,
7184 step_ledger: None,
7185 caveats: &caveats,
7186 persona_tools: None,
7187 max_tool_rounds: 1,
7188 narration_nudge_cap: 1,
7189 workflow_grace_rounds: 0,
7190 tool_output_lines: 20,
7191 debug: false,
7192 trace: false,
7193 num_ctx: None,
7194 input_ceiling_pct: 80,
7195 low_budget_pct: 15,
7196 connect_timeout_secs: 5,
7197 inference_timeout_secs: 120,
7198 mid_loop_trim_threshold: 40,
7199 mid_loop_trim_tokens: None,
7200 max_ok_input: None,
7201 build_check_cmd: None,
7202 safe_context: None,
7203 recover_cw_400: None,
7204 note_sink: None,
7205 note_nudge: None,
7206 recall_source: None,
7207 memory_source: None,
7208 summarizer: None,
7209 compress_state: None,
7210 tool_events: Some(&mut events),
7211 phantom_reaches: None,
7212 end_reason: None,
7213 permission_gate: None,
7214 on_round_usage: None,
7215 estimate_ratio: None,
7216 estimation: crate::tokens::TokenEstimation::default(),
7217 summary_input_cap_floor_chars: 8_192,
7218 exec_floor: None,
7219 write_ledger: None,
7220 cancel: None,
7221 git_tool: None,
7222 crew_runner: None,
7223 },
7224 &mut NoMcp,
7225 )
7226 .await
7227 .expect("chat_complete should succeed");
7228
7229 assert_eq!(events.len(), 2, "one event per tool call: {events:?}");
7230 assert_eq!(events[0].tool, "list_dir");
7231 assert!(events[0].ok, "a real listing reads as success");
7232 assert!(events[0].args_digest.contains("path"));
7233 assert!(events[0].duration_ms.is_some());
7234 assert_eq!(events[1].tool, "definitely_not_a_real_tool");
7235 assert!(!events[1].ok, "an unknown tool reads as failure");
7236 assert!(events[1].args_digest.contains("token"));
7238 assert!(
7239 !events[1].args_digest.contains("tippy-top-secret"),
7240 "raw arg value leaked: {}",
7241 events[1].args_digest
7242 );
7243 }
7244
7245 #[tokio::test]
7249 async fn openai_loop_records_tool_events_with_digested_args() {
7250 let server = MockServer::start().await;
7251 struct OneToolResponder;
7252 impl Respond for OneToolResponder {
7253 fn respond(&self, req: &Request) -> ResponseTemplate {
7254 if request_has_tools(req) {
7255 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7256 "choices": [{ "message": {
7257 "content": null,
7258 "tool_calls": [{
7259 "id": "call_1",
7260 "type": "function",
7261 "function": { "name": "list_dir",
7262 "arguments": "{\"path\": \".\"}" }
7263 }]
7264 }}]
7265 }))
7266 } else {
7267 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7268 "choices": [{ "message": { "content": "done" } }]
7269 }))
7270 }
7271 }
7272 }
7273 Mock::given(method("POST"))
7274 .and(path("/v1/chat/completions"))
7275 .respond_with(OneToolResponder)
7276 .mount(&server)
7277 .await;
7278
7279 let ws = tempfile::TempDir::new().unwrap();
7280 let workspace = ws.path().to_string_lossy().into_owned();
7281 let messages = msgs();
7282 let caveats = Caveats::top();
7283 let mut events: Vec<crate::ToolEvent> = Vec::new();
7284 openai_chat_complete(
7285 ChatCtx {
7286 url: &server.uri(),
7287 model: "test-model",
7288 kind: BackendKind::Openai,
7289 api_key: Some("sk-test"),
7290 messages: &messages,
7291 task: "do the thing",
7292 workspace: &workspace,
7293 color: false,
7294 markdown: false,
7295 tool_offload: false,
7296 spill_store: None,
7297 compaction_store: None,
7298 scratchpad: false,
7299 scratchpad_store: None,
7300 code_search: None,
7301 experience_store: None,
7302 step_ledger: None,
7303 caveats: &caveats,
7304 persona_tools: None,
7305 max_tool_rounds: 1,
7306 narration_nudge_cap: 1,
7307 workflow_grace_rounds: 0,
7308 tool_output_lines: 20,
7309 debug: false,
7310 trace: false,
7311 num_ctx: None,
7312 input_ceiling_pct: 80,
7313 low_budget_pct: 15,
7314 connect_timeout_secs: 5,
7315 inference_timeout_secs: 120,
7316 mid_loop_trim_threshold: 40,
7317 mid_loop_trim_tokens: None,
7318 max_ok_input: None,
7319 build_check_cmd: None,
7320 safe_context: None,
7321 recover_cw_400: None,
7322 note_sink: None,
7323 note_nudge: None,
7324 recall_source: None,
7325 memory_source: None,
7326 summarizer: None,
7327 compress_state: None,
7328 tool_events: Some(&mut events),
7329 phantom_reaches: None,
7330 end_reason: None,
7331 permission_gate: None,
7332 on_round_usage: None,
7333 estimate_ratio: None,
7334 estimation: crate::tokens::TokenEstimation::default(),
7335 summary_input_cap_floor_chars: 8_192,
7336 exec_floor: None,
7337 write_ledger: None,
7338 cancel: None,
7339 git_tool: None,
7340 crew_runner: None,
7341 },
7342 &mut NoMcp,
7343 )
7344 .await
7345 .expect("openai_chat_complete should succeed");
7346
7347 assert_eq!(events.len(), 1, "one event per tool call: {events:?}");
7348 assert_eq!(events[0].tool, "list_dir");
7349 assert!(events[0].ok);
7350 assert_eq!(
7351 events[0].args_digest,
7352 crate::ToolEvent::from_call("x", &serde_json::json!({"path": "."}), true, None)
7353 .args_digest,
7354 "string-encoded args must digest like parsed args"
7355 );
7356 }
7357
7358 #[tokio::test]
7359 async fn cap_exit_fallback_when_final_summary_errors() {
7360 let server = MockServer::start().await;
7366 let served = Arc::new(AtomicUsize::new(0));
7367 struct ErrOnFinal {
7368 served: Arc<AtomicUsize>,
7369 }
7370 impl Respond for ErrOnFinal {
7371 fn respond(&self, req: &Request) -> ResponseTemplate {
7372 if request_has_tools(req) {
7373 self.served.fetch_add(1, Ordering::SeqCst);
7374 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7375 "message": { "content": "", "tool_calls": [{
7376 "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
7377 }]}
7378 }))
7379 } else {
7380 ResponseTemplate::new(500).set_body_string("boom")
7381 }
7382 }
7383 }
7384 Mock::given(method("POST"))
7385 .and(path("/api/chat"))
7386 .respond_with(ErrOnFinal {
7387 served: served.clone(),
7388 })
7389 .mount(&server)
7390 .await;
7391
7392 let messages = msgs();
7393 let caveats = Caveats::top();
7394 let (reply, _streamed, _usage, _hallu) = chat_complete(
7395 ChatCtx {
7396 url: &server.uri(),
7397 model: "test-model",
7398 kind: BackendKind::Ollama,
7399 api_key: None,
7400 messages: &messages,
7401 task: "do the thing",
7402 workspace: ".",
7403 color: false,
7404 markdown: false,
7405 tool_offload: false,
7406 spill_store: None,
7407 compaction_store: None,
7408 scratchpad: false,
7409 scratchpad_store: None,
7410 code_search: None,
7411 experience_store: None,
7412 step_ledger: None,
7413 caveats: &caveats,
7414 persona_tools: None,
7415 max_tool_rounds: 2,
7416 narration_nudge_cap: 1,
7417 workflow_grace_rounds: 0,
7418 tool_output_lines: 20,
7419 debug: false,
7420 trace: false,
7421 num_ctx: None,
7422 input_ceiling_pct: 80,
7423 low_budget_pct: 15,
7424 connect_timeout_secs: 5,
7425 inference_timeout_secs: 120,
7426 mid_loop_trim_threshold: 40,
7427 mid_loop_trim_tokens: None,
7428 max_ok_input: None,
7429 build_check_cmd: None,
7430 safe_context: None,
7431 recover_cw_400: None,
7432 note_sink: None,
7433 note_nudge: None,
7434 recall_source: None,
7435 memory_source: None,
7436 summarizer: None,
7437 compress_state: None,
7438 tool_events: None,
7439 phantom_reaches: None,
7440 end_reason: None,
7441 permission_gate: None,
7442 on_round_usage: None,
7443 estimate_ratio: None,
7444 estimation: crate::tokens::TokenEstimation::default(),
7445 summary_input_cap_floor_chars: 8_192,
7446 exec_floor: None,
7447 write_ledger: None,
7448 cancel: None,
7449 git_tool: None,
7450 crew_runner: None,
7451 },
7452 &mut NoMcp,
7453 )
7454 .await
7455 .expect("chat_complete should succeed even when final summary errors");
7456
7457 assert!(reply.contains("tool-call limit"));
7460 assert!(reply.contains("max_tool_rounds"));
7461 }
7462
7463 #[tokio::test]
7466 async fn run_command_refuses_tool_name_as_shell_command() {
7467 let ws = tempfile::TempDir::new().unwrap();
7468 let caveats = Caveats::top();
7469 for tool in [
7470 "list_dir",
7471 "read_file",
7472 "write_file",
7473 "use_skill",
7474 "web_fetch",
7475 ] {
7476 let args = serde_json::json!({ "command": format!("{tool} some/path") });
7477 let out = execute_tool(
7478 "run_command",
7479 &args,
7480 &ws.path().to_string_lossy(),
7481 false,
7482 20,
7483 &caveats,
7484 &mut NoMcp,
7485 None,
7486 None,
7487 None,
7488 None, None,
7490 None,
7491 None, None, None, None, None, None, )
7498 .await;
7499 assert!(
7500 out.contains("is a tool, not a shell command"),
7501 "expected corrective message for '{tool}', got: {out}"
7502 );
7503 }
7504 }
7505
7506 #[tokio::test]
7509 async fn accumulated_usage_survives_summary_failure() {
7510 let server = MockServer::start().await;
7511 let served = Arc::new(AtomicUsize::new(0));
7512
7513 struct UsageRoundsErrFinal {
7514 served: Arc<AtomicUsize>,
7515 }
7516 impl Respond for UsageRoundsErrFinal {
7517 fn respond(&self, req: &Request) -> ResponseTemplate {
7518 if request_has_tools(req) {
7519 self.served.fetch_add(1, Ordering::SeqCst);
7520 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7521 "message": { "content": "", "tool_calls": [{
7522 "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
7523 }]},
7524 "prompt_eval_count": 100,
7526 "eval_count": 20,
7527 }))
7528 } else {
7529 ResponseTemplate::new(500).set_body_string("boom")
7530 }
7531 }
7532 }
7533
7534 Mock::given(method("POST"))
7535 .and(path("/api/chat"))
7536 .respond_with(UsageRoundsErrFinal {
7537 served: served.clone(),
7538 })
7539 .mount(&server)
7540 .await;
7541
7542 let messages = msgs();
7543 let caveats = Caveats::top();
7544 let cap = 2;
7545 let (reply, _streamed, usage, hallu) = chat_complete(
7546 ChatCtx {
7547 url: &server.uri(),
7548 model: "test-model",
7549 kind: BackendKind::Ollama,
7550 api_key: None,
7551 messages: &messages,
7552 task: "do the thing",
7553 workspace: ".",
7554 color: false,
7555 markdown: false,
7556 tool_offload: false,
7557 spill_store: None,
7558 compaction_store: None,
7559 scratchpad: false,
7560 scratchpad_store: None,
7561 code_search: None,
7562 experience_store: None,
7563 step_ledger: None,
7564 caveats: &caveats,
7565 persona_tools: None,
7566 max_tool_rounds: cap,
7567 narration_nudge_cap: 1,
7568 workflow_grace_rounds: 0,
7569 tool_output_lines: 20,
7570 debug: false,
7571 trace: false,
7572 num_ctx: None,
7573 input_ceiling_pct: 80,
7574 low_budget_pct: 15,
7575 connect_timeout_secs: 5,
7576 inference_timeout_secs: 120,
7577 mid_loop_trim_threshold: 40,
7578 mid_loop_trim_tokens: None,
7579 max_ok_input: None,
7580 build_check_cmd: None,
7581 safe_context: None,
7582 recover_cw_400: None,
7583 note_sink: None,
7584 note_nudge: None,
7585 recall_source: None,
7586 memory_source: None,
7587 summarizer: None,
7588 compress_state: None,
7589 tool_events: None,
7590 phantom_reaches: None,
7591 end_reason: None,
7592 permission_gate: None,
7593 on_round_usage: None,
7594 estimate_ratio: None,
7595 estimation: crate::tokens::TokenEstimation::default(),
7596 summary_input_cap_floor_chars: 8_192,
7597 exec_floor: None,
7598 write_ledger: None,
7599 cancel: None,
7600 git_tool: None,
7601 crew_runner: None,
7602 },
7603 &mut NoMcp,
7604 )
7605 .await
7606 .expect("chat_complete must succeed even when final summary errors");
7607
7608 assert!(reply.contains("tool-call limit"), "got: {reply}");
7610 assert!(
7611 reply.contains("in / ") && reply.contains("out tokens"),
7612 "fallback must include accumulated token counts, got: {reply}"
7613 );
7614
7615 let u = usage.expect("usage must be Some even when final summary fails");
7617 assert_eq!(
7621 u.input_tokens, 100,
7622 "largest single prompt across 2 rounds, not the sum"
7623 );
7624 assert_eq!(
7625 u.output_tokens, 40,
7626 "2 rounds × 20 output tokens each = 40 total"
7627 );
7628
7629 assert_eq!(
7631 hallu, cap as u32,
7632 "each round had one hallucinated tool call"
7633 );
7634 }
7635
7636 struct ReadOnlyNudgeResponder {
7646 nudge_seen: Arc<std::sync::atomic::AtomicBool>,
7648 }
7649
7650 impl Respond for ReadOnlyNudgeResponder {
7651 fn respond(&self, req: &Request) -> ResponseTemplate {
7652 let body = serde_json::from_slice::<serde_json::Value>(&req.body).unwrap_or_default();
7653 let has_nudge = body["messages"]
7654 .as_array()
7655 .map(|msgs| {
7656 msgs.iter().any(|m| {
7657 m["content"]
7658 .as_str()
7659 .map(|c| c.contains("read-only rounds so far"))
7660 .unwrap_or(false)
7661 })
7662 })
7663 .unwrap_or(false);
7664
7665 if has_nudge {
7666 self.nudge_seen
7667 .store(true, std::sync::atomic::Ordering::SeqCst);
7668 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7670 "message": { "content": "nudge received, writing file now" }
7671 }))
7672 } else if request_has_tools(req) {
7673 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7675 "message": {
7676 "content": "",
7677 "tool_calls": [{ "function": {
7678 "name": "list_dir",
7679 "arguments": { "path": "." }
7680 }}]
7681 }
7682 }))
7683 } else {
7684 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7685 "message": { "content": "final summary" }
7686 }))
7687 }
7688 }
7689 }
7690
7691 #[tokio::test]
7692 async fn read_only_nudge_injected_after_three_rounds() {
7693 let server = MockServer::start().await;
7694 let nudge_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
7695
7696 Mock::given(method("POST"))
7697 .and(path("/api/chat"))
7698 .respond_with(ReadOnlyNudgeResponder {
7699 nudge_seen: nudge_seen.clone(),
7700 })
7701 .mount(&server)
7702 .await;
7703
7704 let messages = msgs();
7705 let caveats = Caveats::top();
7706 let (reply, _streamed, _usage, _hallu) = chat_complete(
7707 ChatCtx {
7708 url: &server.uri(),
7709 model: "test-model",
7710 kind: BackendKind::Ollama,
7711 api_key: None,
7712 messages: &messages,
7713 task: "list all files",
7714 workspace: ".",
7715 color: false,
7716 markdown: false,
7717 tool_offload: false,
7718 spill_store: None,
7719 compaction_store: None,
7720 scratchpad: false,
7721 scratchpad_store: None,
7722 code_search: None,
7723 experience_store: None,
7724 step_ledger: None,
7725 caveats: &caveats,
7726 persona_tools: None,
7727 max_tool_rounds: 10,
7728 narration_nudge_cap: 1,
7729 workflow_grace_rounds: 0,
7730 tool_output_lines: 5,
7731 debug: false,
7732 trace: false,
7733 num_ctx: None,
7734 input_ceiling_pct: 80,
7735 low_budget_pct: 15,
7736 connect_timeout_secs: 5,
7737 inference_timeout_secs: 30,
7738 mid_loop_trim_threshold: 40,
7739 mid_loop_trim_tokens: None,
7740 max_ok_input: None,
7741 build_check_cmd: None,
7742 safe_context: None,
7743 recover_cw_400: None,
7744 note_sink: None,
7745 note_nudge: None,
7746 recall_source: None,
7747 memory_source: None,
7748 summarizer: None,
7749 compress_state: None,
7750 tool_events: None,
7751 phantom_reaches: None,
7752 end_reason: None,
7753 permission_gate: None,
7754 on_round_usage: None,
7755 estimate_ratio: None,
7756 estimation: crate::tokens::TokenEstimation::default(),
7757 summary_input_cap_floor_chars: 8_192,
7758 exec_floor: None,
7759 write_ledger: None,
7760 cancel: None,
7761 git_tool: None,
7762 crew_runner: None,
7763 },
7764 &mut NoMcp,
7765 )
7766 .await
7767 .expect("chat_complete should succeed");
7768
7769 assert!(
7770 nudge_seen.load(std::sync::atomic::Ordering::SeqCst),
7771 "nudge was never injected after 3 consecutive read-only rounds"
7772 );
7773 assert_eq!(
7774 reply, "nudge received, writing file now",
7775 "model should have responded to the nudge with a final answer"
7776 );
7777 }
7778}
7779
7780#[cfg(test)]
7786mod http_loop_tests {
7787 use super::*;
7788 use crate::caveats::Caveats;
7789 use crate::{BackendKind, MemMessage};
7790 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7791 use std::sync::Arc;
7792 use wiremock::matchers::{header, method, path};
7793 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
7794
7795 fn msgs() -> Vec<MemMessage> {
7796 vec![
7797 MemMessage::system("you are a test"),
7798 MemMessage::user("do the thing"),
7799 ]
7800 }
7801
7802 fn ctx<'a>(
7803 server_uri: &'a str,
7804 messages: &'a [MemMessage],
7805 caveats: &'a Caveats,
7806 ) -> ChatCtx<'a> {
7807 ChatCtx {
7808 url: server_uri,
7809 model: "test-model",
7810 kind: BackendKind::Ollama,
7811 api_key: None,
7812 messages,
7813 task: "do the thing",
7814 workspace: ".",
7815 color: false,
7816 markdown: false,
7817 tool_offload: false,
7818 spill_store: None,
7819 compaction_store: None,
7820 scratchpad: false,
7821 scratchpad_store: None,
7822 code_search: None,
7823 experience_store: None,
7824 step_ledger: None,
7825 caveats,
7826 persona_tools: None,
7827 max_tool_rounds: 8,
7828 narration_nudge_cap: 1,
7829 workflow_grace_rounds: 0,
7830 tool_output_lines: 20,
7831 debug: false,
7832 trace: false,
7833 num_ctx: None,
7834 input_ceiling_pct: 80,
7835 low_budget_pct: 15,
7836 connect_timeout_secs: 5,
7837 inference_timeout_secs: 30,
7838 mid_loop_trim_threshold: 40,
7839 mid_loop_trim_tokens: None,
7840 max_ok_input: None,
7841 build_check_cmd: None,
7842 safe_context: None,
7843 recover_cw_400: None,
7844 note_sink: None,
7845 note_nudge: None,
7846 recall_source: None,
7847 memory_source: None,
7848 summarizer: None,
7849 compress_state: None,
7850 tool_events: None,
7851 phantom_reaches: None,
7852 end_reason: None,
7853 permission_gate: None,
7854 on_round_usage: None,
7855 estimate_ratio: None,
7856 estimation: crate::tokens::TokenEstimation::default(),
7857 summary_input_cap_floor_chars: 8_192,
7858 exec_floor: None,
7859 write_ledger: None,
7860 cancel: None,
7861 git_tool: None,
7862 crew_runner: None,
7863 }
7864 }
7865
7866 fn body_json(req: &Request) -> serde_json::Value {
7867 serde_json::from_slice(&req.body).unwrap_or_default()
7868 }
7869
7870 fn is_stream(req: &Request) -> bool {
7871 body_json(req)["stream"].as_bool().unwrap_or(false)
7872 }
7873
7874 fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
7875 let body: String = lines
7876 .iter()
7877 .map(|l| format!("{l}\n"))
7878 .collect::<Vec<_>>()
7879 .join("");
7880 ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
7881 }
7882
7883 struct StreamHappyResponder;
7886 impl Respond for StreamHappyResponder {
7887 fn respond(&self, req: &Request) -> ResponseTemplate {
7888 if is_stream(req) {
7889 ndjson(&[
7890 serde_json::json!({"message": {"content": "Hello "}, "done": false}),
7891 serde_json::json!({
7892 "message": {"content": "world"}, "done": true,
7893 "prompt_eval_count": 7, "eval_count": 3
7894 }),
7895 ])
7896 } else {
7897 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7898 "message": {"content": "probe answer"},
7899 "prompt_eval_count": 5, "eval_count": 2,
7900 }))
7901 }
7902 }
7903 }
7904
7905 #[tokio::test]
7906 async fn ollama_streams_final_answer_and_merges_usage() {
7907 let server = MockServer::start().await;
7908 Mock::given(method("POST"))
7909 .and(path("/api/chat"))
7910 .respond_with(StreamHappyResponder)
7911 .mount(&server)
7912 .await;
7913
7914 let messages = msgs();
7915 let caveats = Caveats::top();
7916 let (reply, streamed, usage, hallu) =
7917 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7918 .await
7919 .expect("chat_complete should succeed");
7920
7921 assert_eq!(reply, "Hello world", "tokens accumulated across chunks");
7922 assert!(streamed, "the streaming path printed the tokens");
7923 let u = usage.expect("probe + stream usage merged");
7924 assert_eq!(u.input_tokens, 7, "max(5 probe, 7 stream), not the sum");
7928 assert_eq!(u.output_tokens, 5, "2 (probe) + 3 (stream)");
7929 assert_eq!(hallu, 0);
7930 }
7931
7932 #[test]
7933 fn detects_tools_unsupported_400_phrasings() {
7934 assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7935 "Ollama 400 Bad Request: registry.ollama.ai/library/deepseek-r1:70b does not support tools"
7936 )));
7937 assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7939 "this model does not support tools at this time"
7940 )));
7941 assert!(!is_tools_unsupported_error(&anyhow::anyhow!(
7943 "Ollama 400 Bad Request: context window exceeded"
7944 )));
7945 }
7946
7947 #[test]
7948 fn detects_ollama_tool_xml_parser_errors() {
7949 assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7950 "{}",
7951 r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 7: element \u003cparameter\u003e closed by \u003c/function\u003e"}"#
7952 )));
7953 assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7954 "{}",
7955 r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 2: element <parameter> closed by </function>"}"#
7956 )));
7957 assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7958 "{}",
7959 r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 3: unexpected end element \u003c/parameter\u003e"}"#
7960 )));
7961 assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7962 "Ollama 500 Internal Server Error: model runner crashed"
7963 )));
7964 assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7965 "OpenAI 400 Bad Request: XML syntax error in user supplied file"
7966 )));
7967 }
7968
7969 struct NoToolsResponder {
7973 rejections: Arc<AtomicUsize>,
7974 served_without_tools: Arc<AtomicBool>,
7975 }
7976 impl Respond for NoToolsResponder {
7977 fn respond(&self, req: &Request) -> ResponseTemplate {
7978 let has_tools = body_json(req).get("tools").is_some();
7979 if has_tools {
7980 self.rejections.fetch_add(1, Ordering::SeqCst);
7981 return ResponseTemplate::new(400).set_body_string(
7982 "registry.ollama.ai/library/deepseek-r1:70b does not support tools",
7983 );
7984 }
7985 self.served_without_tools.store(true, Ordering::SeqCst);
7986 if is_stream(req) {
7987 ndjson(&[serde_json::json!({
7988 "message": {"content": "hello there"}, "done": true,
7989 "prompt_eval_count": 4, "eval_count": 2
7990 })])
7991 } else {
7992 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7993 "message": {"content": "probe answer"},
7994 "prompt_eval_count": 4, "eval_count": 2,
7995 }))
7996 }
7997 }
7998 }
7999
8000 #[tokio::test]
8001 async fn no_tools_model_recovers_by_dropping_tools() {
8002 let server = MockServer::start().await;
8003 let rejections = Arc::new(AtomicUsize::new(0));
8004 let served_without_tools = Arc::new(AtomicBool::new(false));
8005 Mock::given(method("POST"))
8006 .and(path("/api/chat"))
8007 .respond_with(NoToolsResponder {
8008 rejections: rejections.clone(),
8009 served_without_tools: served_without_tools.clone(),
8010 })
8011 .mount(&server)
8012 .await;
8013
8014 let messages = msgs();
8015 let caveats = Caveats::top();
8016 let (reply, streamed, _usage, _) =
8017 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8018 .await
8019 .expect("a no-tools model still answers a bare prompt");
8020
8021 assert_eq!(reply, "hello there", "the tools-absent retry answered");
8022 assert!(streamed);
8023 assert!(
8024 served_without_tools.load(Ordering::SeqCst),
8025 "a request without the tools field was eventually served"
8026 );
8027 assert_eq!(
8028 rejections.load(Ordering::SeqCst),
8029 1,
8030 "exactly one tools-bearing request 400s — the drop is self-limiting"
8031 );
8032 }
8033
8034 struct MalformedToolXmlResponder {
8039 rejections: Arc<AtomicUsize>,
8040 served_with_tools_after_error: Arc<AtomicBool>,
8041 served_without_tools: Arc<AtomicBool>,
8042 }
8043 impl Respond for MalformedToolXmlResponder {
8044 fn respond(&self, req: &Request) -> ResponseTemplate {
8045 if body_json(req).get("tools").is_some() {
8046 if self.rejections.fetch_add(1, Ordering::SeqCst) == 0 {
8047 return ResponseTemplate::new(500).set_body_json(serde_json::json!({
8048 "error": "XML syntax error on line 7: element <parameter> closed by </function>"
8049 }));
8050 }
8051 self.served_with_tools_after_error
8052 .store(true, Ordering::SeqCst);
8053 if is_stream(req) {
8054 return ndjson(&[serde_json::json!({
8055 "message": {"content": "recovered with tools still available"},
8056 "done": true,
8057 "prompt_eval_count": 4,
8058 "eval_count": 3
8059 })]);
8060 }
8061 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
8062 "message": {"content": "probe answer with tools still available"},
8063 "prompt_eval_count": 4,
8064 "eval_count": 3,
8065 }));
8066 }
8067 self.served_without_tools.store(true, Ordering::SeqCst);
8068 if is_stream(req) {
8069 ndjson(&[serde_json::json!({
8070 "message": {"content": "unexpected no-tools stream"},
8071 "done": true,
8072 "prompt_eval_count": 4, "eval_count": 3
8073 })])
8074 } else {
8075 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8076 "message": {"content": "unexpected no-tools probe"},
8077 "prompt_eval_count": 4, "eval_count": 3,
8078 }))
8079 }
8080 }
8081 }
8082
8083 #[tokio::test]
8084 async fn ollama_tool_xml_error_recovers_with_tools_still_available() {
8085 let server = MockServer::start().await;
8086 let rejections = Arc::new(AtomicUsize::new(0));
8087 let served_with_tools_after_error = Arc::new(AtomicBool::new(false));
8088 let served_without_tools = Arc::new(AtomicBool::new(false));
8089 Mock::given(method("POST"))
8090 .and(path("/api/chat"))
8091 .respond_with(MalformedToolXmlResponder {
8092 rejections: rejections.clone(),
8093 served_with_tools_after_error: served_with_tools_after_error.clone(),
8094 served_without_tools: served_without_tools.clone(),
8095 })
8096 .mount(&server)
8097 .await;
8098
8099 let messages = msgs();
8100 let caveats = Caveats::top();
8101 let (reply, streamed, _usage, _) =
8102 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8103 .await
8104 .expect("malformed XML tool-call parser errors should retry with tools");
8105
8106 assert_eq!(reply, "recovered with tools still available");
8107 assert!(streamed);
8108 assert!(
8109 served_with_tools_after_error.load(Ordering::SeqCst),
8110 "a tools-bearing request was served after the XML parser failure"
8111 );
8112 assert!(
8113 !served_without_tools.load(Ordering::SeqCst),
8114 "malformed XML must not disable tools for the turn"
8115 );
8116 assert_eq!(
8117 rejections.load(Ordering::SeqCst),
8118 3,
8119 "the XML error probe, retry probe, and streaming re-issue all keep tools advertised"
8120 );
8121 }
8122
8123 struct EmptyStreamResponder;
8126 impl Respond for EmptyStreamResponder {
8127 fn respond(&self, req: &Request) -> ResponseTemplate {
8128 if is_stream(req) {
8129 ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
8130 } else {
8131 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8132 "message": {"content": "probe says hi"},
8133 "prompt_eval_count": 5, "eval_count": 2,
8134 }))
8135 }
8136 }
8137 }
8138
8139 #[tokio::test]
8140 async fn empty_stream_falls_back_to_probe_content() {
8141 let server = MockServer::start().await;
8142 Mock::given(method("POST"))
8143 .and(path("/api/chat"))
8144 .respond_with(EmptyStreamResponder)
8145 .mount(&server)
8146 .await;
8147
8148 let messages = msgs();
8149 let caveats = Caveats::top();
8150 let (reply, streamed, usage, _) =
8151 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8152 .await
8153 .expect("chat_complete should succeed");
8154
8155 assert_eq!(reply, "probe says hi");
8156 assert!(!streamed, "fallback content was never streamed");
8157 assert_eq!(usage.unwrap().input_tokens, 5);
8158 }
8159
8160 struct EmptyStreamPendingActionResponder {
8165 probes: Arc<AtomicUsize>,
8166 }
8167 impl Respond for EmptyStreamPendingActionResponder {
8168 fn respond(&self, req: &Request) -> ResponseTemplate {
8169 if is_stream(req) {
8170 return ndjson(&[serde_json::json!({
8171 "message": {"content": ""},
8172 "done": true,
8173 "prompt_eval_count": 6,
8174 "eval_count": 0
8175 })]);
8176 }
8177 let probe = self.probes.fetch_add(1, Ordering::SeqCst);
8178 let content = if probe == 0 {
8179 "Now I understand the issue. Let me verify by looking at format_rollup_detail."
8180 } else {
8181 "Verified after the automatic continue."
8182 };
8183 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8184 "message": {"content": content},
8185 "prompt_eval_count": 5 + probe as u32,
8186 "eval_count": 2,
8187 }))
8188 }
8189 }
8190
8191 #[tokio::test]
8192 async fn empty_stream_probe_fallback_pending_action_nudges_and_continues() {
8193 let server = MockServer::start().await;
8194 let probes = Arc::new(AtomicUsize::new(0));
8195 Mock::given(method("POST"))
8196 .and(path("/api/chat"))
8197 .respond_with(EmptyStreamPendingActionResponder {
8198 probes: probes.clone(),
8199 })
8200 .mount(&server)
8201 .await;
8202
8203 let messages = msgs();
8204 let caveats = Caveats::top();
8205 let (reply, streamed, _usage, _) =
8206 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8207 .await
8208 .expect("chat_complete should auto-continue after pending probe fallback");
8209
8210 assert_eq!(
8211 probes.load(Ordering::SeqCst),
8212 2,
8213 "the nudge ran a second probe"
8214 );
8215 assert_eq!(reply, "Verified after the automatic continue.");
8216 assert!(!streamed, "the second answer also came from probe fallback");
8217 assert!(
8218 !reply.contains("Let me verify"),
8219 "must not return the pending-action narration"
8220 );
8221 }
8222
8223 struct AllEmptyResponder;
8226 impl Respond for AllEmptyResponder {
8227 fn respond(&self, req: &Request) -> ResponseTemplate {
8228 if is_stream(req) {
8229 ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
8230 } else {
8231 ResponseTemplate::new(200)
8232 .set_body_json(serde_json::json!({"message": {"content": ""}}))
8233 }
8234 }
8235 }
8236
8237 #[tokio::test]
8238 async fn fully_empty_response_yields_diagnostic_message() {
8239 let server = MockServer::start().await;
8240 Mock::given(method("POST"))
8241 .and(path("/api/chat"))
8242 .respond_with(AllEmptyResponder)
8243 .mount(&server)
8244 .await;
8245
8246 let messages = msgs();
8247 let caveats = Caveats::top();
8248 let (reply, streamed, _, _) =
8249 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8250 .await
8251 .expect("chat_complete should succeed");
8252
8253 assert!(
8254 reply.contains("model returned an empty response"),
8255 "got: {reply}"
8256 );
8257 assert!(reply.contains("newt doctor"), "points at diagnostics");
8258 assert!(!streamed);
8259 }
8260
8261 struct SuspiciousEmptyThenRecover {
8262 probes: Arc<AtomicUsize>,
8263 saw_nudge: Arc<AtomicBool>,
8264 }
8265 impl Respond for SuspiciousEmptyThenRecover {
8266 fn respond(&self, req: &Request) -> ResponseTemplate {
8267 if is_stream(req) {
8268 if self.probes.load(Ordering::SeqCst) <= 1 {
8269 ndjson(&[serde_json::json!({
8270 "message": {"content": ""},
8271 "done": true,
8272 "prompt_eval_count": 9,
8273 "eval_count": 4
8274 })])
8275 } else {
8276 ndjson(&[
8277 serde_json::json!({"message": {"content": "recovered "}, "done": false}),
8278 serde_json::json!({
8279 "message": {"content": "after empty retry"},
8280 "done": true,
8281 "prompt_eval_count": 5,
8282 "eval_count": 3
8283 }),
8284 ])
8285 }
8286 } else {
8287 let body = body_json(req);
8288 if body["messages"].as_array().into_iter().flatten().any(|m| {
8289 m["content"]
8290 .as_str()
8291 .unwrap_or("")
8292 .contains("no assistant-visible content")
8293 }) {
8294 self.saw_nudge.store(true, Ordering::SeqCst);
8295 }
8296 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8297 if n == 1 {
8298 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8299 "message": {
8300 "content": "",
8301 "thinking": "I know what to do but did not emit final text."
8302 },
8303 "prompt_eval_count": 10,
8304 "eval_count": 2559,
8305 }))
8306 } else {
8307 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8308 "message": {"content": "recovered after empty retry"},
8309 "prompt_eval_count": 5,
8310 "eval_count": 3,
8311 }))
8312 }
8313 }
8314 }
8315 }
8316
8317 #[tokio::test]
8318 async fn suspicious_empty_generated_output_retries_with_nudge() {
8319 let server = MockServer::start().await;
8320 let probes = Arc::new(AtomicUsize::new(0));
8321 let saw_nudge = Arc::new(AtomicBool::new(false));
8322 Mock::given(method("POST"))
8323 .and(path("/api/chat"))
8324 .respond_with(SuspiciousEmptyThenRecover {
8325 probes: probes.clone(),
8326 saw_nudge: saw_nudge.clone(),
8327 })
8328 .mount(&server)
8329 .await;
8330
8331 let messages = msgs();
8332 let caveats = Caveats::top();
8333 let (reply, streamed, usage, _) =
8334 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8335 .await
8336 .expect("chat_complete should succeed");
8337
8338 assert_eq!(reply, "recovered after empty retry");
8339 assert!(streamed);
8340 assert_eq!(probes.load(Ordering::SeqCst), 2);
8341 assert!(saw_nudge.load(Ordering::SeqCst));
8342 assert!(
8343 usage
8344 .expect("usage survives suspicious retry")
8345 .output_tokens
8346 >= 2566,
8347 "usage from the suspicious empty round must be preserved"
8348 );
8349 }
8350
8351 struct SuspiciousEmptyTwiceThenRecover {
8352 probes: Arc<AtomicUsize>,
8353 saw_strong_nudge: Arc<AtomicBool>,
8354 }
8355 impl Respond for SuspiciousEmptyTwiceThenRecover {
8356 fn respond(&self, req: &Request) -> ResponseTemplate {
8357 if is_stream(req) {
8358 if self.probes.load(Ordering::SeqCst) <= 2 {
8359 ndjson(&[serde_json::json!({
8360 "message": {"content": ""},
8361 "done": true,
8362 "prompt_eval_count": 9,
8363 "eval_count": 4
8364 })])
8365 } else {
8366 ndjson(&[serde_json::json!({
8367 "message": {"content": "recovered after strong hidden-only nudge"},
8368 "done": true,
8369 "prompt_eval_count": 5,
8370 "eval_count": 3
8371 })])
8372 }
8373 } else {
8374 let body = body_json(req);
8375 if body["messages"].as_array().into_iter().flatten().any(|m| {
8376 m["content"]
8377 .as_str()
8378 .unwrap_or("")
8379 .contains("Hidden thinking is not an action")
8380 }) {
8381 self.saw_strong_nudge.store(true, Ordering::SeqCst);
8382 }
8383 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8384 if n <= 2 {
8385 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8386 "message": {
8387 "content": "",
8388 "thinking": "I know the next action but did not emit it."
8389 },
8390 "prompt_eval_count": 10,
8391 "eval_count": 2559,
8392 }))
8393 } else {
8394 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8395 "message": {"content": "recovered after strong hidden-only nudge"},
8396 "prompt_eval_count": 5,
8397 "eval_count": 3,
8398 }))
8399 }
8400 }
8401 }
8402 }
8403
8404 #[tokio::test]
8405 async fn repeated_thinking_only_gets_stronger_second_nudge() {
8406 let server = MockServer::start().await;
8407 let probes = Arc::new(AtomicUsize::new(0));
8408 let saw_strong_nudge = Arc::new(AtomicBool::new(false));
8409 Mock::given(method("POST"))
8410 .and(path("/api/chat"))
8411 .respond_with(SuspiciousEmptyTwiceThenRecover {
8412 probes: probes.clone(),
8413 saw_strong_nudge: saw_strong_nudge.clone(),
8414 })
8415 .mount(&server)
8416 .await;
8417
8418 let messages = msgs();
8419 let caveats = Caveats::top();
8420 let (reply, streamed, _, _) =
8421 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8422 .await
8423 .expect("second hidden-only nudge should recover the turn");
8424
8425 assert_eq!(reply, "recovered after strong hidden-only nudge");
8426 assert!(streamed);
8427 assert_eq!(probes.load(Ordering::SeqCst), 3);
8428 assert!(saw_strong_nudge.load(Ordering::SeqCst));
8429 }
8430
8431 struct SuspiciousEmptyStaysEmpty;
8432 impl Respond for SuspiciousEmptyStaysEmpty {
8433 fn respond(&self, req: &Request) -> ResponseTemplate {
8434 if is_stream(req) {
8435 ndjson(&[serde_json::json!({
8436 "message": {"content": ""},
8437 "done": true,
8438 "prompt_eval_count": 9,
8439 "eval_count": 4
8440 })])
8441 } else {
8442 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8443 "message": {
8444 "content": "",
8445 "reasoning_content": "internal-only response"
8446 },
8447 "prompt_eval_count": 10,
8448 "eval_count": 12,
8449 }))
8450 }
8451 }
8452 }
8453
8454 #[tokio::test]
8455 async fn suspicious_empty_generated_output_reports_targeted_diagnostic() {
8456 let server = MockServer::start().await;
8457 Mock::given(method("POST"))
8458 .and(path("/api/chat"))
8459 .respond_with(SuspiciousEmptyStaysEmpty)
8460 .mount(&server)
8461 .await;
8462
8463 let messages = msgs();
8464 let caveats = Caveats::top();
8465 let uri = server.uri();
8466 let mut c = ctx(&uri, &messages, &caveats);
8467 c.trace = true;
8468 let (reply, streamed, _, _) = chat_complete(c, &mut NoMcp)
8469 .await
8470 .expect("chat_complete should succeed");
8471
8472 assert!(reply.contains("generated output tokens"), "got: {reply}");
8473 assert!(
8474 reply.contains("reasoning_content"),
8475 "diagnostic should name the non-content field: {reply}"
8476 );
8477 assert!(reply.contains("--trace"), "points at trace diagnostics");
8478 assert!(!streamed);
8479 }
8480
8481 struct OverflowThenRecover {
8485 probes: Arc<AtomicUsize>,
8486 }
8487 impl Respond for OverflowThenRecover {
8488 fn respond(&self, req: &Request) -> ResponseTemplate {
8489 if is_stream(req) {
8490 if self.probes.load(Ordering::SeqCst) <= 1 {
8492 ndjson(&[serde_json::json!({
8493 "message": {"content": ""}, "done": true,
8494 "prompt_eval_count": 90, "eval_count": 1
8495 })])
8496 } else {
8497 ndjson(&[
8498 serde_json::json!({"message": {"content": "recovered "}, "done": false}),
8499 serde_json::json!({
8500 "message": {"content": "after trim"}, "done": true,
8501 "prompt_eval_count": 12, "eval_count": 4
8502 }),
8503 ])
8504 }
8505 } else {
8506 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8507 if n == 1 {
8508 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8509 "message": {"content": ""},
8510 "prompt_eval_count": 90, "eval_count": 1,
8511 }))
8512 } else {
8513 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8514 "message": {"content": "recovered after trim"},
8515 "prompt_eval_count": 12, "eval_count": 4,
8516 }))
8517 }
8518 }
8519 }
8520 }
8521
8522 #[tokio::test]
8523 async fn context_overflow_trims_and_retries_then_recovers() {
8524 let server = MockServer::start().await;
8525 let probes = Arc::new(AtomicUsize::new(0));
8526 Mock::given(method("POST"))
8527 .and(path("/api/chat"))
8528 .respond_with(OverflowThenRecover {
8529 probes: probes.clone(),
8530 })
8531 .mount(&server)
8532 .await;
8533
8534 let messages = msgs();
8535 let caveats = Caveats::top();
8536 let uri = server.uri();
8537 let mut c = ctx(&uri, &messages, &caveats);
8538 c.safe_context = Some(100);
8544 let (reply, streamed, usage, _) = chat_complete(c, &mut NoMcp)
8545 .await
8546 .expect("chat_complete should succeed");
8547
8548 assert_eq!(
8549 probes.load(Ordering::SeqCst),
8550 2,
8551 "overflow must trigger exactly one trim-and-retry probe"
8552 );
8553 assert_eq!(reply, "recovered after trim");
8554 assert!(streamed);
8555 assert_eq!(
8556 usage
8557 .expect("accumulated usage survives the retry")
8558 .input_tokens,
8559 90,
8560 "largest single prompt across the overflowed + recovered rounds"
8561 );
8562 }
8563
8564 struct TrimObservingResponder {
8569 marker_seen: Arc<AtomicBool>,
8570 old_placeholder_seen: Arc<AtomicBool>,
8571 }
8572 impl Respond for TrimObservingResponder {
8573 fn respond(&self, req: &Request) -> ResponseTemplate {
8574 let body = body_json(req);
8575 let contains = |needle: &str| {
8576 body["messages"]
8577 .as_array()
8578 .map(|m| {
8579 m.iter().any(|msg| {
8580 msg["content"]
8581 .as_str()
8582 .map(|c| c.contains(needle))
8583 .unwrap_or(false)
8584 })
8585 })
8586 .unwrap_or(false)
8587 };
8588 if contains(SUMMARY_PREFIX) && contains("Summary generation was unavailable.") {
8589 self.marker_seen.store(true, Ordering::SeqCst);
8590 }
8591 if contains("earlier tool-call messages omitted") {
8592 self.old_placeholder_seen.store(true, Ordering::SeqCst);
8593 }
8594 if body.get("tools").is_some() {
8595 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8596 "message": {"content": "", "tool_calls": [{
8597 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8598 }]}
8599 }))
8600 } else {
8601 ResponseTemplate::new(200)
8602 .set_body_json(serde_json::json!({"message": {"content": "final after trim"}}))
8603 }
8604 }
8605 }
8606
8607 #[tokio::test]
8608 async fn mid_loop_compression_fires_when_message_list_grows() {
8609 let server = MockServer::start().await;
8610 let marker_seen = Arc::new(AtomicBool::new(false));
8611 let old_placeholder_seen = Arc::new(AtomicBool::new(false));
8612 Mock::given(method("POST"))
8613 .and(path("/api/chat"))
8614 .respond_with(TrimObservingResponder {
8615 marker_seen: marker_seen.clone(),
8616 old_placeholder_seen: old_placeholder_seen.clone(),
8617 })
8618 .mount(&server)
8619 .await;
8620
8621 let messages = msgs();
8622 let caveats = Caveats::top();
8623 let uri = server.uri();
8624 let mut c = ctx(&uri, &messages, &caveats);
8625 c.max_tool_rounds = 3;
8626 c.mid_loop_trim_threshold = 4;
8627 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8628 .await
8629 .expect("chat_complete should succeed");
8630
8631 assert!(
8632 marker_seen.load(Ordering::SeqCst),
8633 "the static compaction marker must have reached the model mid-loop"
8634 );
8635 assert!(
8636 !old_placeholder_seen.load(Ordering::SeqCst),
8637 "the pre-18.4 amputation placeholder must never be emitted"
8638 );
8639 assert_eq!(reply, "final after trim");
8640 }
8641
8642 struct EmptyFinalSummary;
8645 impl Respond for EmptyFinalSummary {
8646 fn respond(&self, req: &Request) -> ResponseTemplate {
8647 if body_json(req).get("tools").is_some() {
8648 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8649 "message": {"content": "", "tool_calls": [{
8650 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8651 }]}
8652 }))
8653 } else {
8654 ResponseTemplate::new(200)
8655 .set_body_json(serde_json::json!({"message": {"content": ""}}))
8656 }
8657 }
8658 }
8659
8660 #[tokio::test]
8661 async fn empty_final_summary_yields_cap_fallback() {
8662 let server = MockServer::start().await;
8663 Mock::given(method("POST"))
8664 .and(path("/api/chat"))
8665 .respond_with(EmptyFinalSummary)
8666 .mount(&server)
8667 .await;
8668
8669 let messages = msgs();
8670 let caveats = Caveats::top();
8671 let uri = server.uri();
8672 let mut c = ctx(&uri, &messages, &caveats);
8673 c.max_tool_rounds = 2;
8674 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8675 .await
8676 .expect("chat_complete should succeed");
8677
8678 assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
8679 assert!(reply.contains("max_tool_rounds"), "names the knob");
8680 }
8681
8682 struct HallucinatingFinalSummary;
8688 impl Respond for HallucinatingFinalSummary {
8689 fn respond(&self, req: &Request) -> ResponseTemplate {
8690 if body_json(req).get("tools").is_some() {
8691 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8692 "message": {"content": "", "tool_calls": [{
8693 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8694 }]}
8695 }))
8696 } else {
8697 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8698 "message": {"content":
8699 "The /end command is defined in newt-tui/src/commands.rs \
8700 (lines 38-40) as enum variants."}
8701 }))
8702 }
8703 }
8704 }
8705
8706 #[tokio::test]
8707 async fn cap_exit_hallucinated_path_gets_claim_check_refutation() {
8708 let server = MockServer::start().await;
8709 Mock::given(method("POST"))
8710 .and(path("/api/chat"))
8711 .respond_with(HallucinatingFinalSummary)
8712 .mount(&server)
8713 .await;
8714
8715 let messages = msgs();
8716 let caveats = Caveats::top();
8717 let uri = server.uri();
8718 let mut c = ctx(&uri, &messages, &caveats);
8719 c.max_tool_rounds = 2;
8720 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8723 .await
8724 .expect("chat_complete should succeed");
8725
8726 assert!(
8727 reply.contains("newt-tui/src/commands.rs (lines 38-40)"),
8728 "the model's prose is preserved verbatim: {reply}"
8729 );
8730 assert!(reply.contains("⚠ claim check (#867)"), "got: {reply}");
8731 assert!(
8732 reply.contains("`newt-tui/src/commands.rs`"),
8733 "the fabricated path is named in the refutation: {reply}"
8734 );
8735 }
8736
8737 #[tokio::test]
8742 async fn chat_complete_dispatches_openai_kind_and_returns_first_round_answer() {
8743 let server = MockServer::start().await;
8744 Mock::given(method("POST"))
8745 .and(path("/v1/chat/completions"))
8746 .and(header("authorization", "Bearer sk-test"))
8747 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8748 "choices": [{"message": {"content": "openai says hi"}}],
8749 "usage": {"prompt_tokens": 10, "completion_tokens": 4},
8750 })))
8751 .mount(&server)
8752 .await;
8753
8754 let messages = msgs();
8755 let caveats = Caveats::top();
8756 let uri = server.uri();
8757 let mut c = ctx(&uri, &messages, &caveats);
8758 c.kind = BackendKind::Openai;
8759 c.api_key = Some("sk-test");
8760 let (reply, streamed, usage, hallu) = chat_complete(c, &mut NoMcp)
8762 .await
8763 .expect("openai dispatch should succeed");
8764
8765 assert_eq!(reply, "openai says hi");
8766 assert!(!streamed, "openai path is non-streaming");
8767 let u = usage.unwrap();
8768 assert_eq!((u.input_tokens, u.output_tokens), (10, 4));
8769 assert_eq!(hallu, 0);
8770 }
8771
8772 #[tokio::test]
8773 async fn openai_strips_inline_think_and_never_returns_reasoning_content() {
8774 let server = MockServer::start().await;
8779 Mock::given(method("POST"))
8780 .and(path("/v1/chat/completions"))
8781 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8782 "choices": [{"message": {
8783 "content": "<think>secret chain of thought</think>The final answer.",
8784 "reasoning_content": "separate-channel reasoning"
8785 }}]
8786 })))
8787 .mount(&server)
8788 .await;
8789
8790 let messages = msgs();
8791 let caveats = Caveats::top();
8792 let uri = server.uri();
8793 let mut c = ctx(&uri, &messages, &caveats);
8794 c.kind = BackendKind::Openai;
8795 let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
8796 .await
8797 .expect("openai dispatch should succeed");
8798
8799 assert_eq!(reply, "The final answer.", "answer is the stripped content");
8800 assert!(!reply.contains("<think>"), "no think markers: {reply}");
8801 assert!(
8802 !reply.contains("secret chain of thought"),
8803 "inline CoT must not leak: {reply}"
8804 );
8805 assert!(
8806 !reply.contains("separate-channel reasoning"),
8807 "reasoning_content must not leak into the reply: {reply}"
8808 );
8809 }
8810
8811 #[tokio::test]
8812 async fn openai_empty_content_yields_diagnostic_message() {
8813 let server = MockServer::start().await;
8814 Mock::given(method("POST"))
8815 .and(path("/v1/chat/completions"))
8816 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8817 "choices": [{"message": {"content": ""}}]
8818 })))
8819 .mount(&server)
8820 .await;
8821
8822 let messages = msgs();
8823 let caveats = Caveats::top();
8824 let uri = server.uri();
8825 let mut c = ctx(&uri, &messages, &caveats);
8826 c.kind = BackendKind::Openai;
8827 let (reply, _, _, _) = chat_complete(c, &mut NoMcp).await.expect("should succeed");
8828 assert!(
8829 reply.contains("model returned an empty response"),
8830 "got: {reply}"
8831 );
8832 }
8833
8834 struct OneToolMcp {
8836 name: &'static str,
8837 result: &'static str,
8838 }
8839 #[async_trait::async_trait]
8840 impl McpTools for OneToolMcp {
8841 fn handles(&self, name: &str) -> bool {
8842 name == self.name
8843 }
8844 fn tool_defs(&self) -> Vec<serde_json::Value> {
8845 Vec::new()
8846 }
8847 async fn call(&mut self, _name: &str, _args: &serde_json::Value) -> String {
8848 self.result.to_string()
8849 }
8850 }
8851
8852 #[tokio::test]
8858 async fn openai_anthropic_native_tool_calls_route_correctly() {
8859 let server = MockServer::start().await;
8860 let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8863 let cc = call_count.clone();
8864 Mock::given(method("POST"))
8865 .and(path("/v1/chat/completions"))
8866 .respond_with(move |_req: &Request| {
8867 let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8868 if n == 0 {
8869 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8870 "choices": [{"message": {
8871 "content": null,
8872 "tool_calls": [{
8873 "type": "tool_use",
8874 "id": "toolu_01ABC",
8875 "name": "my_server__my_tool",
8876 "input": {"key": "value"}
8877 }]
8878 }}],
8879 "usage": {"prompt_tokens": 50, "completion_tokens": 10}
8880 }))
8881 } else {
8882 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8883 "choices": [{"message": {"content": "done after anthropic-native tool"}}],
8884 "usage": {"prompt_tokens": 60, "completion_tokens": 8}
8885 }))
8886 }
8887 })
8888 .mount(&server)
8889 .await;
8890
8891 let messages = msgs();
8892 let caveats = Caveats::top();
8893 let uri = server.uri();
8894 let mut c = ctx(&uri, &messages, &caveats);
8895 c.kind = BackendKind::Openai;
8896 let mut mcp = OneToolMcp {
8897 name: "my_server__my_tool",
8898 result: "tool-result-text",
8899 };
8900 let (reply, _, _, hallu) = chat_complete(c, &mut mcp)
8901 .await
8902 .expect("should succeed with anthropic-native tool format");
8903 assert_eq!(reply, "done after anthropic-native tool");
8904 assert_eq!(hallu, 0, "should not be counted as hallucination");
8905 assert_eq!(
8906 call_count.load(std::sync::atomic::Ordering::SeqCst),
8907 2,
8908 "must have done both rounds (tool call + final answer)"
8909 );
8910 }
8911
8912 #[tokio::test]
8916 async fn openai_hyphenated_server_name_routes_through_mcp() {
8917 let server = MockServer::start().await;
8918 let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8919 let cc = call_count.clone();
8920 Mock::given(method("POST"))
8921 .and(path("/v1/chat/completions"))
8922 .respond_with(move |_req: &Request| {
8923 let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8924 if n == 0 {
8925 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8928 "choices": [{"message": {
8929 "content": "",
8930 "tool_calls": [{
8931 "index": 0,
8932 "function": {
8933 "arguments": "{}",
8934 "name": "acme_server__probe_tool"
8935 },
8936 "id": "call_probe_01",
8937 "type": "function"
8938 }]
8939 }}],
8940 "usage": {"prompt_tokens": 599, "completion_tokens": 30}
8941 }))
8942 } else {
8943 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8944 "choices": [{"message": {"content": "outlook routed correctly"}}]
8945 }))
8946 }
8947 })
8948 .mount(&server)
8949 .await;
8950
8951 let messages = msgs();
8952 let caveats = Caveats::top();
8953 let uri = server.uri();
8954 let mut c = ctx(&uri, &messages, &caveats);
8955 c.kind = BackendKind::Openai;
8956 let mut mcp = OneToolMcp {
8958 name: "acme_server__probe_tool",
8959 result: "ok",
8960 };
8961 let (reply, _, _, _) = chat_complete(c, &mut mcp)
8962 .await
8963 .expect("should route hyphenated server name through mcp");
8964 assert_eq!(reply, "outlook routed correctly");
8965 assert_eq!(
8966 call_count.load(std::sync::atomic::Ordering::SeqCst),
8967 2,
8968 "must have completed both rounds"
8969 );
8970 }
8971
8972 struct OpenAiErrOnFinal;
8975 impl Respond for OpenAiErrOnFinal {
8976 fn respond(&self, req: &Request) -> ResponseTemplate {
8977 if body_json(req).get("tools").is_some() {
8978 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8979 "choices": [{"message": {
8980 "content": null,
8981 "tool_calls": [{
8982 "id": "call_1",
8983 "type": "function",
8984 "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
8985 }]
8986 }}]
8987 }))
8988 } else {
8989 ResponseTemplate::new(400).set_body_string("bad request")
8990 }
8991 }
8992 }
8993
8994 #[tokio::test]
8995 async fn openai_cap_exit_fallback_when_final_summary_errors() {
8996 let server = MockServer::start().await;
8997 Mock::given(method("POST"))
8998 .and(path("/v1/chat/completions"))
8999 .respond_with(OpenAiErrOnFinal)
9000 .mount(&server)
9001 .await;
9002
9003 let messages = msgs();
9004 let caveats = Caveats::top();
9005 let uri = server.uri();
9006 let mut c = ctx(&uri, &messages, &caveats);
9007 c.kind = BackendKind::Openai;
9008 c.max_tool_rounds = 2;
9009 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9010 .await
9011 .expect("must succeed even when the summary errors");
9012 assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
9013 assert!(reply.contains("max_tool_rounds"));
9014 }
9015
9016 struct ScriptedOpenAi {
9021 round: Arc<AtomicUsize>,
9022 script: Vec<serde_json::Value>,
9023 }
9024 impl Respond for ScriptedOpenAi {
9025 fn respond(&self, _req: &Request) -> ResponseTemplate {
9026 let i = self.round.fetch_add(1, Ordering::SeqCst);
9027 let msg = self
9028 .script
9029 .get(i)
9030 .or_else(|| self.script.last())
9031 .cloned()
9032 .unwrap_or_else(|| serde_json::json!({ "content": "final." }));
9033 ResponseTemplate::new(200)
9034 .set_body_json(serde_json::json!({ "choices": [{ "message": msg }] }))
9035 }
9036 }
9037
9038 async fn run_openai_script_with_ledger(
9040 script: Vec<serde_json::Value>,
9041 step_ledger: Option<&dyn StepLedger>,
9042 ) -> (String, usize) {
9043 let server = MockServer::start().await;
9044 let round = Arc::new(AtomicUsize::new(0));
9045 Mock::given(method("POST"))
9046 .and(path("/v1/chat/completions"))
9047 .respond_with(ScriptedOpenAi {
9048 round: round.clone(),
9049 script,
9050 })
9051 .mount(&server)
9052 .await;
9053 let messages = msgs();
9054 let caveats = Caveats::top();
9055 let uri = server.uri();
9056 let mut c = ctx(&uri, &messages, &caveats);
9057 c.kind = BackendKind::Openai;
9058 c.step_ledger = step_ledger;
9059 let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9060 (reply, round.load(Ordering::SeqCst))
9061 }
9062
9063 async fn run_openai_script(script: Vec<serde_json::Value>) -> (String, usize) {
9064 run_openai_script_with_ledger(script, None).await
9065 }
9066
9067 async fn run_openai_script_with_cap(
9070 script: Vec<serde_json::Value>,
9071 narration_nudge_cap: usize,
9072 ) -> (String, usize) {
9073 let server = MockServer::start().await;
9074 let round = Arc::new(AtomicUsize::new(0));
9075 Mock::given(method("POST"))
9076 .and(path("/v1/chat/completions"))
9077 .respond_with(ScriptedOpenAi {
9078 round: round.clone(),
9079 script,
9080 })
9081 .mount(&server)
9082 .await;
9083 let messages = msgs();
9084 let caveats = Caveats::top();
9085 let uri = server.uri();
9086 let mut c = ctx(&uri, &messages, &caveats);
9087 c.kind = BackendKind::Openai;
9088 c.narration_nudge_cap = narration_nudge_cap;
9089 let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9090 (reply, round.load(Ordering::SeqCst))
9091 }
9092
9093 #[tokio::test]
9094 async fn narrated_intent_with_no_tool_call_nudges_and_continues() {
9095 let (reply, rounds) = run_openai_script(vec![
9099 serde_json::json!({ "content": "Let me edit the file now." }),
9100 serde_json::json!({ "content": "All done — the edit is complete." }),
9101 ])
9102 .await;
9103 assert_eq!(rounds, 2, "must run a second round after the nudge");
9104 assert!(
9105 reply.contains("complete"),
9106 "returns the post-nudge answer: {reply}"
9107 );
9108 assert!(
9109 !reply.contains("Let me edit"),
9110 "must not return the narration: {reply}"
9111 );
9112 }
9113
9114 #[tokio::test]
9115 async fn narration_auto_continue_is_bounded_by_the_cap() {
9116 let (reply, rounds) = run_openai_script(vec![
9119 serde_json::json!({ "content": "Let me keep editing now." }),
9120 serde_json::json!({ "content": "Let me keep editing now." }),
9121 serde_json::json!({ "content": "Let me keep editing now." }),
9122 ])
9123 .await;
9124 assert_eq!(
9125 rounds, 2,
9126 "exactly one nudge (cap=1), then accept, got {rounds}"
9127 );
9128 assert!(
9129 reply.contains("editing"),
9130 "narration accepted as final: {reply}"
9131 );
9132 }
9133
9134 #[tokio::test]
9135 async fn narration_nudge_cap_two_allows_a_second_escalated_rescue() {
9136 let (reply, rounds) = run_openai_script_with_cap(
9141 vec![
9142 serde_json::json!({ "content": "Let me keep editing now." }),
9143 serde_json::json!({ "content": "Let me keep editing now." }),
9144 serde_json::json!({ "content": "All done — the edit is complete." }),
9145 ],
9146 2,
9147 )
9148 .await;
9149 assert_eq!(
9150 rounds, 3,
9151 "two nudges (cap=2) before the recovery, got {rounds}"
9152 );
9153 assert!(
9154 reply.contains("complete"),
9155 "returns the post-nudge answer: {reply}"
9156 );
9157 }
9158
9159 #[tokio::test]
9160 async fn narration_nudge_cap_two_still_accepts_after_exhaustion() {
9161 let (reply, rounds) = run_openai_script_with_cap(
9164 vec![
9165 serde_json::json!({ "content": "Let me keep editing now." }),
9166 serde_json::json!({ "content": "Let me keep editing now." }),
9167 serde_json::json!({ "content": "Let me keep editing now." }),
9168 serde_json::json!({ "content": "Let me keep editing now." }),
9169 ],
9170 2,
9171 )
9172 .await;
9173 assert_eq!(rounds, 3, "two nudges, then accept, got {rounds}");
9174 assert!(
9175 reply.contains("editing"),
9176 "narration accepted as final: {reply}"
9177 );
9178 }
9179
9180 #[test]
9181 fn escalated_narration_nudge_names_attempt_cap_and_active_step() {
9182 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
9183
9184 let ledger = SessionStepLedger::default();
9185 ledger.restore(&PlanSnapshot {
9186 steps: vec![
9187 Step {
9188 description: "inspect".to_string(),
9189 status: StepStatus::Done,
9190 },
9191 Step {
9192 description: "fix conflict markers".to_string(),
9193 status: StepStatus::Active,
9194 },
9195 ],
9196 });
9197 let text = escalated_narration_action_nudge(2, 3, Some(&ledger as &dyn StepLedger));
9198 assert!(text.contains("Reminder 2/3"), "{text}");
9199 assert!(text.contains("fix conflict markers"), "{text}");
9200 assert!(text.contains("tool call"), "{text}");
9201
9202 let bare = escalated_narration_action_nudge(2, 2, None);
9204 assert!(bare.contains("Reminder 2/2"), "{bare}");
9205 assert!(!bare.contains("Active step"), "{bare}");
9206 }
9207
9208 #[tokio::test]
9209 async fn accepted_narration_reports_cap_exhausted_end_reason() {
9210 let server = MockServer::start().await;
9214 let round = Arc::new(AtomicUsize::new(0));
9215 Mock::given(method("POST"))
9216 .and(path("/v1/chat/completions"))
9217 .respond_with(ScriptedOpenAi {
9218 round: round.clone(),
9219 script: vec![
9220 serde_json::json!({ "content": "Let me keep editing now." }),
9221 serde_json::json!({ "content": "Let me keep editing now." }),
9222 ],
9223 })
9224 .mount(&server)
9225 .await;
9226 let messages = msgs();
9227 let caveats = Caveats::top();
9228 let uri = server.uri();
9229 let mut end_reason: Option<crate::TurnEndReason> = None;
9230 let mut c = ctx(&uri, &messages, &caveats);
9231 c.kind = BackendKind::Openai;
9232 c.end_reason = Some(&mut end_reason);
9233 let _ = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9234 assert_eq!(
9235 end_reason,
9236 Some(crate::TurnEndReason::NarrationCapExhausted)
9237 );
9238 }
9239
9240 #[tokio::test]
9241 async fn genuine_completion_reports_completed_end_reason() {
9242 let server = MockServer::start().await;
9243 let round = Arc::new(AtomicUsize::new(0));
9244 Mock::given(method("POST"))
9245 .and(path("/v1/chat/completions"))
9246 .respond_with(ScriptedOpenAi {
9247 round: round.clone(),
9248 script: vec![serde_json::json!({ "content": "The capital of France is Paris." })],
9249 })
9250 .mount(&server)
9251 .await;
9252 let messages = msgs();
9253 let caveats = Caveats::top();
9254 let uri = server.uri();
9255 let mut end_reason: Option<crate::TurnEndReason> = None;
9256 let mut c = ctx(&uri, &messages, &caveats);
9257 c.kind = BackendKind::Openai;
9258 c.end_reason = Some(&mut end_reason);
9259 let _ = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9260 assert_eq!(end_reason, Some(crate::TurnEndReason::Completed));
9261 }
9262
9263 #[tokio::test]
9264 async fn narration_nudge_reaches_the_wire_tagged_as_loop_guidance() {
9265 let server = MockServer::start().await;
9268 let saw_tag = Arc::new(AtomicBool::new(false));
9269
9270 struct TagProbe {
9271 saw_tag: Arc<AtomicBool>,
9272 }
9273 impl Respond for TagProbe {
9274 fn respond(&self, req: &Request) -> ResponseTemplate {
9275 let body = body_json(req);
9276 let tagged = body["messages"].as_array().is_some_and(|ms| {
9277 ms.iter().any(|m| {
9278 m["role"] == "user"
9279 && m["content"]
9280 .as_str()
9281 .is_some_and(|c| c.starts_with(compress::LOOP_GUIDANCE_PREFIX))
9282 })
9283 });
9284 if tagged {
9285 self.saw_tag.store(true, Ordering::SeqCst);
9286 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
9287 "choices": [{ "message": { "content": "All done — edit complete." } }]
9288 }));
9289 }
9290 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9291 "choices": [{ "message": { "content": "Let me edit the file now." } }]
9292 }))
9293 }
9294 }
9295 Mock::given(method("POST"))
9296 .and(path("/v1/chat/completions"))
9297 .respond_with(TagProbe {
9298 saw_tag: saw_tag.clone(),
9299 })
9300 .mount(&server)
9301 .await;
9302
9303 let messages = msgs();
9304 let caveats = Caveats::top();
9305 let uri = server.uri();
9306 let mut c = ctx(&uri, &messages, &caveats);
9307 c.kind = BackendKind::Openai;
9308 let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9309 assert!(
9310 saw_tag.load(Ordering::SeqCst),
9311 "the narration nudge must carry LOOP_GUIDANCE_PREFIX on the wire"
9312 );
9313 assert!(reply.contains("complete"), "{reply}");
9314 }
9315
9316 #[tokio::test]
9317 async fn ollama_loop_honors_cap_two_and_escalates_the_second_nudge() {
9318 let server = MockServer::start().await;
9324 let saw_first = Arc::new(AtomicBool::new(false));
9325 let saw_escalated = Arc::new(AtomicBool::new(false));
9326
9327 struct EscalationProbe {
9328 saw_first: Arc<AtomicBool>,
9329 saw_escalated: Arc<AtomicBool>,
9330 }
9331 impl Respond for EscalationProbe {
9332 fn respond(&self, req: &Request) -> ResponseTemplate {
9333 let body = body_json(req);
9334 let has = |needle: &str| {
9335 body["messages"].as_array().is_some_and(|ms| {
9336 ms.iter()
9337 .any(|m| m["content"].as_str().is_some_and(|c| c.contains(needle)))
9338 })
9339 };
9340 if has("Reminder 2/2") {
9341 self.saw_escalated.store(true, Ordering::SeqCst);
9342 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
9343 "message": { "content": "All done — the edit is complete." }
9344 }));
9345 }
9346 if has(compress::LOOP_GUIDANCE_PREFIX) {
9347 self.saw_first.store(true, Ordering::SeqCst);
9348 }
9349 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9350 "message": { "content": "Let me keep editing now." }
9351 }))
9352 }
9353 }
9354 Mock::given(method("POST"))
9355 .and(path("/api/chat"))
9356 .respond_with(EscalationProbe {
9357 saw_first: saw_first.clone(),
9358 saw_escalated: saw_escalated.clone(),
9359 })
9360 .mount(&server)
9361 .await;
9362
9363 let messages = msgs();
9364 let caveats = Caveats::top();
9365 let uri = server.uri();
9366 let mut c = ctx(&uri, &messages, &caveats);
9367 c.kind = BackendKind::Ollama;
9368 c.narration_nudge_cap = 2;
9369 let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9370 assert!(
9371 saw_first.load(Ordering::SeqCst),
9372 "the first nudge must reach the Ollama wire tagged [loop-guidance]"
9373 );
9374 assert!(
9375 saw_escalated.load(Ordering::SeqCst),
9376 "the second nudge must be the escalated Reminder 2/2 variant"
9377 );
9378 assert!(reply.contains("complete"), "{reply}");
9379 }
9380
9381 #[test]
9382 fn post_compaction_refunds_rescue_budget_and_appends_one_directive() {
9383 let directive_count = |messages: &[serde_json::Value]| {
9384 messages
9385 .iter()
9386 .filter(|m| {
9387 m["content"]
9388 .as_str()
9389 .is_some_and(|c| c.starts_with(compress::CONTINUATION_PREFIX))
9390 })
9391 .count()
9392 };
9393 let mut messages = vec![
9394 serde_json::json!({"role": "system", "content": "you are a test"}),
9395 serde_json::json!({"role": "user", "content": "do the thing"}),
9396 serde_json::json!({
9397 "role": "user",
9398 "content": format!("{} stale directive", compress::CONTINUATION_PREFIX)
9399 }),
9400 ];
9401 let mut nudges = 1usize;
9402
9403 apply_post_compaction_continuation(
9405 &mut messages,
9406 &mut nudges,
9407 CompressAction::Pruned,
9408 None,
9409 true,
9410 );
9411 assert_eq!(nudges, 1, "prune must not refund the rescue budget");
9412 assert_eq!(messages.len(), 3, "prune must not touch the directive");
9413
9414 apply_post_compaction_continuation(
9418 &mut messages,
9419 &mut nudges,
9420 CompressAction::Summarized,
9421 None,
9422 false,
9423 );
9424 assert_eq!(nudges, 1, "round 0 must not touch the rescue budget");
9425 assert_eq!(messages.len(), 3, "round 0 must not inject the directive");
9426
9427 apply_post_compaction_continuation(
9431 &mut messages,
9432 &mut nudges,
9433 CompressAction::Summarized,
9434 None,
9435 true,
9436 );
9437 assert_eq!(nudges, 0, "summarization refunds the rescue budget");
9438 assert_eq!(directive_count(&messages), 1, "at most one directive alive");
9439 let last = messages.last().unwrap();
9440 assert_eq!(last["role"], "user");
9441 let content = last["content"].as_str().unwrap();
9442 assert!(
9443 content.starts_with(compress::CONTINUATION_PREFIX),
9444 "{content}"
9445 );
9446 assert!(content.contains("tool call"), "{content}");
9447 assert!(!content.contains("stale directive"), "{content}");
9448 }
9449
9450 #[tokio::test]
9451 async fn genuine_final_answer_is_not_nudged() {
9452 let (reply, rounds) = run_openai_script(vec![
9455 serde_json::json!({ "content": "The capital of France is Paris." }),
9456 ])
9457 .await;
9458 assert_eq!(
9459 rounds, 1,
9460 "a plain final answer is not nudged, got {rounds}"
9461 );
9462 assert!(reply.contains("Paris"), "returns the answer: {reply}");
9463 }
9464
9465 #[tokio::test]
9466 async fn final_answer_after_a_tool_call_is_not_nudged() {
9467 let (reply, rounds) = run_openai_script(vec![
9471 serde_json::json!({
9472 "content": null,
9473 "tool_calls": [{
9474 "id": "c1", "type": "function",
9475 "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
9476 }]
9477 }),
9478 serde_json::json!({ "content": "The files were examined; everything checks out." }),
9479 ])
9480 .await;
9481 assert_eq!(
9482 rounds, 2,
9483 "tool call (r0) then final answer (r1) — no extra round, got {rounds}"
9484 );
9485 assert!(
9486 reply.contains("checks out"),
9487 "returns the final answer as-is: {reply}"
9488 );
9489 }
9490
9491 #[tokio::test]
9492 async fn observed_fix_intent_after_a_tool_call_nudges_and_continues() {
9493 let (reply, rounds) = run_openai_script(vec![
9496 serde_json::json!({
9497 "content": null,
9498 "tool_calls": [{
9499 "id": "c1", "type": "function",
9500 "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
9501 }]
9502 }),
9503 serde_json::json!({
9504 "content": "I found the issue - there's an extra closing brace } on line 809 of help_sections.rs that's causing a syntax error. I need to remove this stray brace."
9505 }),
9506 serde_json::json!({ "content": "The stray brace is removed and the compile error is fixed." }),
9507 ])
9508 .await;
9509 assert_eq!(
9510 rounds, 3,
9511 "tool call, narrated edit intent, then post-nudge answer; got {rounds}"
9512 );
9513 assert!(
9514 reply.contains("compile error is fixed"),
9515 "returns the post-nudge answer: {reply}"
9516 );
9517 assert!(
9518 !reply.contains("I need to remove"),
9519 "must not stop on the narrated edit intent: {reply}"
9520 );
9521 }
9522
9523 #[tokio::test]
9524 async fn pending_plan_final_answer_nudges_before_handoff() {
9525 let ledger = SessionStepLedger::default();
9526 ledger.restore(&PlanSnapshot {
9527 steps: vec![
9528 Step {
9529 description: "convert help sections".to_string(),
9530 status: StepStatus::Done,
9531 },
9532 Step {
9533 description: "fix format_command_list and update lib.rs".to_string(),
9534 status: StepStatus::Active,
9535 },
9536 Step {
9537 description: "add tests".to_string(),
9538 status: StepStatus::Todo,
9539 },
9540 ],
9541 });
9542 let (reply, rounds) = run_openai_script_with_ledger(
9543 vec![
9544 serde_json::json!({
9545 "content": "I need to finish Step 2, then Steps 3-5."
9546 }),
9547 serde_json::json!({
9548 "content": "Plan updated; continuing with the active step."
9549 }),
9550 serde_json::json!({
9551 "content": "The active step is now complete."
9552 }),
9553 ],
9554 Some(&ledger as &dyn StepLedger),
9555 )
9556 .await;
9557 assert_eq!(
9558 rounds, 3,
9559 "open plan should force a completion-gate round and action-nudge follow-on narration"
9560 );
9561 assert!(
9562 reply.contains("complete"),
9563 "returns the post-nudge answer: {reply}"
9564 );
9565 assert!(
9566 !reply.contains("I need to finish"),
9567 "must not accept a plain handoff while plan is open: {reply}"
9568 );
9569 }
9570
9571 #[tokio::test]
9572 async fn findings_summary_with_stale_plan_nudges_update_plan_then_continues() {
9573 let ledger = SessionStepLedger::default();
9574 ledger.restore(&PlanSnapshot {
9575 steps: vec![
9576 Step {
9577 description: "convert help sections".to_string(),
9578 status: StepStatus::Done,
9579 },
9580 Step {
9581 description: "wire progressive dispatch in lib.rs".to_string(),
9582 status: StepStatus::Active,
9583 },
9584 Step {
9585 description: "add tests".to_string(),
9586 status: StepStatus::Todo,
9587 },
9588 ],
9589 });
9590 let findings = "\
9591Summary of Findings
9592
9593Across the tool calls, I observed two issues in newt-tui/src/help_sections.rs:
95941. Duplicate function definitions
95952. Stray closing brace
9596
9597Current Status
9598
9599The build is broken due to these syntax errors. The plan was at step 2, but we need to fix the immediate compilation issues first before proceeding with feature work.
9600
9601Next Steps Required
9602
9603To continue, I would need to remove the duplicate function using edit_file, locate and remove the stray brace, verify cargo check, then proceed with step 2 of the plan.
9604
9605However, I've reached the tool-call limit and cannot make these edits now.";
9606 let (reply, rounds) = run_openai_script_with_ledger(
9607 vec![
9608 serde_json::json!({ "content": findings }),
9609 serde_json::json!({
9610 "content": null,
9611 "tool_calls": [{
9612 "id": "plan_1",
9613 "type": "function",
9614 "function": {
9615 "name": "update_plan",
9616 "arguments": serde_json::json!({
9617 "plan": [
9618 {"step": "fix duplicate help rollup functions and stray brace", "status": "in_progress"},
9619 {"step": "wire progressive dispatch in lib.rs", "status": "pending"},
9620 {"step": "add rollup tests", "status": "pending"}
9621 ]
9622 }).to_string()
9623 }
9624 }]
9625 }),
9626 serde_json::json!({
9627 "content": null,
9628 "tool_calls": [{
9629 "id": "edit_1",
9630 "type": "function",
9631 "function": {
9632 "name": "definitely_not_a_real_tool",
9633 "arguments": "{}"
9634 }
9635 }]
9636 }),
9637 serde_json::json!({ "content": "Done." }),
9638 ],
9639 Some(&ledger as &dyn StepLedger),
9640 )
9641 .await;
9642 assert_eq!(
9643 rounds, 4,
9644 "findings summary should be nudged into update_plan, then a concrete tool"
9645 );
9646 assert_eq!(reply, "Done.");
9647 assert!(
9648 !reply.contains("tool-call limit"),
9649 "must not accept the handoff summary: {reply}"
9650 );
9651 let snap = ledger.snapshot();
9652 assert_eq!(
9653 snap.steps[0].description,
9654 "fix duplicate help rollup functions and stray brace"
9655 );
9656 assert_eq!(snap.steps[0].status, StepStatus::Active);
9657 }
9658
9659 #[tokio::test]
9660 async fn completed_plan_final_answer_is_accepted() {
9661 let ledger = SessionStepLedger::default();
9662 ledger.restore(&PlanSnapshot {
9663 steps: vec![Step {
9664 description: "done".to_string(),
9665 status: StepStatus::Done,
9666 }],
9667 });
9668 let (reply, rounds) = run_openai_script_with_ledger(
9669 vec![serde_json::json!({
9670 "content": "All plan steps are complete."
9671 })],
9672 Some(&ledger as &dyn StepLedger),
9673 )
9674 .await;
9675 assert_eq!(rounds, 1, "completed plan must not be nudged");
9676 assert!(reply.contains("complete"), "returns final answer: {reply}");
9677 }
9678
9679 #[tokio::test]
9680 async fn continuing_with_active_step_after_plan_nudge_gets_action_nudge() {
9681 let ledger = SessionStepLedger::default();
9682 ledger.restore(&PlanSnapshot {
9683 steps: vec![
9684 Step {
9685 description: "convert help sections".to_string(),
9686 status: StepStatus::Done,
9687 },
9688 Step {
9689 description: "insert progressive dispatch".to_string(),
9690 status: StepStatus::Active,
9691 },
9692 Step {
9693 description: "add tests".to_string(),
9694 status: StepStatus::Todo,
9695 },
9696 ],
9697 });
9698 let (reply, rounds) = run_openai_script_with_ledger(
9699 vec![
9700 serde_json::json!({
9701 "content": "I need to finish Step 2, then Steps 3-5."
9702 }),
9703 serde_json::json!({
9704 "content": "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
9705 }),
9706 serde_json::json!({
9707 "content": "The edit is now complete."
9708 }),
9709 ],
9710 Some(&ledger as &dyn StepLedger),
9711 )
9712 .await;
9713 assert_eq!(
9714 rounds, 3,
9715 "plan nudge should be followed by an action nudge for continuing-with narration"
9716 );
9717 assert!(
9718 reply.contains("complete"),
9719 "returns the post-action-nudge answer: {reply}"
9720 );
9721 assert!(
9722 !reply.contains("Continuing with step 2"),
9723 "must not stop on the continuing-with narration: {reply}"
9724 );
9725 }
9726
9727 #[tokio::test]
9728 async fn stale_file_blocker_nudges_ground_truth_check_and_continues() {
9729 let blocker = "\
9730Summary
9731
9732What happened: The lib.rs file I was editing grew from ~9400 to ~16808 lines \
9733between reads — likely modified concurrently by another agent or tool. This \
9734means my old edit contexts are stale.
9735
9736Why I'm blocked: I cannot safely use edit_file on lib.rs because the file has \
9737been modified out from under me. My old line references and context are invalid \
9738for an 8400-line larger file.
9739
9740Final Answer / Recommendation
9741
9742The operator should restore lib.rs to a known-good state (e.g., git checkout \
9743newt-tui/src/lib.rs).";
9744 let (reply, rounds) = run_openai_script(vec![
9745 serde_json::json!({ "content": blocker }),
9746 serde_json::json!({ "content": "Ground truth checked; lib.rs is clean, so I am continuing." }),
9747 ])
9748 .await;
9749 assert_eq!(
9750 rounds, 2,
9751 "stale-file blocker should get one verification nudge"
9752 );
9753 assert!(
9754 reply.contains("lib.rs is clean"),
9755 "returns the post-nudge answer: {reply}"
9756 );
9757 assert!(
9758 !reply.contains("git checkout"),
9759 "must not accept the unverified revert recommendation: {reply}"
9760 );
9761 }
9762
9763 #[test]
9764 fn looks_like_intent_to_act_separates_narration_from_final_answers() {
9765 assert!(looks_like_intent_to_act(
9767 "Now I have everything I need. Let me make both edits now."
9768 ));
9769 assert!(looks_like_intent_to_act(
9770 "Now I'll add the --home flag to the Cli struct."
9771 ));
9772 assert!(looks_like_intent_to_act("Let me keep editing now."));
9773 assert!(looks_like_intent_to_act(
9774 "I'm going to edit the config file."
9775 ));
9776 assert!(looks_like_intent_to_act(
9777 "Let me understand what was already done on this branch and compare it with the issue requirements."
9778 ));
9779 assert!(looks_like_intent_to_act(
9780 "Let me check the current implementation and identify any gaps."
9781 ));
9782 assert!(looks_like_intent_to_act(
9783 "The help section logic itself has no tests yet.\n\nLet me commit this first step, then move on:"
9784 ));
9785 assert!(looks_like_intent_to_act(
9786 "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
9787 ));
9788 assert!(looks_like_intent_to_act(
9789 "I found the issue - there's an extra closing brace } on line 809 of help_sections.rs that's causing a syntax error. I need to remove this stray brace."
9790 ));
9791 assert!(!looks_like_intent_to_act("The capital of France is Paris."));
9793 assert!(!looks_like_intent_to_act(
9794 "I have finished editing the file and the tests pass."
9795 ));
9796 assert!(!looks_like_intent_to_act(
9797 "Here is a summary of what I found across the tool calls."
9798 ));
9799 assert!(!looks_like_intent_to_act(
9801 "Done. Let me know if you want any further changes."
9802 ));
9803 let multibyte = format!("{}let me edit", "…".repeat(200));
9807 assert!(looks_like_intent_to_act(&multibyte));
9808 }
9809
9810 #[test]
9811 fn looks_like_unverified_stale_file_blocker_requires_file_stale_and_blocker_cues() {
9812 assert!(looks_like_unverified_stale_file_blocker(
9813 "The lib.rs file I was editing grew from ~9400 to ~16808 lines between reads. \
9814 Why I'm blocked: I cannot safely use edit_file because the file has been \
9815 modified out from under me. The operator should restore lib.rs."
9816 ));
9817 assert!(looks_like_unverified_stale_file_blocker(
9818 "My old line references are invalid and the context is stale. Any edit could \
9819 land in the wrong place and corrupt the code; recommendation: restore the file."
9820 ));
9821 assert!(!looks_like_unverified_stale_file_blocker(
9822 "The cache entry is stale, so I refreshed it and continued."
9823 ));
9824 assert!(!looks_like_unverified_stale_file_blocker(
9825 "I checked git diff and the file is clean, so I can continue from the verified contents."
9826 ));
9827 }
9828
9829 #[test]
9830 fn stale_file_ground_truth_nudge_names_read_only_checks_and_revert_guard() {
9831 let nudge = stale_file_ground_truth_nudge();
9832 assert!(nudge.contains("git status --short"), "{nudge}");
9833 assert!(nudge.contains("git diff -- <file>"), "{nudge}");
9834 assert!(nudge.contains("wc -l <file>"), "{nudge}");
9835 assert!(nudge.contains("re-read the exact target range"), "{nudge}");
9836 assert!(
9837 nudge.contains("Never recommend git checkout/revert"),
9838 "{nudge}"
9839 );
9840 }
9841}
9842
9843#[cfg(test)]
9855mod save_note_loop_tests {
9856 use super::note_sink::tests::MockSink;
9857 use super::*;
9858 use crate::caveats::Caveats;
9859 use crate::{BackendKind, MemMessage};
9860 use std::sync::atomic::{AtomicBool, Ordering};
9861 use std::sync::Arc;
9862 use wiremock::matchers::{method, path};
9863 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
9864
9865 fn msgs() -> Vec<MemMessage> {
9866 vec![
9867 MemMessage::system("you are a test"),
9868 MemMessage::user("do the thing"),
9869 ]
9870 }
9871
9872 fn ctx<'a>(
9873 server_uri: &'a str,
9874 messages: &'a [MemMessage],
9875 caveats: &'a Caveats,
9876 ) -> ChatCtx<'a> {
9877 ChatCtx {
9878 url: server_uri,
9879 model: "test-model",
9880 kind: BackendKind::Ollama,
9881 api_key: None,
9882 messages,
9883 task: "do the thing",
9884 workspace: ".",
9885 color: false,
9886 markdown: false,
9887 tool_offload: false,
9888 spill_store: None,
9889 compaction_store: None,
9890 scratchpad: false,
9891 scratchpad_store: None,
9892 code_search: None,
9893 experience_store: None,
9894 step_ledger: None,
9895 caveats,
9896 persona_tools: None,
9897 max_tool_rounds: 6,
9898 narration_nudge_cap: 1,
9899 workflow_grace_rounds: 0,
9900 tool_output_lines: 20,
9901 debug: false,
9902 trace: false,
9903 num_ctx: None,
9904 input_ceiling_pct: 80,
9905 low_budget_pct: 15,
9906 connect_timeout_secs: 5,
9907 inference_timeout_secs: 30,
9908 mid_loop_trim_threshold: 40,
9909 mid_loop_trim_tokens: None,
9910 max_ok_input: None,
9911 build_check_cmd: None,
9912 safe_context: None,
9913 recover_cw_400: None,
9914 note_sink: None,
9915 note_nudge: None,
9916 recall_source: None,
9917 memory_source: None,
9918 summarizer: None,
9919 compress_state: None,
9920 tool_events: None,
9921 phantom_reaches: None,
9922 end_reason: None,
9923 permission_gate: None,
9924 on_round_usage: None,
9925 estimate_ratio: None,
9926 estimation: crate::tokens::TokenEstimation::default(),
9927 summary_input_cap_floor_chars: 8_192,
9928 exec_floor: None,
9929 write_ledger: None,
9930 cancel: None,
9931 git_tool: None,
9932 crew_runner: None,
9933 }
9934 }
9935
9936 fn body_json(req: &Request) -> serde_json::Value {
9937 serde_json::from_slice(&req.body).unwrap_or_default()
9938 }
9939
9940 fn advertised_tool_names(body: &serde_json::Value) -> Vec<String> {
9941 body["tools"]
9942 .as_array()
9943 .map(|a| {
9944 a.iter()
9945 .filter_map(|d| d["function"]["name"].as_str())
9946 .map(String::from)
9947 .collect()
9948 })
9949 .unwrap_or_default()
9950 }
9951
9952 fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
9953 body["messages"]
9954 .as_array()
9955 .map(|msgs| {
9956 msgs.iter().any(|m| {
9957 m["content"]
9958 .as_str()
9959 .map(|c| c.contains(needle))
9960 .unwrap_or(false)
9961 })
9962 })
9963 .unwrap_or(false)
9964 }
9965
9966 struct SaveNoteResponder {
9971 save_note_advertised: Arc<AtomicBool>,
9972 nudge_seen: Arc<AtomicBool>,
9973 final_answer: String,
9974 }
9975
9976 impl Respond for SaveNoteResponder {
9977 fn respond(&self, req: &Request) -> ResponseTemplate {
9978 let body = body_json(req);
9979 if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9980 self.save_note_advertised.store(true, Ordering::SeqCst);
9981 }
9982 if messages_contain(&body, "[system reminder:")
9983 && messages_contain(&body, "without a saved note")
9984 {
9985 self.nudge_seen.store(true, Ordering::SeqCst);
9986 }
9987 if messages_contain(&body, "note saved:") {
9988 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9990 "message": { "content": self.final_answer }
9991 }))
9992 } else if body.get("tools").is_some() {
9993 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9994 "message": {
9995 "content": "",
9996 "tool_calls": [{ "function": {
9997 "name": "save_note",
9998 "arguments": {
9999 "action": "add",
10000 "text": "user prefers vi keybindings"
10001 }
10002 }}]
10003 }
10004 }))
10005 } else {
10006 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10007 "message": { "content": "final summary" }
10008 }))
10009 }
10010 }
10011 }
10012
10013 #[tokio::test]
10014 async fn ollama_save_note_routes_to_sink_and_result_feeds_back() {
10015 let server = MockServer::start().await;
10016 let advertised = Arc::new(AtomicBool::new(false));
10017 let nudge_seen = Arc::new(AtomicBool::new(false));
10018 Mock::given(method("POST"))
10019 .and(path("/api/chat"))
10020 .respond_with(SaveNoteResponder {
10021 save_note_advertised: advertised.clone(),
10022 nudge_seen: nudge_seen.clone(),
10023 final_answer: "noted, moving on".into(),
10024 })
10025 .mount(&server)
10026 .await;
10027
10028 let messages = msgs();
10029 let caveats = Caveats::top();
10030 let uri = server.uri();
10031 let mut sink = MockSink::default();
10032 let mut c = ctx(&uri, &messages, &caveats);
10033 c.note_sink = Some(&mut sink);
10034 let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
10035 .await
10036 .expect("chat_complete should succeed");
10037
10038 assert!(
10039 advertised.load(Ordering::SeqCst),
10040 "save_note must be advertised when a sink is present"
10041 );
10042 assert_eq!(
10043 sink.calls,
10044 vec!["add:user prefers vi keybindings"],
10045 "the tool call must route through the sink"
10046 );
10047 assert_eq!(reply, "noted, moving on");
10048 assert_eq!(hallu, 0, "save_note is a real tool, not a hallucination");
10049 assert!(
10050 !nudge_seen.load(Ordering::SeqCst),
10051 "no nudge configured — none may be injected"
10052 );
10053 }
10054
10055 struct NoSinkObserver {
10058 save_note_advertised: Arc<AtomicBool>,
10059 nudge_seen: Arc<AtomicBool>,
10060 }
10061
10062 impl Respond for NoSinkObserver {
10063 fn respond(&self, req: &Request) -> ResponseTemplate {
10064 let body = body_json(req);
10065 if advertised_tool_names(&body).contains(&"save_note".to_string()) {
10066 self.save_note_advertised.store(true, Ordering::SeqCst);
10067 }
10068 if messages_contain(&body, "[system reminder:") {
10069 self.nudge_seen.store(true, Ordering::SeqCst);
10070 }
10071 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10072 "message": { "content": "plain answer" }
10073 }))
10074 }
10075 }
10076
10077 #[tokio::test]
10078 async fn without_sink_no_tool_and_no_nudge_even_when_due() {
10079 let server = MockServer::start().await;
10080 let advertised = Arc::new(AtomicBool::new(false));
10081 let nudge_seen = Arc::new(AtomicBool::new(false));
10082 Mock::given(method("POST"))
10083 .and(path("/api/chat"))
10084 .respond_with(NoSinkObserver {
10085 save_note_advertised: advertised.clone(),
10086 nudge_seen: nudge_seen.clone(),
10087 })
10088 .mount(&server)
10089 .await;
10090
10091 let messages = msgs();
10092 let caveats = Caveats::top();
10093 let uri = server.uri();
10094 let mut nudge = NoteNudge::new(1);
10096 let _ = nudge.begin_turn();
10097 let mut c = ctx(&uri, &messages, &caveats);
10098 c.note_nudge = Some(&mut nudge);
10100 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10101 .await
10102 .expect("chat_complete should succeed");
10103
10104 assert_eq!(reply, "plain answer");
10105 assert!(
10106 !advertised.load(Ordering::SeqCst),
10107 "save_note advertised without a sink"
10108 );
10109 assert!(
10110 !nudge_seen.load(Ordering::SeqCst),
10111 "nudge injected without a sink"
10112 );
10113 }
10114
10115 #[tokio::test]
10116 async fn nudge_appended_to_user_message_when_due() {
10117 let server = MockServer::start().await;
10118 let advertised = Arc::new(AtomicBool::new(false));
10119 let nudge_seen = Arc::new(AtomicBool::new(false));
10120 Mock::given(method("POST"))
10121 .and(path("/api/chat"))
10122 .respond_with(NoSinkObserver {
10123 save_note_advertised: advertised.clone(),
10124 nudge_seen: nudge_seen.clone(),
10125 })
10126 .mount(&server)
10127 .await;
10128
10129 let messages = msgs();
10130 let caveats = Caveats::top();
10131 let uri = server.uri();
10132 let mut sink = MockSink::default();
10133 let mut nudge = NoteNudge::new(1);
10135 let _ = nudge.begin_turn();
10136 let mut c = ctx(&uri, &messages, &caveats);
10137 c.note_sink = Some(&mut sink);
10138 c.note_nudge = Some(&mut nudge);
10139 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10140 .await
10141 .expect("chat_complete should succeed");
10142
10143 assert_eq!(reply, "plain answer");
10144 assert!(
10145 nudge_seen.load(Ordering::SeqCst),
10146 "the reminder line must reach the model on the due turn"
10147 );
10148 }
10149
10150 #[tokio::test]
10151 async fn organic_save_resets_the_nudge_counter() {
10152 let server = MockServer::start().await;
10153 let advertised = Arc::new(AtomicBool::new(false));
10154 let nudge_seen = Arc::new(AtomicBool::new(false));
10155 Mock::given(method("POST"))
10156 .and(path("/api/chat"))
10157 .respond_with(SaveNoteResponder {
10158 save_note_advertised: advertised.clone(),
10159 nudge_seen: nudge_seen.clone(),
10160 final_answer: "done".into(),
10161 })
10162 .mount(&server)
10163 .await;
10164
10165 let messages = msgs();
10166 let caveats = Caveats::top();
10167 let uri = server.uri();
10168 let mut sink = MockSink::default();
10169 let mut nudge = NoteNudge::new(1);
10170 let mut c = ctx(&uri, &messages, &caveats);
10171 c.note_sink = Some(&mut sink);
10172 c.note_nudge = Some(&mut nudge);
10173 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10174 .await
10175 .expect("chat_complete should succeed");
10176 assert_eq!(reply, "done");
10177 assert_eq!(sink.calls.len(), 1, "the model saved organically");
10178
10179 assert!(
10183 nudge.begin_turn().is_none(),
10184 "organic save_note use must reset the nudge counter"
10185 );
10186 }
10187
10188 struct OpenAiSaveNoteResponder {
10190 save_note_advertised: Arc<AtomicBool>,
10191 nudge_seen: Arc<AtomicBool>,
10192 }
10193
10194 impl Respond for OpenAiSaveNoteResponder {
10195 fn respond(&self, req: &Request) -> ResponseTemplate {
10196 let body = body_json(req);
10197 if advertised_tool_names(&body).contains(&"save_note".to_string()) {
10198 self.save_note_advertised.store(true, Ordering::SeqCst);
10199 }
10200 if messages_contain(&body, "[system reminder:") {
10201 self.nudge_seen.store(true, Ordering::SeqCst);
10202 }
10203 if messages_contain(&body, "note saved:") {
10204 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10205 "choices": [{ "message": { "content": "openai noted" } }]
10206 }))
10207 } else {
10208 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10209 "choices": [{ "message": {
10210 "content": null,
10211 "tool_calls": [{
10212 "id": "call_1",
10213 "type": "function",
10214 "function": {
10215 "name": "save_note",
10216 "arguments": "{\"action\":\"add\",\"text\":\"CI gate is just check\"}"
10217 }
10218 }]
10219 }}]
10220 }))
10221 }
10222 }
10223 }
10224
10225 #[tokio::test]
10226 async fn openai_save_note_routes_and_nudge_appends() {
10227 let server = MockServer::start().await;
10228 let advertised = Arc::new(AtomicBool::new(false));
10229 let nudge_seen = Arc::new(AtomicBool::new(false));
10230 Mock::given(method("POST"))
10231 .and(path("/v1/chat/completions"))
10232 .respond_with(OpenAiSaveNoteResponder {
10233 save_note_advertised: advertised.clone(),
10234 nudge_seen: nudge_seen.clone(),
10235 })
10236 .mount(&server)
10237 .await;
10238
10239 let messages = msgs();
10240 let caveats = Caveats::top();
10241 let uri = server.uri();
10242 let mut sink = MockSink::default();
10243 let mut nudge = NoteNudge::new(1);
10244 let _ = nudge.begin_turn(); let mut c = ctx(&uri, &messages, &caveats);
10246 c.kind = BackendKind::Openai;
10247 c.note_sink = Some(&mut sink);
10248 c.note_nudge = Some(&mut nudge);
10249 let (reply, _, _, hallu) = chat_complete(c, &mut NoMcp)
10250 .await
10251 .expect("openai loop should succeed");
10252
10253 assert_eq!(reply, "openai noted");
10254 assert_eq!(sink.calls, vec!["add:CI gate is just check"]);
10255 assert!(advertised.load(Ordering::SeqCst));
10256 assert!(nudge_seen.load(Ordering::SeqCst));
10257 assert_eq!(hallu, 0);
10258 }
10259
10260 struct ErrorEchoResponder {
10264 error_seen_by_model: Arc<AtomicBool>,
10265 }
10266
10267 impl Respond for ErrorEchoResponder {
10268 fn respond(&self, req: &Request) -> ResponseTemplate {
10269 let body = body_json(req);
10270 if messages_contain(&body, "Replace or remove existing entries first")
10271 && messages_contain(&body, "1. an existing entry")
10272 {
10273 self.error_seen_by_model.store(true, Ordering::SeqCst);
10274 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10275 "message": { "content": "I will curate first" }
10276 }))
10277 } else if body.get("tools").is_some() {
10278 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10279 "message": {
10280 "content": "",
10281 "tool_calls": [{ "function": {
10282 "name": "save_note",
10283 "arguments": { "action": "add", "text": "too big" }
10284 }}]
10285 }
10286 }))
10287 } else {
10288 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10289 "message": { "content": "final summary" }
10290 }))
10291 }
10292 }
10293 }
10294
10295 #[tokio::test]
10296 async fn over_budget_error_round_trips_to_the_model() {
10297 let server = MockServer::start().await;
10298 let error_seen = Arc::new(AtomicBool::new(false));
10299 Mock::given(method("POST"))
10300 .and(path("/api/chat"))
10301 .respond_with(ErrorEchoResponder {
10302 error_seen_by_model: error_seen.clone(),
10303 })
10304 .mount(&server)
10305 .await;
10306
10307 let messages = msgs();
10308 let caveats = Caveats::top();
10309 let uri = server.uri();
10310 let mut sink = MockSink {
10311 fail_with: Some(
10312 "NOTES.md is full: this write needs 99/50 chars. \
10313 Replace or remove existing entries first.\nCurrent entries:\n 1. an existing entry"
10314 .into(),
10315 ),
10316 ..Default::default()
10317 };
10318 let mut c = ctx(&uri, &messages, &caveats);
10319 c.note_sink = Some(&mut sink);
10320 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10321 .await
10322 .expect("chat_complete should succeed");
10323
10324 assert_eq!(reply, "I will curate first");
10325 assert!(
10326 error_seen.load(Ordering::SeqCst),
10327 "the curator error (full entry list + instruction) must reach the model verbatim"
10328 );
10329 }
10330}
10331
10332#[cfg(test)]
10343mod compression_loop_tests {
10344 use super::*;
10345 use crate::caveats::Caveats;
10346 use crate::{BackendKind, MemMessage};
10347 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10348 use std::sync::{Arc, Mutex};
10349 use wiremock::matchers::{method, path};
10350 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
10351
10352 const TASK: &str =
10353 "ACTIVE TASK GAUNTLET-7f3d9c: read big.txt until told to stop, then restate this marker";
10354 const CANNED_SUMMARY: &str =
10355 "## Active Task\nACTIVE TASK GAUNTLET-7f3d9c (canned summary)\n## Completed Actions\n1. read big.txt";
10356
10357 fn msgs() -> Vec<MemMessage> {
10358 vec![MemMessage::system("you are a test"), MemMessage::user(TASK)]
10359 }
10360
10361 fn ctx<'a>(
10362 server_uri: &'a str,
10363 messages: &'a [MemMessage],
10364 caveats: &'a Caveats,
10365 workspace: &'a str,
10366 ) -> ChatCtx<'a> {
10367 ChatCtx {
10368 url: server_uri,
10369 model: "test-model",
10370 kind: BackendKind::Ollama,
10371 api_key: None,
10372 messages,
10373 task: TASK,
10374 workspace,
10375 color: false,
10376 markdown: false,
10377 tool_offload: false,
10378 spill_store: None,
10379 compaction_store: None,
10380 scratchpad: false,
10381 scratchpad_store: None,
10382 code_search: None,
10383 experience_store: None,
10384 step_ledger: None,
10385 caveats,
10386 persona_tools: None,
10387 max_tool_rounds: 12,
10388 narration_nudge_cap: 1,
10389 workflow_grace_rounds: 0,
10390 tool_output_lines: 2,
10391 debug: false,
10392 trace: false,
10393 num_ctx: None,
10394 input_ceiling_pct: 80,
10395 low_budget_pct: 15,
10396 connect_timeout_secs: 5,
10397 inference_timeout_secs: 30,
10398 mid_loop_trim_threshold: 40,
10399 mid_loop_trim_tokens: Some(5_000),
10402 max_ok_input: None,
10403 build_check_cmd: None,
10404 safe_context: None,
10405 recover_cw_400: None,
10406 note_sink: None,
10407 note_nudge: None,
10408 recall_source: None,
10409 memory_source: None,
10410 summarizer: None,
10411 compress_state: None,
10412 tool_events: None,
10413 phantom_reaches: None,
10414 end_reason: None,
10415 permission_gate: None,
10416 on_round_usage: None,
10417 estimate_ratio: None,
10418 estimation: crate::tokens::TokenEstimation::default(),
10419 summary_input_cap_floor_chars: 8_192,
10420 exec_floor: None,
10421 write_ledger: None,
10422 cancel: None,
10423 git_tool: None,
10424 crew_runner: None,
10425 }
10426 }
10427
10428 fn gauntlet_workspace() -> tempfile::TempDir {
10430 let ws = tempfile::TempDir::new().unwrap();
10431 let line = "the quick brown newt compresses context without discarding it\n";
10432 std::fs::write(ws.path().join("big.txt"), line.repeat(64)).unwrap();
10433 ws
10434 }
10435
10436 fn body_json(req: &Request) -> serde_json::Value {
10437 serde_json::from_slice(&req.body).unwrap_or_default()
10438 }
10439
10440 fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
10441 body["messages"]
10442 .as_array()
10443 .map(|msgs| {
10444 msgs.iter().any(|m| {
10445 m["content"]
10446 .as_str()
10447 .map(|c| c.contains(needle))
10448 .unwrap_or(false)
10449 })
10450 })
10451 .unwrap_or(false)
10452 }
10453
10454 fn body_message_tokens(body: &serde_json::Value) -> usize {
10457 body["messages"]
10458 .as_array()
10459 .map(|msgs| {
10460 msgs.iter()
10461 .map(|m| {
10462 crate::tokens::TokenEstimation::default()
10463 .tokens_for_chars(m.to_string().chars().count())
10464 })
10465 .sum()
10466 })
10467 .unwrap_or(0)
10468 }
10469
10470 fn canned_summarizer(prompts: Arc<Mutex<Vec<String>>>) -> Summarizer {
10473 Box::new(move |prompt: String| {
10474 let prompts = prompts.clone();
10475 Box::pin(async move {
10476 prompts.lock().unwrap().push(prompt);
10477 Ok(CANNED_SUMMARY.to_string())
10478 })
10479 })
10480 }
10481
10482 struct GauntletResponder {
10486 final_answer: String,
10487 log: Arc<Mutex<Vec<(bool, usize)>>>,
10489 task_in_marker_request: Arc<AtomicBool>,
10490 summary_in_marker_request: Arc<AtomicBool>,
10491 old_placeholder_seen: Arc<AtomicBool>,
10492 static_marker_instead: bool,
10493 }
10494
10495 impl Respond for GauntletResponder {
10496 fn respond(&self, req: &Request) -> ResponseTemplate {
10497 let body = body_json(req);
10498 let has_marker = messages_contain(&body, SUMMARY_PREFIX);
10499 if !body["stream"].as_bool().unwrap_or(false) {
10500 self.log
10501 .lock()
10502 .unwrap()
10503 .push((has_marker, body_message_tokens(&body)));
10504 }
10505 if messages_contain(&body, "earlier tool-call messages omitted") {
10506 self.old_placeholder_seen.store(true, Ordering::SeqCst);
10507 }
10508 if has_marker {
10509 if messages_contain(&body, TASK) {
10510 self.task_in_marker_request.store(true, Ordering::SeqCst);
10511 }
10512 let summary_needle = if self.static_marker_instead {
10513 "Summary generation was unavailable."
10514 } else {
10515 CANNED_SUMMARY
10516 };
10517 if messages_contain(&body, summary_needle) {
10518 self.summary_in_marker_request.store(true, Ordering::SeqCst);
10519 }
10520 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10521 "message": { "content": self.final_answer }
10522 }));
10523 }
10524 if body.get("tools").is_some() {
10525 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10526 "message": { "content": "", "tool_calls": [{
10527 "function": { "name": "read_file", "arguments": { "path": "big.txt" } }
10528 }]}
10529 }))
10530 } else {
10531 ResponseTemplate::new(200)
10532 .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
10533 }
10534 }
10535 }
10536
10537 #[tokio::test]
10541 async fn active_task_survives_compression() {
10542 let server = MockServer::start().await;
10543 let log = Arc::new(Mutex::new(Vec::new()));
10544 let task_in_marker = Arc::new(AtomicBool::new(false));
10545 let summary_in_marker = Arc::new(AtomicBool::new(false));
10546 let old_placeholder = Arc::new(AtomicBool::new(false));
10547 Mock::given(method("POST"))
10548 .and(path("/api/chat"))
10549 .respond_with(GauntletResponder {
10550 final_answer: "the marker is GAUNTLET-7f3d9c".into(),
10551 log: log.clone(),
10552 task_in_marker_request: task_in_marker.clone(),
10553 summary_in_marker_request: summary_in_marker.clone(),
10554 old_placeholder_seen: old_placeholder.clone(),
10555 static_marker_instead: false,
10556 })
10557 .mount(&server)
10558 .await;
10559
10560 let ws = gauntlet_workspace();
10561 let workspace = ws.path().to_string_lossy().to_string();
10562 let messages = msgs();
10563 let caveats = Caveats::top();
10564 let uri = server.uri();
10565 let prompts = Arc::new(Mutex::new(Vec::new()));
10566 let summarizer = canned_summarizer(prompts.clone());
10567 let mut compress_state = CompressState::new();
10568 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10569 c.summarizer = Some(&*summarizer);
10570 c.compress_state = Some(&mut compress_state);
10571 let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
10572 .await
10573 .expect("chat_complete should succeed");
10574
10575 assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
10577 assert_eq!(hallu, 0);
10578
10579 let prompts = prompts.lock().unwrap();
10582 assert_eq!(prompts.len(), 1, "one compression, one summary request");
10583 assert!(
10584 prompts[0].contains(TASK),
10585 "summary request must quote the task verbatim"
10586 );
10587
10588 assert!(task_in_marker.load(Ordering::SeqCst), "B5 property");
10591 assert!(summary_in_marker.load(Ordering::SeqCst));
10592 assert!(
10593 !old_placeholder.load(Ordering::SeqCst),
10594 "the old amputation placeholder must never be dispatched"
10595 );
10596
10597 let log = log.lock().unwrap();
10600 let before = log
10601 .iter()
10602 .filter(|(m, _)| !m)
10603 .map(|&(_, t)| t)
10604 .max()
10605 .expect("pre-compression requests were dispatched");
10606 let after = log
10607 .iter()
10608 .find(|(m, _)| *m)
10609 .map(|&(_, t)| t)
10610 .expect("a compressed request was dispatched");
10611 println!("e2e reclaim: ~{before} -> ~{after} est. message tokens");
10612 assert!(
10613 after < before * 6 / 10,
10614 "compression must reclaim >40% here (got {before} -> {after})"
10615 );
10616 }
10617
10618 #[tokio::test]
10626 async fn first_turn_over_num_ctx_ceiling_compresses_before_dispatch() {
10627 let server = MockServer::start().await;
10628 let log = Arc::new(Mutex::new(Vec::new()));
10629 let task_in_marker = Arc::new(AtomicBool::new(false));
10630 let summary_in_marker = Arc::new(AtomicBool::new(false));
10631 let old_placeholder = Arc::new(AtomicBool::new(false));
10632 Mock::given(method("POST"))
10633 .and(path("/api/chat"))
10634 .respond_with(GauntletResponder {
10635 final_answer: "the marker is GAUNTLET-7f3d9c".into(),
10636 log: log.clone(),
10637 task_in_marker_request: task_in_marker.clone(),
10638 summary_in_marker_request: summary_in_marker.clone(),
10639 old_placeholder_seen: old_placeholder.clone(),
10640 static_marker_instead: false,
10641 })
10642 .mount(&server)
10643 .await;
10644
10645 let filler = "the quick brown newt reads three fifty-kilobyte fixtures\n".repeat(50);
10654 let mut messages = vec![MemMessage::system("you are a test"), MemMessage::user(TASK)];
10655 for _ in 0..12 {
10656 messages.push(MemMessage::assistant(format!("file contents: {filler}")));
10657 messages.push(MemMessage::user("continue"));
10658 }
10659
10660 let ws = gauntlet_workspace();
10661 let workspace = ws.path().to_string_lossy().to_string();
10662 let caveats = Caveats::top();
10663 let uri = server.uri();
10664 let prompts = Arc::new(Mutex::new(Vec::new()));
10665 let summarizer = canned_summarizer(prompts.clone());
10666 let mut compress_state = CompressState::new();
10667 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10668 c.max_ok_input = None;
10672 c.safe_context = None;
10673 c.mid_loop_trim_tokens = None;
10674 c.num_ctx = Some(4_096);
10675 c.summarizer = Some(&*summarizer);
10676 c.compress_state = Some(&mut compress_state);
10677 let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
10678 .await
10679 .expect("the first turn must complete");
10680
10681 assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
10684
10685 assert!(
10693 !prompts.lock().unwrap().is_empty(),
10694 "compression ran before the first dispatch (≥1 bounded summary request)"
10695 );
10696 let log = log.lock().unwrap();
10697 let (first_had_marker, first_tokens) =
10698 *log.first().expect("at least one request dispatched");
10699 assert!(
10700 first_had_marker,
10701 "B6: the first dispatched request must already be compressed — \
10702 pre-#282 it went out raw at ~9k tokens"
10703 );
10704
10705 assert!(
10708 first_tokens <= 3_276,
10709 "first dispatch must fit the num_ctx input ceiling \
10710 (got ~{first_tokens} est. message tokens > 3,276)"
10711 );
10712
10713 assert!(task_in_marker.load(Ordering::SeqCst), "task survives");
10715 assert!(summary_in_marker.load(Ordering::SeqCst), "summary present");
10716 assert!(!old_placeholder.load(Ordering::SeqCst));
10717 }
10718
10719 #[tokio::test]
10722 async fn summarizer_500_degrades_to_static_marker_and_turn_completes() {
10723 let server = MockServer::start().await;
10724 Mock::given(method("POST"))
10725 .and(path("/summarize"))
10726 .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
10727 .mount(&server)
10728 .await;
10729 let log = Arc::new(Mutex::new(Vec::new()));
10730 let task_in_marker = Arc::new(AtomicBool::new(false));
10731 let static_in_marker = Arc::new(AtomicBool::new(false));
10732 let old_placeholder = Arc::new(AtomicBool::new(false));
10733 Mock::given(method("POST"))
10734 .and(path("/api/chat"))
10735 .respond_with(GauntletResponder {
10736 final_answer: "completed despite summarizer outage".into(),
10737 log: log.clone(),
10738 task_in_marker_request: task_in_marker.clone(),
10739 summary_in_marker_request: static_in_marker.clone(),
10740 old_placeholder_seen: old_placeholder.clone(),
10741 static_marker_instead: true,
10742 })
10743 .mount(&server)
10744 .await;
10745
10746 let attempts = Arc::new(AtomicUsize::new(0));
10748 let summarize_url = format!("{}/summarize", server.uri());
10749 let attempts_in = attempts.clone();
10750 let summarizer: Summarizer = Box::new(move |prompt: String| {
10751 let url = summarize_url.clone();
10752 let attempts = attempts_in.clone();
10753 Box::pin(async move {
10754 attempts.fetch_add(1, Ordering::SeqCst);
10755 let resp = reqwest::Client::new()
10756 .post(&url)
10757 .body(prompt)
10758 .send()
10759 .await?;
10760 if !resp.status().is_success() {
10761 anyhow::bail!("summarizer endpoint {}", resp.status());
10762 }
10763 Ok(resp.text().await?)
10764 })
10765 });
10766
10767 let ws = gauntlet_workspace();
10768 let workspace = ws.path().to_string_lossy().to_string();
10769 let messages = msgs();
10770 let caveats = Caveats::top();
10771 let uri = server.uri();
10772 let mut compress_state = CompressState::new();
10773 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10774 c.summarizer = Some(&*summarizer);
10775 c.compress_state = Some(&mut compress_state);
10776 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10777 .await
10778 .expect("a summarizer failure must never abort the turn");
10779
10780 assert_eq!(reply, "completed despite summarizer outage");
10781 assert!(
10782 attempts.load(Ordering::SeqCst) >= 1,
10783 "the summarizer endpoint must have been attempted"
10784 );
10785 assert!(
10786 static_in_marker.load(Ordering::SeqCst),
10787 "the static fallback marker must reach the model"
10788 );
10789 assert!(task_in_marker.load(Ordering::SeqCst), "task still anchored");
10790 assert!(!old_placeholder.load(Ordering::SeqCst));
10791 }
10792
10793 struct OpenAiGauntletResponder {
10796 final_answer: String,
10797 task_in_marker_request: Arc<AtomicBool>,
10798 summary_in_marker_request: Arc<AtomicBool>,
10799 directive_in_marker_request: Arc<AtomicBool>,
10800 }
10801
10802 impl Respond for OpenAiGauntletResponder {
10803 fn respond(&self, req: &Request) -> ResponseTemplate {
10804 let body = body_json(req);
10805 if messages_contain(&body, SUMMARY_PREFIX) {
10806 if messages_contain(&body, TASK) {
10807 self.task_in_marker_request.store(true, Ordering::SeqCst);
10808 }
10809 if messages_contain(&body, CANNED_SUMMARY) {
10810 self.summary_in_marker_request.store(true, Ordering::SeqCst);
10811 }
10812 if messages_contain(&body, compress::CONTINUATION_PREFIX) {
10813 self.directive_in_marker_request
10814 .store(true, Ordering::SeqCst);
10815 }
10816 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10817 "choices": [{ "message": { "content": self.final_answer } }]
10818 }));
10819 }
10820 if body.get("tools").is_some() {
10821 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10822 "choices": [{ "message": {
10823 "content": null,
10824 "tool_calls": [{
10825 "id": "call_1",
10826 "type": "function",
10827 "function": { "name": "read_file", "arguments": "{\"path\":\"big.txt\"}" }
10828 }]
10829 }}]
10830 }))
10831 } else {
10832 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10833 "choices": [{ "message": { "content": "cap exit" } }]
10834 }))
10835 }
10836 }
10837 }
10838
10839 #[tokio::test]
10840 async fn openai_loop_compresses_with_the_same_pipeline() {
10841 let server = MockServer::start().await;
10842 let task_in_marker = Arc::new(AtomicBool::new(false));
10843 let summary_in_marker = Arc::new(AtomicBool::new(false));
10844 let directive_in_marker = Arc::new(AtomicBool::new(false));
10845 Mock::given(method("POST"))
10846 .and(path("/v1/chat/completions"))
10847 .respond_with(OpenAiGauntletResponder {
10848 final_answer: "openai: marker is GAUNTLET-7f3d9c".into(),
10849 task_in_marker_request: task_in_marker.clone(),
10850 summary_in_marker_request: summary_in_marker.clone(),
10851 directive_in_marker_request: directive_in_marker.clone(),
10852 })
10853 .mount(&server)
10854 .await;
10855
10856 let ws = gauntlet_workspace();
10857 let workspace = ws.path().to_string_lossy().to_string();
10858 let messages = msgs();
10859 let caveats = Caveats::top();
10860 let uri = server.uri();
10861 let prompts = Arc::new(Mutex::new(Vec::new()));
10862 let summarizer = canned_summarizer(prompts.clone());
10863 let mut compress_state = CompressState::new();
10864 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10865 c.kind = BackendKind::Openai;
10866 c.api_key = Some("sk-test");
10867 c.summarizer = Some(&*summarizer);
10868 c.compress_state = Some(&mut compress_state);
10869 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10870 .await
10871 .expect("openai loop should succeed");
10872
10873 assert_eq!(reply, "openai: marker is GAUNTLET-7f3d9c");
10874 assert!(!prompts.lock().unwrap().is_empty(), "summarizer engaged");
10875 assert!(task_in_marker.load(Ordering::SeqCst));
10876 assert!(summary_in_marker.load(Ordering::SeqCst));
10877 assert!(
10881 directive_in_marker.load(Ordering::SeqCst),
10882 "the post-compaction act-now directive must reach the wire"
10883 );
10884 }
10885
10886 type HaulLog = Arc<Mutex<Vec<(usize, Option<usize>)>>>;
10895
10896 struct LongHaulResponder {
10906 path: &'static str,
10907 log: HaulLog,
10908 }
10909
10910 impl Respond for LongHaulResponder {
10911 fn respond(&self, req: &Request) -> ResponseTemplate {
10912 let body = body_json(req);
10913 if body.get("tools").is_some() {
10914 let empty = Vec::new();
10915 let msgs = body["messages"].as_array().unwrap_or(&empty);
10916 let last_tool_len = msgs
10917 .iter()
10918 .rev()
10919 .find(|m| m["role"].as_str() == Some("tool"))
10920 .and_then(|m| m["content"].as_str())
10921 .map(|c| c.chars().count());
10922 self.log.lock().unwrap().push((msgs.len(), last_tool_len));
10923 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10924 "message": { "content": "", "tool_calls": [
10925 { "function": { "name": "apply_patch", "arguments": {} } },
10926 { "function": { "name": "read_file", "arguments": { "path": self.path } } }
10927 ]}
10928 }))
10929 } else {
10930 ResponseTemplate::new(200).set_body_json(
10931 serde_json::json!({ "message": { "content": "long haul done" } }),
10932 )
10933 }
10934 }
10935 }
10936
10937 fn multi_turn_msgs() -> Vec<MemMessage> {
10940 vec![
10941 MemMessage::system("you are a test"),
10942 MemMessage::user("prior turn: inspect the workspace"),
10943 MemMessage::assistant("inspected — looks healthy"),
10944 MemMessage::user("prior turn: run the linters"),
10945 MemMessage::assistant("linters are green"),
10946 MemMessage::user("prior turn: sketch a fix"),
10947 MemMessage::assistant("sketched in my head"),
10948 MemMessage::user(TASK),
10949 ]
10950 }
10951
10952 async fn run_long_haul(
10955 mem_messages: Vec<MemMessage>,
10956 rounds: usize,
10957 threshold: usize,
10958 file: &'static str,
10959 content: &str,
10960 ) -> (Vec<(usize, Option<usize>)>, usize, String, bool) {
10961 let server = MockServer::start().await;
10962 let log = Arc::new(Mutex::new(Vec::new()));
10963 Mock::given(method("POST"))
10964 .and(path("/api/chat"))
10965 .respond_with(LongHaulResponder {
10966 path: file,
10967 log: log.clone(),
10968 })
10969 .mount(&server)
10970 .await;
10971 let ws = tempfile::TempDir::new().unwrap();
10972 std::fs::write(ws.path().join(file), content).unwrap();
10973 let workspace = ws.path().to_string_lossy().to_string();
10974 let caveats = Caveats::top();
10975 let uri = server.uri();
10976 let prompts = Arc::new(Mutex::new(Vec::new()));
10977 let summarizer = canned_summarizer(prompts.clone());
10978 let mut compress_state = CompressState::new();
10979 let mut c = ctx(&uri, &mem_messages, &caveats, &workspace);
10980 c.max_tool_rounds = rounds;
10981 c.mid_loop_trim_threshold = threshold;
10982 c.mid_loop_trim_tokens = None; c.summarizer = Some(&*summarizer);
10984 c.compress_state = Some(&mut compress_state);
10985 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10986 .await
10987 .expect("the long haul must complete");
10988 let log = log.lock().unwrap().clone();
10989 let calls = prompts.lock().unwrap().len();
10990 (log, calls, reply, compress_state.is_disabled())
10991 }
10992
10993 #[tokio::test]
11000 async fn forty_rounds_single_turn_stay_bounded_with_fresh_results_intact() {
11001 let line = "the quick brown newt compresses context without discarding it\n";
11002 let threshold = 15usize;
11003 let (log, summarizer_calls, reply, latched) =
11004 run_long_haul(msgs(), 40, threshold, "big.txt", &line.repeat(64)).await;
11005
11006 assert_eq!(reply, "long haul done");
11007 assert!(!latched, "count-only pressure must never latch anti-thrash");
11008 assert_eq!(log.len(), 40, "all 40 tool rounds dispatched");
11009 for (round, (len, last_tool)) in log.iter().enumerate() {
11010 assert!(
11011 *len <= threshold + 6,
11012 "round {round}: dispatched {len} messages — the count must stay \
11013 bounded after every compression (threshold {threshold} + slack)"
11014 );
11015 if let Some(n) = last_tool {
11016 assert!(
11017 *n > 1_000,
11018 "round {round}: the fresh tool result was destroyed before \
11019 dispatch ({n} chars — a one-liner)"
11020 );
11021 }
11022 }
11023 assert!(summarizer_calls >= 2, "the long haul compresses repeatedly");
11024 assert!(
11025 summarizer_calls <= 16,
11026 "summarizer invocations must be bounded, not per-round \
11027 (got {summarizer_calls} in 40 rounds)"
11028 );
11029 let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
11030 println!(
11031 "forty-round trace: max dispatched len {max_len}, \
11032 summarizer calls {summarizer_calls}"
11033 );
11034 }
11035
11036 #[tokio::test]
11042 async fn thirty_rounds_multi_turn_stay_bounded_with_fresh_results_intact() {
11043 let line = "the quick brown newt compresses context without discarding it\n";
11044 let threshold = 15usize;
11045 let (log, summarizer_calls, reply, latched) = run_long_haul(
11046 multi_turn_msgs(),
11047 30,
11048 threshold,
11049 "big.txt",
11050 &line.repeat(64),
11051 )
11052 .await;
11053
11054 assert_eq!(reply, "long haul done");
11055 assert!(!latched, "count-only pressure must never latch anti-thrash");
11056 assert_eq!(log.len(), 30, "all 30 tool rounds dispatched");
11057 for (round, (len, last_tool)) in log.iter().enumerate() {
11058 assert!(
11059 *len <= threshold + 6,
11060 "round {round}: dispatched {len} messages — bounded"
11061 );
11062 if let Some(n) = last_tool {
11063 assert!(
11064 *n > 1_000,
11065 "round {round}: fresh tool result destroyed pre-dispatch ({n} chars)"
11066 );
11067 }
11068 }
11069 assert!(summarizer_calls >= 2);
11070 assert!(
11071 summarizer_calls <= 14,
11072 "summarizer invocations must be bounded, not per-round \
11073 (got {summarizer_calls} in 30 rounds)"
11074 );
11075 let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
11076 println!(
11077 "thirty-round multi-turn trace: max dispatched len {max_len}, \
11078 summarizer calls {summarizer_calls}"
11079 );
11080 }
11081
11082 #[tokio::test]
11089 async fn count_only_low_reclaim_never_latches_or_bails() {
11090 let (log, _calls, reply, latched) =
11091 run_long_haul(multi_turn_msgs(), 30, 15, "small.txt", &"x".repeat(600)).await;
11092 assert_eq!(reply, "long haul done", "the turn must complete — no bail");
11093 assert!(
11094 !latched,
11095 "count-only passes must never latch the disable switch"
11096 );
11097 assert_eq!(log.len(), 30, "all 30 rounds ran (no Refused early-exit)");
11098 }
11099
11100 type NudgedLog = Arc<Mutex<Vec<(usize, bool, Vec<usize>)>>>;
11109
11110 struct NudgedHaulResponder {
11119 log: NudgedLog,
11120 }
11121
11122 impl Respond for NudgedHaulResponder {
11123 fn respond(&self, req: &Request) -> ResponseTemplate {
11124 let body = body_json(req);
11125 if body.get("tools").is_some() {
11126 let empty = Vec::new();
11127 let msgs = body["messages"].as_array().unwrap_or(&empty);
11128 let group_start = msgs
11129 .iter()
11130 .rposition(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()))
11131 .map(|i| i + 1)
11132 .unwrap_or(msgs.len());
11133 let group_lens: Vec<usize> = msgs[group_start..]
11134 .iter()
11135 .filter(|m| m["role"].as_str() == Some("tool"))
11136 .filter_map(|m| m["content"].as_str())
11137 .map(|c| c.chars().count())
11138 .collect();
11139 let nudged = messages_contain(&body, "read-only rounds so far");
11140 self.log
11141 .lock()
11142 .unwrap()
11143 .push((msgs.len(), nudged, group_lens));
11144 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11147 "message": { "role": "assistant", "content": "", "tool_calls": [
11148 { "function": { "name": "read_file", "arguments": { "path": "big1.txt" } } },
11149 { "function": { "name": "read_file", "arguments": { "path": "big2.txt" } } },
11150 { "function": { "name": "read_file", "arguments": { "path": "small.txt" } } }
11151 ]}
11152 }))
11153 } else {
11154 ResponseTemplate::new(200).set_body_json(
11155 serde_json::json!({ "message": { "content": "nudged haul done" } }),
11156 )
11157 }
11158 }
11159 }
11160
11161 #[tokio::test]
11173 async fn nudged_long_haul_keeps_fresh_group_results_intact() {
11174 let server = MockServer::start().await;
11175 let log: NudgedLog = Arc::new(Mutex::new(Vec::new()));
11176 Mock::given(method("POST"))
11177 .and(path("/api/chat"))
11178 .respond_with(NudgedHaulResponder { log: log.clone() })
11179 .mount(&server)
11180 .await;
11181 let ws = tempfile::TempDir::new().unwrap();
11184 std::fs::write(
11185 ws.path().join("big1.txt"),
11186 "the first big fixture keeps unseen results intact\n".repeat(200),
11187 )
11188 .unwrap();
11189 std::fs::write(
11190 ws.path().join("big2.txt"),
11191 "the second big fixture must survive every nudge round\n".repeat(200),
11192 )
11193 .unwrap();
11194 std::fs::write(
11195 ws.path().join("small.txt"),
11196 "the small newest fixture stays whole\n".repeat(5),
11197 )
11198 .unwrap();
11199 let workspace = ws.path().to_string_lossy().to_string();
11200 let messages = msgs();
11201 let caveats = Caveats::top();
11202 let uri = server.uri();
11203 let prompts = Arc::new(Mutex::new(Vec::new()));
11204 let summarizer = canned_summarizer(prompts.clone());
11205 let mut compress_state = CompressState::new();
11206 let mut c = ctx(&uri, &messages, &caveats, &workspace);
11207 c.max_tool_rounds = 12;
11208 c.mid_loop_trim_tokens = Some(4_000); c.summarizer = Some(&*summarizer);
11210 c.compress_state = Some(&mut compress_state);
11211 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11212 .await
11213 .expect("the nudged haul must complete");
11214
11215 assert_eq!(reply, "nudged haul done");
11216 assert!(
11217 !compress_state.is_disabled(),
11218 "real reclaims every round must never latch anti-thrash"
11219 );
11220 let log = log.lock().unwrap();
11221 for (round, entry) in log.iter().enumerate() {
11222 println!("nudged haul round {round}: {entry:?}");
11223 }
11224 assert_eq!(log.len(), 12, "all 12 tool rounds dispatched");
11225 let nudged_rounds = log.iter().filter(|(_, n, _)| *n).count();
11226 assert!(
11227 nudged_rounds >= 2,
11228 "the read-only nudge regime must actually be active \
11229 (got {nudged_rounds} nudged requests)"
11230 );
11231 for (round, (len, nudged, group_lens)) in log.iter().enumerate() {
11232 if group_lens.is_empty() {
11233 continue; }
11235 assert_eq!(group_lens.len(), 3, "round {round}: pairing intact");
11236 assert!(
11238 group_lens[2] > 150,
11239 "round {round} (nudged={nudged}): the newest fresh result \
11240 was destroyed pre-dispatch ({} chars)",
11241 group_lens[2]
11242 );
11243 assert!(
11247 group_lens[1] > 1_000,
11248 "round {round} (nudged={nudged}, {len} msgs): the middle \
11249 fresh result was destroyed pre-dispatch ({} chars)",
11250 group_lens[1]
11251 );
11252 }
11253 let max_tokens = log.iter().map(|(l, _, _)| *l).max().unwrap();
11254 println!(
11255 "nudged haul trace: {nudged_rounds} nudged rounds, \
11256 max dispatched len {max_tokens}, group lens e.g. {:?}",
11257 log.last().unwrap().2
11258 );
11259 }
11260
11261 type OversizedLog = Arc<Mutex<Vec<(usize, bool, bool, bool, bool)>>>;
11264
11265 struct OversizedRoundResponder {
11268 log: OversizedLog,
11269 }
11270
11271 impl Respond for OversizedRoundResponder {
11272 fn respond(&self, req: &Request) -> ResponseTemplate {
11273 let body = body_json(req);
11274 let a_onelined = messages_contain(&body, "[read_file] read 'a.txt'");
11275 if !body["stream"].as_bool().unwrap_or(false) {
11276 self.log.lock().unwrap().push((
11277 body_message_tokens(&body),
11278 a_onelined,
11279 messages_contain(&body, "[read_file] read 'b.txt'"),
11280 messages_contain(&body, "NEWEST-PAYLOAD-C-INTACT"),
11281 messages_contain(&body, TASK),
11282 ));
11283 }
11284 if a_onelined {
11285 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
11286 "message": { "content": "the three files are summarized" }
11287 }));
11288 }
11289 if body.get("tools").is_some() {
11290 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11293 "message": { "role": "assistant", "content": "", "tool_calls": [
11294 { "function": { "name": "read_file", "arguments": { "path": "a.txt" } } },
11295 { "function": { "name": "read_file", "arguments": { "path": "b.txt" } } },
11296 { "function": { "name": "read_file", "arguments": { "path": "c.txt" } } }
11297 ]}
11298 }))
11299 } else {
11300 ResponseTemplate::new(200)
11301 .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
11302 }
11303 }
11304 }
11305
11306 #[tokio::test]
11316 async fn oversized_single_round_dispatches_within_the_window() {
11317 let server = MockServer::start().await;
11318 let log: OversizedLog = Arc::new(Mutex::new(Vec::new()));
11319 Mock::given(method("POST"))
11320 .and(path("/api/chat"))
11321 .respond_with(OversizedRoundResponder { log: log.clone() })
11322 .mount(&server)
11323 .await;
11324 let ws = tempfile::TempDir::new().unwrap();
11330 std::fs::write(
11331 ws.path().join("a.txt"),
11332 "fixture a arrives first in the one giant trailing group\n".repeat(125),
11333 )
11334 .unwrap();
11335 std::fs::write(
11336 ws.path().join("b.txt"),
11337 "fixture b arrives second and is older than the newest\n".repeat(130),
11338 )
11339 .unwrap();
11340 std::fs::write(
11341 ws.path().join("c.txt"),
11342 format!(
11343 "{}NEWEST-PAYLOAD-C-INTACT\n",
11344 "fixture c arrives last and must reach the model whole\n".repeat(130)
11345 ),
11346 )
11347 .unwrap();
11348 let workspace = ws.path().to_string_lossy().to_string();
11349 let messages = msgs();
11350 let caveats = Caveats::top();
11351 let uri = server.uri();
11352 let prompts = Arc::new(Mutex::new(Vec::new()));
11353 let summarizer = canned_summarizer(prompts.clone());
11354 let mut compress_state = CompressState::new();
11355 let mut c = ctx(&uri, &messages, &caveats, &workspace);
11356 c.max_ok_input = None;
11359 c.safe_context = None;
11360 c.mid_loop_trim_tokens = None;
11361 c.num_ctx = Some(4_096);
11362 c.summarizer = Some(&*summarizer);
11363 c.compress_state = Some(&mut compress_state);
11364 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11365 .await
11366 .expect("the oversized round must complete");
11367
11368 let log = log.lock().unwrap();
11369 for (i, entry) in log.iter().enumerate() {
11370 println!("oversized round request {i}: {entry:?}");
11371 }
11372 assert_eq!(reply, "the three files are summarized");
11374
11375 for (i, &(tokens, ..)) in log.iter().enumerate() {
11380 assert!(
11381 tokens <= 3_276,
11382 "request {i} dispatched over the window: ~{tokens} est. \
11383 message tokens > 3,276 (the pre-#285 B6 residual)"
11384 );
11385 }
11386
11387 let (tokens, _, b_onelined, c_intact, task_present) = *log
11388 .iter()
11389 .find(|(_, a, ..)| *a)
11390 .expect("a dispatch with a.txt one-lined must have happened");
11391 assert!(
11392 b_onelined,
11393 "older members one-lined in order: b.txt one-liner missing"
11394 );
11395 assert!(
11396 c_intact,
11397 "#285: the NEWEST result must reach the model whole"
11398 );
11399 assert!(task_present, "the task survives the within-group reclaim");
11400 assert!(
11403 tokens <= 3_276,
11404 "#285: the reclaimed dispatch must fit the window \
11405 (got ~{tokens} est. message tokens > 3,276)"
11406 );
11407 println!(
11408 "#285 e2e trace: reclaimed dispatch ~{tokens} est. tokens \
11409 (ceiling 3,276), a/b one-lined, c intact"
11410 );
11411 }
11412
11413 #[tokio::test]
11420 async fn hard_budget_thrash_latches_then_bails_with_named_error() {
11421 let server = MockServer::start().await;
11422 let log = Arc::new(Mutex::new(Vec::new()));
11423 Mock::given(method("POST"))
11424 .and(path("/api/chat"))
11425 .respond_with(LongHaulResponder {
11426 path: "tiny.txt",
11427 log: log.clone(),
11428 })
11429 .mount(&server)
11430 .await;
11431 let ws = tempfile::TempDir::new().unwrap();
11432 std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
11433 let workspace = ws.path().to_string_lossy().to_string();
11434 let messages = vec![
11437 MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
11438 MemMessage::user(TASK),
11439 ];
11440 let caveats = Caveats::top();
11441 let uri = server.uri();
11442 let mut compress_state = CompressState::new();
11443 let mut c = ctx(&uri, &messages, &caveats, &workspace);
11444 c.mid_loop_trim_tokens = Some(50); c.compress_state = Some(&mut compress_state);
11446 let err = chat_complete(c, &mut NoMcp)
11447 .await
11448 .expect_err("the post-latch over-budget round must refuse the send");
11449 let msg = err.to_string();
11450 assert!(msg.contains("exceeds the model's input budget"), "{msg}");
11451 assert!(msg.contains("auto-compression is disabled"), "{msg}");
11452 assert!(compress_state.is_disabled(), "anti-thrash latched first");
11453 assert!(
11454 log.lock().unwrap().len() <= 3,
11455 "the refusal must stop the loop within a round or two"
11456 );
11457 }
11458
11459 #[tokio::test]
11468 async fn lone_hwm_budget_fails_open_and_does_not_bail() {
11469 let server = MockServer::start().await;
11470 let log = Arc::new(Mutex::new(Vec::new()));
11471 Mock::given(method("POST"))
11472 .and(path("/api/chat"))
11473 .respond_with(LongHaulResponder {
11474 path: "tiny.txt",
11475 log: log.clone(),
11476 })
11477 .mount(&server)
11478 .await;
11479 let ws = tempfile::TempDir::new().unwrap();
11480 std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
11481 let workspace = ws.path().to_string_lossy().to_string();
11482 let messages = vec![
11483 MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
11484 MemMessage::user(TASK),
11485 ];
11486 let caveats = Caveats::top();
11487 let uri = server.uri();
11488 let mut compress_state = CompressState::new();
11489 let mut c = ctx(&uri, &messages, &caveats, &workspace);
11490 c.mid_loop_trim_tokens = None;
11492 c.max_ok_input = Some(50); c.safe_context = None; c.num_ctx = None; c.compress_state = Some(&mut compress_state);
11496 let result = chat_complete(c, &mut NoMcp).await;
11497 if let Err(e) = &result {
11499 let msg = e.to_string();
11500 assert!(
11501 !msg.contains("exceeds the model's input budget"),
11502 "a lone-HWM budget must never refuse the send: {msg}"
11503 );
11504 }
11505 assert!(
11506 compress_state.is_disabled(),
11507 "anti-thrash still latches on the poor passes"
11508 );
11509 assert!(
11510 log.lock().unwrap().len() > 3,
11511 "the loop kept dispatching past the latch instead of bailing"
11512 );
11513 }
11514}
11515
11516#[cfg(test)]
11522mod observation_hook_tests {
11523 use super::*;
11524 use crate::caveats::Caveats;
11525 use crate::{BackendKind, MemMessage};
11526 use std::sync::atomic::{AtomicUsize, Ordering};
11527 use std::sync::Arc;
11528 use wiremock::matchers::{method, path};
11529 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
11530
11531 fn ctx<'a>(
11532 server_uri: &'a str,
11533 messages: &'a [MemMessage],
11534 caveats: &'a Caveats,
11535 ) -> ChatCtx<'a> {
11536 ChatCtx {
11537 url: server_uri,
11538 model: "test-model",
11539 kind: BackendKind::Ollama,
11540 api_key: None,
11541 messages,
11542 task: "do the thing",
11543 workspace: ".",
11544 color: false,
11545 markdown: false,
11546 tool_offload: false,
11547 spill_store: None,
11548 compaction_store: None,
11549 scratchpad: false,
11550 scratchpad_store: None,
11551 code_search: None,
11552 experience_store: None,
11553 step_ledger: None,
11554 caveats,
11555 persona_tools: None,
11556 max_tool_rounds: 8,
11557 narration_nudge_cap: 1,
11558 workflow_grace_rounds: 0,
11559 tool_output_lines: 20,
11560 debug: false,
11561 trace: false,
11562 num_ctx: None,
11563 input_ceiling_pct: 80,
11564 low_budget_pct: 15,
11565 connect_timeout_secs: 5,
11566 inference_timeout_secs: 30,
11567 mid_loop_trim_threshold: 40,
11568 mid_loop_trim_tokens: None,
11569 max_ok_input: None,
11570 build_check_cmd: None,
11571 safe_context: None,
11572 recover_cw_400: None,
11573 note_sink: None,
11574 note_nudge: None,
11575 recall_source: None,
11576 memory_source: None,
11577 summarizer: None,
11578 compress_state: None,
11579 tool_events: None,
11580 phantom_reaches: None,
11581 end_reason: None,
11582 permission_gate: None,
11583 on_round_usage: None,
11584 estimate_ratio: None,
11585 estimation: crate::tokens::TokenEstimation::default(),
11586 summary_input_cap_floor_chars: 8_192,
11587 exec_floor: None,
11589 write_ledger: None,
11590 cancel: None,
11591 git_tool: None,
11592 crew_runner: None,
11593 }
11594 }
11595
11596 fn body_json(req: &Request) -> serde_json::Value {
11597 serde_json::from_slice(&req.body).unwrap_or_default()
11598 }
11599
11600 fn is_stream(req: &Request) -> bool {
11601 body_json(req)["stream"].as_bool().unwrap_or(false)
11602 }
11603
11604 fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
11605 let body: String = lines
11606 .iter()
11607 .map(|l| format!("{l}\n"))
11608 .collect::<Vec<_>>()
11609 .join("");
11610 ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
11611 }
11612
11613 struct AcceptsLargePrompts {
11616 tools_rounds: Arc<AtomicUsize>,
11617 }
11618 impl Respond for AcceptsLargePrompts {
11619 fn respond(&self, req: &Request) -> ResponseTemplate {
11620 if is_stream(req) {
11621 return ndjson(&[serde_json::json!({
11622 "message": {"content": "budget raised, here is the answer"},
11623 "done": true, "prompt_eval_count": 8_700, "eval_count": 12
11624 })]);
11625 }
11626 let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
11627 if n < 2 {
11628 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11629 "message": {"content": "", "tool_calls": [{
11630 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11631 }]},
11632 "prompt_eval_count": 8_734, "eval_count": 10,
11633 }))
11634 } else {
11635 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11636 "message": {"content": "budget raised, here is the answer"},
11637 "prompt_eval_count": 8_700, "eval_count": 12,
11638 }))
11639 }
11640 }
11641 }
11642
11643 #[tokio::test]
11651 async fn poisoned_low_budget_recovers_via_accepted_observation_and_raise() {
11652 let server = MockServer::start().await;
11653 let tools_rounds = Arc::new(AtomicUsize::new(0));
11654 Mock::given(method("POST"))
11655 .and(path("/api/chat"))
11656 .respond_with(AcceptsLargePrompts {
11657 tools_rounds: tools_rounds.clone(),
11658 })
11659 .mount(&server)
11660 .await;
11661
11662 let big_task = "study the workspace and report. ".repeat(380);
11665 let messages = vec![
11666 MemMessage::system("you are a test"),
11667 MemMessage::user(&big_task),
11668 ];
11669 let caveats = Caveats::top();
11670 let uri = server.uri();
11671 let mut observations: Vec<RoundObservation> = Vec::new();
11672 let mut hook = |obs: RoundObservation| observations.push(obs);
11673 let mut c = ctx(&uri, &messages, &caveats);
11674 c.max_ok_input = Some(2_000); c.on_round_usage = Some(&mut hook);
11676 let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
11677 .await
11678 .expect("the turn must complete — no Refused bail after the raise");
11679
11680 assert_eq!(reply, "budget raised, here is the answer");
11681 assert!(
11682 observations.iter().any(|o| matches!(
11683 o,
11684 RoundObservation::Accepted {
11685 prompt_tokens: 8_734,
11686 ..
11687 }
11688 )),
11689 "the accepted 8,734-token prompt must reach the hook: {observations:?}"
11690 );
11691 for o in &observations {
11694 if let RoundObservation::Accepted {
11695 estimated_tokens, ..
11696 } = o
11697 {
11698 assert!(*estimated_tokens > 0, "estimate rides along: {o:?}");
11699 }
11700 }
11701 }
11702
11703 struct ToolCallsWithUsage;
11706 impl Respond for ToolCallsWithUsage {
11707 fn respond(&self, req: &Request) -> ResponseTemplate {
11708 if body_json(req).get("tools").is_some() {
11709 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11710 "message": {"content": "", "tool_calls": [{
11711 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11712 }]},
11713 "prompt_eval_count": 100, "eval_count": 5,
11714 }))
11715 } else {
11716 ResponseTemplate::new(200)
11717 .set_body_json(serde_json::json!({"message": {"content": "cap exit"}}))
11718 }
11719 }
11720 }
11721
11722 #[tokio::test]
11728 async fn err_turn_still_delivered_accepted_observations_first() {
11729 let server = MockServer::start().await;
11730 Mock::given(method("POST"))
11731 .and(path("/api/chat"))
11732 .respond_with(ToolCallsWithUsage)
11733 .mount(&server)
11734 .await;
11735
11736 let messages = vec![
11739 MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
11740 MemMessage::user("do the thing"),
11741 ];
11742 let caveats = Caveats::top();
11743 let uri = server.uri();
11744 let mut compress_state = CompressState::new();
11745 let mut observations: Vec<RoundObservation> = Vec::new();
11746 let mut hook = |obs: RoundObservation| observations.push(obs);
11747 let mut c = ctx(&uri, &messages, &caveats);
11748 c.mid_loop_trim_tokens = Some(50);
11749 c.compress_state = Some(&mut compress_state);
11750 c.on_round_usage = Some(&mut hook);
11751 let err = chat_complete(c, &mut NoMcp)
11752 .await
11753 .expect_err("the post-latch over-budget round must refuse the send");
11754
11755 let msg = err.to_string();
11756 assert!(msg.contains("exceeds the model's input budget"), "{msg}");
11757 assert!(
11758 msg.contains("newt tunings reset test-model"),
11759 "the bail must name the model's reset command: {msg}"
11760 );
11761 assert!(
11762 observations.iter().any(|o| matches!(
11763 o,
11764 RoundObservation::Accepted {
11765 prompt_tokens: 100,
11766 ..
11767 }
11768 )),
11769 "accepted rounds before the bail must have been reported: {observations:?}"
11770 );
11771 }
11772
11773 struct ThinkingOnlyThenRecover {
11777 probes: Arc<AtomicUsize>,
11778 }
11779 impl Respond for ThinkingOnlyThenRecover {
11780 fn respond(&self, req: &Request) -> ResponseTemplate {
11781 if is_stream(req) {
11782 if self.probes.load(Ordering::SeqCst) <= 1 {
11783 ndjson(&[serde_json::json!({
11784 "message": {"content": ""}, "done": true,
11785 "prompt_eval_count": 9, "eval_count": 4
11786 })])
11787 } else {
11788 ndjson(&[serde_json::json!({
11789 "message": {"content": "recovered after thinking-only"},
11790 "done": true, "prompt_eval_count": 12, "eval_count": 3
11791 })])
11792 }
11793 } else {
11794 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
11795 if n == 1 {
11796 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11797 "message": {
11798 "content": "",
11799 "thinking": "all reasoning, no final text"
11800 },
11801 "prompt_eval_count": 10, "eval_count": 2559,
11802 }))
11803 } else {
11804 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11805 "message": {"content": "recovered after thinking-only"},
11806 "prompt_eval_count": 12, "eval_count": 3,
11807 }))
11808 }
11809 }
11810 }
11811 }
11812
11813 #[tokio::test]
11814 async fn thinking_only_response_emits_one_thinking_only_observation() {
11815 let server = MockServer::start().await;
11816 let probes = Arc::new(AtomicUsize::new(0));
11817 Mock::given(method("POST"))
11818 .and(path("/api/chat"))
11819 .respond_with(ThinkingOnlyThenRecover {
11820 probes: probes.clone(),
11821 })
11822 .mount(&server)
11823 .await;
11824
11825 let messages = vec![
11826 MemMessage::system("you are a test"),
11827 MemMessage::user("do the thing"),
11828 ];
11829 let caveats = Caveats::top();
11830 let uri = server.uri();
11831 let mut observations: Vec<RoundObservation> = Vec::new();
11832 let mut hook = |obs: RoundObservation| observations.push(obs);
11833 let mut c = ctx(&uri, &messages, &caveats);
11834 c.on_round_usage = Some(&mut hook);
11835 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11836 .await
11837 .expect("the corrective retry recovers the turn");
11838
11839 assert_eq!(reply, "recovered after thinking-only");
11840 let thinking = observations
11841 .iter()
11842 .filter(|o| matches!(o, RoundObservation::ThinkingOnly))
11843 .count();
11844 assert_eq!(thinking, 1, "exactly once per turn: {observations:?}");
11845 assert!(
11846 observations
11847 .iter()
11848 .any(|o| matches!(o, RoundObservation::Accepted { .. })),
11849 "the recovered round is usable output: {observations:?}"
11850 );
11851 }
11852
11853 struct TruncationSuspectResponder {
11858 tools_rounds: Arc<AtomicUsize>,
11859 }
11860 impl Respond for TruncationSuspectResponder {
11861 fn respond(&self, req: &Request) -> ResponseTemplate {
11862 if is_stream(req) {
11863 return ndjson(&[serde_json::json!({
11864 "message": {"content": "suspect answer"}, "done": true,
11865 "prompt_eval_count": 4_000, "eval_count": 5
11866 })]);
11867 }
11868 let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
11869 if n == 0 {
11870 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11871 "message": {"content": "", "tool_calls": [{
11872 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11873 }]},
11874 "prompt_eval_count": 4_000, "eval_count": 5,
11876 }))
11877 } else {
11878 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11879 "message": {"content": "suspect answer"},
11880 "prompt_eval_count": 4_000, "eval_count": 5,
11881 }))
11882 }
11883 }
11884 }
11885
11886 #[tokio::test]
11887 async fn truncation_suspect_rounds_emit_nothing() {
11888 let server = MockServer::start().await;
11889 let tools_rounds = Arc::new(AtomicUsize::new(0));
11890 Mock::given(method("POST"))
11891 .and(path("/api/chat"))
11892 .respond_with(TruncationSuspectResponder {
11893 tools_rounds: tools_rounds.clone(),
11894 })
11895 .mount(&server)
11896 .await;
11897
11898 let messages = vec![
11899 MemMessage::system("you are a test"),
11900 MemMessage::user("do the thing"),
11901 ];
11902 let caveats = Caveats::top();
11903 let uri = server.uri();
11904 let mut observations: Vec<RoundObservation> = Vec::new();
11905 let mut hook = |obs: RoundObservation| observations.push(obs);
11906 let mut c = ctx(&uri, &messages, &caveats);
11907 c.num_ctx = Some(4_096);
11908 c.on_round_usage = Some(&mut hook);
11909 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11910 .await
11911 .expect("suspect rounds still complete the turn");
11912
11913 assert_eq!(reply, "suspect answer");
11914 assert!(
11915 observations.is_empty(),
11916 "a possibly head-truncated prompt is evidence of nothing: \
11917 {observations:?}"
11918 );
11919 }
11920
11921 struct OpenAiAcceptsResponder;
11925 impl Respond for OpenAiAcceptsResponder {
11926 fn respond(&self, req: &Request) -> ResponseTemplate {
11927 if body_json(req).get("tools").is_some()
11928 && !body_json(req)["messages"]
11929 .as_array()
11930 .map(|m| m.iter().any(|x| x["role"] == "tool"))
11931 .unwrap_or(false)
11932 {
11933 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11934 "choices": [{"message": {
11935 "content": null,
11936 "tool_calls": [{
11937 "id": "call_1",
11938 "type": "function",
11939 "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
11940 }]
11941 }}],
11942 "usage": {"prompt_tokens": 5_120, "completion_tokens": 9},
11943 }))
11944 } else {
11945 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11946 "choices": [{"message": {"content": "openai accepted"}}],
11947 "usage": {"prompt_tokens": 5_200, "completion_tokens": 11},
11948 }))
11949 }
11950 }
11951 }
11952
11953 #[tokio::test]
11954 async fn openai_loop_reports_accepted_rounds() {
11955 let server = MockServer::start().await;
11956 Mock::given(method("POST"))
11957 .and(path("/v1/chat/completions"))
11958 .respond_with(OpenAiAcceptsResponder)
11959 .mount(&server)
11960 .await;
11961
11962 let messages = vec![
11963 MemMessage::system("you are a test"),
11964 MemMessage::user("do the thing"),
11965 ];
11966 let caveats = Caveats::top();
11967 let uri = server.uri();
11968 let mut observations: Vec<RoundObservation> = Vec::new();
11969 let mut hook = |obs: RoundObservation| observations.push(obs);
11970 let mut c = ctx(&uri, &messages, &caveats);
11971 c.kind = BackendKind::Openai;
11972 c.api_key = Some("sk-test");
11973 c.on_round_usage = Some(&mut hook);
11974 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11975 .await
11976 .expect("openai loop should succeed");
11977
11978 assert_eq!(reply, "openai accepted");
11979 let accepted: Vec<u32> = observations
11980 .iter()
11981 .filter_map(|o| match o {
11982 RoundObservation::Accepted { prompt_tokens, .. } => Some(*prompt_tokens),
11983 _ => None,
11984 })
11985 .collect();
11986 assert_eq!(
11987 accepted,
11988 vec![5_120, 5_200],
11989 "both usable rounds reported, in order: {observations:?}"
11990 );
11991 }
11992
11993 struct PersistentEmptyOverflow;
12003 impl Respond for PersistentEmptyOverflow {
12004 fn respond(&self, _req: &Request) -> ResponseTemplate {
12005 if is_stream(_req) {
12006 return ndjson(&[serde_json::json!({
12009 "message": {"content": ""}, "done": true,
12010 "prompt_eval_count": 8_734, "eval_count": 0
12011 })]);
12012 }
12013 ResponseTemplate::new(200).set_body_json(serde_json::json!({
12016 "message": {"content": ""},
12017 "prompt_eval_count": 8_734, "eval_count": 0,
12018 }))
12019 }
12020 }
12021
12022 #[tokio::test]
12023 async fn persistent_empty_over_safe_context_emits_suspected_overflow() {
12024 let server = MockServer::start().await;
12025 Mock::given(method("POST"))
12026 .and(path("/api/chat"))
12027 .respond_with(PersistentEmptyOverflow)
12028 .mount(&server)
12029 .await;
12030
12031 let messages = vec![
12032 MemMessage::system("you are a test"),
12033 MemMessage::user("do the thing"),
12034 ];
12035 let caveats = Caveats::top();
12036 let uri = server.uri();
12037 let mut observations: Vec<RoundObservation> = Vec::new();
12038 let mut hook = |obs: RoundObservation| observations.push(obs);
12039 let mut c = ctx(&uri, &messages, &caveats);
12040 c.safe_context = Some(4_000);
12042 c.on_round_usage = Some(&mut hook);
12043 let (_reply, streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
12044 .await
12045 .expect("persistent empties return the empty-response message, not Err");
12046
12047 assert!(
12049 !streamed,
12050 "the silent-overflow exit is not a streamed reply"
12051 );
12052 let overflow: Vec<u32> = observations
12055 .iter()
12056 .filter_map(|o| match o {
12057 RoundObservation::SuspectedOverflow { prompt_tokens } => Some(*prompt_tokens),
12058 _ => None,
12059 })
12060 .collect();
12061 assert_eq!(
12062 overflow,
12063 vec![8_734],
12064 "one SuspectedOverflow at the merged prompt size: {observations:?}"
12065 );
12066 assert!(
12069 !observations
12070 .iter()
12071 .any(|o| matches!(o, RoundObservation::Accepted { .. })),
12072 "empty rounds are not Accepted evidence: {observations:?}"
12073 );
12074 }
12075}