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 resume;
108mod routing;
112mod tool_search;
116mod tools;
117mod transcript;
118mod trim;
119mod warmup;
120
121pub use compress::{
122 compress_user_initiated, CompressCounters, CompressState, ManualCompressOutcome, SummarizeFn,
123 SummarizeFuture, Summarizer, SUMMARY_END_MARKER, SUMMARY_PREFIX,
124};
125pub use crew_attest::{crew_authz, crew_step_up_policy, CrewAuthz, Presence};
126pub use crew_tool::{compose_roster_tool_definition, crew_tool_definition, CrewRunner};
127pub use display::{
128 fmt_token_gauge, fmt_tokens_compact, gauge_level, print_harness_notice, print_list_item,
129 print_newt, GaugeLevel, NEWT_ORANGE_CT,
130};
131pub use driver::{
132 TurnDriver, TurnDriverConfig, TurnDriverError, TurnOutcome, TurnStatus,
133 VISIBLE_TRANSCRIPT_ROLES,
134};
135pub use experiential::{
136 experience_block, ExperienceStore, SessionExperienceStore, EXPERIENCE_TOP_K,
137};
138pub use git_tool::{git_tool_definition, GitTool};
139pub use markdown::{render_markdown, MarkdownStreamWriter, RenderOpts};
140pub use mcp::{McpTools, NoMcp};
141pub use plan_exec::{run_plan, run_plan_with_reground, NoReground, PlanRun, Reground};
142pub use scheduled::{
143 plan_block, plan_reseat_pointer, PlanSnapshot, SessionStepLedger, Step, StepLedger, StepStatus,
144};
145pub use scratchpad::{scratchpad_state_block, ScratchpadStore, SessionScratchpadStore};
146pub use semantic::{
147 chunk_source, code_evidence_block, code_search_tool_definition, cosine, gather_code_files,
148 index_files, retrieve_evidence, CodeChunk, CodeSearch, Embedder, EmbeddingsClient,
149 SemanticIndex, SessionSemanticIndex,
150};
151pub use spill::{SessionSpillStore, SpillStore};
152
153#[cfg(feature = "markdown-table-formatter")]
160pub fn tidy_markdown_tables(src: &str) -> String {
161 markdown_table_formatter::format_tables(src)
162}
163
164#[cfg(not(feature = "markdown-table-formatter"))]
166pub fn tidy_markdown_tables(src: &str) -> String {
167 src.to_string()
168}
169
170#[cfg(test)]
171mod tidy_tables_tests {
172 #[cfg(not(feature = "markdown-table-formatter"))]
173 #[test]
174 fn identity_without_the_feature() {
175 let ragged = "| a | bb |\n|---|---|\n| ccc | d |";
178 assert_eq!(super::tidy_markdown_tables(ragged), ragged);
179 }
180
181 #[cfg(feature = "markdown-table-formatter")]
182 #[test]
183 fn aligns_pipes_with_the_feature() {
184 let ragged = "| a | bb |\n| --- | --- |\n| ccc | d |\n";
185 let tidy = super::tidy_markdown_tables(ragged);
186 assert_ne!(tidy, ragged, "the table should be reformatted");
187 assert!(tidy.contains("ccc"), "content preserved");
188 let pipe_cols = |s: &str| {
190 s.char_indices()
191 .filter(|(_, c)| *c == '|')
192 .map(|(i, _)| i)
193 .collect::<Vec<_>>()
194 };
195 let rows: Vec<&str> = tidy.lines().filter(|l| l.contains('|')).collect();
196 let first = pipe_cols(rows[0]);
197 for r in &rows {
198 assert_eq!(pipe_cols(r), first, "pipes aligned across rows: {r:?}");
199 }
200 }
201}
202pub use budget::get_context_remaining_tool_definition;
203pub use memory_fetch::{
204 memory_fetch_tool_definition, MemAddr, MemPayload, MemorySource, StoreMemorySource,
205};
206pub use note_sink::{save_note_tool_definition, NoteNudge, NoteSink};
207pub use observation::{ShellObservation, SHELL_OBSERVATION_PREFIX};
208pub use permissions::{
209 append_denial, load_denials, widen_caveats, DenialKind, PermissionDecision, PermissionGate,
210 PermissionRecord, PermissionRequest, PersistentDenial,
211};
212pub use recall::{recall_tool_definition, RecallSource, StoreRecallSource};
213pub use resume::resume_context_tool_definition;
214pub use tools::{
215 execute_tool, execute_tool_with_offload, full_access_requested, ocap_disabled,
216 set_max_output_tokens, set_output_head_tokens, tool_definitions, venv_cmd_prefix,
217};
218pub use transcript::{
219 transcript_lines, transcript_lines_styled, TranscriptLine, TranscriptRole, TranscriptStyle,
220};
221pub use trim::trim_for_summary;
222pub use warmup::warmup_if_cold;
223
224use crate::retry::{with_backoff_notify, RetryPolicy};
225use compress::{compress, compression_trigger, CompressAction, CompressRequest};
226use crossterm::{
227 execute,
228 style::{Color as CtColor, Print, ResetColor, SetForegroundColor},
229};
230use display::{
231 emit_compression_notice, emit_overflow_notice, print_debug, print_retry_indicator, print_trace,
232};
233use std::io::{self, Write as _};
234use tools::{is_hallucination, merged_tool_definitions};
235use trim::{
236 estimate_request_tokens, estimate_tokens, estimate_value_tokens, merge_round_usage,
237 ollama_usage, openai_usage, PromptTracker,
238};
239
240fn tui_retry_policy() -> RetryPolicy {
245 RetryPolicy::for_local_inference()
246}
247
248pub type RecoverCw400 = fn(&anyhow::Error, &str, &str) -> Option<u32>;
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum RoundObservation {
260 Accepted {
267 prompt_tokens: u32,
268 estimated_tokens: usize,
269 },
270 SuspectedOverflow { prompt_tokens: u32 },
273 ThinkingOnly,
276}
277
278fn num_ctx_input_ceiling(num_ctx: Option<u32>) -> Option<usize> {
287 num_ctx.map(|c| (c as usize) * 80 / 100).filter(|&c| c > 0)
288}
289
290fn initial_send_budget(
312 max_ok_input: Option<u32>,
313 safe_context: Option<u32>,
314 num_ctx: Option<u32>,
315) -> Option<usize> {
316 let cached = match (max_ok_input, safe_context) {
317 (Some(m), Some(s)) => Some(m.max(s) as usize),
318 (m, s) => m.or(s).map(|c| c as usize),
319 };
320 match (cached, num_ctx_input_ceiling(num_ctx)) {
321 (Some(budget), Some(ceiling)) => Some(budget.min(ceiling)),
322 (budget, ceiling) => budget.or(ceiling),
323 }
324}
325
326fn calibrate_up(est: usize, ratio: f32) -> usize {
331 (est as f32 * ratio).ceil() as usize
332}
333
334fn calibrate_down(real: usize, ratio: f32) -> usize {
338 (real as f32 / ratio).floor() as usize
339}
340
341fn sanitize_estimate_ratio(estimate_ratio: Option<f32>) -> f32 {
346 estimate_ratio
347 .filter(|r| r.is_finite() && (0.5..=3.0).contains(r))
348 .unwrap_or(1.0)
349}
350
351fn emit_accepted(
357 hook: &mut Option<&mut dyn FnMut(RoundObservation)>,
358 round_usage: Option<crate::TokenUsage>,
359 truncation_suspect: bool,
360 estimated_tokens: usize,
361) {
362 if truncation_suspect {
363 return;
364 }
365 if let (Some(hook), Some(u)) = (hook.as_deref_mut(), round_usage) {
366 hook(RoundObservation::Accepted {
367 prompt_tokens: u.input_tokens,
368 estimated_tokens,
369 });
370 }
371}
372
373#[cfg(test)]
378mod send_budget_tests {
379 use super::compress::compression_trigger;
380 use super::{initial_send_budget, num_ctx_input_ceiling};
381
382 #[test]
387 fn first_turn_fresh_cache_trigger_sees_the_num_ctx_ceiling() {
388 let budget = initial_send_budget(None, None, Some(4096));
389 assert_eq!(budget, Some(3276), "80% of 4096 — reply headroom reserved");
390 let trigger = compression_trigger(3, 41_355, 39_900, 40, None, budget, 1_432)
394 .expect("the ceiling must fire the trigger on the first turn");
395 assert!(trigger.hard_budget, "a real token budget, not a soft halve");
396 assert_eq!(
397 trigger.budget,
398 3_276 - 1_432,
399 "budget lands in message space: ceiling minus tool-schema tokens"
400 );
401 assert_eq!(trigger.max_messages, None, "no count firing here");
402 }
403
404 #[test]
410 fn absent_num_ctx_leaves_the_budget_unchanged() {
411 assert_eq!(initial_send_budget(None, None, None), None);
412 assert_eq!(initial_send_budget(Some(2_000), None, None), Some(2_000));
413 assert_eq!(initial_send_budget(None, Some(5_000), None), Some(5_000));
414 assert_eq!(
415 initial_send_budget(Some(2_000), Some(5_000), None),
416 Some(5_000),
417 "an HWM below safe_context is a floor, not a cap — safe_context wins"
418 );
419 assert_eq!(
421 compression_trigger(3, 41_355, 39_900, 40, None, None, 1_432),
422 None
423 );
424 }
425
426 #[test]
431 fn cached_budget_is_max_of_proven_and_believed() {
432 assert_eq!(
436 initial_send_budget(Some(6_068), Some(26_214), None),
437 Some(26_214),
438 "HWM below safe_context → safe_context"
439 );
440 assert_eq!(
443 initial_send_budget(Some(8_734), Some(6_553), None),
444 Some(8_734),
445 "HWM above safe_context (proven beyond the claim) → HWM"
446 );
447 assert_eq!(
452 initial_send_budget(Some(800_000), Some(64_000), None),
453 Some(800_000),
454 "post-cw-400: max_ok_input is the authoritative cap"
455 );
456 assert_eq!(
457 initial_send_budget(Some(800_000), Some(800_000), None),
458 Some(800_000)
459 );
460 }
461
462 #[test]
466 fn calibration_helpers_round_in_the_safe_direction() {
467 use super::{calibrate_down, calibrate_up, sanitize_estimate_ratio};
468 assert_eq!(calibrate_up(6_068, 1.0), 6_068);
470 assert_eq!(calibrate_down(6_068, 1.0), 6_068);
471 assert_eq!(calibrate_up(1_000, 1.3), 1_300);
473 assert_eq!(calibrate_down(1_000, 1.3), 769, "floor, never round up");
474 assert_eq!(calibrate_up(3, 1.5), 5, "4.5 ceils to 5");
476 assert_eq!(calibrate_down(3, 2.0), 1, "1.5 floors to 1");
477 assert_eq!(sanitize_estimate_ratio(None), 1.0);
479 assert_eq!(sanitize_estimate_ratio(Some(f32::NAN)), 1.0);
480 assert_eq!(sanitize_estimate_ratio(Some(0.1)), 1.0);
481 assert_eq!(sanitize_estimate_ratio(Some(5.0)), 1.0);
482 assert_eq!(sanitize_estimate_ratio(Some(1.29)), 1.29);
483 assert_eq!(sanitize_estimate_ratio(Some(0.5)), 0.5, "clamp inclusive");
484 assert_eq!(sanitize_estimate_ratio(Some(3.0)), 3.0, "clamp inclusive");
485 }
486
487 #[test]
493 fn calibration_composes_across_the_trigger_boundary() {
494 use super::{calibrate_down, calibrate_up};
495 let cal = 1.3_f32;
496 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(
501 3,
502 current_real,
503 9_000,
504 40,
505 None,
506 Some(send_budget),
507 tool_tokens_real,
508 )
509 .expect("over-budget context fires the guard");
510 assert!(trigger.hard_budget);
511 assert_eq!(trigger.budget, send_budget - tool_tokens_real);
514 let pipeline_budget = calibrate_down(trigger.budget, cal);
515 assert_eq!(pipeline_budget, calibrate_down(8_734 - 1_300, cal));
516 assert!(
517 pipeline_budget < trigger.budget,
518 "ratio > 1: the estimate-space target is tighter than the real one"
519 );
520 }
521
522 #[test]
525 fn ceiling_composes_with_cached_budgets_via_min() {
526 assert_eq!(
529 initial_send_budget(Some(2_135), None, Some(4_096)),
530 Some(2_135)
531 );
532 assert_eq!(
535 initial_send_budget(None, Some(104_857), Some(4_096)),
536 Some(3_276)
537 );
538 assert_eq!(
539 initial_send_budget(Some(104_857), Some(104_857), Some(4_096)),
540 Some(3_276)
541 );
542 }
543
544 #[test]
547 fn zero_or_tiny_num_ctx_is_no_budget_at_all() {
548 assert_eq!(num_ctx_input_ceiling(None), None);
549 assert_eq!(num_ctx_input_ceiling(Some(0)), None);
550 assert_eq!(num_ctx_input_ceiling(Some(1)), None, "80% rounds to zero");
551 assert_eq!(initial_send_budget(None, None, Some(0)), None);
552 assert_eq!(initial_send_budget(Some(2_000), None, Some(0)), Some(2_000));
554 }
555}
556
557pub struct ChatCtx<'a> {
561 pub url: &'a str,
562 pub model: &'a str,
563 pub kind: crate::BackendKind,
565 pub api_key: Option<&'a str>,
567 pub messages: &'a [crate::MemMessage],
569 pub task: &'a str,
570 pub workspace: &'a str,
571 pub color: bool,
572 pub markdown: bool,
576 pub tool_offload: bool,
580 pub spill_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
584 pub compaction_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
590 pub scratchpad: bool,
593 pub scratchpad_store: Option<&'a dyn crate::agentic::scratchpad::ScratchpadStore>,
596 pub code_search: Option<crate::agentic::semantic::CodeSearch<'a>>,
599 pub experience_store: Option<&'a dyn crate::agentic::experiential::ExperienceStore>,
602 pub step_ledger: Option<&'a dyn crate::agentic::scheduled::StepLedger>,
605 pub caveats: &'a crate::caveats::Caveats,
606 pub max_tool_rounds: usize,
609 pub workflow_grace_rounds: usize,
613 pub tool_output_lines: usize,
617 pub debug: bool,
620 pub trace: bool,
623 pub num_ctx: Option<u32>,
630 pub connect_timeout_secs: u64,
633 pub inference_timeout_secs: u64,
636 pub mid_loop_trim_threshold: usize,
639 pub mid_loop_trim_tokens: Option<usize>,
644 pub max_ok_input: Option<u32>,
649 pub build_check_cmd: Option<String>,
653 pub safe_context: Option<u32>,
658 pub recover_cw_400: Option<RecoverCw400>,
665 pub note_sink: Option<&'a mut dyn NoteSink>,
671 pub note_nudge: Option<&'a mut NoteNudge>,
675 pub recall_source: Option<&'a dyn RecallSource>,
681 pub memory_source: Option<&'a dyn MemorySource>,
690 pub summarizer: Option<&'a SummarizeFn>,
697 pub compress_state: Option<&'a mut CompressState>,
701 pub tool_events: Option<&'a mut Vec<crate::ToolEvent>>,
709 pub phantom_reaches: Option<&'a mut Vec<crate::PhantomReach>>,
715 pub permission_gate: Option<&'a mut dyn PermissionGate>,
722 pub on_round_usage: Option<&'a mut dyn FnMut(RoundObservation)>,
733 pub estimate_ratio: Option<f32>,
739 pub estimation: crate::tokens::TokenEstimation,
743 pub summary_input_cap_floor_chars: usize,
746 pub exec_floor: Option<&'a crate::caveats::Scope<String>>,
755 pub write_ledger: Option<&'a std::cell::RefCell<crate::verify_gate::WriteLedger>>,
763 pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
771 pub git_tool: Option<&'a dyn GitTool>,
778 pub crew_runner: Option<&'a dyn CrewRunner>,
784}
785
786fn ledger_note_write(
794 write_ledger: Option<&std::cell::RefCell<crate::verify_gate::WriteLedger>>,
795 name: &str,
796 args: &serde_json::Value,
797 workspace: &str,
798) {
799 let Some(led) = write_ledger else {
800 return;
801 };
802 if name != "write_file" && name != "edit_file" {
803 return;
804 }
805 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
806 let abs = lexical_normalize(&std::path::Path::new(workspace).join(p));
811 led.borrow_mut().note_before_write(abs);
812 }
813}
814
815fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
821 use std::path::Component;
822 let mut out = std::path::PathBuf::new();
823 for comp in path.components() {
824 match comp {
825 Component::ParentDir => {
826 if matches!(out.components().next_back(), Some(Component::Normal(_))) {
828 out.pop();
829 } else {
830 out.push("..");
831 }
832 }
833 Component::CurDir => {}
834 other => out.push(other.as_os_str()),
835 }
836 }
837 out
838}
839
840#[cfg(test)]
841mod retry_ledger_tests {
842 use super::*;
843
844 #[test]
845 fn lexical_normalize_collapses_dot_and_parent() {
846 assert_eq!(
847 lexical_normalize(std::path::Path::new("/ws/examples/../foo.py")),
848 std::path::PathBuf::from("/ws/foo.py")
849 );
850 assert_eq!(
851 lexical_normalize(std::path::Path::new("/ws/./a//b/foo.py")),
852 std::path::PathBuf::from("/ws/a/b/foo.py")
853 );
854 assert_eq!(
856 lexical_normalize(std::path::Path::new("../x.py")),
857 std::path::PathBuf::from("../x.py")
858 );
859 }
860
861 #[test]
862 fn ledger_note_write_keys_on_the_normalized_path() {
863 let tmp = tempfile::tempdir().unwrap();
864 std::fs::write(tmp.path().join("foo.py"), "real\n").unwrap();
865 let led = std::cell::RefCell::new(crate::verify_gate::WriteLedger::new());
866 let args = serde_json::json!({ "path": "examples/../foo.py" });
868 ledger_note_write(
869 Some(&led),
870 "write_file",
871 &args,
872 tmp.path().to_str().unwrap(),
873 );
874 assert!(
877 led.borrow().revert(&tmp.path().join("foo.py")).unwrap(),
878 "the normalized key matches the gate's path"
879 );
880 ledger_note_write(
882 Some(&led),
883 "read_file",
884 &serde_json::json!({ "path": "foo.py" }),
885 tmp.path().to_str().unwrap(),
886 );
887 assert_eq!(led.borrow().len(), 1, "only write tools are tracked");
888 }
889}
890
891fn is_tools_unsupported_error(e: &anyhow::Error) -> bool {
896 let s = e.to_string().to_lowercase();
897 s.contains("does not support tools") || s.contains("not support tools")
898}
899
900fn is_ollama_tool_xml_error(e: &anyhow::Error) -> bool {
906 let s = e.to_string().to_lowercase();
907 s.contains("ollama") && s.contains("xml syntax error")
908}
909
910fn ollama_tool_xml_retry_nudge() -> &'static str {
911 "The previous assistant turn failed inside Ollama's XML tool-call parser. \
912 Keep using tools, but emit exactly one valid native tool call with well-formed \
913 arguments now. Do not answer in prose or wrap the call in explanatory XML."
914}
915
916fn is_cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) -> bool {
929 cancel.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
930}
931
932async fn cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) {
937 match cancel {
938 None => std::future::pending().await,
939 Some(flag) => {
940 while !flag.load(std::sync::atomic::Ordering::Relaxed) {
944 tokio::time::sleep(std::time::Duration::from_millis(15)).await;
945 }
946 }
947 }
948}
949
950async fn cancellable<F: std::future::Future>(
954 cancel: Option<&std::sync::atomic::AtomicBool>,
955 fut: F,
956) -> Option<F::Output> {
957 tokio::select! {
958 biased;
959 _ = cancelled(cancel) => None,
960 v = fut => Some(v),
961 }
962}
963
964async fn with_thinking_spinner<F: std::future::Future>(
970 animate: bool,
971 label: &str,
972 fut: F,
973) -> F::Output {
974 if !animate {
975 return fut.await;
976 }
977 tokio::pin!(fut);
978 let start = std::time::Instant::now();
979 let mut frame = 0usize;
980 let mut ticker = tokio::time::interval(std::time::Duration::from_millis(120));
981 loop {
982 tokio::select! {
983 biased;
984 out = &mut fut => {
985 let _ = write!(io::stdout(), "\r\x1b[K");
986 let _ = io::stdout().flush();
987 return out;
988 }
989 _ = ticker.tick() => {
990 let line = format_spinner(frame, start.elapsed().as_secs_f32(), label, 0);
991 let mut out = io::stdout();
992 let _ = execute!(
993 out,
994 Print("\r\x1b[K"),
995 SetForegroundColor(CtColor::DarkGrey),
996 Print(&line),
997 ResetColor,
998 );
999 let _ = out.flush();
1000 frame = frame.wrapping_add(1);
1001 }
1002 }
1003 }
1004}
1005
1006pub async fn chat_complete(
1007 ctx: ChatCtx<'_>,
1008 mcp: &mut dyn McpTools,
1009) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
1010 if ctx.kind == crate::BackendKind::Openai {
1013 if responses_api_selected() {
1017 return openai_responses_complete(ctx, mcp).await;
1018 }
1019 return openai_chat_complete(ctx, mcp).await;
1020 }
1021 let markdown = ctx.markdown;
1024 let ChatCtx {
1025 url,
1026 model,
1027 kind: _,
1028 api_key: _,
1029 messages: mem_messages,
1030 task,
1031 workspace,
1032 color,
1033 markdown: _,
1034 tool_offload,
1035 spill_store,
1036 compaction_store,
1037 scratchpad,
1038 scratchpad_store,
1039 code_search,
1040 experience_store,
1041 step_ledger,
1042 caveats,
1043 max_tool_rounds,
1044 workflow_grace_rounds,
1045 tool_output_lines,
1046 debug,
1047 trace,
1048 num_ctx,
1049 connect_timeout_secs,
1050 inference_timeout_secs,
1051 mid_loop_trim_threshold,
1052 mid_loop_trim_tokens,
1053 max_ok_input,
1054 build_check_cmd,
1055 safe_context,
1056 recover_cw_400,
1057 mut note_sink,
1058 mut note_nudge,
1059 recall_source,
1060 memory_source,
1061 summarizer,
1062 compress_state,
1063 mut tool_events,
1064 mut phantom_reaches,
1065 mut permission_gate,
1066 mut on_round_usage,
1067 estimate_ratio,
1068 estimation,
1069 summary_input_cap_floor_chars,
1070 exec_floor,
1071 write_ledger,
1072 cancel,
1073 git_tool,
1074 crew_runner,
1075 } = ctx;
1076 let mut local_compress_state = CompressState::new();
1079 let compress_state = match compress_state {
1080 Some(s) => s,
1081 None => &mut local_compress_state,
1082 };
1083 let client = reqwest::Client::builder()
1084 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1085 .timeout(std::time::Duration::from_secs(inference_timeout_secs))
1086 .build()?;
1087 let stream_client = reqwest::Client::builder()
1098 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1099 .read_timeout(std::time::Duration::from_secs(inference_timeout_secs))
1100 .build()?;
1101 let chat_url = format!("{}/api/chat", url.trim_end_matches('/'));
1102 let retry = tui_retry_policy();
1103 let advertise_save_note = note_sink.is_some();
1107 let advertise_recall = recall_source.is_some();
1108 let advertise_memory_fetch = memory_source.is_some();
1109 let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
1111 let advertise_code_search = code_search.is_some();
1113 let advertise_experiential = experience_store.is_some();
1115 let advertise_scheduled = step_ledger.is_some();
1117 let advertise_git = git_tool.is_some();
1118 let advertise_team = crew_runner.is_some();
1119
1120 let mut messages: Vec<serde_json::Value> = mem_messages
1123 .iter()
1124 .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
1125 .collect();
1126
1127 if note_sink.is_some() {
1132 if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
1133 append_nudge_line(&mut messages, &line);
1134 }
1135 }
1136
1137 let mut accumulated_usage: Option<crate::TokenUsage> = None;
1138 let mut hallucination_count: u32 = 0;
1139 let mut repeat_calls = RepeatCallGuard::default();
1141 let mut overflow_retries: u32 = 0;
1142 let mut suspicious_empty_retries: u32 = 0;
1143 let mut cw_retries: u32 = 0;
1145 let mut tools_supported = true;
1149 let mut tools_unsupported_notified = false;
1150 let num_ctx_ceiling = num_ctx_input_ceiling(num_ctx);
1158 let mut send_budget: Option<usize> = initial_send_budget(max_ok_input, safe_context, num_ctx);
1159 let mut send_budget_authoritative = safe_context.is_some() || num_ctx_ceiling.is_some();
1166 let tools = merged_tool_definitions(
1170 mcp,
1171 advertise_save_note,
1172 advertise_recall,
1173 advertise_memory_fetch,
1174 advertise_git,
1175 advertise_team,
1176 advertise_scratchpad,
1177 advertise_code_search,
1178 advertise_experiential,
1179 advertise_scheduled,
1180 );
1181 let tool_tokens = estimate_value_tokens(&tools, estimation);
1182 let cal = sanitize_estimate_ratio(estimate_ratio);
1187 let tool_tokens_real = calibrate_up(tool_tokens, cal);
1188 let animate = color && thinking_stream_enabled();
1192 let mut prompt_tracker = PromptTracker::new();
1195 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1196 let mut read_only_rounds: usize = 0;
1200 let mut narration_nudges: usize = 0;
1208 let mut pending_plan_nudges: usize = 0;
1211 let mut stale_file_nudges: usize = 0;
1215 let nudge_classifier = crate::NudgeClassifier::load_default();
1216 let workflow_steerer = crate::WorkflowSteerer::load_default();
1217 let mut workflow_runtime = WorkflowRuntimeState::default();
1218 workflow_runtime.set_progress_horizon(
1223 workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
1224 );
1225 let mut ollama_xml_retry_nudges: usize = 0;
1226 let mut thinking_only_reported = false;
1229 let mut observed_paths = claim_check::ObservedPaths::default();
1232 let observed_resolver = claim_check::workspace_resolver(workspace);
1233
1234 let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
1238 let mut workflow_grace_active = false;
1239 let mut current_tool_round_limit = max_tool_rounds;
1240 'round_loop: for round in 0..hard_tool_rounds {
1241 if round >= current_tool_round_limit {
1242 if workflow_grace_active {
1243 break;
1244 }
1245 let Some(nudge) = workflow_runtime.cap_grace_nudge(
1246 step_ledger,
1247 max_tool_rounds,
1248 workflow_grace_rounds,
1249 ) else {
1250 break;
1251 };
1252 workflow_grace_active = true;
1253 current_tool_round_limit = hard_tool_rounds;
1254 if debug {
1255 print_debug(
1256 "workflow progress at soft round cap — granting configured grace window",
1257 color,
1258 );
1259 }
1260 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1261 }
1262 if is_cancelled(cancel) {
1266 return Ok((String::new(), false, accumulated_usage, hallucination_count));
1267 }
1268 if round > 0 {
1269 if color {
1271 execute!(
1272 io::stdout(),
1273 SetForegroundColor(CtColor::DarkGrey),
1274 Print("…\n"),
1275 ResetColor
1276 )
1277 .ok();
1278 }
1279 }
1280
1281 if round > 0 {
1288 if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
1289 messages.push(serde_json::json!({ "role": "user", "content": ptr }));
1290 }
1291 if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
1292 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1293 }
1294 }
1295
1296 const READ_ONLY_NUDGE_AFTER: usize = 3;
1303 if read_only_rounds >= READ_ONLY_NUDGE_AFTER {
1304 let remaining = current_tool_round_limit.saturating_sub(round + 1);
1305 let delegate_hint = workflow_steerer
1311 .delegate_hint(&workflow_classifier_text(&messages, ""), advertise_team);
1312 messages.push(serde_json::json!({
1313 "role": "user",
1314 "content": read_only_action_nudge(
1315 read_only_rounds,
1316 remaining,
1317 step_ledger,
1318 delegate_hint.as_deref(),
1319 )
1320 }));
1321 read_only_rounds = 0;
1322 }
1323
1324 {
1333 let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
1337 let message_tokens = estimate_tokens(&messages, estimation);
1342 if let Some(trigger) = compression_trigger(
1343 messages.len(),
1344 current,
1345 message_tokens,
1346 mid_loop_trim_threshold,
1347 mid_loop_trim_tokens,
1348 send_budget,
1349 tool_tokens_real,
1350 ) {
1351 let pipeline_budget = if trigger.hard_budget {
1356 calibrate_down(trigger.budget, cal)
1357 } else {
1358 trigger.budget
1359 };
1360 let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
1365 let outcome = match with_thinking_spinner(
1370 animate,
1371 "compressing context…",
1372 cancellable(
1373 cancel,
1374 compress(
1375 CompressRequest {
1376 messages: &messages,
1377 budget: pipeline_budget,
1378 max_messages: trigger.max_messages,
1379 task,
1380 hard_budget: trigger.hard_budget,
1381 authoritative: token_fired || send_budget_authoritative,
1382 focus: None,
1383 est: estimation,
1384 summary_input_cap_floor_chars,
1385 compaction_store,
1386 },
1387 summarizer,
1388 compress_state,
1389 ),
1390 ),
1391 )
1392 .await
1393 {
1394 Some(o) => o,
1395 None => {
1396 return Ok((String::new(), false, accumulated_usage, hallucination_count))
1397 }
1398 };
1399 if let Some(notice) = outcome.notice {
1400 print_harness_notice(¬ice, color);
1401 }
1402 if outcome.action == CompressAction::Refused {
1403 anyhow::bail!(
1409 "context (~{current} tokens) exceeds the model's input budget and \
1410 auto-compression is disabled after repeated ineffective passes — \
1411 start a new conversation or ask a more focused question, or run \
1412 `newt tunings reset {model}` if this model's learned budget looks wrong"
1413 );
1414 }
1415 if outcome.fired {
1416 let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
1422 ", still over budget"
1423 } else {
1424 ""
1425 };
1426 emit_compression_notice(
1427 color,
1428 outcome.tokens_before,
1429 outcome.tokens_after,
1430 outcome.action,
1431 suffix,
1432 );
1433 if debug {
1434 print_debug(
1435 &format!(
1436 "compression: {} → {} messages (budget ~{} tokens, \
1437 +~{tool_tokens} tool-schema tokens ride along)",
1438 messages.len(),
1439 outcome.messages.len(),
1440 pipeline_budget,
1441 ),
1442 color,
1443 );
1444 }
1445 messages = outcome.messages;
1446 prompt_tracker.invalidate();
1447 }
1448 }
1449 }
1450
1451 let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
1456
1457 let mut body_no_stream = if let Some(ctx_size) = num_ctx {
1462 serde_json::json!({
1463 "model": model,
1464 "messages": messages,
1465 "stream": false,
1466 "tools": tools.clone(),
1467 "options": { "num_ctx": ctx_size },
1468 })
1469 } else {
1470 serde_json::json!({
1471 "model": model,
1472 "messages": messages,
1473 "stream": false,
1474 "tools": tools.clone(),
1475 })
1476 };
1477 if !tools_supported {
1481 if let Some(o) = body_no_stream.as_object_mut() {
1482 o.remove("tools");
1483 }
1484 }
1485
1486 let dispatch = match with_thinking_spinner(
1493 animate,
1494 "thinking…",
1495 cancellable(
1496 cancel,
1497 with_backoff_notify(
1498 &retry,
1499 || async {
1500 let resp = client
1501 .post(&chat_url)
1502 .json(&body_no_stream)
1503 .send()
1504 .await
1505 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
1506 if !resp.status().is_success() {
1507 let status = resp.status();
1508 let text = resp.text().await.unwrap_or_default();
1509 anyhow::bail!("Ollama {status}: {text}");
1510 }
1511 resp.json::<serde_json::Value>()
1512 .await
1513 .map_err(anyhow::Error::from)
1514 },
1515 |attempt, delay| print_retry_indicator(attempt, delay, color),
1516 ),
1517 ),
1518 )
1519 .await
1520 {
1521 Some(d) => d,
1522 None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
1524 };
1525 let json: serde_json::Value = match dispatch {
1526 Ok(j) => j,
1527 Err(e) => {
1528 let tools_unsupported = is_tools_unsupported_error(&e);
1536 let malformed_xml_tool_call = is_ollama_tool_xml_error(&e);
1537 if tools_supported && tools_unsupported {
1538 tools_supported = false;
1539 if !tools_unsupported_notified {
1540 tools_unsupported_notified = true;
1541 let notice = format!(
1542 "{model} does not support tools — tools disabled for this turn"
1543 );
1544 print_newt(¬ice, color, false);
1545 }
1546 continue 'round_loop;
1547 }
1548 if tools_supported && malformed_xml_tool_call && ollama_xml_retry_nudges < 2 {
1549 ollama_xml_retry_nudges += 1;
1550 print_newt(
1551 &format!(
1552 "{model} produced malformed Ollama XML tool-call syntax — \
1553 retrying with a stricter tool-call nudge"
1554 ),
1555 color,
1556 false,
1557 );
1558 messages.push(serde_json::json!({
1559 "role": "user",
1560 "content": ollama_tool_xml_retry_nudge()
1561 }));
1562 continue 'round_loop;
1563 }
1564 if cw_retries < 2 {
1568 if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
1569 emit_overflow_notice(
1570 color,
1571 accumulated_usage.as_ref(),
1572 Some(new_cap),
1573 model,
1574 cw_retries + 1,
1575 );
1576 let new_budget =
1579 num_ctx_ceiling.map_or(new_cap as usize, |c| (new_cap as usize).min(c));
1580 send_budget = Some(new_budget);
1581 send_budget_authoritative = true;
1584 let outcome = compress(
1585 CompressRequest {
1586 messages: &messages,
1590 budget: calibrate_down(
1591 new_budget.saturating_sub(tool_tokens_real),
1592 cal,
1593 ),
1594 max_messages: None,
1595 task,
1596 hard_budget: true,
1597 authoritative: true,
1598 focus: None,
1599 est: estimation,
1600 summary_input_cap_floor_chars,
1601 compaction_store,
1602 },
1603 summarizer,
1604 compress_state,
1605 )
1606 .await;
1607 if let Some(notice) = outcome.notice {
1608 print_harness_notice(¬ice, color);
1609 }
1610 if outcome.action == CompressAction::Refused {
1611 return Err(e);
1613 }
1614 if outcome.fired {
1615 messages = outcome.messages;
1616 prompt_tracker.invalidate();
1617 }
1618 cw_retries += 1;
1619 continue 'round_loop;
1620 }
1621 }
1622 return Err(e);
1623 }
1624 };
1625
1626 let round_usage = ollama_usage(&json);
1630 if let Some(u) = round_usage {
1631 prompt_tracker.record(u.input_tokens, messages.len());
1632 }
1633 accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
1634
1635 let truncation_suspect = round_usage
1640 .is_some_and(|u| num_ctx.is_some_and(|c| u.input_tokens >= c.saturating_mul(95) / 100));
1641 if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
1647 let raised = (u.input_tokens as usize).min(num_ctx_ceiling.unwrap_or(usize::MAX));
1648 if raised > budget {
1649 send_budget = Some(raised);
1650 if debug {
1651 print_debug(
1652 &format!(
1653 "send budget raised to ~{raised} tokens (backend accepted \
1654 {}-token prompt)",
1655 u.input_tokens
1656 ),
1657 color,
1658 );
1659 }
1660 }
1661 }
1662
1663 let message = &json["message"];
1664 let probe_content = message["content"].as_str().unwrap_or("").to_string();
1667
1668 let native_calls = message["tool_calls"].as_array();
1669 let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
1675 tool_recovery::recover_tool_calls(&probe_content)
1676 } else {
1677 tool_recovery::Recovery::default()
1678 };
1679 let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
1680 Some(t) if !t.is_empty() => Some(t),
1681 _ if !recovered.calls.is_empty() => Some(&recovered.calls),
1682 _ => None,
1683 };
1684 let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
1685 if debug && !recovered.calls.is_empty() {
1686 print_debug(
1687 &format!(
1688 "recovered {} tool call(s) from content (non-native emission)",
1689 recovered.calls.len()
1690 ),
1691 color,
1692 );
1693 }
1694
1695 if debug {
1696 let content_excerpt = if probe_content.is_empty() {
1697 "(empty)".to_string()
1698 } else {
1699 let chars: String = probe_content.chars().take(80).collect();
1700 if probe_content.len() > 80 {
1701 format!("{chars}…")
1702 } else {
1703 chars
1704 }
1705 };
1706 let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
1707 let usage_str = match round_usage {
1708 Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
1709 None => "no usage".into(),
1710 };
1711 print_debug(
1712 &format!(
1713 "round {round} probe: tool_calls={tc_count} usage=[{usage_str}] content={content_excerpt:?}"
1714 ),
1715 color,
1716 );
1717 }
1718
1719 if !has_tools {
1720 if recovered.tool_shaped {
1724 hallucination_count += 1;
1725 if debug {
1726 print_debug(
1727 "format-hallucination: tool call emitted as unrecoverable text",
1728 color,
1729 );
1730 }
1731 }
1732 macro_rules! maybe_nudge_no_tool_content {
1737 ($content:expr, $usage:expr) => {{
1738 let content = $content;
1739 if !content.is_empty() {
1740 let nudge_classification = nudge_classifier.classify(content);
1741 let workflow_classifier_text =
1742 workflow_classifier_text(&messages, content);
1743 let workflow_hint = nudge_classification
1744 .is_plan_update()
1745 .then(|| workflow_steerer.plan_update_hint(&workflow_classifier_text))
1746 .flatten();
1747 let classifier_plan_direction = nudge_classification
1748 .is_plan_update()
1749 .then(|| {
1750 nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate)
1751 })
1752 .flatten();
1753 let plan_nudge_hint =
1754 combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
1755 if round + 1 < current_tool_round_limit {
1756 if let Some(nudge) = workflow_runtime.rediscovery_nudge(
1757 Some(&nudge_classification),
1758 content,
1759 step_ledger,
1760 ) {
1761 if debug {
1762 print_debug(
1763 "workflow evidence rediscovery — nudging toward active repair",
1764 color,
1765 );
1766 }
1767 messages.push(serde_json::json!({
1768 "role": "assistant",
1769 "content": content
1770 }));
1771 messages.push(serde_json::json!({
1772 "role": "user",
1773 "content": nudge
1774 }));
1775 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1776 continue 'round_loop;
1777 }
1778 }
1779 if pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
1780 && round + 1 < current_tool_round_limit
1781 {
1782 if let Some(nudge) = pending_plan_completion_nudge(
1783 step_ledger,
1784 nudge_classification.is_plan_update(),
1785 plan_nudge_hint.as_deref(),
1786 ) {
1787 if debug {
1788 print_debug(
1789 "active plan has unfinished steps — nudging before final answer",
1790 color,
1791 );
1792 }
1793 messages.push(serde_json::json!({
1794 "role": "assistant",
1795 "content": content
1796 }));
1797 messages.push(serde_json::json!({
1798 "role": "user",
1799 "content": nudge
1800 }));
1801 pending_plan_nudges += 1;
1802 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1803 continue 'round_loop;
1804 }
1805 }
1806 if stale_file_nudges < STALE_FILE_NUDGE_CAP
1807 && round + 1 < current_tool_round_limit
1808 && looks_like_unverified_stale_file_blocker(content)
1809 {
1810 if debug {
1811 print_debug(
1812 "unverified stale-file blocker — nudging to check ground truth",
1813 color,
1814 );
1815 }
1816 messages.push(serde_json::json!({
1817 "role": "assistant",
1818 "content": content
1819 }));
1820 messages.push(serde_json::json!({
1821 "role": "user",
1822 "content": stale_file_ground_truth_nudge(),
1823 }));
1824 stale_file_nudges += 1;
1825 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1826 continue 'round_loop;
1827 }
1828 if narration_nudges < NARRATION_NUDGE_CAP
1829 && round + 1 < current_tool_round_limit
1830 && nudge_classification.is_pending_action()
1831 {
1832 if debug {
1833 print_debug(
1834 "narrated intent with no tool call — nudging to act and continuing",
1835 color,
1836 );
1837 }
1838 messages.push(serde_json::json!({
1842 "role": "assistant",
1843 "content": content
1844 }));
1845 let direction = nudge_classifier
1846 .direction_for(nudge_classification.class)
1847 .map(str::to_string)
1848 .unwrap_or_else(narration_action_nudge);
1849 messages.push(serde_json::json!({
1850 "role": "user",
1851 "content": direction,
1852 }));
1853 narration_nudges += 1;
1854 accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1855 continue 'round_loop;
1856 }
1857 }
1858 }};
1859 }
1860 let mut body_stream = if let Some(ctx_size) = num_ctx {
1869 serde_json::json!({
1870 "model": model,
1871 "messages": &messages,
1872 "stream": true,
1873 "tools": tools.clone(),
1874 "options": { "num_ctx": ctx_size },
1875 })
1876 } else {
1877 serde_json::json!({
1878 "model": model,
1879 "messages": &messages,
1880 "stream": true,
1881 "tools": tools.clone(),
1882 })
1883 };
1884 if !tools_supported {
1887 if let Some(o) = body_stream.as_object_mut() {
1888 o.remove("tools");
1889 }
1890 }
1891 let sresp = match cancellable(
1895 cancel,
1896 with_backoff_notify(
1897 &retry,
1898 || async {
1899 stream_client
1900 .post(&chat_url)
1901 .json(&body_stream)
1902 .send()
1903 .await
1904 .map_err(|e| anyhow::anyhow!("stream request failed: {e}"))
1905 },
1906 |attempt, delay| print_retry_indicator(attempt, delay, color),
1907 ),
1908 )
1909 .await
1910 {
1911 Some(r) => r?,
1912 None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
1913 };
1914
1915 if !sresp.status().is_success() {
1916 if debug {
1917 print_debug("stream request non-2xx — using probe content", color);
1918 }
1919 maybe_nudge_no_tool_content!(probe_content.as_str(), None);
1920 if !probe_content.is_empty() {
1923 emit_accepted(
1924 &mut on_round_usage,
1925 round_usage,
1926 truncation_suspect,
1927 round_est_raw,
1928 );
1929 }
1930 return Ok((probe_content, false, accumulated_usage, hallucination_count));
1931 }
1932 let show_thinking = color && thinking_stream_enabled();
1935 let leading_reasoning = crate::reasoning::emits_leading_reasoning(model);
1939 let (streamed, stream_usage) = match stream_response(
1943 sresp,
1944 color,
1945 show_thinking,
1946 leading_reasoning,
1947 cancel,
1948 markdown,
1949 )
1950 .await
1951 {
1952 Ok(v) => v,
1953 Err(e) => {
1954 print_harness_notice(
1966 &format!(
1967 "stream broke mid-response ({e}) — recovered the answer \
1968 from the non-streamed probe"
1969 ),
1970 color,
1971 );
1972 if !probe_content.is_empty() {
1973 maybe_nudge_no_tool_content!(probe_content.as_str(), None);
1974 emit_accepted(
1975 &mut on_round_usage,
1976 round_usage,
1977 truncation_suspect,
1978 round_est_raw,
1979 );
1980 }
1981 return Ok((probe_content, false, accumulated_usage, hallucination_count));
1982 }
1983 };
1984
1985 if streamed.is_empty() {
1986 if debug {
1989 print_debug(
1990 &format!(
1991 "stream returned empty — falling back to probe content ({} chars)",
1992 probe_content.len()
1993 ),
1994 color,
1995 );
1996 }
1997 if probe_content.is_empty() {
1998 let merged = merge_round_usage(accumulated_usage, stream_usage);
1999 let empty_round_usage = merge_round_usage(round_usage, stream_usage);
2000 let generated_unusable_output = empty_round_usage
2001 .as_ref()
2002 .map(|u| u.output_tokens > 0)
2003 .unwrap_or(false);
2004
2005 if generated_unusable_output
2006 && suspicious_empty_retries < SUSPICIOUS_EMPTY_RETRY_CAP
2007 {
2008 if trace {
2009 print_trace(&ollama_response_shape(&json), color);
2010 }
2011 if debug {
2012 let fields = ollama_non_content_fields(&json);
2013 let field_note = if fields.is_empty() {
2014 "no known non-content fields".to_string()
2015 } else {
2016 format!("non-content fields: {}", fields.join(", "))
2017 };
2018 print_debug(
2019 &format!(
2020 "empty assistant content with generated tokens — retrying ({}/{SUSPICIOUS_EMPTY_RETRY_CAP}; {field_note})",
2021 suspicious_empty_retries + 1
2022 ),
2023 color,
2024 );
2025 }
2026 if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2032 thinking_only_reported = true;
2033 if let Some(hook) = on_round_usage.as_deref_mut() {
2034 hook(RoundObservation::ThinkingOnly);
2035 }
2036 }
2037 messages.push(serde_json::json!({
2038 "role": "user",
2039 "content": suspicious_empty_retry_nudge(suspicious_empty_retries, &json)
2040 }));
2041 accumulated_usage = merged;
2042 suspicious_empty_retries += 1;
2043 continue 'round_loop;
2044 }
2045
2046 let overflow_likely = merged
2052 .as_ref()
2053 .zip(safe_context)
2054 .map(|(u, safe)| u.input_tokens >= safe * 85 / 100)
2055 .unwrap_or(false);
2056 if overflow_likely && overflow_retries < 2 {
2057 emit_overflow_notice(
2058 color,
2059 merged.as_ref(),
2060 safe_context,
2061 model,
2062 overflow_retries + 1,
2063 );
2064 let target = calibrate_down(
2073 safe_context
2074 .map(|s| (s as usize).saturating_mul(3) / 4)
2075 .unwrap_or(0)
2076 .saturating_sub(tool_tokens_real),
2077 cal,
2078 );
2079 let outcome = compress(
2080 CompressRequest {
2081 messages: &messages,
2082 budget: target,
2083 max_messages: None,
2084 task,
2085 hard_budget: true,
2086 authoritative: true,
2089 focus: None,
2090 est: estimation,
2091 summary_input_cap_floor_chars,
2092 compaction_store,
2093 },
2094 summarizer,
2095 compress_state,
2096 )
2097 .await;
2098 if let Some(notice) = outcome.notice {
2099 print_harness_notice(¬ice, color);
2100 }
2101 if outcome.fired {
2102 messages = outcome.messages;
2103 prompt_tracker.invalidate();
2104 } else {
2105 let fallback = crate::prune::prune(
2110 &messages,
2111 &crate::prune::PruneConfig {
2112 keep_last: 2,
2113 ..Default::default()
2114 },
2115 );
2116 if fallback.chars_reclaimed > 0 {
2117 messages = fallback.messages;
2118 prompt_tracker.invalidate();
2119 }
2120 }
2121 accumulated_usage = merged;
2122 overflow_retries += 1;
2123 continue 'round_loop;
2124 }
2125 if overflow_likely {
2130 if let (Some(hook), Some(u)) =
2131 (on_round_usage.as_deref_mut(), merged.as_ref())
2132 {
2133 hook(RoundObservation::SuspectedOverflow {
2134 prompt_tokens: u.input_tokens,
2135 });
2136 }
2137 }
2138 if generated_unusable_output {
2139 if trace {
2140 print_trace(&ollama_response_shape(&json), color);
2141 }
2142 if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2147 if let Some(hook) = on_round_usage.as_deref_mut() {
2148 hook(RoundObservation::ThinkingOnly);
2149 }
2150 }
2151 return Ok((
2152 suspicious_empty_ollama_diagnostic(&json),
2153 false,
2154 merged,
2155 hallucination_count,
2156 ));
2157 }
2158 let msg = "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)";
2159 return Ok((msg.to_string(), false, merged, hallucination_count));
2160 }
2161 maybe_nudge_no_tool_content!(probe_content.as_str(), stream_usage);
2163 emit_accepted(
2165 &mut on_round_usage,
2166 round_usage,
2167 truncation_suspect,
2168 round_est_raw,
2169 );
2170 return Ok((
2171 probe_content,
2172 false,
2173 merge_round_usage(accumulated_usage, stream_usage),
2174 hallucination_count,
2175 ));
2176 }
2177
2178 maybe_nudge_no_tool_content!(streamed.as_str(), stream_usage);
2187 emit_accepted(
2189 &mut on_round_usage,
2190 round_usage,
2191 truncation_suspect,
2192 round_est_raw,
2193 );
2194 return Ok((
2195 streamed,
2196 true,
2197 merge_round_usage(accumulated_usage, stream_usage),
2198 hallucination_count,
2199 ));
2200 }
2201
2202 emit_accepted(
2206 &mut on_round_usage,
2207 round_usage,
2208 truncation_suspect,
2209 round_est_raw,
2210 );
2211 messages.push(message.clone());
2212 let mut round_wrote = false;
2213 let mut round_modified_workspace = false;
2214 let mut round_progress = false;
2215 for tc in tool_calls.unwrap() {
2216 let anthropic_native = tc["function"].is_null();
2217 let name = if anthropic_native {
2218 tc["name"].as_str().unwrap_or("unknown")
2219 } else {
2220 tc["function"]["name"].as_str().unwrap_or("unknown")
2221 };
2222 let args = if anthropic_native {
2223 tc["input"].clone()
2224 } else {
2225 match &tc["function"]["arguments"] {
2226 serde_json::Value::String(s) => {
2227 serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
2228 }
2229 v => v.clone(),
2230 }
2231 };
2232 if is_hallucination(name, &args) {
2233 hallucination_count += 1;
2234 }
2235 if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
2239 if let Some(rec) = tool_events.as_deref_mut() {
2240 rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
2241 }
2242 messages.push(serde_json::json!({ "role": "tool", "content": steer }));
2243 continue;
2244 }
2245 if !is_read_only_call(name, &args) {
2246 round_wrote = true;
2247 }
2248 if name == "save_note" && note_sink.is_some() {
2251 if let Some(n) = note_nudge.as_deref_mut() {
2252 n.note_saved();
2253 }
2254 }
2255 ledger_note_write(write_ledger, name, &args, workspace);
2258 let tool_t0 = std::time::Instant::now();
2259 let result = if tools::is_context_remaining_call(name) {
2267 let report = budget::render_context_budget(
2268 prompt_tracker.current(&messages, Some(&tools), cal, estimation),
2269 num_ctx_input_ceiling(num_ctx),
2270 num_ctx,
2271 );
2272 display::print_tool_call("get_context_remaining", "", color);
2273 display::print_tool_output(&report, tool_output_lines, color);
2274 report
2275 } else {
2276 execute_tool_with_offload(
2277 name,
2278 &args,
2279 workspace,
2280 color,
2281 tool_output_lines,
2282 caveats,
2283 mcp,
2284 build_check_cmd.as_deref(),
2285 note_sink
2289 .as_deref_mut()
2290 .map(|s| &mut *s as &mut dyn NoteSink),
2291 recall_source,
2292 memory_source,
2293 permission_gate
2295 .as_deref_mut()
2296 .map(|g| &mut *g as &mut dyn PermissionGate),
2297 exec_floor,
2299 git_tool,
2301 crew_runner,
2303 scratchpad_store,
2304 code_search,
2305 experience_store,
2306 step_ledger,
2307 tool_offload,
2308 spill_store,
2309 )
2310 .await
2311 };
2312 let ok = tools::tool_result_ok(&result);
2317 if ok && is_workspace_write_call(name) {
2318 round_modified_workspace = true;
2319 }
2320 if ok && meaningful_workflow_progress(name, &result) {
2321 round_progress = true;
2322 }
2323 repeat_calls.record(name, &args, ok, &result);
2324 if workflow_runtime.record_tool_result(&result) {
2325 round_progress = true;
2326 }
2327 if let Some(rec) = tool_events.as_deref_mut() {
2328 rec.push(crate::ToolEvent::from_call(
2329 name,
2330 &args,
2331 ok,
2332 u64::try_from(tool_t0.elapsed().as_millis()).ok(),
2333 ));
2334 }
2335 if let Some(pr) = phantom_reaches.as_deref_mut() {
2342 if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
2343 .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
2344 {
2345 pr.push(crate::PhantomReach {
2346 name_as_called: name.to_string(),
2347 resolution,
2348 active_context_features: Vec::new(),
2349 });
2350 }
2351 }
2352 observed_paths.record(&result, &observed_resolver);
2355 messages.push(serde_json::json!({
2356 "role": "tool",
2357 "content": maybe_offload_tool_result(name, result, tool_offload, spill_store)
2360 }));
2361 }
2362 if round_wrote {
2363 read_only_rounds = 0;
2364 } else {
2365 read_only_rounds = read_only_rounds.saturating_add(1);
2366 }
2367 workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
2368 }
2369
2370 let trimmed = trim_for_summary(&messages, 2, 6);
2374 let progress = cap_exit_progress(step_ledger, scratchpad_store);
2377 let (text, streamed, usage) = final_summary_ollama(
2378 &client,
2379 &chat_url,
2380 model,
2381 trimmed,
2382 CapExit {
2383 max_tool_rounds,
2384 accumulated: accumulated_usage,
2385 wasted_calls: repeat_calls.total_failures(),
2386 progress,
2387 observed: observed_paths.into_vec(),
2388 },
2389 )
2390 .await?;
2391 let text = claim_check::annotate_against_workspace(text, workspace);
2396 Ok((text, streamed, usage, hallucination_count))
2397}
2398
2399fn first_line(s: &str) -> String {
2407 s.lines().next().unwrap_or("").chars().take(200).collect()
2408}
2409
2410#[derive(Debug, Clone, PartialEq, Eq)]
2428enum RepeatMemo {
2429 Failure {
2430 first_line: String,
2431 },
2432 NoResult {
2433 reason: String,
2434 },
2435 EvidenceObserved {
2436 subject: String,
2437 advice: &'static str,
2438 },
2439}
2440
2441#[derive(Default)]
2442struct RepeatCallGuard {
2443 repeat_memos: std::collections::HashMap<String, RepeatMemo>,
2445 fails_by_tool: std::collections::HashMap<String, usize>,
2447}
2448
2449impl RepeatCallGuard {
2450 const ESCALATE_AFTER: usize = 2;
2453
2454 fn key(name: &str, args: &serde_json::Value) -> String {
2455 format!("{name}\u{1}{args}")
2458 }
2459
2460 fn repeat_steer(&self, name: &str, args: &serde_json::Value) -> Option<String> {
2463 let key = Self::key(name, args);
2464 match self.repeat_memos.get(&key)? {
2465 RepeatMemo::Failure { first_line: prev } => {
2466 let mut msg = format!(
2467 "You already called `{name}` with these exact arguments and it failed: {prev}. \
2468 Do NOT repeat the same call — use a different tool or different arguments."
2469 );
2470 if self.fails_by_tool.get(name).copied().unwrap_or(0) >= Self::ESCALATE_AFTER {
2471 msg.push_str(&format!(
2472 " `{name}` has failed repeatedly this session; stop using it and prefer \
2473 the embedded tools (read_file, edit_file, write_file, find, git)."
2474 ));
2475 }
2476 Some(msg)
2477 }
2478 RepeatMemo::NoResult { reason } => Some(format!(
2479 "You already ran `{name}` with these exact arguments this turn and {reason}. \
2480 Don't repeat the identical call — create or update the missing state, change \
2481 the arguments when the tool accepts them, or use a different tool."
2482 )),
2483 RepeatMemo::EvidenceObserved { subject, advice } => Some(format!(
2484 "You already observed {subject} with `{name}` and received output. Do NOT repeat \
2485 the identical call — {advice}"
2486 )),
2487 }
2488 }
2489
2490 fn successful_fetch_url(name: &str, args: &serde_json::Value, result: &str) -> Option<String> {
2491 if name != "web_fetch" {
2492 return None;
2493 }
2494 let url = args.get("url")?.as_str()?.trim();
2495 if url.is_empty() || result.trim().is_empty() {
2496 None
2497 } else {
2498 Some(url.chars().take(200).collect())
2499 }
2500 }
2501
2502 fn successful_read_only_shell_command(
2503 name: &str,
2504 args: &serde_json::Value,
2505 result: &str,
2506 ) -> Option<String> {
2507 if name != "run_command" || result.trim().is_empty() {
2508 return None;
2509 }
2510 let command = args.get("command")?.as_str()?.trim();
2511 if is_read_only_shell_probe(command) {
2512 Some(command.chars().take(200).collect())
2513 } else {
2514 None
2515 }
2516 }
2517
2518 fn no_result_reason(name: &str, result: &str) -> Option<&'static str> {
2525 match name {
2526 "recall" if result.starts_with("no matches in past conversations") => Some(
2527 "it returned no matches (and recall cannot see the current conversation \
2528 — use resume_context for THIS conversation)",
2529 ),
2530 "state_get" if result.starts_with("no such key") => {
2531 Some("the key is not set (state_get returned \"no such key\")")
2532 }
2533 "plan_get" if result.starts_with("no active plan") => Some(
2534 "it found no active plan; call update_plan now with a short ordered plan if the \
2535 work has more than one step",
2536 ),
2537 _ => None,
2538 }
2539 }
2540
2541 fn classify_repeat_memo(
2546 name: &str,
2547 args: &serde_json::Value,
2548 ok: bool,
2549 result: &str,
2550 ) -> Option<RepeatMemo> {
2551 if !ok {
2552 return Some(RepeatMemo::Failure {
2553 first_line: first_line(result),
2554 });
2555 }
2556 if let Some(reason) = Self::no_result_reason(name, result) {
2557 return Some(RepeatMemo::NoResult {
2558 reason: reason.to_string(),
2559 });
2560 }
2561 if let Some(url) = Self::successful_fetch_url(name, args, result) {
2562 return Some(RepeatMemo::EvidenceObserved {
2563 subject: format!("`{url}`"),
2564 advice: "use the fetched content above, fetch a different URL, inspect local \
2565 files, or answer the user.",
2566 });
2567 }
2568 if let Some(command) = Self::successful_read_only_shell_command(name, args, result) {
2569 return Some(RepeatMemo::EvidenceObserved {
2570 subject: format!("read-only shell probe `{command}`"),
2571 advice: "use the observed output above, change the query, inspect a different \
2572 file, or make the next edit/test decision.",
2573 });
2574 }
2575 None
2576 }
2577
2578 fn record(&mut self, name: &str, args: &serde_json::Value, ok: bool, result: &str) {
2582 if !ok {
2583 *self.fails_by_tool.entry(name.to_string()).or_default() += 1;
2584 }
2585 if let Some(memo) = Self::classify_repeat_memo(name, args, ok, result) {
2586 self.repeat_memos.insert(Self::key(name, args), memo);
2587 }
2588 }
2589
2590 fn total_failures(&self) -> usize {
2593 self.fails_by_tool.values().sum()
2594 }
2595}
2596
2597#[derive(Debug, Clone, PartialEq, Eq)]
2598struct WorkflowErrorEvidence {
2599 fingerprint: String,
2600 observations: usize,
2601}
2602
2603#[derive(Debug, Default)]
2604struct WorkflowRuntimeState {
2605 error_evidence: Option<WorkflowErrorEvidence>,
2606 read_only_rounds_after_evidence: usize,
2607 writes_after_evidence: usize,
2608 rounds_since_progress: Option<usize>,
2609 progress_horizon_rounds: Option<usize>,
2615 step_lock_nudges: usize,
2616 rediscovery_nudges: usize,
2617}
2618
2619impl WorkflowRuntimeState {
2620 const STEP_LOCK_NUDGE_CAP: usize = 3;
2621 const REDISCOVERY_NUDGE_CAP: usize = 2;
2622
2623 fn set_progress_horizon(&mut self, rounds: Option<usize>) {
2626 self.progress_horizon_rounds = rounds;
2627 }
2628
2629 fn progress_horizon(&self) -> usize {
2630 self.progress_horizon_rounds
2631 .unwrap_or(WORKFLOW_RECENT_PROGRESS_ROUNDS)
2632 }
2633
2634 fn record_tool_result(&mut self, result: &str) -> bool {
2635 let Some(fingerprint) = workflow_error_fingerprint(result) else {
2636 return false;
2637 };
2638 match self.error_evidence.as_mut() {
2639 Some(evidence) if evidence.fingerprint == fingerprint => {
2640 evidence.observations = evidence.observations.saturating_add(1);
2641 false
2642 }
2643 _ => {
2644 self.error_evidence = Some(WorkflowErrorEvidence {
2645 fingerprint,
2646 observations: 1,
2647 });
2648 self.read_only_rounds_after_evidence = 0;
2649 self.writes_after_evidence = 0;
2650 self.step_lock_nudges = 0;
2651 self.rediscovery_nudges = 0;
2652 true
2653 }
2654 }
2655 }
2656
2657 fn record_round_outcome(&mut self, round_wrote: bool, round_progress: bool) {
2658 if round_progress {
2659 self.rounds_since_progress = Some(0);
2660 } else if let Some(rounds) = self.rounds_since_progress.as_mut() {
2661 *rounds = rounds.saturating_add(1);
2662 }
2663 if self.error_evidence.is_none() {
2664 return;
2665 }
2666 if round_wrote {
2667 self.writes_after_evidence = self.writes_after_evidence.saturating_add(1);
2668 self.read_only_rounds_after_evidence = 0;
2669 } else {
2670 self.read_only_rounds_after_evidence =
2671 self.read_only_rounds_after_evidence.saturating_add(1);
2672 }
2673 }
2674
2675 fn round_start_nudge(
2676 &mut self,
2677 step_ledger: Option<&dyn scheduled::StepLedger>,
2678 ) -> Option<String> {
2679 let evidence = self.error_evidence.as_ref()?;
2680 if self.writes_after_evidence > 0 || self.read_only_rounds_after_evidence == 0 {
2681 return None;
2682 }
2683 if self.step_lock_nudges >= Self::STEP_LOCK_NUDGE_CAP {
2684 return None;
2685 }
2686 self.step_lock_nudges += 1;
2687 Some(workflow_step_lock_nudge(
2688 &evidence.fingerprint,
2689 evidence.observations,
2690 active_step_description(step_ledger).as_deref(),
2691 ))
2692 }
2693
2694 fn rediscovery_nudge(
2695 &mut self,
2696 classification: Option<&crate::NudgeClassification>,
2697 content: &str,
2698 step_ledger: Option<&dyn scheduled::StepLedger>,
2699 ) -> Option<String> {
2700 let evidence = self.error_evidence.as_ref()?;
2701 if self.writes_after_evidence > 0 {
2702 return None;
2703 }
2704 if self.rediscovery_nudges >= Self::REDISCOVERY_NUDGE_CAP {
2705 return None;
2706 }
2707 let classified_stall = classification.is_some_and(|c| {
2708 matches!(
2709 c.class,
2710 crate::NudgeClass::PendingAction | crate::NudgeClass::PlanUpdate
2711 )
2712 });
2713 if !classified_stall && !looks_like_error_rediscovery(content) {
2714 return None;
2715 }
2716 self.rediscovery_nudges += 1;
2717 Some(workflow_rediscovery_nudge(
2718 &evidence.fingerprint,
2719 active_step_description(step_ledger).as_deref(),
2720 ))
2721 }
2722
2723 fn cap_grace_nudge(
2724 &mut self,
2725 step_ledger: Option<&dyn scheduled::StepLedger>,
2726 max_tool_rounds: usize,
2727 workflow_grace_rounds: usize,
2728 ) -> Option<String> {
2729 if workflow_grace_rounds == 0 {
2730 return None;
2731 }
2732 let active_step = active_step_description(step_ledger);
2733 let recent_progress = self
2734 .rounds_since_progress
2735 .is_some_and(|rounds| rounds <= self.progress_horizon());
2736 if let Some(evidence) = self.error_evidence.as_ref() {
2737 if self.writes_after_evidence > 0 {
2738 return Some(workflow_post_write_grace_nudge(
2739 &evidence.fingerprint,
2740 active_step.as_deref(),
2741 max_tool_rounds,
2742 workflow_grace_rounds,
2743 ));
2744 }
2745 if self.read_only_rounds_after_evidence > 0 || recent_progress {
2746 return Some(workflow_cap_grace_nudge(
2747 &evidence.fingerprint,
2748 active_step.as_deref(),
2749 max_tool_rounds,
2750 workflow_grace_rounds,
2751 ));
2752 }
2753 }
2754 if active_step.is_some() && recent_progress {
2755 return Some(workflow_progress_grace_nudge(
2756 active_step.as_deref(),
2757 max_tool_rounds,
2758 workflow_grace_rounds,
2759 ));
2760 }
2761 None
2762 }
2763}
2764
2765fn workflow_error_fingerprint(result: &str) -> Option<String> {
2766 build_error_fingerprint(result).or_else(|| edit_miss_fingerprint(result))
2767}
2768
2769fn build_error_fingerprint(result: &str) -> Option<String> {
2770 let mut pending_error: Option<String> = None;
2771 let mut fingerprints = Vec::new();
2772 for line in result.lines() {
2773 let trimmed = line.trim();
2774 if trimmed.starts_with("error[") || trimmed.starts_with("error:") {
2775 pending_error = Some(normalize_error_line(trimmed));
2776 continue;
2777 }
2778 if let Some(rest) = trimmed.strip_prefix("-->") {
2779 if let Some(error) = pending_error.take() {
2780 let location = rest
2781 .split_whitespace()
2782 .next()
2783 .unwrap_or("")
2784 .trim()
2785 .trim_start_matches("./");
2786 if location.is_empty() {
2787 fingerprints.push(error);
2788 } else {
2789 fingerprints.push(format!("{location} {error}"));
2790 }
2791 if fingerprints.len() >= 3 {
2792 break;
2793 }
2794 }
2795 }
2796 }
2797 if fingerprints.is_empty() {
2798 pending_error.map(|e| e.chars().take(240).collect())
2799 } else {
2800 Some(fingerprints.join(" | ").chars().take(500).collect())
2801 }
2802}
2803
2804fn edit_miss_fingerprint(result: &str) -> Option<String> {
2805 let lc = result.to_ascii_lowercase();
2806 if !(lc.contains("old_string")
2807 && (lc.contains("not found")
2808 || lc.contains("old string not found")
2809 || lc.contains("matches 0")
2810 || lc.contains("no match")))
2811 {
2812 return None;
2813 }
2814 let line = result
2815 .lines()
2816 .find(|line| {
2817 let l = line.to_ascii_lowercase();
2818 l.contains("old_string")
2819 && (l.contains("not found") || l.contains("matches 0") || l.contains("no match"))
2820 })
2821 .unwrap_or("edit_file old_string not found");
2822 Some(format!("edit_file {}", normalize_error_line(line)))
2823}
2824
2825fn normalize_error_line(line: &str) -> String {
2826 let mut out = String::new();
2827 let mut in_ws = false;
2828 for c in line.chars() {
2829 if c.is_whitespace() {
2830 if !in_ws {
2831 out.push(' ');
2832 in_ws = true;
2833 }
2834 } else if c != '`' {
2835 out.push(c);
2836 in_ws = false;
2837 }
2838 }
2839 out.chars().take(240).collect()
2840}
2841
2842fn active_step_description(step_ledger: Option<&dyn scheduled::StepLedger>) -> Option<String> {
2843 let snapshot = step_ledger?.snapshot();
2844 snapshot
2845 .steps
2846 .iter()
2847 .find(|step| step.status == StepStatus::Active)
2848 .or_else(|| {
2849 snapshot
2850 .steps
2851 .iter()
2852 .find(|step| step.status != StepStatus::Done)
2853 })
2854 .map(|step| step.description.clone())
2855}
2856
2857fn workflow_step_lock_nudge(
2858 fingerprint: &str,
2859 observations: usize,
2860 active_step: Option<&str>,
2861) -> String {
2862 let active = active_step
2863 .map(|step| format!(" Active step: '{step}'."))
2864 .unwrap_or_default();
2865 format!(
2866 "<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."
2867 )
2868}
2869
2870fn workflow_rediscovery_nudge(fingerprint: &str, active_step: Option<&str>) -> String {
2871 let active = active_step
2872 .map(|step| format!(" Active step: '{step}'."))
2873 .unwrap_or_default();
2874 format!(
2875 "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."
2876 )
2877}
2878
2879fn workflow_cap_grace_nudge(
2880 fingerprint: &str,
2881 active_step: Option<&str>,
2882 max_tool_rounds: usize,
2883 workflow_grace_rounds: usize,
2884) -> String {
2885 let active = active_step
2886 .map(|step| format!(" Active step: '{step}'."))
2887 .unwrap_or_default();
2888 format!(
2889 "<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."
2890 )
2891}
2892
2893fn workflow_post_write_grace_nudge(
2894 fingerprint: &str,
2895 active_step: Option<&str>,
2896 max_tool_rounds: usize,
2897 workflow_grace_rounds: usize,
2898) -> String {
2899 let active = active_step
2900 .map(|step| format!(" Active step: '{step}'."))
2901 .unwrap_or_default();
2902 format!(
2903 "<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."
2904 )
2905}
2906
2907fn workflow_progress_grace_nudge(
2908 active_step: Option<&str>,
2909 max_tool_rounds: usize,
2910 workflow_grace_rounds: usize,
2911) -> String {
2912 let active = active_step
2913 .map(|step| format!(" Active step: '{step}'."))
2914 .unwrap_or_default();
2915 format!(
2916 "<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."
2917 )
2918}
2919
2920fn looks_like_error_rediscovery(content: &str) -> bool {
2921 let lc = content.to_ascii_lowercase();
2922 (lc.contains("summary of findings")
2923 || lc.contains("root cause")
2924 || lc.contains("current state")
2925 || lc.contains("remaining work")
2926 || lc.contains("build failure"))
2927 && (lc.contains("error") || lc.contains("build") || lc.contains("compile"))
2928}
2929
2930fn cap_exit_progress(
2934 step_ledger: Option<&dyn scheduled::StepLedger>,
2935 scratchpad_store: Option<&dyn scratchpad::ScratchpadStore>,
2936) -> Option<String> {
2937 let plan = step_ledger.and_then(scheduled::plan_block);
2938 let state = scratchpad_store.and_then(scratchpad::scratchpad_state_block);
2939 let parts: Vec<String> = [plan, state].into_iter().flatten().collect();
2940 (!parts.is_empty()).then(|| parts.join("\n\n"))
2941}
2942
2943fn is_read_only_tool(name: &str) -> bool {
2944 matches!(
2945 name,
2946 "list_dir"
2947 | "read_file"
2948 | "find"
2949 | "search"
2950 | "web_fetch"
2951 | "use_skill"
2952 | "save_note"
2953 | "recall"
2954 )
2955}
2956
2957fn is_read_only_call(name: &str, args: &serde_json::Value) -> bool {
2958 is_read_only_tool(name)
2959 || (name == "run_command"
2960 && args
2961 .get("command")
2962 .and_then(|v| v.as_str())
2963 .is_some_and(is_read_only_shell_probe))
2964}
2965
2966fn is_workspace_write_call(name: &str) -> bool {
2967 matches!(name, "write_file" | "edit_file")
2968}
2969
2970fn maybe_offload_tool_result(
2971 name: &str,
2972 result: String,
2973 tool_offload: bool,
2974 spill_store: Option<&dyn spill::SpillStore>,
2975) -> String {
2976 if matches!(name, "run_command" | "lifecycle") {
2977 result
2978 } else {
2979 spill::maybe_offload(result, tool_offload, spill_store)
2980 }
2981}
2982
2983fn meaningful_workflow_progress(name: &str, result: &str) -> bool {
2984 match name {
2985 "update_plan" => true,
2986 "write_file" => result.starts_with("wrote ") || result.starts_with("✓ wrote "),
2987 "edit_file" => edit_result_changed_file(result),
2988 _ => false,
2989 }
2990}
2991
2992fn edit_result_changed_file(result: &str) -> bool {
2993 result.starts_with("edited ") || result.starts_with("✓ edited ")
2994}
2995
2996fn is_read_only_shell_probe(command: &str) -> bool {
2997 let command = command.trim();
2998 if command.is_empty() {
2999 return false;
3000 }
3001 const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
3002 if command.contains(SHELL_META) {
3003 return false;
3004 }
3005 let mut tokens = command.split_ascii_whitespace();
3006 let Some(program) = tokens.next() else {
3007 return false;
3008 };
3009 match program {
3010 "grep" | "rg" | "head" | "tail" | "wc" | "pwd" => true,
3011 "sed" => !tokens.any(|t| t == "-i" || t.starts_with("-i")),
3012 _ => false,
3013 }
3014}
3015
3016const NARRATION_NUDGE_CAP: usize = 1;
3021const PENDING_PLAN_NUDGE_CAP: usize = 1;
3024const SUSPICIOUS_EMPTY_RETRY_CAP: u32 = 2;
3029const STALE_FILE_NUDGE_CAP: usize = 1;
3033const WORKFLOW_RECENT_PROGRESS_ROUNDS: usize = 3;
3036
3037fn tail_on_char_boundary(s: &str, max_bytes: usize) -> &str {
3038 let cut = s.len().saturating_sub(max_bytes);
3039 let start = (cut..=s.len())
3040 .find(|&i| s.is_char_boundary(i))
3041 .unwrap_or(0);
3042 &s[start..]
3043}
3044
3045#[cfg(test)]
3049fn looks_like_intent_to_act(content: &str) -> bool {
3050 crate::NudgeClassifier::builtin().is_pending_action(content)
3051}
3052
3053fn looks_like_unverified_stale_file_blocker(content: &str) -> bool {
3059 const FILE_CUES: &[&str] = &[
3060 "file",
3061 "line reference",
3062 "line references",
3063 "old_string",
3064 "edit_file",
3065 ".rs",
3066 ".toml",
3067 ".md",
3068 ];
3069 const STALE_CUES: &[&str] = &[
3070 "modified out from under",
3071 "changed out from under",
3072 "edited out from under",
3073 "modified concurrently",
3074 "changed concurrently",
3075 "stale context",
3076 "contexts are stale",
3077 "context is stale",
3078 "old line references",
3079 "line references are invalid",
3080 "file grew from",
3081 "grew from",
3082 ];
3083 const BLOCKER_CUES: &[&str] = &[
3084 "blocked",
3085 "cannot safely",
3086 "can't safely",
3087 "could land in the wrong place",
3088 "corrupt the code",
3089 "restore",
3090 "git checkout",
3091 "revert",
3092 "operator should",
3093 "human should",
3094 "recommendation",
3095 ];
3096
3097 let lc = content.to_lowercase();
3098 let tail = tail_on_char_boundary(&lc, 1_200);
3099 FILE_CUES.iter().any(|c| tail.contains(c))
3100 && STALE_CUES.iter().any(|c| tail.contains(c))
3101 && BLOCKER_CUES.iter().any(|c| tail.contains(c))
3102}
3103
3104fn narration_action_nudge() -> String {
3107 "You described what you were about to do but did not call any tool, so \
3108 nothing actually happened. If you intended to act, emit the tool call now \
3109 (for example edit_file or write_file with the real arguments) — do not just \
3110 describe it. If you are genuinely finished, say so explicitly in one \
3111 sentence."
3112 .to_string()
3113}
3114
3115fn stale_file_ground_truth_nudge() -> String {
3116 "You claimed the file changed under you or that your edit context is stale, \
3117 but you did not prove that with ground truth. Before stopping or asking the \
3118 operator to restore/revert anything, run read-only verification: git status \
3119 --short, git diff -- <file>, wc -l <file>, and re-read the exact target \
3120 range. If those checks do not prove an actual concurrent change, continue \
3121 from the verified file contents. Never recommend git checkout/revert unless \
3122 git diff proves unwanted changes and the operator approves."
3123 .to_string()
3124}
3125
3126fn workflow_classifier_text(messages: &[serde_json::Value], current_content: &str) -> String {
3127 let mut parts = Vec::new();
3128 let start = messages.len().saturating_sub(12);
3129 for message in &messages[start..] {
3130 if let Some(text) = message_text(message) {
3131 if !text.trim().is_empty() {
3132 parts.push(text);
3133 }
3134 }
3135 }
3136 if !current_content.trim().is_empty() {
3137 parts.push(current_content.to_string());
3138 }
3139 parts.join("\n")
3140}
3141
3142fn combine_nudge_hints(first: Option<&str>, second: Option<&str>) -> Option<String> {
3143 let text = [first, second]
3144 .into_iter()
3145 .flatten()
3146 .map(str::trim)
3147 .filter(|hint| !hint.is_empty())
3148 .collect::<Vec<_>>()
3149 .join("\n\n");
3150 (!text.is_empty()).then_some(text)
3151}
3152
3153fn message_text(message: &serde_json::Value) -> Option<String> {
3154 match message.get("content")? {
3155 serde_json::Value::String(s) => Some(s.clone()),
3156 serde_json::Value::Array(parts) => {
3157 let text = parts
3158 .iter()
3159 .filter_map(|part| part.get("text").and_then(|text| text.as_str()))
3160 .collect::<Vec<_>>()
3161 .join("\n");
3162 (!text.is_empty()).then_some(text)
3163 }
3164 _ => None,
3165 }
3166}
3167
3168fn pending_plan_completion_nudge(
3169 step_ledger: Option<&dyn scheduled::StepLedger>,
3170 needs_plan_update: bool,
3171 workflow_hint: Option<&str>,
3172) -> Option<String> {
3173 let snapshot = step_ledger?.snapshot();
3174 let total = snapshot.steps.len();
3175 if total == 0 {
3176 return None;
3177 }
3178 let unfinished = snapshot
3179 .steps
3180 .iter()
3181 .filter(|s| s.status != StepStatus::Done)
3182 .count();
3183 if unfinished == 0 {
3184 return None;
3185 }
3186 let active = snapshot
3187 .steps
3188 .iter()
3189 .find(|s| s.status == StepStatus::Active)
3190 .or_else(|| snapshot.steps.iter().find(|s| s.status != StepStatus::Done));
3191 let active_clause = active
3192 .map(|s| format!(" Active step: '{}'.", s.description))
3193 .unwrap_or_default();
3194 let step_word = if unfinished == 1 { "step" } else { "steps" };
3195 let workflow_clause = workflow_hint
3196 .map(str::trim)
3197 .filter(|hint| !hint.is_empty())
3198 .map(|hint| format!("\n\n{hint}"))
3199 .unwrap_or_default();
3200 if needs_plan_update {
3201 Some(format!(
3202 "You ended with a findings/next-steps summary while the active plan still has \
3203 {unfinished}/{total} unfinished {step_word}.{active_clause} Your summary says \
3204 immediate prerequisite repair work now blocks the active step. Call update_plan now \
3205 with the full ordered plan: mark completed steps completed, make the immediate \
3206 blocker repair the active step, and keep later feature work pending. Then call the \
3207 next concrete tool for that active repair. Do not repeat the findings summary or \
3208 claim a tool-call limit while this nudge is giving you another round.{workflow_clause}"
3209 ))
3210 } else {
3211 Some(format!(
3212 "You ended the turn while the active plan still has {unfinished}/{total} unfinished \
3213 {step_word}.{active_clause} Either call update_plan with completed steps marked \
3214 completed, call the next tool for the active step, or state the concrete blocker. \
3215 Do not hand off by only describing remaining work."
3216 ))
3217 }
3218}
3219
3220fn read_only_action_nudge(
3221 read_only_rounds: usize,
3222 remaining_rounds: usize,
3223 step_ledger: Option<&dyn scheduled::StepLedger>,
3224 delegate_hint: Option<&str>,
3225) -> String {
3226 let plan_clause = if step_ledger.and_then(plan_reseat_pointer).is_some() {
3227 " You have an active multi-step plan; keep working the ACTIVE step instead of \
3228 restarting or re-planning."
3229 } else {
3230 ""
3231 };
3232 let delegate_clause = delegate_hint
3233 .map(|hint| format!(" {hint}"))
3234 .unwrap_or_default();
3235 format!(
3236 "[{read_only_rounds} read-only rounds so far. Stop AIMLESS exploring and start \
3237 making the change. This is a nudge, not a limit — you may still read, but if \
3238 you have enough context, call edit_file or write_file now. If a capability \
3239 denial blocks you, call request_permissions with the exact capability and \
3240 target, or take a different approach. If you truly cannot edit yet, state the \
3241 exact blocker. Before edit_file, read the ONE file you are about to change so \
3242 old_string matches exact text; never guess old_string or repeat a failed edit.\
3243 {plan_clause}{delegate_clause} ~{remaining_rounds} round(s) left.]"
3244 )
3245}
3246
3247fn append_nudge_line(messages: &mut Vec<serde_json::Value>, line: &str) {
3252 match messages.last_mut() {
3253 Some(last) if last["role"] == "user" => {
3254 let cur = last["content"].as_str().unwrap_or_default();
3255 last["content"] = serde_json::Value::String(format!("{cur}\n\n{line}"));
3256 }
3257 _ => messages.push(serde_json::json!({"role": "user", "content": line})),
3258 }
3259}
3260
3261fn ollama_non_content_fields(json: &serde_json::Value) -> Vec<&'static str> {
3262 let message = &json["message"];
3263 ["reasoning", "reasoning_content", "thinking"]
3264 .into_iter()
3265 .filter(|field| {
3266 message[*field]
3267 .as_str()
3268 .map(|value| !value.trim().is_empty())
3269 .unwrap_or(false)
3270 })
3271 .collect()
3272}
3273
3274fn ollama_response_shape(json: &serde_json::Value) -> String {
3275 let message = &json["message"];
3276 let message_keys = message
3277 .as_object()
3278 .map(|obj| obj.keys().cloned().collect::<Vec<_>>().join(","))
3279 .unwrap_or_else(|| "<missing>".to_string());
3280 let content_chars = message["content"]
3281 .as_str()
3282 .map(|content| content.chars().count())
3283 .unwrap_or(0);
3284 let tool_calls = message["tool_calls"]
3285 .as_array()
3286 .map(|calls| calls.len())
3287 .unwrap_or(0);
3288 let non_content = ollama_non_content_fields(json);
3289 let non_content = if non_content.is_empty() {
3290 "none".to_string()
3291 } else {
3292 non_content.join(",")
3293 };
3294 format!(
3295 "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={}",
3296 json["prompt_eval_count"]
3297 .as_u64()
3298 .map_or("missing".to_string(), |n| n.to_string()),
3299 json["eval_count"]
3300 .as_u64()
3301 .map_or("missing".to_string(), |n| n.to_string())
3302 )
3303}
3304
3305fn suspicious_empty_retry_nudge(retry_index: u32, json: &serde_json::Value) -> String {
3306 if retry_index == 0 {
3307 return "Your previous response produced generated tokens but no assistant-visible content \
3308 and no tool call. Reply with either a tool call or final assistant content."
3309 .to_string();
3310 }
3311 let fields = ollama_non_content_fields(json);
3312 let field_note = if fields.is_empty() {
3313 "hidden/non-content fields".to_string()
3314 } else {
3315 format!("hidden/non-content field(s): {}", fields.join(", "))
3316 };
3317 format!(
3318 "Your previous response again produced generated tokens only in {field_note}, \
3319 with no assistant-visible content and no tool call. Hidden thinking is not an \
3320 action. If you intend to act, emit the exact tool call now; otherwise reply \
3321 with final assistant-visible content. Do not continue with hidden-only reasoning."
3322 )
3323}
3324
3325fn suspicious_empty_ollama_diagnostic(json: &serde_json::Value) -> String {
3326 let fields = ollama_non_content_fields(json);
3327 let field_note = if fields.is_empty() {
3328 "no known non-content fields were present".to_string()
3329 } else {
3330 format!("non-content field(s) present: {}", fields.join(", "))
3331 };
3332 format!(
3333 "(model generated output tokens but returned no assistant-visible content or tool calls; {field_note}; rerun with `newt --trace` to capture the response shape)"
3334 )
3335}
3336
3337fn cap_exit_nudge(max_tool_rounds: usize, progress: Option<&str>, observed: &[String]) -> String {
3342 let mut nudge = format!(
3348 "You have reached the tool-call limit ({max_tool_rounds} rounds). \
3349 Do NOT call any more tools. Summarize what you found across the tool \
3350 calls above and give your best final answer now. Cite only file paths \
3351 that appear verbatim in the messages above — if the evidence you need \
3352 was in the omitted messages, say so plainly instead of reconstructing \
3353 file names or line numbers from memory. Do not answer with an intention \
3354 to keep working (for example, \"let me read/edit/verify\"); if work remains, \
3355 list it as remaining work and state that the round cap stopped further tool calls."
3356 );
3357 if !observed.is_empty() {
3360 nudge.push_str(
3361 "\n\nFile paths actually observed in tool results this run \
3362 (these exist — cite from this list):",
3363 );
3364 for p in observed {
3365 nudge.push_str("\n- ");
3366 nudge.push_str(p);
3367 }
3368 }
3369 if let Some(p) = progress {
3370 nudge.push_str(&format!("\n\nYour progress so far:\n{p}"));
3371 }
3372 nudge
3373}
3374
3375fn cap_exit_tokens_hint(max_tool_rounds: usize, accumulated: Option<crate::TokenUsage>) -> String {
3381 match accumulated {
3382 Some(u) => format!(
3383 " ({} in / {} out tokens consumed across {max_tool_rounds} rounds)",
3384 u.input_tokens, u.output_tokens,
3385 ),
3386 None => String::new(),
3387 }
3388}
3389
3390fn cap_exit_advice(max_tool_rounds: usize, wasted_calls: usize) -> &'static str {
3391 if wasted_calls >= max_tool_rounds.max(1) {
3394 "most of those rounds were spent on tool calls that failed — the model \
3395 could not find a working edit/shell path, which is usually a tooling or \
3396 permissions issue rather than too few rounds; check `newt doctor`"
3397 } else {
3398 "raise [tui].max_tool_rounds in your config, or ask a more focused question"
3399 }
3400}
3401
3402fn cap_exit_progress_block(label: &str, progress: Option<&str>) -> String {
3403 match progress {
3404 Some(p) => format!("\n\n{label}:\n{p}"),
3405 None => String::new(),
3406 }
3407}
3408
3409fn cap_exit_fallback(
3410 max_tool_rounds: usize,
3411 accumulated: Option<crate::TokenUsage>,
3412 wasted_calls: usize,
3413 progress: Option<&str>,
3414) -> String {
3415 let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3416 let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3417 let salvaged = cap_exit_progress_block("Progress captured before the summary failed", progress);
3418 format!(
3419 "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}, \
3420 and the final summarization request also failed — {advice}){salvaged}"
3421 )
3422}
3423
3424fn cap_exit_action_handoff_fallback(
3425 max_tool_rounds: usize,
3426 accumulated: Option<crate::TokenUsage>,
3427 wasted_calls: usize,
3428 progress: Option<&str>,
3429) -> String {
3430 let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3431 let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3432 let salvaged = cap_exit_progress_block("Progress captured at the tool-call limit", progress);
3433 format!(
3434 "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}; \
3435 the final tools-disabled summary described future tool actions instead \
3436 of final state, so Newt preserved the verified progress instead of \
3437 accepting that handoff — {advice}){salvaged}"
3438 )
3439}
3440
3441fn cap_exit_summary_is_action_handoff(content: &str) -> bool {
3442 crate::NudgeClassifier::load_default()
3443 .classify(content)
3444 .class
3445 == crate::NudgeClass::PendingAction
3446 || looks_like_unverified_stale_file_blocker(content)
3447}
3448
3449struct CapExit {
3455 max_tool_rounds: usize,
3456 accumulated: Option<crate::TokenUsage>,
3457 wasted_calls: usize,
3458 progress: Option<String>,
3459 observed: Vec<String>,
3460}
3461
3462async fn final_summary_ollama(
3468 client: &reqwest::Client,
3469 chat_url: &str,
3470 model: &str,
3471 mut messages: Vec<serde_json::Value>,
3472 cap: CapExit,
3473) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3474 let CapExit {
3475 max_tool_rounds,
3476 accumulated,
3477 wasted_calls,
3478 progress,
3479 observed,
3480 } = cap;
3481 messages.push(serde_json::json!({
3482 "role": "user",
3483 "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3484 }));
3485 let body = serde_json::json!({
3487 "model": model,
3488 "messages": &messages,
3489 "stream": false,
3490 });
3491 let retry = tui_retry_policy();
3492 let result = with_backoff_notify(
3493 &retry,
3494 || async {
3495 let resp = client
3496 .post(chat_url)
3497 .json(&body)
3498 .send()
3499 .await
3500 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3501 if !resp.status().is_success() {
3502 let status = resp.status();
3503 let text = resp.text().await.unwrap_or_default();
3504 anyhow::bail!("Ollama {status}: {text}");
3505 }
3506 resp.json::<serde_json::Value>()
3507 .await
3508 .map_err(anyhow::Error::from)
3509 },
3510 |_, _| {}, )
3512 .await;
3513 match result {
3514 Ok(json) => {
3515 let (content, _reasoning) = crate::reasoning::split_reasoning(
3519 json["message"]["content"].as_str().unwrap_or(""),
3520 );
3521 let total = merge_round_usage(accumulated, ollama_usage(&json));
3522 if content.is_empty() {
3523 Ok((
3524 cap_exit_fallback(
3525 max_tool_rounds,
3526 accumulated,
3527 wasted_calls,
3528 progress.as_deref(),
3529 ),
3530 false,
3531 accumulated,
3532 ))
3533 } else if cap_exit_summary_is_action_handoff(&content) {
3534 Ok((
3535 cap_exit_action_handoff_fallback(
3536 max_tool_rounds,
3537 accumulated,
3538 wasted_calls,
3539 progress.as_deref(),
3540 ),
3541 false,
3542 total,
3543 ))
3544 } else {
3545 Ok((content, false, total))
3546 }
3547 }
3548 Err(_) => Ok((
3551 cap_exit_fallback(
3552 max_tool_rounds,
3553 accumulated,
3554 wasted_calls,
3555 progress.as_deref(),
3556 ),
3557 false,
3558 accumulated,
3559 )),
3560 }
3561}
3562
3563async fn final_summary_openai(
3568 client: &reqwest::Client,
3569 chat_url: &str,
3570 model: &str,
3571 api_key: Option<&str>,
3572 mut messages: Vec<serde_json::Value>,
3573 cap: CapExit,
3574) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3575 let CapExit {
3576 max_tool_rounds,
3577 accumulated,
3578 wasted_calls,
3579 progress,
3580 observed,
3581 } = cap;
3582 messages.push(serde_json::json!({
3583 "role": "user",
3584 "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3585 }));
3586 let body = serde_json::json!({
3588 "model": model,
3589 "messages": &messages,
3590 "stream": false,
3591 });
3592 let retry = tui_retry_policy();
3593 let result = with_backoff_notify(
3594 &retry,
3595 || async {
3596 let mut req = client.post(chat_url).json(&body);
3597 if let Some(key) = api_key {
3598 req = req.bearer_auth(key);
3599 }
3600 let resp = req
3601 .send()
3602 .await
3603 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3604 if !resp.status().is_success() {
3605 let status = resp.status();
3606 let text = resp.text().await.unwrap_or_default();
3607 anyhow::bail!("inference endpoint {status}: {text}");
3608 }
3609 resp.json::<serde_json::Value>()
3610 .await
3611 .map_err(anyhow::Error::from)
3612 },
3613 |_, _| {},
3614 )
3615 .await;
3616 match result {
3617 Ok(json) => {
3618 let (content, _reasoning) = crate::reasoning::split_reasoning(
3620 json["choices"][0]["message"]["content"]
3621 .as_str()
3622 .unwrap_or(""),
3623 );
3624 let total = merge_round_usage(accumulated, openai_usage(&json["usage"]));
3625 if content.is_empty() {
3626 Ok((
3627 cap_exit_fallback(
3628 max_tool_rounds,
3629 accumulated,
3630 wasted_calls,
3631 progress.as_deref(),
3632 ),
3633 false,
3634 accumulated,
3635 ))
3636 } else if cap_exit_summary_is_action_handoff(&content) {
3637 Ok((
3638 cap_exit_action_handoff_fallback(
3639 max_tool_rounds,
3640 accumulated,
3641 wasted_calls,
3642 progress.as_deref(),
3643 ),
3644 false,
3645 total,
3646 ))
3647 } else {
3648 Ok((content, false, total))
3649 }
3650 }
3651 Err(_) => Ok((
3652 cap_exit_fallback(
3653 max_tool_rounds,
3654 accumulated,
3655 wasted_calls,
3656 progress.as_deref(),
3657 ),
3658 false,
3659 accumulated,
3660 )),
3661 }
3662}
3663
3664pub async fn openai_chat_complete(
3672 ctx: ChatCtx<'_>,
3673 mcp: &mut dyn McpTools,
3674) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
3675 let ChatCtx {
3676 url,
3677 model,
3678 kind: _,
3679 api_key,
3680 messages: mem_messages,
3681 task,
3682 workspace,
3683 color,
3684 markdown: _,
3685 tool_offload,
3686 spill_store,
3687 compaction_store,
3688 scratchpad,
3689 scratchpad_store,
3690 code_search,
3691 experience_store,
3692 step_ledger,
3693 caveats,
3694 max_tool_rounds,
3695 workflow_grace_rounds,
3696 tool_output_lines,
3697 debug,
3698 trace,
3699 num_ctx,
3700 connect_timeout_secs,
3701 inference_timeout_secs,
3702 mid_loop_trim_threshold,
3703 mid_loop_trim_tokens,
3704 max_ok_input,
3705 build_check_cmd,
3706 safe_context,
3707 recover_cw_400,
3708 mut note_sink,
3709 mut note_nudge,
3710 recall_source,
3711 memory_source,
3712 summarizer,
3713 compress_state,
3714 mut tool_events,
3715 mut phantom_reaches,
3716 mut permission_gate,
3717 mut on_round_usage,
3718 estimate_ratio,
3719 estimation,
3720 summary_input_cap_floor_chars,
3721 exec_floor,
3722 write_ledger,
3723 cancel,
3724 git_tool,
3725 crew_runner,
3726 } = ctx;
3727 let mut local_compress_state = CompressState::new();
3729 let compress_state = match compress_state {
3730 Some(s) => s,
3731 None => &mut local_compress_state,
3732 };
3733 let client = reqwest::Client::builder()
3734 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
3735 .timeout(std::time::Duration::from_secs(inference_timeout_secs))
3736 .build()?;
3737 let chat_url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
3738 let retry = tui_retry_policy();
3739 let advertise_save_note = note_sink.is_some();
3743 let advertise_recall = recall_source.is_some();
3744 let advertise_memory_fetch = memory_source.is_some();
3745 let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
3747 let advertise_code_search = code_search.is_some();
3749 let advertise_experiential = experience_store.is_some();
3751 let advertise_scheduled = step_ledger.is_some();
3753 let advertise_git = git_tool.is_some();
3754 let advertise_team = crew_runner.is_some();
3755
3756 let mut messages: Vec<serde_json::Value> = mem_messages
3757 .iter()
3758 .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
3759 .collect();
3760
3761 if note_sink.is_some() {
3763 if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
3764 append_nudge_line(&mut messages, &line);
3765 }
3766 }
3767
3768 let mut accumulated_usage: Option<crate::TokenUsage> = None;
3769 let mut hallucination_count: u32 = 0;
3770 let mut repeat_calls = RepeatCallGuard::default();
3772 let mut cw_retries: u32 = 0;
3774 let mut tools_supported = true;
3777 let mut tools_unsupported_notified = false;
3778 let mut send_budget: Option<usize> = initial_send_budget(max_ok_input, safe_context, None);
3783 let mut send_budget_authoritative = safe_context.is_some();
3789 let tools = merged_tool_definitions(
3791 mcp,
3792 advertise_save_note,
3793 advertise_recall,
3794 advertise_memory_fetch,
3795 advertise_git,
3796 advertise_team,
3797 advertise_scratchpad,
3798 advertise_code_search,
3799 advertise_experiential,
3800 advertise_scheduled,
3801 );
3802 let tool_tokens = estimate_value_tokens(&tools, estimation);
3803 let cal = sanitize_estimate_ratio(estimate_ratio);
3806 let tool_tokens_real = calibrate_up(tool_tokens, cal);
3807 let mut prompt_tracker = PromptTracker::new();
3809 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
3810
3811 let mut observed_paths = claim_check::ObservedPaths::default();
3813 let observed_resolver = claim_check::workspace_resolver(workspace);
3814
3815 let mut narration_nudges: usize = 0;
3817 let mut pending_plan_nudges: usize = 0;
3819 let mut stale_file_nudges: usize = 0;
3821 let nudge_classifier = crate::NudgeClassifier::load_default();
3822 let workflow_steerer = crate::WorkflowSteerer::load_default();
3823 let mut workflow_runtime = WorkflowRuntimeState::default();
3824 workflow_runtime.set_progress_horizon(
3826 workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
3827 );
3828
3829 let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
3833 let mut workflow_grace_active = false;
3834 let mut current_tool_round_limit = max_tool_rounds;
3835 'round_loop: for round in 0..hard_tool_rounds {
3836 if round >= current_tool_round_limit {
3837 if workflow_grace_active {
3838 break;
3839 }
3840 let Some(nudge) = workflow_runtime.cap_grace_nudge(
3841 step_ledger,
3842 max_tool_rounds,
3843 workflow_grace_rounds,
3844 ) else {
3845 break;
3846 };
3847 workflow_grace_active = true;
3848 current_tool_round_limit = hard_tool_rounds;
3849 if debug {
3850 print_debug(
3851 "workflow progress at soft round cap — granting configured grace window",
3852 color,
3853 );
3854 }
3855 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
3856 }
3857 if is_cancelled(cancel) {
3863 return Ok((String::new(), false, accumulated_usage, hallucination_count));
3864 }
3865 if round > 0 && color {
3866 execute!(
3867 io::stdout(),
3868 SetForegroundColor(CtColor::DarkGrey),
3869 Print("…\n"),
3870 ResetColor
3871 )
3872 .ok();
3873 }
3874
3875 if round > 0 {
3878 if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
3879 messages.push(serde_json::json!({ "role": "user", "content": ptr }));
3880 }
3881 if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
3882 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
3883 }
3884 }
3885
3886 {
3890 let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
3893 let message_tokens = estimate_tokens(&messages, estimation);
3896 if let Some(trigger) = compression_trigger(
3897 messages.len(),
3898 current,
3899 message_tokens,
3900 mid_loop_trim_threshold,
3901 mid_loop_trim_tokens,
3902 send_budget,
3903 tool_tokens_real,
3904 ) {
3905 let pipeline_budget = if trigger.hard_budget {
3908 calibrate_down(trigger.budget, cal)
3909 } else {
3910 trigger.budget
3911 };
3912 let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
3916 let outcome = compress(
3917 CompressRequest {
3918 messages: &messages,
3919 budget: pipeline_budget,
3920 max_messages: trigger.max_messages,
3921 task,
3922 hard_budget: trigger.hard_budget,
3923 authoritative: token_fired || send_budget_authoritative,
3924 focus: None,
3925 est: estimation,
3926 summary_input_cap_floor_chars,
3927 compaction_store,
3928 },
3929 summarizer,
3930 compress_state,
3931 )
3932 .await;
3933 if let Some(notice) = outcome.notice {
3934 print_harness_notice(¬ice, color);
3935 }
3936 if outcome.action == CompressAction::Refused {
3937 anyhow::bail!(
3938 "context (~{current} tokens) exceeds the model's input budget and \
3939 auto-compression is disabled after repeated ineffective passes — \
3940 start a new conversation or ask a more focused question, or run \
3941 `newt tunings reset {model}` if this model's learned budget looks wrong"
3942 );
3943 }
3944 if outcome.fired {
3945 let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
3949 ", still over budget"
3950 } else {
3951 ""
3952 };
3953 emit_compression_notice(
3954 color,
3955 outcome.tokens_before,
3956 outcome.tokens_after,
3957 outcome.action,
3958 suffix,
3959 );
3960 if debug {
3961 print_debug(
3962 &format!(
3963 "compression: {} → {} messages (budget ~{} tokens, \
3964 +~{tool_tokens} tool-schema tokens ride along)",
3965 messages.len(),
3966 outcome.messages.len(),
3967 pipeline_budget,
3968 ),
3969 color,
3970 );
3971 }
3972 messages = outcome.messages;
3973 prompt_tracker.invalidate();
3974 }
3975 }
3976 }
3977
3978 let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
3981
3982 let mut body = serde_json::json!({
3985 "model": model,
3986 "messages": messages,
3987 "tools": tools.clone(),
3988 "tool_choice": "auto",
3989 "stream": false,
3990 });
3991 if !tools_supported {
3994 if let Some(o) = body.as_object_mut() {
3995 o.remove("tools");
3996 o.remove("tool_choice");
3997 }
3998 }
3999 let _ = num_ctx; let dispatch = with_backoff_notify(
4006 &retry,
4007 || async {
4008 let mut req = client.post(&chat_url).json(&body);
4009 if let Some(key) = api_key {
4010 req = req.bearer_auth(key);
4011 }
4012 let resp = req
4013 .send()
4014 .await
4015 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
4016 if !resp.status().is_success() {
4017 let status = resp.status();
4018 let text = resp.text().await.unwrap_or_default();
4019 anyhow::bail!("inference endpoint {status}: {text}");
4020 }
4021 resp.json::<serde_json::Value>()
4022 .await
4023 .map_err(anyhow::Error::from)
4024 },
4025 |attempt, delay| print_retry_indicator(attempt, delay, color),
4026 )
4027 .await;
4028 let json: serde_json::Value = match dispatch {
4029 Ok(j) => j,
4030 Err(e) => {
4031 if tools_supported && is_tools_unsupported_error(&e) {
4036 tools_supported = false;
4037 if !tools_unsupported_notified {
4038 tools_unsupported_notified = true;
4039 print_newt(
4040 &format!(
4041 "{model} does not support tools — tools disabled for this session"
4042 ),
4043 color,
4044 false,
4045 );
4046 }
4047 continue 'round_loop;
4048 }
4049 if cw_retries < 2 {
4053 if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
4054 emit_overflow_notice(
4055 color,
4056 accumulated_usage.as_ref(),
4057 Some(new_cap),
4058 model,
4059 cw_retries + 1,
4060 );
4061 send_budget = Some(new_cap as usize);
4062 send_budget_authoritative = true;
4065 let outcome = compress(
4066 CompressRequest {
4067 messages: &messages,
4071 budget: calibrate_down(
4072 (new_cap as usize).saturating_sub(tool_tokens_real),
4073 cal,
4074 ),
4075 max_messages: None,
4076 task,
4077 hard_budget: true,
4078 authoritative: true,
4079 focus: None,
4080 est: estimation,
4081 summary_input_cap_floor_chars,
4082 compaction_store,
4083 },
4084 summarizer,
4085 compress_state,
4086 )
4087 .await;
4088 if let Some(notice) = outcome.notice {
4089 print_harness_notice(¬ice, color);
4090 }
4091 if outcome.action == CompressAction::Refused {
4092 return Err(e);
4094 }
4095 if outcome.fired {
4096 messages = outcome.messages;
4097 prompt_tracker.invalidate();
4098 }
4099 cw_retries += 1;
4100 continue 'round_loop;
4101 }
4102 }
4103 return Err(e);
4104 }
4105 };
4106 let round_usage = openai_usage(&json["usage"]);
4109 if let Some(u) = round_usage {
4110 prompt_tracker.record(u.input_tokens, messages.len());
4111 }
4112 accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
4113
4114 let truncation_suspect = false;
4118 if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
4119 let raised = u.input_tokens as usize;
4120 if raised > budget {
4121 send_budget = Some(raised);
4122 if debug {
4123 print_debug(
4124 &format!(
4125 "send budget raised to ~{raised} tokens (backend accepted \
4126 {}-token prompt)",
4127 u.input_tokens
4128 ),
4129 color,
4130 );
4131 }
4132 }
4133 }
4134
4135 let message = &json["choices"][0]["message"];
4136
4137 let (oa_content, inline_reasoning) =
4143 crate::reasoning::split_reasoning(message["content"].as_str().unwrap_or(""));
4144 let separate_reasoning = message["reasoning_content"]
4145 .as_str()
4146 .map(str::trim)
4147 .filter(|s| !s.is_empty());
4148 if debug && (separate_reasoning.is_some() || inline_reasoning.is_some()) {
4149 let n = separate_reasoning
4150 .map(str::len)
4151 .or_else(|| inline_reasoning.as_deref().map(str::len))
4152 .unwrap_or(0);
4153 print_debug(
4154 &format!("reasoning ({n} chars) surfaced to the trace, not the answer"),
4155 color,
4156 );
4157 }
4158 let native_calls = message["tool_calls"].as_array();
4159 let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
4165 tool_recovery::recover_tool_calls(&oa_content)
4166 } else {
4167 tool_recovery::Recovery::default()
4168 };
4169 let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
4170 Some(t) if !t.is_empty() => Some(t),
4171 _ if !recovered.calls.is_empty() => Some(&recovered.calls),
4172 _ => None,
4173 };
4174 let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
4175 if debug && !recovered.calls.is_empty() {
4176 print_debug(
4177 &format!(
4178 "recovered {} tool call(s) from content (non-native emission)",
4179 recovered.calls.len()
4180 ),
4181 color,
4182 );
4183 }
4184
4185 if debug {
4186 let excerpt: String = oa_content.chars().take(80).collect();
4187 let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
4188 let usage_str = match round_usage {
4189 Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
4190 None => "no usage".into(),
4191 };
4192 print_debug(
4193 &format!(
4194 "round {round}: tool_calls={tc_count} usage=[{usage_str}] content={excerpt:?}"
4195 ),
4196 color,
4197 );
4198 }
4199
4200 if !has_tools {
4201 if recovered.tool_shaped {
4204 hallucination_count += 1;
4205 if debug {
4206 print_debug(
4207 "format-hallucination: tool call emitted as unrecoverable text",
4208 color,
4209 );
4210 }
4211 }
4212 let content = oa_content.clone();
4213 if content.is_empty() && debug {
4214 print_debug(
4215 "empty content with no tool calls — model produced nothing",
4216 color,
4217 );
4218 }
4219 let nudge_classification =
4223 (!content.is_empty()).then(|| nudge_classifier.classify(&content));
4224 let workflow_classifier_text = workflow_classifier_text(&messages, &content);
4225 let workflow_hint = nudge_classification
4226 .as_ref()
4227 .filter(|classification| classification.is_plan_update())
4228 .and_then(|_| workflow_steerer.plan_update_hint(&workflow_classifier_text));
4229 let classifier_plan_direction = nudge_classification
4230 .as_ref()
4231 .filter(|classification| classification.is_plan_update())
4232 .and_then(|_| nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate));
4233 let plan_nudge_hint =
4234 combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
4235 if !content.is_empty() && round + 1 < current_tool_round_limit {
4236 if let Some(nudge) = workflow_runtime.rediscovery_nudge(
4237 nudge_classification.as_ref(),
4238 &content,
4239 step_ledger,
4240 ) {
4241 if debug {
4242 print_debug(
4243 "workflow evidence rediscovery — nudging toward active repair",
4244 color,
4245 );
4246 }
4247 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4248 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4249 continue 'round_loop;
4250 }
4251 }
4252 if !content.is_empty()
4253 && pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
4254 && round + 1 < current_tool_round_limit
4255 {
4256 let needs_plan_update = nudge_classification
4257 .as_ref()
4258 .is_some_and(|c| c.is_plan_update());
4259 if let Some(nudge) = pending_plan_completion_nudge(
4260 step_ledger,
4261 needs_plan_update,
4262 plan_nudge_hint.as_deref(),
4263 ) {
4264 if debug {
4265 print_debug(
4266 "active plan has unfinished steps — nudging before final answer",
4267 color,
4268 );
4269 }
4270 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4271 messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4272 pending_plan_nudges += 1;
4273 continue 'round_loop;
4274 }
4275 }
4276 if !content.is_empty()
4277 && stale_file_nudges < STALE_FILE_NUDGE_CAP
4278 && round + 1 < current_tool_round_limit
4279 && looks_like_unverified_stale_file_blocker(&content)
4280 {
4281 if debug {
4282 print_debug(
4283 "unverified stale-file blocker — nudging to check ground truth",
4284 color,
4285 );
4286 }
4287 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4288 messages.push(serde_json::json!({
4289 "role": "user",
4290 "content": stale_file_ground_truth_nudge(),
4291 }));
4292 stale_file_nudges += 1;
4293 continue 'round_loop;
4294 }
4295 if !content.is_empty()
4296 && narration_nudges < NARRATION_NUDGE_CAP
4297 && round + 1 < current_tool_round_limit
4298 && nudge_classification
4299 .as_ref()
4300 .is_some_and(|c| c.is_pending_action())
4301 {
4302 if debug {
4303 print_debug(
4304 "narrated intent with no tool call — nudging to act and continuing",
4305 color,
4306 );
4307 }
4308 messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4309 let direction = nudge_classification
4310 .as_ref()
4311 .and_then(|classification| nudge_classifier.direction_for(classification.class))
4312 .map(str::to_string)
4313 .unwrap_or_else(narration_action_nudge);
4314 messages.push(serde_json::json!({
4315 "role": "user",
4316 "content": direction,
4317 }));
4318 narration_nudges += 1;
4319 continue 'round_loop;
4320 }
4321 if !content.is_empty() {
4324 emit_accepted(
4325 &mut on_round_usage,
4326 round_usage,
4327 truncation_suspect,
4328 round_est_raw,
4329 );
4330 }
4331 let out = if content.is_empty() {
4332 "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
4333 } else {
4334 content
4335 };
4336 return Ok((out, false, accumulated_usage, hallucination_count));
4337 }
4338
4339 emit_accepted(
4343 &mut on_round_usage,
4344 round_usage,
4345 truncation_suspect,
4346 round_est_raw,
4347 );
4348 let mut assistant_turn = message.clone();
4352 assistant_turn["content"] = serde_json::Value::String(oa_content.clone());
4353 if let Some(obj) = assistant_turn.as_object_mut() {
4354 obj.remove("reasoning_content");
4355 }
4356 messages.push(assistant_turn);
4357 let mut round_modified_workspace = false;
4358 let mut round_progress = false;
4359 for tc in tool_calls.unwrap() {
4360 let id = tc["id"].as_str().unwrap_or("");
4361 let anthropic_native = tc["function"].is_null();
4368 if anthropic_native && debug {
4369 let raw_name = tc["name"].as_str().unwrap_or("<missing>");
4370 print_debug(
4371 &format!(
4372 "tool call in Anthropic-native format inside tool_calls array \
4373 (no `function` key) — name={raw_name:?}"
4374 ),
4375 color,
4376 );
4377 }
4378 let name = if anthropic_native {
4379 tc["name"].as_str().unwrap_or("unknown")
4380 } else {
4381 tc["function"]["name"].as_str().unwrap_or("unknown")
4382 };
4383 let args = if anthropic_native {
4384 tc["input"].clone()
4385 } else {
4386 match &tc["function"]["arguments"] {
4387 serde_json::Value::String(s) => {
4388 serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
4389 }
4390 v => v.clone(),
4391 }
4392 };
4393 if trace {
4394 print_trace(
4395 &format!(
4396 "raw tool_call element: {}",
4397 serde_json::to_string(tc).unwrap_or_else(|_| "?".into())
4398 ),
4399 color,
4400 );
4401 }
4402 let mcp_handles = mcp.handles(name);
4403 if debug {
4404 print_debug(
4405 &format!("dispatching tool name={name:?} mcp_handles={mcp_handles}"),
4406 color,
4407 );
4408 }
4409 if is_hallucination(name, &args) {
4410 hallucination_count += 1;
4411 }
4412 if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
4416 if let Some(rec) = tool_events.as_deref_mut() {
4417 rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
4418 }
4419 messages.push(serde_json::json!({
4420 "role": "tool",
4421 "tool_call_id": id,
4422 "content": steer,
4423 }));
4424 continue;
4425 }
4426 if name == "save_note" && note_sink.is_some() {
4429 if let Some(n) = note_nudge.as_deref_mut() {
4430 n.note_saved();
4431 }
4432 }
4433 ledger_note_write(write_ledger, name, &args, workspace);
4436 let tool_t0 = std::time::Instant::now();
4437 let result = if tools::is_context_remaining_call(name) {
4442 let report = budget::render_context_budget(
4443 prompt_tracker.current(&messages, Some(&tools), cal, estimation),
4444 num_ctx_input_ceiling(num_ctx),
4445 num_ctx,
4446 );
4447 display::print_tool_call("get_context_remaining", "", color);
4448 display::print_tool_output(&report, tool_output_lines, color);
4449 report
4450 } else {
4451 execute_tool_with_offload(
4452 name,
4453 &args,
4454 workspace,
4455 color,
4456 tool_output_lines,
4457 caveats,
4458 mcp,
4459 build_check_cmd.as_deref(),
4460 note_sink
4464 .as_deref_mut()
4465 .map(|s| &mut *s as &mut dyn NoteSink),
4466 recall_source,
4467 memory_source,
4468 permission_gate
4470 .as_deref_mut()
4471 .map(|g| &mut *g as &mut dyn PermissionGate),
4472 exec_floor,
4474 git_tool,
4476 crew_runner,
4478 scratchpad_store,
4479 code_search,
4480 experience_store,
4481 step_ledger,
4482 tool_offload,
4483 spill_store,
4484 )
4485 .await
4486 };
4487 if debug {
4488 let excerpt: String = result.chars().take(120).collect();
4489 print_debug(&format!("tool result: {excerpt:?}"), color);
4490 }
4491 let ok = tools::tool_result_ok(&result);
4496 if ok && is_workspace_write_call(name) {
4497 round_modified_workspace = true;
4498 }
4499 if ok && meaningful_workflow_progress(name, &result) {
4500 round_progress = true;
4501 }
4502 repeat_calls.record(name, &args, ok, &result);
4503 if workflow_runtime.record_tool_result(&result) {
4504 round_progress = true;
4505 }
4506 if let Some(rec) = tool_events.as_deref_mut() {
4507 rec.push(crate::ToolEvent::from_call(
4508 name,
4509 &args,
4510 ok,
4511 u64::try_from(tool_t0.elapsed().as_millis()).ok(),
4512 ));
4513 }
4514 if let Some(pr) = phantom_reaches.as_deref_mut() {
4521 if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
4522 .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
4523 {
4524 pr.push(crate::PhantomReach {
4525 name_as_called: name.to_string(),
4526 resolution,
4527 active_context_features: Vec::new(),
4528 });
4529 }
4530 }
4531 observed_paths.record(&result, &observed_resolver);
4533 messages.push(serde_json::json!({
4534 "role": "tool",
4535 "tool_call_id": id,
4536 "content": maybe_offload_tool_result(name, result, tool_offload, spill_store),
4538 }));
4539 }
4540 workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
4541 }
4542
4543 let trimmed = trim_for_summary(&messages, 2, 6);
4546 let progress = cap_exit_progress(step_ledger, scratchpad_store);
4548 let (text, streamed, usage) = final_summary_openai(
4549 &client,
4550 &chat_url,
4551 model,
4552 api_key,
4553 trimmed,
4554 CapExit {
4555 max_tool_rounds,
4556 accumulated: accumulated_usage,
4557 wasted_calls: repeat_calls.total_failures(),
4558 progress,
4559 observed: observed_paths.into_vec(),
4560 },
4561 )
4562 .await?;
4563 let text = claim_check::annotate_against_workspace(text, workspace);
4565 Ok((text, streamed, usage, hallucination_count))
4566}
4567
4568fn responses_api_selected() -> bool {
4582 std::env::var("NEWT_OPENAI_API")
4583 .ok()
4584 .is_some_and(|v| v.eq_ignore_ascii_case("responses"))
4585}
4586
4587fn build_responses_input(
4593 messages: &[serde_json::Value],
4594) -> (Option<String>, Vec<serde_json::Value>) {
4595 let mut instructions: Vec<String> = Vec::new();
4596 let mut input: Vec<serde_json::Value> = Vec::new();
4597 for m in messages {
4598 if m.get("type").is_some() {
4599 input.push(m.clone());
4600 continue;
4601 }
4602 let role = m["role"].as_str().unwrap_or("user");
4603 let content = m["content"].as_str().unwrap_or("");
4604 match role {
4605 "system" | "developer" => instructions.push(content.to_string()),
4606 _ => input.push(serde_json::json!({ "role": role, "content": content })),
4607 }
4608 }
4609 let ins = (!instructions.is_empty()).then(|| instructions.join("\n\n"));
4610 (ins, input)
4611}
4612
4613fn tools_to_responses(tools: &serde_json::Value) -> Vec<serde_json::Value> {
4618 tools
4619 .as_array()
4620 .map(|arr| {
4621 arr.iter()
4622 .map(|t| {
4623 let f = &t["function"];
4624 if f.is_object() {
4625 serde_json::json!({
4626 "type": "function",
4627 "name": f["name"],
4628 "description": f["description"],
4629 "parameters": f["parameters"],
4630 })
4631 } else {
4632 t.clone()
4633 }
4634 })
4635 .collect()
4636 })
4637 .unwrap_or_default()
4638}
4639
4640fn parse_responses_output(json: &serde_json::Value) -> (String, Vec<serde_json::Value>) {
4647 let mut text = String::new();
4648 let mut calls = Vec::new();
4649 if let Some(items) = json["output"].as_array() {
4650 for item in items {
4651 match item["type"].as_str() {
4652 Some("message") => {
4653 if let Some(parts) = item["content"].as_array() {
4654 for p in parts {
4655 if let Some(t) = p["text"].as_str() {
4656 text.push_str(t);
4657 }
4658 }
4659 }
4660 }
4661 Some("function_call") => calls.push(item.clone()),
4662 _ => {}
4663 }
4664 }
4665 }
4666 if text.is_empty() {
4667 if let Some(t) = json["output_text"].as_str() {
4668 text.push_str(t);
4669 }
4670 }
4671 (text, calls)
4672}
4673
4674fn responses_usage(v: &serde_json::Value) -> Option<crate::TokenUsage> {
4677 let input = v["input_tokens"].as_u64().map(|n| n as u32);
4678 let output = v["output_tokens"].as_u64().map(|n| n as u32);
4679 input.zip(output).map(|(i, o)| crate::TokenUsage {
4680 input_tokens: i,
4681 output_tokens: o,
4682 })
4683}
4684
4685pub async fn openai_responses_complete(
4691 ctx: ChatCtx<'_>,
4692 mcp: &mut dyn McpTools,
4693) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
4694 let ChatCtx {
4695 url,
4696 model,
4697 kind: _,
4698 api_key,
4699 messages: mem_messages,
4700 task: _,
4701 workspace,
4702 color,
4703 markdown: _,
4704 tool_offload,
4705 spill_store,
4706 compaction_store,
4707 scratchpad,
4708 scratchpad_store,
4709 code_search,
4710 experience_store,
4711 step_ledger,
4712 caveats,
4713 max_tool_rounds,
4714 workflow_grace_rounds: _,
4715 tool_output_lines,
4716 debug,
4717 trace,
4718 num_ctx,
4722 connect_timeout_secs,
4723 inference_timeout_secs,
4724 mid_loop_trim_threshold: _,
4725 mid_loop_trim_tokens: _,
4726 max_ok_input: _,
4727 build_check_cmd,
4728 safe_context: _,
4729 recover_cw_400: _,
4730 mut note_sink,
4731 mut note_nudge,
4732 recall_source,
4733 memory_source,
4734 summarizer: _,
4735 compress_state: _,
4736 mut tool_events,
4737 mut phantom_reaches,
4738 mut permission_gate,
4739 on_round_usage: _,
4740 estimate_ratio: _,
4741 estimation,
4743 summary_input_cap_floor_chars: _,
4744 exec_floor,
4745 write_ledger,
4746 cancel,
4747 git_tool,
4748 crew_runner,
4749 } = ctx;
4750 let _ = compaction_store;
4753
4754 let client = reqwest::Client::builder()
4755 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
4756 .timeout(std::time::Duration::from_secs(inference_timeout_secs))
4757 .build()?;
4758 let responses_url = format!("{}/v1/responses", url.trim_end_matches('/'));
4759 let retry = tui_retry_policy();
4760 let advertise_save_note = note_sink.is_some();
4761 let advertise_recall = recall_source.is_some();
4762 let advertise_memory_fetch = memory_source.is_some();
4763 let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
4765 let advertise_code_search = code_search.is_some();
4767 let advertise_experiential = experience_store.is_some();
4769 let advertise_scheduled = step_ledger.is_some();
4771 let advertise_git = git_tool.is_some();
4772 let advertise_team = crew_runner.is_some();
4773
4774 let msgs_json: Vec<serde_json::Value> = mem_messages
4775 .iter()
4776 .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
4777 .collect();
4778 let (instructions, mut input) = build_responses_input(&msgs_json);
4779 let tools_chat = merged_tool_definitions(
4780 mcp,
4781 advertise_save_note,
4782 advertise_recall,
4783 advertise_memory_fetch,
4784 advertise_git,
4785 advertise_team,
4786 advertise_scratchpad,
4787 advertise_code_search,
4788 advertise_experiential,
4789 advertise_scheduled,
4790 );
4791 let tools = tools_to_responses(&tools_chat);
4792
4793 let mut accumulated_usage: Option<crate::TokenUsage> = None;
4794 let mut hallucination_count: u32 = 0;
4795 let mut repeat_calls = RepeatCallGuard::default();
4797 let mut tools_supported = true;
4798 let mut tools_unsupported_notified = false;
4799
4800 let build_body = |input: &[serde_json::Value], with_tools: bool| {
4801 let mut body = serde_json::json!({ "model": model, "input": input, "stream": false });
4802 if let Some(ins) = &instructions {
4803 body["instructions"] = serde_json::json!(ins);
4804 }
4805 if with_tools && !tools.is_empty() {
4806 body["tools"] = serde_json::json!(tools);
4807 body["tool_choice"] = serde_json::json!("auto");
4808 }
4809 body
4810 };
4811
4812 for round in 0..max_tool_rounds {
4813 if is_cancelled(cancel) {
4814 return Ok((String::new(), false, accumulated_usage, hallucination_count));
4815 }
4816 let body = build_body(&input, tools_supported);
4817 let dispatch = with_backoff_notify(
4818 &retry,
4819 || async {
4820 let mut req = client.post(&responses_url).json(&body);
4821 if let Some(key) = api_key {
4822 req = req.bearer_auth(key);
4823 }
4824 let resp = req
4825 .send()
4826 .await
4827 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
4828 if !resp.status().is_success() {
4829 let status = resp.status();
4830 let text = resp.text().await.unwrap_or_default();
4831 anyhow::bail!("inference endpoint {status}: {text}");
4832 }
4833 resp.json::<serde_json::Value>()
4834 .await
4835 .map_err(anyhow::Error::from)
4836 },
4837 |attempt, delay| print_retry_indicator(attempt, delay, color),
4838 )
4839 .await;
4840
4841 let json = match dispatch {
4842 Ok(j) => j,
4843 Err(e) => {
4844 if tools_supported && is_tools_unsupported_error(&e) {
4845 tools_supported = false;
4846 if !tools_unsupported_notified {
4847 tools_unsupported_notified = true;
4848 print_newt(
4849 &format!(
4850 "{model} does not support tools — tools disabled for this session"
4851 ),
4852 color,
4853 false,
4854 );
4855 }
4856 continue;
4857 }
4858 return Err(e);
4859 }
4860 };
4861
4862 let round_usage = responses_usage(&json["usage"]);
4863 accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
4864 let (text, calls) = parse_responses_output(&json);
4865
4866 if debug {
4867 let excerpt: String = text.chars().take(80).collect();
4868 print_debug(
4869 &format!(
4870 "responses round {round}: function_calls={} content={excerpt:?}",
4871 calls.len()
4872 ),
4873 color,
4874 );
4875 }
4876
4877 if calls.is_empty() {
4878 let out = if text.is_empty() {
4879 "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
4880 } else {
4881 text
4882 };
4883 return Ok((out, false, accumulated_usage, hallucination_count));
4884 }
4885
4886 for call in &calls {
4889 input.push(call.clone());
4890 }
4891 for call in &calls {
4892 let call_id = call["call_id"]
4893 .as_str()
4894 .or_else(|| call["id"].as_str())
4895 .unwrap_or("");
4896 let name = call["name"].as_str().unwrap_or("unknown");
4897 let args = match &call["arguments"] {
4898 serde_json::Value::String(s) => {
4899 serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
4900 }
4901 v => v.clone(),
4902 };
4903 if trace {
4904 print_trace(
4905 &format!(
4906 "raw function_call: {}",
4907 serde_json::to_string(call).unwrap_or_else(|_| "?".into())
4908 ),
4909 color,
4910 );
4911 }
4912 if is_hallucination(name, &args) {
4913 hallucination_count += 1;
4914 }
4915 if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
4919 if let Some(rec) = tool_events.as_deref_mut() {
4920 rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
4921 }
4922 input.push(serde_json::json!({
4923 "type": "function_call_output",
4924 "call_id": call_id,
4925 "output": steer,
4926 }));
4927 continue;
4928 }
4929 if name == "save_note" && note_sink.is_some() {
4930 if let Some(n) = note_nudge.as_deref_mut() {
4931 n.note_saved();
4932 }
4933 }
4934 ledger_note_write(write_ledger, name, &args, workspace);
4935 let tool_t0 = std::time::Instant::now();
4936 let result = if tools::is_context_remaining_call(name) {
4941 let report = budget::render_context_budget(
4942 estimate_request_tokens(&input, Some(&tools_chat), estimation),
4943 num_ctx_input_ceiling(num_ctx),
4944 num_ctx,
4945 );
4946 display::print_tool_call("get_context_remaining", "", color);
4947 display::print_tool_output(&report, tool_output_lines, color);
4948 report
4949 } else {
4950 execute_tool_with_offload(
4951 name,
4952 &args,
4953 workspace,
4954 color,
4955 tool_output_lines,
4956 caveats,
4957 mcp,
4958 build_check_cmd.as_deref(),
4959 note_sink
4960 .as_deref_mut()
4961 .map(|s| &mut *s as &mut dyn NoteSink),
4962 recall_source,
4963 memory_source,
4964 permission_gate
4965 .as_deref_mut()
4966 .map(|g| &mut *g as &mut dyn PermissionGate),
4967 exec_floor,
4968 git_tool,
4969 crew_runner,
4970 scratchpad_store,
4971 code_search,
4972 experience_store,
4973 step_ledger,
4974 tool_offload,
4975 spill_store,
4976 )
4977 .await
4978 };
4979 if debug {
4980 let excerpt: String = result.chars().take(120).collect();
4981 print_debug(&format!("tool result: {excerpt:?}"), color);
4982 }
4983 let ok = tools::tool_result_ok(&result);
4986 repeat_calls.record(name, &args, ok, &result);
4987 if let Some(rec) = tool_events.as_deref_mut() {
4988 rec.push(crate::ToolEvent::from_call(
4989 name,
4990 &args,
4991 ok,
4992 u64::try_from(tool_t0.elapsed().as_millis()).ok(),
4993 ));
4994 }
4995 if let Some(pr) = phantom_reaches.as_deref_mut() {
5002 if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
5003 .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
5004 {
5005 pr.push(crate::PhantomReach {
5006 name_as_called: name.to_string(),
5007 resolution,
5008 active_context_features: Vec::new(),
5009 });
5010 }
5011 }
5012 input.push(serde_json::json!({
5013 "type": "function_call_output",
5014 "call_id": call_id,
5015 "output": maybe_offload_tool_result(name, result, tool_offload, spill_store),
5017 }));
5018 }
5019 }
5020
5021 let body = build_body(&input, false);
5024 let mut req = client.post(&responses_url).json(&body);
5025 if let Some(key) = api_key {
5026 req = req.bearer_auth(key);
5027 }
5028 let resp = req.send().await?;
5029 if !resp.status().is_success() {
5030 let status = resp.status();
5031 let text = resp.text().await.unwrap_or_default();
5032 anyhow::bail!("inference endpoint {status}: {text}");
5033 }
5034 let json: serde_json::Value = resp.json().await?;
5035 accumulated_usage = merge_round_usage(accumulated_usage, responses_usage(&json["usage"]));
5036 let (text, _) = parse_responses_output(&json);
5037 Ok((text, false, accumulated_usage, hallucination_count))
5038}
5039
5040fn thinking_stream_enabled() -> bool {
5043 match std::env::var("NEWT_THINKING").ok().as_deref() {
5044 Some("off") => return false,
5045 Some("on" | "stream") => return true,
5046 _ => {}
5047 }
5048 crate::Config::resolve()
5049 .ok()
5050 .and_then(|c| c.tui)
5051 .map(|t| t.thinking == crate::ThinkingMode::Stream)
5052 .unwrap_or(true)
5053}
5054
5055const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5057
5058fn format_spinner(frame: usize, secs: f32, label: &str, chars: usize) -> String {
5065 let braille = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()];
5066 if chars == 0 {
5067 format!("{braille} {label} {secs:.1}s")
5068 } else {
5069 format!("{braille} {label} {secs:.1}s · {chars} chars")
5070 }
5071}
5072
5073struct ThinkingSpinner {
5080 color: bool,
5081 frame: usize,
5082 start: std::time::Instant,
5083 chars: usize,
5084 line_buf: String,
5085 spinner_drawn: bool,
5086}
5087
5088impl ThinkingSpinner {
5089 fn new(color: bool) -> Self {
5090 Self {
5091 color,
5092 frame: 0,
5093 start: std::time::Instant::now(),
5094 chars: 0,
5095 line_buf: String::new(),
5096 spinner_drawn: false,
5097 }
5098 }
5099
5100 fn reasoning(&mut self, chunk: &str) {
5103 self.chars += chunk.chars().count();
5104 self.line_buf.push_str(chunk);
5105 while let Some(nl) = self.line_buf.find('\n') {
5106 let line: String = self.line_buf.drain(..=nl).collect();
5107 self.print_dim_line(line.trim_end_matches(['\n', '\r']));
5108 }
5109 self.redraw_spinner();
5110 }
5111
5112 fn print_dim_line(&mut self, line: &str) {
5113 self.erase_spinner();
5114 if line.trim().is_empty() {
5115 return;
5116 }
5117 let mut out = io::stdout();
5118 if self.color {
5119 let _ = execute!(
5120 out,
5121 SetForegroundColor(CtColor::DarkGrey),
5122 Print(" "),
5123 Print(line),
5124 ResetColor,
5125 Print("\n"),
5126 );
5127 } else {
5128 let _ = writeln!(out, " {line}");
5129 }
5130 }
5131
5132 fn redraw_spinner(&mut self) {
5133 self.frame = self.frame.wrapping_add(1);
5134 let line = format_spinner(
5135 self.frame,
5136 self.start.elapsed().as_secs_f32(),
5137 "thinking…",
5138 self.chars,
5139 );
5140 let fitted = display::fit_line(&line, display::term_cols());
5144 let mut out = io::stdout();
5145 if self.color {
5146 let _ = execute!(
5147 out,
5148 Print("\r\x1b[K"),
5149 SetForegroundColor(CtColor::DarkGrey),
5150 Print(&fitted.head),
5151 SetForegroundColor(display::FADE_CT),
5152 Print(&fitted.fade),
5153 Print(fitted.ellipsis),
5154 ResetColor,
5155 );
5156 } else {
5157 let _ = write!(out, "\r{}{}{}", fitted.head, fitted.fade, fitted.ellipsis);
5158 }
5159 let _ = out.flush();
5160 self.spinner_drawn = true;
5161 }
5162
5163 fn erase_spinner(&mut self) {
5164 if self.spinner_drawn {
5165 let _ = write!(io::stdout(), "\r\x1b[K");
5166 let _ = io::stdout().flush();
5167 self.spinner_drawn = false;
5168 }
5169 }
5170
5171 fn finish(&mut self) {
5174 if !self.line_buf.is_empty() {
5175 let tail = std::mem::take(&mut self.line_buf);
5176 self.print_dim_line(tail.trim_end_matches(['\n', '\r']));
5177 }
5178 self.erase_spinner();
5179 }
5180}
5181
5182async fn stream_response(
5187 resp: reqwest::Response,
5188 color: bool,
5189 show_thinking: bool,
5190 leading_reasoning: bool,
5191 cancel: Option<&std::sync::atomic::AtomicBool>,
5192 markdown: bool,
5193) -> anyhow::Result<(String, Option<crate::TokenUsage>)> {
5194 let mut spinner = show_thinking.then(|| ThinkingSpinner::new(color));
5195 let mut full = String::new();
5196 let mut started = false;
5197 let mut usage: Option<crate::TokenUsage> = None;
5198 let cols = display::term_cols();
5204 let mut md =
5205 markdown.then(|| MarkdownStreamWriter::new(io::stdout(), RenderOpts { color: true, cols }));
5206 let mut think = if leading_reasoning {
5211 crate::reasoning::ThinkFilter::with_leading_reasoning()
5212 } else {
5213 crate::reasoning::ThinkFilter::new()
5214 };
5215
5216 let mut resp = resp;
5217 while let Some(chunk) = match cancellable(cancel, resp.chunk()).await {
5220 Some(c) => c?,
5221 None => None,
5222 } {
5223 let text = String::from_utf8_lossy(&chunk);
5224 for line in text.lines() {
5225 if line.is_empty() {
5226 continue;
5227 }
5228 let Ok(json) = serde_json::from_str::<serde_json::Value>(line) else {
5229 continue;
5230 };
5231 let raw = json["message"]["content"].as_str().unwrap_or("");
5232 let (token, reasoning) = think.feed_split(raw);
5233 if let Some(sp) = spinner.as_mut() {
5236 if !reasoning.is_empty() {
5237 sp.reasoning(&reasoning);
5238 }
5239 if let Some(t) = json["message"]["thinking"].as_str() {
5240 if !t.is_empty() {
5241 sp.reasoning(t);
5242 }
5243 }
5244 }
5245 let token = token.as_str();
5246 if !token.is_empty() {
5247 if !started {
5248 if let Some(sp) = spinner.as_mut() {
5250 sp.finish();
5251 }
5252 if color {
5253 execute!(
5254 io::stdout(),
5255 SetForegroundColor(NEWT_ORANGE_CT),
5256 Print("▸ "),
5257 ResetColor,
5258 )
5259 .ok();
5260 } else {
5261 print!("▸ ");
5262 }
5263 started = true;
5264 }
5265 if let Some(w) = md.as_mut() {
5266 w.push(token).ok();
5267 } else {
5268 print!("{token}");
5269 io::stdout().flush().ok();
5270 }
5271 full.push_str(token);
5272 }
5273 if json["done"].as_bool().unwrap_or(false) {
5274 let input = json["prompt_eval_count"].as_u64().map(|n| n as u32);
5276 let output = json["eval_count"].as_u64().map(|n| n as u32);
5277 usage = input.zip(output).map(|(i, o)| crate::TokenUsage {
5278 input_tokens: i,
5279 output_tokens: o,
5280 });
5281 break;
5282 }
5283 }
5284 }
5285 let tail = think.finish();
5288 if !tail.is_empty() {
5289 if !started {
5290 if let Some(sp) = spinner.as_mut() {
5291 sp.finish();
5292 }
5293 print!("▸ ");
5294 started = true;
5295 }
5296 if let Some(w) = md.as_mut() {
5297 w.push(&tail).ok();
5298 } else {
5299 print!("{tail}");
5300 io::stdout().flush().ok();
5301 }
5302 full.push_str(&tail);
5303 }
5304 if let Some(sp) = spinner.as_mut() {
5307 sp.finish();
5308 }
5309 if let Some(w) = md.as_mut() {
5312 w.finish().ok();
5313 }
5314 if started && md.is_none() {
5315 println!();
5316 }
5317 Ok((full, usage))
5318}
5319
5320#[cfg(test)]
5321mod repeat_call_guard_tests {
5322 use super::*;
5323
5324 #[test]
5325 fn short_circuits_exact_repeat_and_escalates() {
5326 let mut g = RepeatCallGuard::default();
5327 let args = serde_json::json!({"command": "mkdir x"});
5328 assert!(g.repeat_steer("run_command", &args).is_none());
5330 g.record("run_command", &args, false, "error: shell unavailable");
5332 let s = g.repeat_steer("run_command", &args).expect("repeat steers");
5333 assert!(s.contains("already called"), "{s}");
5334 assert!(s.contains("error: shell unavailable"), "{s}");
5335 assert!(
5336 !s.contains("stop using"),
5337 "one failure → no escalation yet: {s}"
5338 );
5339 g.record(
5341 "run_command",
5342 &serde_json::json!({"command": "ls"}),
5343 false,
5344 "error: denied",
5345 );
5346 let s2 = g.repeat_steer("run_command", &args).expect("still steers");
5347 assert!(s2.contains("stop using"), "escalates: {s2}");
5348 }
5349
5350 #[test]
5351 fn ignores_successes_and_distinct_calls() {
5352 let mut g = RepeatCallGuard::default();
5353 let a = serde_json::json!({"path": "f.rs"});
5354 g.record("read_file", &a, true, "file contents"); assert!(g.repeat_steer("read_file", &a).is_none());
5356 let b = serde_json::json!({"path": "g.rs"});
5358 g.record("read_file", &b, false, "error reading g.rs");
5359 assert!(
5360 g.repeat_steer("read_file", &a).is_none(),
5361 "distinct args still run"
5362 );
5363 assert!(g.repeat_steer("read_file", &b).is_some());
5364 }
5365
5366 #[test]
5367 fn steers_no_result_repeats_on_second_issuance() {
5368 let mut g = RepeatCallGuard::default();
5372
5373 let q = serde_json::json!({"query": "newt-tui PyO3 bindings"});
5376 assert!(
5377 g.repeat_steer("recall", &q).is_none(),
5378 "first recall must run"
5379 );
5380 g.record(
5381 "recall",
5382 &q,
5383 true,
5384 "no matches in past conversations for \"newt-tui PyO3 bindings\" — try different keywords.",
5385 );
5386 let s = g
5387 .repeat_steer("recall", &q)
5388 .expect("2nd identical recall steers");
5389 assert!(s.contains("no matches"), "{s}");
5390 assert!(
5391 s.contains("resume_context"),
5392 "recall steer points at resume_context: {s}"
5393 );
5394 assert!(
5395 !s.contains("stop using"),
5396 "a no-result is not a hard failure — no escalation: {s}"
5397 );
5398
5399 let k = serde_json::json!({"key": "current_task"});
5401 assert!(g.repeat_steer("state_get", &k).is_none());
5402 g.record("state_get", &k, true, "no such key: current_task");
5403 assert!(
5404 g.repeat_steer("state_get", &k).is_some(),
5405 "2nd identical state_get steers"
5406 );
5407
5408 let empty_plan_args = serde_json::json!({});
5411 assert!(g.repeat_steer("plan_get", &empty_plan_args).is_none());
5412 g.record(
5413 "plan_get",
5414 &empty_plan_args,
5415 true,
5416 "no active plan — if this is multi-step work, call update_plan next",
5417 );
5418 let plan_steer = g
5419 .repeat_steer("plan_get", &empty_plan_args)
5420 .expect("2nd identical empty plan_get steers");
5421 assert!(plan_steer.contains("update_plan"), "{plan_steer}");
5422
5423 let f = serde_json::json!({"path": "f.rs"});
5425 g.record("read_file", &f, true, "file contents");
5426 assert!(g.repeat_steer("read_file", &f).is_none());
5427
5428 let q2 = serde_json::json!({"query": "something else entirely"});
5430 assert!(
5431 g.repeat_steer("recall", &q2).is_none(),
5432 "distinct recall args still run"
5433 );
5434 }
5435
5436 #[test]
5437 fn steers_duplicate_successful_web_fetch() {
5438 let mut g = RepeatCallGuard::default();
5439 let issue = serde_json::json!({
5440 "url": "https://github.com/Gilamonster-Foundation/newt-agent/issues/771"
5441 });
5442
5443 assert!(
5444 g.repeat_steer("web_fetch", &issue).is_none(),
5445 "first fetch must run"
5446 );
5447 g.record("web_fetch", &issue, true, "# Issue\n\nbody");
5448 let steer = g
5449 .repeat_steer("web_fetch", &issue)
5450 .expect("2nd identical successful fetch steers");
5451 assert!(steer.contains("already observed"), "{steer}");
5452 assert!(steer.contains("`web_fetch`"), "{steer}");
5453 assert!(
5454 steer.contains("https://github.com/Gilamonster-Foundation/newt-agent/issues/771"),
5455 "{steer}"
5456 );
5457 assert!(
5458 g.repeat_steer(
5459 "web_fetch",
5460 &serde_json::json!({"url": "https://github.com/hartsock/scrybe"})
5461 )
5462 .is_none(),
5463 "distinct URLs still run"
5464 );
5465
5466 let file = serde_json::json!({"path": "src/lib.rs"});
5467 g.record("read_file", &file, true, "file contents");
5468 assert!(
5469 g.repeat_steer("read_file", &file).is_none(),
5470 "ordinary successful reads are still not steered"
5471 );
5472 }
5473
5474 #[test]
5475 fn steers_duplicate_successful_read_only_run_command() {
5476 let mut g = RepeatCallGuard::default();
5477 let args = serde_json::json!({
5478 "command": "grep -n 'help_lines' /Users/shawnhartsock/workspaces/newt-agent/newt-tui/src/lib.rs"
5479 });
5480
5481 assert!(
5482 g.repeat_steer("run_command", &args).is_none(),
5483 "first grep should run"
5484 );
5485 g.record(
5486 "run_command",
5487 &args,
5488 true,
5489 "9439:fn help_lines() -> &'static [&'static str] {",
5490 );
5491
5492 let steer = g
5493 .repeat_steer("run_command", &args)
5494 .expect("second identical grep should steer");
5495 assert!(steer.contains("already observed"), "{steer}");
5496 assert!(steer.contains("read-only shell probe"), "{steer}");
5497 assert!(steer.contains("`run_command`"), "{steer}");
5498 assert!(steer.contains("grep -n"), "{steer}");
5499 assert!(steer.contains("Do NOT repeat"), "{steer}");
5500 }
5501
5502 #[test]
5503 fn does_not_steer_successful_write_capable_run_command() {
5504 let mut g = RepeatCallGuard::default();
5505 let args = serde_json::json!({"command": "cargo test -p newt-tui"});
5506
5507 g.record("run_command", &args, true, "test result: ok");
5508
5509 assert!(
5510 g.repeat_steer("run_command", &args).is_none(),
5511 "successful build/test commands are still repeatable"
5512 );
5513 }
5514
5515 #[test]
5516 fn classifier_leaves_ordinary_successes_repeatable() {
5517 let file = serde_json::json!({"path": "src/lib.rs"});
5518 assert_eq!(
5519 RepeatCallGuard::classify_repeat_memo("read_file", &file, true, "file contents"),
5520 None
5521 );
5522
5523 let tests = serde_json::json!({"command": "cargo test -p newt-core"});
5524 assert_eq!(
5525 RepeatCallGuard::classify_repeat_memo("run_command", &tests, true, "test result: ok"),
5526 None
5527 );
5528
5529 let mut g = RepeatCallGuard::default();
5530 g.record("read_file", &file, true, "file contents");
5531 g.record("run_command", &tests, true, "test result: ok");
5532 assert!(
5533 g.repeat_memos.is_empty(),
5534 "ordinary successful calls must stay repeatable"
5535 );
5536 }
5537
5538 #[test]
5539 fn workflow_error_fingerprint_captures_cargo_location() {
5540 let output = r#"
5541error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5542 --> newt-tui/src/help_sections.rs:523:22
5543 |
5544523 | lines: SECTION_PROMPT_TOKENS,
5545 | ^^^^^^^^^^^^^^^^^^^^^ help: a static with a similar name exists: `SECTION_PROMPT`
5546"#;
5547
5548 let fp = build_error_fingerprint(output).expect("cargo error should fingerprint");
5549
5550 assert!(fp.contains("newt-tui/src/help_sections.rs:523:22"), "{fp}");
5551 assert!(fp.contains("error[E0425]"), "{fp}");
5552 assert!(fp.contains("SECTION_PROMPT_TOKENS"), "{fp}");
5553 }
5554
5555 #[test]
5556 fn workflow_runtime_nudges_after_error_without_writes() {
5557 let output = r#"
5558error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5559 --> newt-tui/src/help_sections.rs:523:22
5560"#;
5561 let mut state = WorkflowRuntimeState::default();
5562
5563 state.record_tool_result(output);
5564 state.record_round_outcome(false, false);
5565
5566 let nudge = state
5567 .round_start_nudge(None)
5568 .expect("read-only round after evidence should lock the active repair");
5569 assert!(nudge.contains("<workflow_state>"), "{nudge}");
5570 assert!(
5571 nudge.contains("newt-tui/src/help_sections.rs:523:22"),
5572 "{nudge}"
5573 );
5574 assert!(nudge.contains("next_allowed_actions"), "{nudge}");
5575 assert!(nudge.contains("disallowed_actions"), "{nudge}");
5576
5577 let classification = crate::NudgeClassification {
5578 class: crate::NudgeClass::PlanUpdate,
5579 score: 1.0,
5580 };
5581 let rediscovery = state
5582 .rediscovery_nudge(
5583 Some(&classification),
5584 "Summary of Findings\nRoot Cause: the build failure is still present.",
5585 None,
5586 )
5587 .expect("classified summary should be steered toward action");
5588 assert!(
5589 rediscovery.contains("Do not restate findings"),
5590 "{rediscovery}"
5591 );
5592 assert!(
5593 rediscovery.contains("newt-tui/src/help_sections.rs:523:22"),
5594 "{rediscovery}"
5595 );
5596 }
5597
5598 #[test]
5599 fn workflow_runtime_tracks_failed_edit_as_unresolved_evidence() {
5600 let output = "error: old_string not found in newt-tui/src/help_sections.rs";
5601 let mut state = WorkflowRuntimeState::default();
5602
5603 state.record_tool_result(output);
5604 state.record_round_outcome(false, false);
5605
5606 let nudge = state
5607 .round_start_nudge(None)
5608 .expect("failed edit should remain unresolved repair evidence");
5609 assert!(nudge.contains("old_string not found"), "{nudge}");
5610
5611 let grace = state
5612 .cap_grace_nudge(None, 25, 5)
5613 .expect("cap after failed edit/read-only recovery should grant an action round");
5614 assert!(
5615 grace.contains("configured_workflow_grace_rounds = 5"),
5616 "{grace}"
5617 );
5618 assert!(
5619 grace.contains("call edit_file or write_file now"),
5620 "{grace}"
5621 );
5622 assert!(
5623 state.cap_grace_nudge(None, 25, 0).is_none(),
5624 "configured zero grace disables soft cap extension"
5625 );
5626
5627 state.record_round_outcome(true, true);
5628 let verify = state
5629 .cap_grace_nudge(None, 25, 3)
5630 .expect("a successful edit at the cap should get a verification window");
5631 assert!(verify.contains("focused verification"), "{verify}");
5632 assert!(
5633 verify.contains("configured_workflow_grace_rounds = 3"),
5634 "{verify}"
5635 );
5636 }
5637
5638 #[test]
5639 fn workflow_runtime_grants_configured_grace_for_recent_plan_progress() {
5640 let ledger = SessionStepLedger::default();
5641 ledger.set_plan(&["finish round-cap grace".to_string(), "verify".to_string()]);
5642 let mut state = WorkflowRuntimeState::default();
5643
5644 state.record_round_outcome(false, true);
5645
5646 let nudge = state
5647 .cap_grace_nudge(Some(&ledger), 2, 4)
5648 .expect("recent active-plan progress should activate configured grace");
5649 assert!(
5650 nudge.contains("configured_workflow_grace_rounds = 4"),
5651 "{nudge}"
5652 );
5653 assert!(nudge.contains("finish round-cap grace"), "{nudge}");
5654 assert!(
5655 state.cap_grace_nudge(Some(&ledger), 2, 0).is_none(),
5656 "zero configured grace keeps the cap hard"
5657 );
5658 }
5659
5660 #[test]
5669 fn progress_horizon_override_widens_the_recent_progress_window() {
5670 let ledger = SessionStepLedger::default();
5671 ledger.set_plan(&["diagnose the failure".to_string(), "fix it".to_string()]);
5672
5673 let mut default_horizon = WorkflowRuntimeState::default();
5674 default_horizon.record_round_outcome(false, true); for _ in 0..4 {
5676 default_horizon.record_round_outcome(false, false); }
5678 assert!(
5679 default_horizon
5680 .cap_grace_nudge(Some(&ledger), 2, 4)
5681 .is_none(),
5682 "4 rounds since the last checkpoint exceeds the default 3-round horizon"
5683 );
5684
5685 let mut widened = WorkflowRuntimeState::default();
5686 widened.set_progress_horizon(Some(6));
5687 widened.record_round_outcome(false, true);
5688 for _ in 0..4 {
5689 widened.record_round_outcome(false, false);
5690 }
5691 assert!(
5692 widened.cap_grace_nudge(Some(&ledger), 2, 4).is_some(),
5693 "a widened 6-round horizon still treats 4-rounds-stale as recent progress"
5694 );
5695 }
5696
5697 #[test]
5698 fn workspace_write_classifier_is_narrow() {
5699 assert!(is_workspace_write_call("edit_file"));
5700 assert!(is_workspace_write_call("write_file"));
5701 assert!(!is_workspace_write_call("run_command"));
5702 assert!(!is_workspace_write_call("read_file"));
5703 }
5704
5705 #[test]
5706 fn no_result_reason_classifies_and_routes() {
5707 assert!(RepeatCallGuard::no_result_reason(
5709 "recall",
5710 "no matches in past conversations for \"x\" — try different keywords."
5711 )
5712 .is_some_and(|r| r.contains("no matches") && r.contains("resume_context")));
5713 assert!(
5714 RepeatCallGuard::no_result_reason("state_get", "no such key: current_task")
5715 .is_some_and(|r| r.contains("not set"))
5716 );
5717 assert!(
5718 RepeatCallGuard::no_result_reason("plan_get", "no active plan — call update_plan")
5719 .is_some_and(|r| r.contains("update_plan"))
5720 );
5721 assert!(
5723 RepeatCallGuard::no_result_reason("recall", "3 match(es) in past conversations")
5724 .is_none()
5725 );
5726 assert!(RepeatCallGuard::no_result_reason("read_file", "file contents").is_none());
5727
5728 let mut g = RepeatCallGuard::default();
5731 let q = serde_json::json!({"query": "x"});
5732 g.record("recall", &q, false, "error: index unavailable");
5733 assert!(matches!(
5734 g.repeat_memos.get(&RepeatCallGuard::key("recall", &q)),
5735 Some(RepeatMemo::Failure { first_line }) if first_line == "error: index unavailable"
5736 ));
5737 }
5738
5739 #[test]
5740 fn first_line_caps_and_takes_first() {
5741 assert_eq!(first_line("one\ntwo\nthree"), "one");
5742 assert_eq!(first_line(""), "");
5743 assert_eq!(first_line(&"x".repeat(500)).chars().count(), 200);
5744 }
5745}
5746
5747#[cfg(test)]
5748mod cap_exit_unit_tests {
5749 use super::*;
5750
5751 #[test]
5752 fn cap_exit_nudge_names_the_limit_and_folds_in_progress() {
5753 let nudge = cap_exit_nudge(5, None, &[]);
5754 assert!(nudge.contains("5 rounds"), "got: {nudge}");
5755 assert!(nudge.contains("Do NOT call any more tools"));
5756 assert!(
5759 nudge.contains("Cite only file paths that appear verbatim"),
5760 "got: {nudge}"
5761 );
5762 assert!(nudge.contains("say so plainly"), "got: {nudge}");
5763 assert!(
5764 !nudge.contains("progress so far"),
5765 "no block when None: {nudge}"
5766 );
5767 assert!(
5768 !nudge.contains("actually observed"),
5769 "no manifest block when the ledger is empty: {nudge}"
5770 );
5771 let with = cap_exit_nudge(5, Some("<plan>1. [x] foo</plan>"), &[]);
5773 assert!(with.contains("Your progress so far"), "got: {with}");
5774 assert!(with.contains("<plan>1. [x] foo</plan>"), "got: {with}");
5775 }
5776
5777 #[test]
5780 fn cap_exit_nudge_folds_in_the_observed_paths_manifest() {
5781 let observed = vec![
5782 "newt-tui/src/lib.rs".to_string(),
5783 "newt-core/src/agentic/mod.rs".to_string(),
5784 ];
5785 let nudge = cap_exit_nudge(5, Some("<state>k=v</state>"), &observed);
5786 assert!(
5787 nudge.contains("File paths actually observed in tool results"),
5788 "got: {nudge}"
5789 );
5790 assert!(nudge.contains("- newt-tui/src/lib.rs"), "got: {nudge}");
5791 assert!(
5792 nudge.contains("- newt-core/src/agentic/mod.rs"),
5793 "got: {nudge}"
5794 );
5795 let manifest_at = nudge.find("actually observed").unwrap();
5797 let progress_at = nudge.find("Your progress so far").unwrap();
5798 assert!(manifest_at < progress_at, "got: {nudge}");
5799 }
5800
5801 #[test]
5802 fn cap_exit_progress_renders_plan_and_state_or_none() {
5803 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
5804 use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
5805 let ledger = SessionStepLedger::default();
5806 let pad = SessionScratchpadStore::default();
5807 assert!(cap_exit_progress(Some(&ledger), Some(&pad)).is_none());
5809 assert!(cap_exit_progress(None, None).is_none());
5810 ledger.set_plan(&["build it".to_string(), "test it".to_string()]);
5812 pad.set("cwd", "/work".to_string());
5813 let p = cap_exit_progress(
5814 Some(&ledger as &dyn StepLedger),
5815 Some(&pad as &dyn ScratchpadStore),
5816 )
5817 .expect("non-empty progress");
5818 assert!(p.contains("build it"), "{p}");
5819 assert!(p.contains("cwd"), "{p}");
5820 }
5821
5822 #[test]
5823 fn spinner_line_formats_and_frame_wraps() {
5824 assert_eq!(
5827 format_spinner(0, 1.23, "thinking…", 340),
5828 "⠋ thinking… 1.2s · 340 chars"
5829 );
5830 assert_eq!(
5832 format_spinner(1, 0.5, "compressing context…", 0),
5833 "⠙ compressing context… 0.5s"
5834 );
5835 assert!(
5837 format_spinner(SPINNER_FRAMES.len(), 0.0, "thinking…", 0).contains(SPINNER_FRAMES[0])
5838 );
5839 }
5840
5841 #[test]
5842 fn cap_exit_fallback_usage_advice_and_salvage() {
5843 let with = cap_exit_fallback(
5845 4,
5846 Some(crate::TokenUsage {
5847 input_tokens: 12,
5848 output_tokens: 34,
5849 }),
5850 0,
5851 None,
5852 );
5853 assert!(with.contains("12 in / 34 out tokens"), "got: {with}");
5854 assert!(with.contains("max_tool_rounds"), "got: {with}");
5855
5856 let without = cap_exit_fallback(4, None, 0, None);
5857 assert!(!without.contains("tokens consumed"), "got: {without}");
5858 assert!(without.contains("tool-call limit of 4"), "got: {without}");
5859
5860 let thrash = cap_exit_fallback(4, None, 6, None);
5863 assert!(thrash.contains("tool calls that failed"), "got: {thrash}");
5864 assert!(
5865 !thrash.contains("raise [tui].max_tool_rounds"),
5866 "thrash advice must not blame the cap: {thrash}"
5867 );
5868
5869 let salvaged = cap_exit_fallback(4, None, 0, Some("<state>cwd=/x</state>"));
5871 assert!(salvaged.contains("Progress captured"), "got: {salvaged}");
5872 assert!(
5873 salvaged.contains("<state>cwd=/x</state>"),
5874 "got: {salvaged}"
5875 );
5876 }
5877
5878 #[test]
5879 fn cap_exit_summary_action_handoff_is_rejected() {
5880 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.";
5881 assert!(cap_exit_summary_is_action_handoff(handoff));
5882 assert!(!cap_exit_summary_is_action_handoff(
5883 "The duplicate helper definitions and stray brace were removed, and the build check passed."
5884 ));
5885
5886 let fallback = cap_exit_action_handoff_fallback(
5887 25,
5888 None,
5889 2,
5890 Some("<plan>1. [ ] fix duplicate helper definitions</plan>"),
5891 );
5892 assert!(fallback.contains("tool-call limit of 25"), "{fallback}");
5893 assert!(
5894 fallback.contains("described future tool actions"),
5895 "{fallback}"
5896 );
5897 assert!(
5898 fallback.contains("preserved the verified progress"),
5899 "{fallback}"
5900 );
5901 assert!(
5902 !fallback.contains("final summarization request also failed"),
5903 "{fallback}"
5904 );
5905 assert!(
5906 fallback.contains("Progress captured at the tool-call limit"),
5907 "{fallback}"
5908 );
5909 }
5910
5911 #[test]
5912 fn read_only_tools_classified_correctly() {
5913 for name in &[
5916 "list_dir",
5917 "read_file",
5918 "find",
5919 "search",
5920 "web_fetch",
5921 "use_skill",
5922 "save_note",
5923 ] {
5924 assert!(is_read_only_tool(name), "{name} should be read-only");
5925 }
5926 }
5927
5928 #[test]
5929 fn write_tools_not_read_only() {
5930 for name in &["edit_file", "write_file", "run_command"] {
5931 assert!(!is_read_only_tool(name), "{name} should NOT be read-only");
5932 }
5933 }
5934
5935 #[test]
5936 fn read_only_call_classifies_simple_shell_probes() {
5937 assert!(is_read_only_call(
5938 "run_command",
5939 &serde_json::json!({"command": "grep -n 'help_lines' newt-tui/src/lib.rs"})
5940 ));
5941 assert!(is_read_only_call(
5942 "run_command",
5943 &serde_json::json!({"command": "rg -n format_help newt-tui/src"})
5944 ));
5945 assert!(is_read_only_call(
5946 "run_command",
5947 &serde_json::json!({"command": "sed -n '1,20p' newt-tui/src/lib.rs"})
5948 ));
5949
5950 assert!(!is_read_only_call(
5951 "run_command",
5952 &serde_json::json!({"command": "cargo test -p newt-tui"})
5953 ));
5954 assert!(!is_read_only_call(
5955 "run_command",
5956 &serde_json::json!({"command": "sed -i 's/a/b/' file.txt"})
5957 ));
5958 assert!(!is_read_only_call(
5959 "run_command",
5960 &serde_json::json!({"command": "grep x file > out.txt"})
5961 ));
5962 }
5963
5964 #[test]
5965 fn read_only_action_nudge_names_edit_permission_and_blocker_paths() {
5966 let nudge = read_only_action_nudge(3, 4, None, None);
5967 assert!(nudge.contains("read-only rounds so far"), "{nudge}");
5968 assert!(nudge.contains("edit_file"), "{nudge}");
5969 assert!(nudge.contains("write_file"), "{nudge}");
5970 assert!(nudge.contains("request_permissions"), "{nudge}");
5971 assert!(nudge.contains("exact blocker"), "{nudge}");
5972 }
5973
5974 #[test]
5975 fn read_only_action_nudge_mentions_active_plan_when_present() {
5976 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
5977
5978 let ledger = SessionStepLedger::default();
5979 ledger.restore(&PlanSnapshot {
5980 steps: vec![
5981 Step {
5982 description: "inspect".to_string(),
5983 status: StepStatus::Done,
5984 },
5985 Step {
5986 description: "edit".to_string(),
5987 status: StepStatus::Active,
5988 },
5989 ],
5990 });
5991 let nudge = read_only_action_nudge(3, 2, Some(&ledger as &dyn StepLedger), None);
5992 assert!(nudge.contains("active multi-step plan"), "{nudge}");
5993 assert!(nudge.contains("ACTIVE step"), "{nudge}");
5994 }
5995
5996 #[test]
6002 fn read_only_action_nudge_includes_a_delegate_hint_when_offered() {
6003 let nudge = read_only_action_nudge(3, 4, None, Some("consider calling crew or team"));
6004 assert!(nudge.contains("consider calling crew or team"), "{nudge}");
6005 assert!(nudge.contains("edit_file"), "{nudge}");
6008 }
6009
6010 #[test]
6011 fn read_only_action_nudge_omits_delegate_clause_when_none_offered() {
6012 let nudge = read_only_action_nudge(3, 4, None, None);
6013 assert!(!nudge.contains("crew"), "{nudge}");
6014 assert!(!nudge.contains("team"), "{nudge}");
6015 }
6016
6017 #[test]
6018 fn pending_plan_completion_nudge_is_state_driven() {
6019 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6020
6021 assert!(pending_plan_completion_nudge(None, false, None).is_none());
6022
6023 let ledger = SessionStepLedger::default();
6024 ledger.restore(&PlanSnapshot {
6025 steps: vec![
6026 Step {
6027 description: "already done".to_string(),
6028 status: StepStatus::Done,
6029 },
6030 Step {
6031 description: "keep working".to_string(),
6032 status: StepStatus::Active,
6033 },
6034 ],
6035 });
6036 let nudge = pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None)
6037 .expect("open plan produces a nudge");
6038 assert!(nudge.contains("1/2 unfinished step"), "{nudge}");
6039 assert!(nudge.contains("Active step: 'keep working'"), "{nudge}");
6040 assert!(nudge.contains("update_plan"), "{nudge}");
6041 assert!(nudge.contains("call the next tool"), "{nudge}");
6042 assert!(nudge.contains("concrete blocker"), "{nudge}");
6043
6044 let plan_update_nudge = pending_plan_completion_nudge(
6045 Some(&ledger as &dyn StepLedger),
6046 true,
6047 Some(
6048 "Configured workflow 'github_pr' is active. Workflow steps:\n- commit_step: Commit the verified step",
6049 ),
6050 )
6051 .expect("open plan produces a plan-update nudge");
6052 assert!(
6053 plan_update_nudge.contains("findings/next-steps summary"),
6054 "{plan_update_nudge}"
6055 );
6056 assert!(
6057 plan_update_nudge.contains("Call update_plan now"),
6058 "{plan_update_nudge}"
6059 );
6060 assert!(
6061 plan_update_nudge.contains("make the immediate blocker repair the active step"),
6062 "{plan_update_nudge}"
6063 );
6064 assert!(
6065 plan_update_nudge.contains("Do not repeat the findings summary"),
6066 "{plan_update_nudge}"
6067 );
6068 assert!(
6069 plan_update_nudge.contains("github_pr"),
6070 "{plan_update_nudge}"
6071 );
6072 assert!(
6073 plan_update_nudge.contains("commit_step"),
6074 "{plan_update_nudge}"
6075 );
6076
6077 ledger.restore(&PlanSnapshot {
6078 steps: vec![Step {
6079 description: "complete".to_string(),
6080 status: StepStatus::Done,
6081 }],
6082 });
6083 assert!(
6084 pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None).is_none()
6085 );
6086 }
6087
6088 #[test]
6089 fn workflow_classifier_text_keeps_recent_user_issue_context() {
6090 let messages = vec![
6091 serde_json::json!({
6092 "role": "user",
6093 "content": "Take a look at https://github.com/Gilamonster-Foundation/newt-agent/issues/548 and get me a PR."
6094 }),
6095 serde_json::json!({
6096 "role": "assistant",
6097 "content": "I will inspect the issue and repo state."
6098 }),
6099 ];
6100 let text = workflow_classifier_text(
6101 &messages,
6102 "Summary of Findings\n\nCurrent Status: the build is broken. Next Steps Required: update the plan.",
6103 );
6104 let hint = crate::WorkflowSteerer::builtin()
6105 .plan_update_hint(&text)
6106 .expect("GitHub issue context should select the PR workflow");
6107 assert!(hint.contains("github_pr"), "{hint}");
6108 assert!(hint.contains("read_issue"), "{hint}");
6109 assert!(hint.contains("open_pr"), "{hint}");
6110 }
6111}
6112
6113#[cfg(test)]
6129mod tool_round_cap_tests {
6130 use super::*;
6131 use crate::caveats::Caveats;
6132 use crate::{BackendKind, MemMessage};
6133 use std::sync::atomic::{AtomicUsize, Ordering};
6134 use std::sync::Arc;
6135 use wiremock::matchers::{method, path};
6136 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
6137
6138 fn request_has_tools(req: &Request) -> bool {
6140 serde_json::from_slice::<serde_json::Value>(&req.body)
6141 .ok()
6142 .map(|v| v.get("tools").is_some())
6143 .unwrap_or(false)
6144 }
6145
6146 struct OllamaResponder {
6150 tool_rounds_served: Arc<AtomicUsize>,
6151 final_answer: String,
6152 }
6153
6154 impl Respond for OllamaResponder {
6155 fn respond(&self, req: &Request) -> ResponseTemplate {
6156 if request_has_tools(req) {
6157 self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6158 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6159 "message": {
6160 "content": "",
6161 "tool_calls": [{
6162 "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
6163 }]
6164 }
6165 }))
6166 } else {
6167 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6168 "message": { "content": self.final_answer }
6169 }))
6170 }
6171 }
6172 }
6173
6174 struct OpenAiResponder {
6176 tool_rounds_served: Arc<AtomicUsize>,
6177 final_answer: String,
6178 }
6179
6180 impl Respond for OpenAiResponder {
6181 fn respond(&self, req: &Request) -> ResponseTemplate {
6182 if request_has_tools(req) {
6183 self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6184 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6185 "choices": [{ "message": {
6186 "content": null,
6187 "tool_calls": [{
6188 "id": "call_1",
6189 "type": "function",
6190 "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
6191 }]
6192 }}]
6193 }))
6194 } else {
6195 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6196 "choices": [{ "message": { "content": self.final_answer } }]
6197 }))
6198 }
6199 }
6200 }
6201
6202 fn msgs() -> Vec<MemMessage> {
6203 vec![
6204 MemMessage::system("you are a test"),
6205 MemMessage::user("do the thing"),
6206 ]
6207 }
6208
6209 #[tokio::test]
6210 async fn ollama_loop_honors_configured_cap_and_returns_real_final_answer() {
6211 let server = MockServer::start().await;
6212 let served = Arc::new(AtomicUsize::new(0));
6213 Mock::given(method("POST"))
6214 .and(path("/api/chat"))
6215 .respond_with(OllamaResponder {
6216 tool_rounds_served: served.clone(),
6217 final_answer: "here is my partial summary".into(),
6218 })
6219 .mount(&server)
6220 .await;
6221
6222 let messages = msgs();
6223 let caveats = Caveats::top();
6224 let cap = 3;
6225 let (reply, streamed, _usage, _hallu) = chat_complete(
6226 ChatCtx {
6227 url: &server.uri(),
6228 model: "test-model",
6229 kind: BackendKind::Ollama,
6230 api_key: None,
6231 messages: &messages,
6232 task: "do the thing",
6233 workspace: ".",
6234 color: false,
6235 markdown: false,
6236 tool_offload: false,
6237 spill_store: None,
6238 compaction_store: None,
6239 scratchpad: false,
6240 scratchpad_store: None,
6241 code_search: None,
6242 experience_store: None,
6243 step_ledger: None,
6244 caveats: &caveats,
6245 max_tool_rounds: cap,
6246 workflow_grace_rounds: 0,
6247 tool_output_lines: 20,
6248 debug: false,
6249 trace: false,
6250 num_ctx: None,
6251 connect_timeout_secs: 5,
6252 inference_timeout_secs: 120,
6253 mid_loop_trim_threshold: 40,
6254 mid_loop_trim_tokens: None,
6255 max_ok_input: None,
6256 build_check_cmd: None,
6257 safe_context: None,
6258 recover_cw_400: None,
6259 note_sink: None,
6260 note_nudge: None,
6261 recall_source: None,
6262 memory_source: None,
6263 summarizer: None,
6264 compress_state: None,
6265 tool_events: None,
6266 phantom_reaches: None,
6267 permission_gate: None,
6268 on_round_usage: None,
6269 estimate_ratio: None,
6270 estimation: crate::tokens::TokenEstimation::default(),
6271 summary_input_cap_floor_chars: 8_192,
6272 exec_floor: None,
6273 write_ledger: None,
6274 cancel: None,
6275 git_tool: None,
6276 crew_runner: None,
6277 },
6278 &mut NoMcp,
6279 )
6280 .await
6281 .expect("chat_complete should succeed");
6282
6283 assert_eq!(served.load(Ordering::SeqCst), cap);
6285 assert_eq!(reply, "here is my partial summary");
6288 assert_ne!(reply, "(reached tool-call limit)");
6289 assert!(!streamed);
6290 }
6291
6292 #[tokio::test]
6293 async fn ollama_cap_exit_rejects_action_intent_summary() {
6294 let server = MockServer::start().await;
6295 Mock::given(method("POST"))
6296 .and(path("/api/chat"))
6297 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6298 "message": {
6299 "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."
6300 }
6301 })))
6302 .mount(&server)
6303 .await;
6304
6305 let client = reqwest::Client::new();
6306 let chat_url = format!("{}/api/chat", server.uri());
6307 let (reply, streamed, _usage) = final_summary_ollama(
6308 &client,
6309 &chat_url,
6310 "test-model",
6311 Vec::new(),
6312 CapExit {
6313 max_tool_rounds: 25,
6314 accumulated: None,
6315 wasted_calls: 0,
6316 progress: Some("<plan>1. [ ] fix duplicate helper definitions</plan>".to_string()),
6317 observed: Vec::new(),
6318 },
6319 )
6320 .await
6321 .expect("final summary helper should return a fallback");
6322
6323 assert!(!streamed);
6324 assert!(reply.contains("tool-call limit of 25"), "{reply}");
6325 assert!(reply.contains("described future tool actions"), "{reply}");
6326 assert!(reply.contains("preserved the verified progress"), "{reply}");
6327 assert!(
6328 !reply.contains("Let me fix both"),
6329 "must not accept action-intent cap summary: {reply}"
6330 );
6331 assert!(
6332 !reply.contains("final summarization request also failed"),
6333 "{reply}"
6334 );
6335 assert!(
6336 reply.contains("Progress captured at the tool-call limit"),
6337 "{reply}"
6338 );
6339 }
6340
6341 struct ThrashResponder {
6346 round: AtomicUsize,
6347 }
6348
6349 impl Respond for ThrashResponder {
6350 fn respond(&self, req: &Request) -> ResponseTemplate {
6351 if request_has_tools(req) {
6352 let n = self.round.fetch_add(1, Ordering::SeqCst);
6353 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6357 "message": {
6358 "content": "",
6359 "tool_calls": [{
6360 "function": { "name": format!("bogus_tool_{n}"), "arguments": {} }
6361 }]
6362 }
6363 }))
6364 } else {
6365 ResponseTemplate::new(500).set_body_string("model exploded")
6368 }
6369 }
6370 }
6371
6372 #[tokio::test]
6373 async fn uat_thrash_run_gets_honest_cap_exit_not_raise_the_limit() {
6374 let server = MockServer::start().await;
6375 Mock::given(method("POST"))
6376 .and(path("/api/chat"))
6377 .respond_with(ThrashResponder {
6378 round: AtomicUsize::new(0),
6379 })
6380 .mount(&server)
6381 .await;
6382
6383 let messages = msgs();
6384 let caveats = Caveats::top();
6385 let cap = 3;
6386 let (reply, _streamed, _usage, hallu) = chat_complete(
6387 ChatCtx {
6388 url: &server.uri(),
6389 model: "test-model",
6390 kind: BackendKind::Ollama,
6391 api_key: None,
6392 messages: &messages,
6393 task: "do the thing",
6394 workspace: ".",
6395 color: false,
6396 markdown: false,
6397 tool_offload: false,
6398 spill_store: None,
6399 compaction_store: None,
6400 scratchpad: false,
6401 scratchpad_store: None,
6402 code_search: None,
6403 experience_store: None,
6404 step_ledger: None,
6405 caveats: &caveats,
6406 max_tool_rounds: cap,
6407 workflow_grace_rounds: 0,
6408 tool_output_lines: 20,
6409 debug: false,
6410 trace: false,
6411 num_ctx: None,
6412 connect_timeout_secs: 5,
6413 inference_timeout_secs: 120,
6414 mid_loop_trim_threshold: 40,
6415 mid_loop_trim_tokens: None,
6416 max_ok_input: None,
6417 build_check_cmd: None,
6418 safe_context: None,
6419 recover_cw_400: None,
6420 note_sink: None,
6421 note_nudge: None,
6422 recall_source: None,
6423 memory_source: None,
6424 summarizer: None,
6425 compress_state: None,
6426 tool_events: None,
6427 phantom_reaches: None,
6428 permission_gate: None,
6429 on_round_usage: None,
6430 estimate_ratio: None,
6431 estimation: crate::tokens::TokenEstimation::default(),
6432 summary_input_cap_floor_chars: 8_192,
6433 exec_floor: None,
6434 write_ledger: None,
6435 cancel: None,
6436 git_tool: None,
6437 crew_runner: None,
6438 },
6439 &mut NoMcp,
6440 )
6441 .await
6442 .expect("chat_complete should succeed even when the summary fails");
6443
6444 assert_eq!(hallu, cap as u32, "each round hallucinated a tool");
6446 assert!(
6448 reply.contains("tool calls that failed"),
6449 "honest advice expected, got: {reply}"
6450 );
6451 assert!(
6452 !reply.contains("raise [tui].max_tool_rounds"),
6453 "must not blame the round cap on a thrash run: {reply}"
6454 );
6455 }
6456
6457 #[tokio::test]
6458 async fn a_set_cancel_flag_abandons_the_turn_before_any_network_call() {
6459 let messages = msgs();
6464 let caveats = Caveats::top();
6465 let flag = std::sync::atomic::AtomicBool::new(true);
6466 let (reply, streamed, usage, hallu) = chat_complete(
6467 ChatCtx {
6468 url: "http://127.0.0.1:1",
6469 model: "test-model",
6470 kind: BackendKind::Ollama,
6471 api_key: None,
6472 messages: &messages,
6473 task: "do the thing",
6474 workspace: ".",
6475 color: false,
6476 markdown: false,
6477 tool_offload: false,
6478 spill_store: None,
6479 compaction_store: None,
6480 scratchpad: false,
6481 scratchpad_store: None,
6482 code_search: None,
6483 experience_store: None,
6484 step_ledger: None,
6485 caveats: &caveats,
6486 max_tool_rounds: 5,
6487 workflow_grace_rounds: 0,
6488 tool_output_lines: 20,
6489 debug: false,
6490 trace: false,
6491 num_ctx: None,
6492 connect_timeout_secs: 5,
6493 inference_timeout_secs: 120,
6494 mid_loop_trim_threshold: 40,
6495 mid_loop_trim_tokens: None,
6496 max_ok_input: None,
6497 build_check_cmd: None,
6498 safe_context: None,
6499 recover_cw_400: None,
6500 note_sink: None,
6501 note_nudge: None,
6502 recall_source: None,
6503 memory_source: None,
6504 summarizer: None,
6505 compress_state: None,
6506 tool_events: None,
6507 phantom_reaches: None,
6508 permission_gate: None,
6509 on_round_usage: None,
6510 estimate_ratio: None,
6511 estimation: crate::tokens::TokenEstimation::default(),
6512 summary_input_cap_floor_chars: 8_192,
6513 exec_floor: None,
6514 write_ledger: None,
6515 cancel: Some(&flag),
6516 git_tool: None,
6517 crew_runner: None,
6518 },
6519 &mut NoMcp,
6520 )
6521 .await
6522 .expect("an interrupted turn still returns Ok, just empty");
6523 assert!(reply.is_empty(), "interrupted before any model output");
6524 assert!(!streamed);
6525 assert!(usage.is_none());
6526 assert_eq!(hallu, 0);
6527 }
6528
6529 #[test]
6530 fn responses_input_splits_system_to_instructions_and_passes_typed_items() {
6531 let msgs = vec![
6532 serde_json::json!({"role": "system", "content": "be terse"}),
6533 serde_json::json!({"role": "user", "content": "hi"}),
6534 serde_json::json!({"role": "assistant", "content": "hello"}),
6535 serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "ok"}),
6537 ];
6538 let (instructions, input) = build_responses_input(&msgs);
6539 assert_eq!(instructions.as_deref(), Some("be terse"));
6540 assert_eq!(input.len(), 3);
6541 assert_eq!(input[0]["role"], "user");
6542 assert_eq!(input[0]["content"], "hi");
6543 assert_eq!(input[2]["type"], "function_call_output");
6544 }
6545
6546 #[test]
6547 fn tools_flatten_to_responses_shape() {
6548 let chat = serde_json::json!([{
6549 "type": "function",
6550 "function": {
6551 "name": "git",
6552 "description": "run git",
6553 "parameters": {"type": "object"}
6554 }
6555 }]);
6556 let out = tools_to_responses(&chat);
6557 assert_eq!(out.len(), 1);
6558 assert_eq!(out[0]["type"], "function");
6559 assert_eq!(
6560 out[0]["name"], "git",
6561 "name hoisted out of the function wrapper"
6562 );
6563 assert_eq!(out[0]["description"], "run git");
6564 assert!(out[0]["function"].is_null(), "no nested function wrapper");
6565 }
6566
6567 #[test]
6568 fn parse_responses_output_extracts_text_calls_and_usage() {
6569 let json = serde_json::json!({
6570 "output": [
6571 {"type": "reasoning", "summary": "…"},
6572 {"type": "message", "role": "assistant",
6573 "content": [{"type": "output_text", "text": "the answer"}]},
6574 {"type": "function_call", "call_id": "call_1", "name": "git",
6575 "arguments": "{\"op\":\"status\"}"}
6576 ],
6577 "usage": {"input_tokens": 100, "output_tokens": 20}
6578 });
6579 let (text, calls) = parse_responses_output(&json);
6580 assert_eq!(text, "the answer");
6581 assert_eq!(calls.len(), 1);
6582 assert_eq!(calls[0]["call_id"], "call_1");
6583 let usage = responses_usage(&json["usage"]).unwrap();
6584 assert_eq!(usage.input_tokens, 100);
6585 assert_eq!(usage.output_tokens, 20);
6586 }
6587
6588 #[tokio::test]
6589 async fn responses_loop_returns_message_text_from_v1_responses() {
6590 let server = MockServer::start().await;
6591 Mock::given(method("POST"))
6592 .and(path("/v1/responses"))
6593 .respond_with(
6594 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6595 "output": [{
6596 "type": "message", "role": "assistant",
6597 "content": [{"type": "output_text", "text": "hello from responses"}]
6598 }],
6599 "usage": {"input_tokens": 12, "output_tokens": 4}
6600 })),
6601 )
6602 .mount(&server)
6603 .await;
6604
6605 let messages = msgs();
6606 let caveats = Caveats::top();
6607 let (reply, streamed, usage, _hallu) = openai_responses_complete(
6608 ChatCtx {
6609 url: &server.uri(),
6610 model: "gpt-5-codex",
6611 kind: BackendKind::Openai,
6612 api_key: Some("sk-test"),
6613 messages: &messages,
6614 task: "do the thing",
6615 workspace: ".",
6616 color: false,
6617 markdown: false,
6618 tool_offload: false,
6619 spill_store: None,
6620 compaction_store: None,
6621 scratchpad: false,
6622 scratchpad_store: None,
6623 code_search: None,
6624 experience_store: None,
6625 step_ledger: None,
6626 caveats: &caveats,
6627 max_tool_rounds: 5,
6628 workflow_grace_rounds: 0,
6629 tool_output_lines: 20,
6630 debug: false,
6631 trace: false,
6632 num_ctx: None,
6633 connect_timeout_secs: 5,
6634 inference_timeout_secs: 120,
6635 mid_loop_trim_threshold: 40,
6636 mid_loop_trim_tokens: None,
6637 max_ok_input: None,
6638 build_check_cmd: None,
6639 safe_context: None,
6640 recover_cw_400: None,
6641 note_sink: None,
6642 note_nudge: None,
6643 recall_source: None,
6644 memory_source: None,
6645 summarizer: None,
6646 compress_state: None,
6647 tool_events: None,
6648 phantom_reaches: None,
6649 permission_gate: None,
6650 on_round_usage: None,
6651 estimate_ratio: None,
6652 estimation: crate::tokens::TokenEstimation::default(),
6653 summary_input_cap_floor_chars: 8_192,
6654 exec_floor: None,
6655 write_ledger: None,
6656 cancel: None,
6657 git_tool: None,
6658 crew_runner: None,
6659 },
6660 &mut NoMcp,
6661 )
6662 .await
6663 .expect("responses loop returns the message text");
6664 assert_eq!(reply, "hello from responses");
6665 assert!(!streamed);
6666 assert_eq!(usage.map(|u| u.input_tokens), Some(12));
6667 }
6668
6669 #[tokio::test]
6670 async fn openai_loop_honors_configured_cap_and_returns_real_final_answer() {
6671 let server = MockServer::start().await;
6672 let served = Arc::new(AtomicUsize::new(0));
6673 Mock::given(method("POST"))
6674 .and(path("/v1/chat/completions"))
6675 .respond_with(OpenAiResponder {
6676 tool_rounds_served: served.clone(),
6677 final_answer: "openai partial answer".into(),
6678 })
6679 .mount(&server)
6680 .await;
6681
6682 let messages = msgs();
6683 let caveats = Caveats::top();
6684 let cap = 2;
6685 let (reply, streamed, _usage, _hallu) = openai_chat_complete(
6686 ChatCtx {
6687 url: &server.uri(),
6688 model: "test-model",
6689 kind: BackendKind::Openai,
6690 api_key: Some("sk-test"),
6691 messages: &messages,
6692 task: "do the thing",
6693 workspace: ".",
6694 color: false,
6695 markdown: false,
6696 tool_offload: false,
6697 spill_store: None,
6698 compaction_store: None,
6699 scratchpad: false,
6700 scratchpad_store: None,
6701 code_search: None,
6702 experience_store: None,
6703 step_ledger: None,
6704 caveats: &caveats,
6705 max_tool_rounds: cap,
6706 workflow_grace_rounds: 0,
6707 tool_output_lines: 20,
6708 debug: false,
6709 trace: false,
6710 num_ctx: None,
6711 connect_timeout_secs: 5,
6712 inference_timeout_secs: 120,
6713 mid_loop_trim_threshold: 40,
6714 mid_loop_trim_tokens: None,
6715 max_ok_input: None,
6716 build_check_cmd: None,
6717 safe_context: None,
6718 recover_cw_400: None,
6719 note_sink: None,
6720 note_nudge: None,
6721 recall_source: None,
6722 memory_source: None,
6723 summarizer: None,
6724 compress_state: None,
6725 tool_events: None,
6726 phantom_reaches: None,
6727 permission_gate: None,
6728 on_round_usage: None,
6729 estimate_ratio: None,
6730 estimation: crate::tokens::TokenEstimation::default(),
6731 summary_input_cap_floor_chars: 8_192,
6732 exec_floor: None,
6733 write_ledger: None,
6734 cancel: None,
6735 git_tool: None,
6736 crew_runner: None,
6737 },
6738 &mut NoMcp,
6739 )
6740 .await
6741 .expect("openai_chat_complete should succeed");
6742
6743 assert_eq!(served.load(Ordering::SeqCst), cap);
6744 assert_eq!(reply, "openai partial answer");
6745 assert_ne!(reply, "(reached tool-call limit)");
6746 assert!(!streamed);
6747 }
6748
6749 #[tokio::test]
6754 async fn ollama_loop_records_tool_events_with_digested_args() {
6755 let server = MockServer::start().await;
6756 struct TwoToolResponder;
6757 impl Respond for TwoToolResponder {
6758 fn respond(&self, req: &Request) -> ResponseTemplate {
6759 if request_has_tools(req) {
6760 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6761 "message": { "content": "", "tool_calls": [
6762 { "function": { "name": "list_dir",
6763 "arguments": {"path": "."} } },
6764 { "function": { "name": "definitely_not_a_real_tool",
6765 "arguments": {"token": "tippy-top-secret"} } }
6766 ]}
6767 }))
6768 } else {
6769 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6770 "message": { "content": "done" }
6771 }))
6772 }
6773 }
6774 }
6775 Mock::given(method("POST"))
6776 .and(path("/api/chat"))
6777 .respond_with(TwoToolResponder)
6778 .mount(&server)
6779 .await;
6780
6781 let ws = tempfile::TempDir::new().unwrap();
6782 let workspace = ws.path().to_string_lossy().into_owned();
6783 let messages = msgs();
6784 let caveats = Caveats::top();
6785 let mut events: Vec<crate::ToolEvent> = Vec::new();
6786 chat_complete(
6787 ChatCtx {
6788 url: &server.uri(),
6789 model: "test-model",
6790 kind: BackendKind::Ollama,
6791 api_key: None,
6792 messages: &messages,
6793 task: "do the thing",
6794 workspace: &workspace,
6795 color: false,
6796 markdown: false,
6797 tool_offload: false,
6798 spill_store: None,
6799 compaction_store: None,
6800 scratchpad: false,
6801 scratchpad_store: None,
6802 code_search: None,
6803 experience_store: None,
6804 step_ledger: None,
6805 caveats: &caveats,
6806 max_tool_rounds: 1,
6807 workflow_grace_rounds: 0,
6808 tool_output_lines: 20,
6809 debug: false,
6810 trace: false,
6811 num_ctx: None,
6812 connect_timeout_secs: 5,
6813 inference_timeout_secs: 120,
6814 mid_loop_trim_threshold: 40,
6815 mid_loop_trim_tokens: None,
6816 max_ok_input: None,
6817 build_check_cmd: None,
6818 safe_context: None,
6819 recover_cw_400: None,
6820 note_sink: None,
6821 note_nudge: None,
6822 recall_source: None,
6823 memory_source: None,
6824 summarizer: None,
6825 compress_state: None,
6826 tool_events: Some(&mut events),
6827 phantom_reaches: None,
6828 permission_gate: None,
6829 on_round_usage: None,
6830 estimate_ratio: None,
6831 estimation: crate::tokens::TokenEstimation::default(),
6832 summary_input_cap_floor_chars: 8_192,
6833 exec_floor: None,
6834 write_ledger: None,
6835 cancel: None,
6836 git_tool: None,
6837 crew_runner: None,
6838 },
6839 &mut NoMcp,
6840 )
6841 .await
6842 .expect("chat_complete should succeed");
6843
6844 assert_eq!(events.len(), 2, "one event per tool call: {events:?}");
6845 assert_eq!(events[0].tool, "list_dir");
6846 assert!(events[0].ok, "a real listing reads as success");
6847 assert!(events[0].args_digest.contains("path"));
6848 assert!(events[0].duration_ms.is_some());
6849 assert_eq!(events[1].tool, "definitely_not_a_real_tool");
6850 assert!(!events[1].ok, "an unknown tool reads as failure");
6851 assert!(events[1].args_digest.contains("token"));
6853 assert!(
6854 !events[1].args_digest.contains("tippy-top-secret"),
6855 "raw arg value leaked: {}",
6856 events[1].args_digest
6857 );
6858 }
6859
6860 #[tokio::test]
6864 async fn openai_loop_records_tool_events_with_digested_args() {
6865 let server = MockServer::start().await;
6866 struct OneToolResponder;
6867 impl Respond for OneToolResponder {
6868 fn respond(&self, req: &Request) -> ResponseTemplate {
6869 if request_has_tools(req) {
6870 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6871 "choices": [{ "message": {
6872 "content": null,
6873 "tool_calls": [{
6874 "id": "call_1",
6875 "type": "function",
6876 "function": { "name": "list_dir",
6877 "arguments": "{\"path\": \".\"}" }
6878 }]
6879 }}]
6880 }))
6881 } else {
6882 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6883 "choices": [{ "message": { "content": "done" } }]
6884 }))
6885 }
6886 }
6887 }
6888 Mock::given(method("POST"))
6889 .and(path("/v1/chat/completions"))
6890 .respond_with(OneToolResponder)
6891 .mount(&server)
6892 .await;
6893
6894 let ws = tempfile::TempDir::new().unwrap();
6895 let workspace = ws.path().to_string_lossy().into_owned();
6896 let messages = msgs();
6897 let caveats = Caveats::top();
6898 let mut events: Vec<crate::ToolEvent> = Vec::new();
6899 openai_chat_complete(
6900 ChatCtx {
6901 url: &server.uri(),
6902 model: "test-model",
6903 kind: BackendKind::Openai,
6904 api_key: Some("sk-test"),
6905 messages: &messages,
6906 task: "do the thing",
6907 workspace: &workspace,
6908 color: false,
6909 markdown: false,
6910 tool_offload: false,
6911 spill_store: None,
6912 compaction_store: None,
6913 scratchpad: false,
6914 scratchpad_store: None,
6915 code_search: None,
6916 experience_store: None,
6917 step_ledger: None,
6918 caveats: &caveats,
6919 max_tool_rounds: 1,
6920 workflow_grace_rounds: 0,
6921 tool_output_lines: 20,
6922 debug: false,
6923 trace: false,
6924 num_ctx: None,
6925 connect_timeout_secs: 5,
6926 inference_timeout_secs: 120,
6927 mid_loop_trim_threshold: 40,
6928 mid_loop_trim_tokens: None,
6929 max_ok_input: None,
6930 build_check_cmd: None,
6931 safe_context: None,
6932 recover_cw_400: None,
6933 note_sink: None,
6934 note_nudge: None,
6935 recall_source: None,
6936 memory_source: None,
6937 summarizer: None,
6938 compress_state: None,
6939 tool_events: Some(&mut events),
6940 phantom_reaches: None,
6941 permission_gate: None,
6942 on_round_usage: None,
6943 estimate_ratio: None,
6944 estimation: crate::tokens::TokenEstimation::default(),
6945 summary_input_cap_floor_chars: 8_192,
6946 exec_floor: None,
6947 write_ledger: None,
6948 cancel: None,
6949 git_tool: None,
6950 crew_runner: None,
6951 },
6952 &mut NoMcp,
6953 )
6954 .await
6955 .expect("openai_chat_complete should succeed");
6956
6957 assert_eq!(events.len(), 1, "one event per tool call: {events:?}");
6958 assert_eq!(events[0].tool, "list_dir");
6959 assert!(events[0].ok);
6960 assert_eq!(
6961 events[0].args_digest,
6962 crate::ToolEvent::from_call("x", &serde_json::json!({"path": "."}), true, None)
6963 .args_digest,
6964 "string-encoded args must digest like parsed args"
6965 );
6966 }
6967
6968 #[tokio::test]
6969 async fn cap_exit_fallback_when_final_summary_errors() {
6970 let server = MockServer::start().await;
6976 let served = Arc::new(AtomicUsize::new(0));
6977 struct ErrOnFinal {
6978 served: Arc<AtomicUsize>,
6979 }
6980 impl Respond for ErrOnFinal {
6981 fn respond(&self, req: &Request) -> ResponseTemplate {
6982 if request_has_tools(req) {
6983 self.served.fetch_add(1, Ordering::SeqCst);
6984 ResponseTemplate::new(200).set_body_json(serde_json::json!({
6985 "message": { "content": "", "tool_calls": [{
6986 "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
6987 }]}
6988 }))
6989 } else {
6990 ResponseTemplate::new(500).set_body_string("boom")
6991 }
6992 }
6993 }
6994 Mock::given(method("POST"))
6995 .and(path("/api/chat"))
6996 .respond_with(ErrOnFinal {
6997 served: served.clone(),
6998 })
6999 .mount(&server)
7000 .await;
7001
7002 let messages = msgs();
7003 let caveats = Caveats::top();
7004 let (reply, _streamed, _usage, _hallu) = chat_complete(
7005 ChatCtx {
7006 url: &server.uri(),
7007 model: "test-model",
7008 kind: BackendKind::Ollama,
7009 api_key: None,
7010 messages: &messages,
7011 task: "do the thing",
7012 workspace: ".",
7013 color: false,
7014 markdown: false,
7015 tool_offload: false,
7016 spill_store: None,
7017 compaction_store: None,
7018 scratchpad: false,
7019 scratchpad_store: None,
7020 code_search: None,
7021 experience_store: None,
7022 step_ledger: None,
7023 caveats: &caveats,
7024 max_tool_rounds: 2,
7025 workflow_grace_rounds: 0,
7026 tool_output_lines: 20,
7027 debug: false,
7028 trace: false,
7029 num_ctx: None,
7030 connect_timeout_secs: 5,
7031 inference_timeout_secs: 120,
7032 mid_loop_trim_threshold: 40,
7033 mid_loop_trim_tokens: None,
7034 max_ok_input: None,
7035 build_check_cmd: None,
7036 safe_context: None,
7037 recover_cw_400: None,
7038 note_sink: None,
7039 note_nudge: None,
7040 recall_source: None,
7041 memory_source: None,
7042 summarizer: None,
7043 compress_state: None,
7044 tool_events: None,
7045 phantom_reaches: None,
7046 permission_gate: None,
7047 on_round_usage: None,
7048 estimate_ratio: None,
7049 estimation: crate::tokens::TokenEstimation::default(),
7050 summary_input_cap_floor_chars: 8_192,
7051 exec_floor: None,
7052 write_ledger: None,
7053 cancel: None,
7054 git_tool: None,
7055 crew_runner: None,
7056 },
7057 &mut NoMcp,
7058 )
7059 .await
7060 .expect("chat_complete should succeed even when final summary errors");
7061
7062 assert!(reply.contains("tool-call limit"));
7065 assert!(reply.contains("max_tool_rounds"));
7066 }
7067
7068 #[tokio::test]
7071 async fn run_command_refuses_tool_name_as_shell_command() {
7072 let ws = tempfile::TempDir::new().unwrap();
7073 let caveats = Caveats::top();
7074 for tool in [
7075 "list_dir",
7076 "read_file",
7077 "write_file",
7078 "use_skill",
7079 "web_fetch",
7080 ] {
7081 let args = serde_json::json!({ "command": format!("{tool} some/path") });
7082 let out = execute_tool(
7083 "run_command",
7084 &args,
7085 &ws.path().to_string_lossy(),
7086 false,
7087 20,
7088 &caveats,
7089 &mut NoMcp,
7090 None,
7091 None,
7092 None,
7093 None, None,
7095 None,
7096 None, None, None, None, None, None, )
7103 .await;
7104 assert!(
7105 out.contains("is a tool, not a shell command"),
7106 "expected corrective message for '{tool}', got: {out}"
7107 );
7108 }
7109 }
7110
7111 #[tokio::test]
7114 async fn accumulated_usage_survives_summary_failure() {
7115 let server = MockServer::start().await;
7116 let served = Arc::new(AtomicUsize::new(0));
7117
7118 struct UsageRoundsErrFinal {
7119 served: Arc<AtomicUsize>,
7120 }
7121 impl Respond for UsageRoundsErrFinal {
7122 fn respond(&self, req: &Request) -> ResponseTemplate {
7123 if request_has_tools(req) {
7124 self.served.fetch_add(1, Ordering::SeqCst);
7125 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7126 "message": { "content": "", "tool_calls": [{
7127 "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
7128 }]},
7129 "prompt_eval_count": 100,
7131 "eval_count": 20,
7132 }))
7133 } else {
7134 ResponseTemplate::new(500).set_body_string("boom")
7135 }
7136 }
7137 }
7138
7139 Mock::given(method("POST"))
7140 .and(path("/api/chat"))
7141 .respond_with(UsageRoundsErrFinal {
7142 served: served.clone(),
7143 })
7144 .mount(&server)
7145 .await;
7146
7147 let messages = msgs();
7148 let caveats = Caveats::top();
7149 let cap = 2;
7150 let (reply, _streamed, usage, hallu) = chat_complete(
7151 ChatCtx {
7152 url: &server.uri(),
7153 model: "test-model",
7154 kind: BackendKind::Ollama,
7155 api_key: None,
7156 messages: &messages,
7157 task: "do the thing",
7158 workspace: ".",
7159 color: false,
7160 markdown: false,
7161 tool_offload: false,
7162 spill_store: None,
7163 compaction_store: None,
7164 scratchpad: false,
7165 scratchpad_store: None,
7166 code_search: None,
7167 experience_store: None,
7168 step_ledger: None,
7169 caveats: &caveats,
7170 max_tool_rounds: cap,
7171 workflow_grace_rounds: 0,
7172 tool_output_lines: 20,
7173 debug: false,
7174 trace: false,
7175 num_ctx: None,
7176 connect_timeout_secs: 5,
7177 inference_timeout_secs: 120,
7178 mid_loop_trim_threshold: 40,
7179 mid_loop_trim_tokens: None,
7180 max_ok_input: None,
7181 build_check_cmd: None,
7182 safe_context: None,
7183 recover_cw_400: None,
7184 note_sink: None,
7185 note_nudge: None,
7186 recall_source: None,
7187 memory_source: None,
7188 summarizer: None,
7189 compress_state: None,
7190 tool_events: None,
7191 phantom_reaches: None,
7192 permission_gate: None,
7193 on_round_usage: None,
7194 estimate_ratio: None,
7195 estimation: crate::tokens::TokenEstimation::default(),
7196 summary_input_cap_floor_chars: 8_192,
7197 exec_floor: None,
7198 write_ledger: None,
7199 cancel: None,
7200 git_tool: None,
7201 crew_runner: None,
7202 },
7203 &mut NoMcp,
7204 )
7205 .await
7206 .expect("chat_complete must succeed even when final summary errors");
7207
7208 assert!(reply.contains("tool-call limit"), "got: {reply}");
7210 assert!(
7211 reply.contains("in / ") && reply.contains("out tokens"),
7212 "fallback must include accumulated token counts, got: {reply}"
7213 );
7214
7215 let u = usage.expect("usage must be Some even when final summary fails");
7217 assert_eq!(
7221 u.input_tokens, 100,
7222 "largest single prompt across 2 rounds, not the sum"
7223 );
7224 assert_eq!(
7225 u.output_tokens, 40,
7226 "2 rounds × 20 output tokens each = 40 total"
7227 );
7228
7229 assert_eq!(
7231 hallu, cap as u32,
7232 "each round had one hallucinated tool call"
7233 );
7234 }
7235
7236 struct ReadOnlyNudgeResponder {
7246 nudge_seen: Arc<std::sync::atomic::AtomicBool>,
7248 }
7249
7250 impl Respond for ReadOnlyNudgeResponder {
7251 fn respond(&self, req: &Request) -> ResponseTemplate {
7252 let body = serde_json::from_slice::<serde_json::Value>(&req.body).unwrap_or_default();
7253 let has_nudge = body["messages"]
7254 .as_array()
7255 .map(|msgs| {
7256 msgs.iter().any(|m| {
7257 m["content"]
7258 .as_str()
7259 .map(|c| c.contains("read-only rounds so far"))
7260 .unwrap_or(false)
7261 })
7262 })
7263 .unwrap_or(false);
7264
7265 if has_nudge {
7266 self.nudge_seen
7267 .store(true, std::sync::atomic::Ordering::SeqCst);
7268 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7270 "message": { "content": "nudge received, writing file now" }
7271 }))
7272 } else if request_has_tools(req) {
7273 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7275 "message": {
7276 "content": "",
7277 "tool_calls": [{ "function": {
7278 "name": "list_dir",
7279 "arguments": { "path": "." }
7280 }}]
7281 }
7282 }))
7283 } else {
7284 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7285 "message": { "content": "final summary" }
7286 }))
7287 }
7288 }
7289 }
7290
7291 #[tokio::test]
7292 async fn read_only_nudge_injected_after_three_rounds() {
7293 let server = MockServer::start().await;
7294 let nudge_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
7295
7296 Mock::given(method("POST"))
7297 .and(path("/api/chat"))
7298 .respond_with(ReadOnlyNudgeResponder {
7299 nudge_seen: nudge_seen.clone(),
7300 })
7301 .mount(&server)
7302 .await;
7303
7304 let messages = msgs();
7305 let caveats = Caveats::top();
7306 let (reply, _streamed, _usage, _hallu) = chat_complete(
7307 ChatCtx {
7308 url: &server.uri(),
7309 model: "test-model",
7310 kind: BackendKind::Ollama,
7311 api_key: None,
7312 messages: &messages,
7313 task: "list all files",
7314 workspace: ".",
7315 color: false,
7316 markdown: false,
7317 tool_offload: false,
7318 spill_store: None,
7319 compaction_store: None,
7320 scratchpad: false,
7321 scratchpad_store: None,
7322 code_search: None,
7323 experience_store: None,
7324 step_ledger: None,
7325 caveats: &caveats,
7326 max_tool_rounds: 10,
7327 workflow_grace_rounds: 0,
7328 tool_output_lines: 5,
7329 debug: false,
7330 trace: false,
7331 num_ctx: None,
7332 connect_timeout_secs: 5,
7333 inference_timeout_secs: 30,
7334 mid_loop_trim_threshold: 40,
7335 mid_loop_trim_tokens: None,
7336 max_ok_input: None,
7337 build_check_cmd: None,
7338 safe_context: None,
7339 recover_cw_400: None,
7340 note_sink: None,
7341 note_nudge: None,
7342 recall_source: None,
7343 memory_source: None,
7344 summarizer: None,
7345 compress_state: None,
7346 tool_events: None,
7347 phantom_reaches: None,
7348 permission_gate: None,
7349 on_round_usage: None,
7350 estimate_ratio: None,
7351 estimation: crate::tokens::TokenEstimation::default(),
7352 summary_input_cap_floor_chars: 8_192,
7353 exec_floor: None,
7354 write_ledger: None,
7355 cancel: None,
7356 git_tool: None,
7357 crew_runner: None,
7358 },
7359 &mut NoMcp,
7360 )
7361 .await
7362 .expect("chat_complete should succeed");
7363
7364 assert!(
7365 nudge_seen.load(std::sync::atomic::Ordering::SeqCst),
7366 "nudge was never injected after 3 consecutive read-only rounds"
7367 );
7368 assert_eq!(
7369 reply, "nudge received, writing file now",
7370 "model should have responded to the nudge with a final answer"
7371 );
7372 }
7373}
7374
7375#[cfg(test)]
7381mod http_loop_tests {
7382 use super::*;
7383 use crate::caveats::Caveats;
7384 use crate::{BackendKind, MemMessage};
7385 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7386 use std::sync::Arc;
7387 use wiremock::matchers::{header, method, path};
7388 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
7389
7390 fn msgs() -> Vec<MemMessage> {
7391 vec![
7392 MemMessage::system("you are a test"),
7393 MemMessage::user("do the thing"),
7394 ]
7395 }
7396
7397 fn ctx<'a>(
7398 server_uri: &'a str,
7399 messages: &'a [MemMessage],
7400 caveats: &'a Caveats,
7401 ) -> ChatCtx<'a> {
7402 ChatCtx {
7403 url: server_uri,
7404 model: "test-model",
7405 kind: BackendKind::Ollama,
7406 api_key: None,
7407 messages,
7408 task: "do the thing",
7409 workspace: ".",
7410 color: false,
7411 markdown: false,
7412 tool_offload: false,
7413 spill_store: None,
7414 compaction_store: None,
7415 scratchpad: false,
7416 scratchpad_store: None,
7417 code_search: None,
7418 experience_store: None,
7419 step_ledger: None,
7420 caveats,
7421 max_tool_rounds: 8,
7422 workflow_grace_rounds: 0,
7423 tool_output_lines: 20,
7424 debug: false,
7425 trace: false,
7426 num_ctx: None,
7427 connect_timeout_secs: 5,
7428 inference_timeout_secs: 30,
7429 mid_loop_trim_threshold: 40,
7430 mid_loop_trim_tokens: None,
7431 max_ok_input: None,
7432 build_check_cmd: None,
7433 safe_context: None,
7434 recover_cw_400: None,
7435 note_sink: None,
7436 note_nudge: None,
7437 recall_source: None,
7438 memory_source: None,
7439 summarizer: None,
7440 compress_state: None,
7441 tool_events: None,
7442 phantom_reaches: None,
7443 permission_gate: None,
7444 on_round_usage: None,
7445 estimate_ratio: None,
7446 estimation: crate::tokens::TokenEstimation::default(),
7447 summary_input_cap_floor_chars: 8_192,
7448 exec_floor: None,
7449 write_ledger: None,
7450 cancel: None,
7451 git_tool: None,
7452 crew_runner: None,
7453 }
7454 }
7455
7456 fn body_json(req: &Request) -> serde_json::Value {
7457 serde_json::from_slice(&req.body).unwrap_or_default()
7458 }
7459
7460 fn is_stream(req: &Request) -> bool {
7461 body_json(req)["stream"].as_bool().unwrap_or(false)
7462 }
7463
7464 fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
7465 let body: String = lines
7466 .iter()
7467 .map(|l| format!("{l}\n"))
7468 .collect::<Vec<_>>()
7469 .join("");
7470 ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
7471 }
7472
7473 struct StreamHappyResponder;
7476 impl Respond for StreamHappyResponder {
7477 fn respond(&self, req: &Request) -> ResponseTemplate {
7478 if is_stream(req) {
7479 ndjson(&[
7480 serde_json::json!({"message": {"content": "Hello "}, "done": false}),
7481 serde_json::json!({
7482 "message": {"content": "world"}, "done": true,
7483 "prompt_eval_count": 7, "eval_count": 3
7484 }),
7485 ])
7486 } else {
7487 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7488 "message": {"content": "probe answer"},
7489 "prompt_eval_count": 5, "eval_count": 2,
7490 }))
7491 }
7492 }
7493 }
7494
7495 #[tokio::test]
7496 async fn ollama_streams_final_answer_and_merges_usage() {
7497 let server = MockServer::start().await;
7498 Mock::given(method("POST"))
7499 .and(path("/api/chat"))
7500 .respond_with(StreamHappyResponder)
7501 .mount(&server)
7502 .await;
7503
7504 let messages = msgs();
7505 let caveats = Caveats::top();
7506 let (reply, streamed, usage, hallu) =
7507 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7508 .await
7509 .expect("chat_complete should succeed");
7510
7511 assert_eq!(reply, "Hello world", "tokens accumulated across chunks");
7512 assert!(streamed, "the streaming path printed the tokens");
7513 let u = usage.expect("probe + stream usage merged");
7514 assert_eq!(u.input_tokens, 7, "max(5 probe, 7 stream), not the sum");
7518 assert_eq!(u.output_tokens, 5, "2 (probe) + 3 (stream)");
7519 assert_eq!(hallu, 0);
7520 }
7521
7522 #[test]
7523 fn detects_tools_unsupported_400_phrasings() {
7524 assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7525 "Ollama 400 Bad Request: registry.ollama.ai/library/deepseek-r1:70b does not support tools"
7526 )));
7527 assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7529 "this model does not support tools at this time"
7530 )));
7531 assert!(!is_tools_unsupported_error(&anyhow::anyhow!(
7533 "Ollama 400 Bad Request: context window exceeded"
7534 )));
7535 }
7536
7537 #[test]
7538 fn detects_ollama_tool_xml_parser_errors() {
7539 assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7540 "{}",
7541 r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 7: element \u003cparameter\u003e closed by \u003c/function\u003e"}"#
7542 )));
7543 assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7544 "{}",
7545 r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 2: element <parameter> closed by </function>"}"#
7546 )));
7547 assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7548 "{}",
7549 r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 3: unexpected end element \u003c/parameter\u003e"}"#
7550 )));
7551 assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7552 "Ollama 500 Internal Server Error: model runner crashed"
7553 )));
7554 assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7555 "OpenAI 400 Bad Request: XML syntax error in user supplied file"
7556 )));
7557 }
7558
7559 struct NoToolsResponder {
7563 rejections: Arc<AtomicUsize>,
7564 served_without_tools: Arc<AtomicBool>,
7565 }
7566 impl Respond for NoToolsResponder {
7567 fn respond(&self, req: &Request) -> ResponseTemplate {
7568 let has_tools = body_json(req).get("tools").is_some();
7569 if has_tools {
7570 self.rejections.fetch_add(1, Ordering::SeqCst);
7571 return ResponseTemplate::new(400).set_body_string(
7572 "registry.ollama.ai/library/deepseek-r1:70b does not support tools",
7573 );
7574 }
7575 self.served_without_tools.store(true, Ordering::SeqCst);
7576 if is_stream(req) {
7577 ndjson(&[serde_json::json!({
7578 "message": {"content": "hello there"}, "done": true,
7579 "prompt_eval_count": 4, "eval_count": 2
7580 })])
7581 } else {
7582 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7583 "message": {"content": "probe answer"},
7584 "prompt_eval_count": 4, "eval_count": 2,
7585 }))
7586 }
7587 }
7588 }
7589
7590 #[tokio::test]
7591 async fn no_tools_model_recovers_by_dropping_tools() {
7592 let server = MockServer::start().await;
7593 let rejections = Arc::new(AtomicUsize::new(0));
7594 let served_without_tools = Arc::new(AtomicBool::new(false));
7595 Mock::given(method("POST"))
7596 .and(path("/api/chat"))
7597 .respond_with(NoToolsResponder {
7598 rejections: rejections.clone(),
7599 served_without_tools: served_without_tools.clone(),
7600 })
7601 .mount(&server)
7602 .await;
7603
7604 let messages = msgs();
7605 let caveats = Caveats::top();
7606 let (reply, streamed, _usage, _) =
7607 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7608 .await
7609 .expect("a no-tools model still answers a bare prompt");
7610
7611 assert_eq!(reply, "hello there", "the tools-absent retry answered");
7612 assert!(streamed);
7613 assert!(
7614 served_without_tools.load(Ordering::SeqCst),
7615 "a request without the tools field was eventually served"
7616 );
7617 assert_eq!(
7618 rejections.load(Ordering::SeqCst),
7619 1,
7620 "exactly one tools-bearing request 400s — the drop is self-limiting"
7621 );
7622 }
7623
7624 struct MalformedToolXmlResponder {
7629 rejections: Arc<AtomicUsize>,
7630 served_with_tools_after_error: Arc<AtomicBool>,
7631 served_without_tools: Arc<AtomicBool>,
7632 }
7633 impl Respond for MalformedToolXmlResponder {
7634 fn respond(&self, req: &Request) -> ResponseTemplate {
7635 if body_json(req).get("tools").is_some() {
7636 if self.rejections.fetch_add(1, Ordering::SeqCst) == 0 {
7637 return ResponseTemplate::new(500).set_body_json(serde_json::json!({
7638 "error": "XML syntax error on line 7: element <parameter> closed by </function>"
7639 }));
7640 }
7641 self.served_with_tools_after_error
7642 .store(true, Ordering::SeqCst);
7643 if is_stream(req) {
7644 return ndjson(&[serde_json::json!({
7645 "message": {"content": "recovered with tools still available"},
7646 "done": true,
7647 "prompt_eval_count": 4,
7648 "eval_count": 3
7649 })]);
7650 }
7651 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
7652 "message": {"content": "probe answer with tools still available"},
7653 "prompt_eval_count": 4,
7654 "eval_count": 3,
7655 }));
7656 }
7657 self.served_without_tools.store(true, Ordering::SeqCst);
7658 if is_stream(req) {
7659 ndjson(&[serde_json::json!({
7660 "message": {"content": "unexpected no-tools stream"},
7661 "done": true,
7662 "prompt_eval_count": 4, "eval_count": 3
7663 })])
7664 } else {
7665 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7666 "message": {"content": "unexpected no-tools probe"},
7667 "prompt_eval_count": 4, "eval_count": 3,
7668 }))
7669 }
7670 }
7671 }
7672
7673 #[tokio::test]
7674 async fn ollama_tool_xml_error_recovers_with_tools_still_available() {
7675 let server = MockServer::start().await;
7676 let rejections = Arc::new(AtomicUsize::new(0));
7677 let served_with_tools_after_error = Arc::new(AtomicBool::new(false));
7678 let served_without_tools = Arc::new(AtomicBool::new(false));
7679 Mock::given(method("POST"))
7680 .and(path("/api/chat"))
7681 .respond_with(MalformedToolXmlResponder {
7682 rejections: rejections.clone(),
7683 served_with_tools_after_error: served_with_tools_after_error.clone(),
7684 served_without_tools: served_without_tools.clone(),
7685 })
7686 .mount(&server)
7687 .await;
7688
7689 let messages = msgs();
7690 let caveats = Caveats::top();
7691 let (reply, streamed, _usage, _) =
7692 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7693 .await
7694 .expect("malformed XML tool-call parser errors should retry with tools");
7695
7696 assert_eq!(reply, "recovered with tools still available");
7697 assert!(streamed);
7698 assert!(
7699 served_with_tools_after_error.load(Ordering::SeqCst),
7700 "a tools-bearing request was served after the XML parser failure"
7701 );
7702 assert!(
7703 !served_without_tools.load(Ordering::SeqCst),
7704 "malformed XML must not disable tools for the turn"
7705 );
7706 assert_eq!(
7707 rejections.load(Ordering::SeqCst),
7708 3,
7709 "the XML error probe, retry probe, and streaming re-issue all keep tools advertised"
7710 );
7711 }
7712
7713 struct EmptyStreamResponder;
7716 impl Respond for EmptyStreamResponder {
7717 fn respond(&self, req: &Request) -> ResponseTemplate {
7718 if is_stream(req) {
7719 ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
7720 } else {
7721 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7722 "message": {"content": "probe says hi"},
7723 "prompt_eval_count": 5, "eval_count": 2,
7724 }))
7725 }
7726 }
7727 }
7728
7729 #[tokio::test]
7730 async fn empty_stream_falls_back_to_probe_content() {
7731 let server = MockServer::start().await;
7732 Mock::given(method("POST"))
7733 .and(path("/api/chat"))
7734 .respond_with(EmptyStreamResponder)
7735 .mount(&server)
7736 .await;
7737
7738 let messages = msgs();
7739 let caveats = Caveats::top();
7740 let (reply, streamed, usage, _) =
7741 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7742 .await
7743 .expect("chat_complete should succeed");
7744
7745 assert_eq!(reply, "probe says hi");
7746 assert!(!streamed, "fallback content was never streamed");
7747 assert_eq!(usage.unwrap().input_tokens, 5);
7748 }
7749
7750 struct EmptyStreamPendingActionResponder {
7755 probes: Arc<AtomicUsize>,
7756 }
7757 impl Respond for EmptyStreamPendingActionResponder {
7758 fn respond(&self, req: &Request) -> ResponseTemplate {
7759 if is_stream(req) {
7760 return ndjson(&[serde_json::json!({
7761 "message": {"content": ""},
7762 "done": true,
7763 "prompt_eval_count": 6,
7764 "eval_count": 0
7765 })]);
7766 }
7767 let probe = self.probes.fetch_add(1, Ordering::SeqCst);
7768 let content = if probe == 0 {
7769 "Now I understand the issue. Let me verify by looking at format_rollup_detail."
7770 } else {
7771 "Verified after the automatic continue."
7772 };
7773 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7774 "message": {"content": content},
7775 "prompt_eval_count": 5 + probe as u32,
7776 "eval_count": 2,
7777 }))
7778 }
7779 }
7780
7781 #[tokio::test]
7782 async fn empty_stream_probe_fallback_pending_action_nudges_and_continues() {
7783 let server = MockServer::start().await;
7784 let probes = Arc::new(AtomicUsize::new(0));
7785 Mock::given(method("POST"))
7786 .and(path("/api/chat"))
7787 .respond_with(EmptyStreamPendingActionResponder {
7788 probes: probes.clone(),
7789 })
7790 .mount(&server)
7791 .await;
7792
7793 let messages = msgs();
7794 let caveats = Caveats::top();
7795 let (reply, streamed, _usage, _) =
7796 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7797 .await
7798 .expect("chat_complete should auto-continue after pending probe fallback");
7799
7800 assert_eq!(
7801 probes.load(Ordering::SeqCst),
7802 2,
7803 "the nudge ran a second probe"
7804 );
7805 assert_eq!(reply, "Verified after the automatic continue.");
7806 assert!(!streamed, "the second answer also came from probe fallback");
7807 assert!(
7808 !reply.contains("Let me verify"),
7809 "must not return the pending-action narration"
7810 );
7811 }
7812
7813 struct AllEmptyResponder;
7816 impl Respond for AllEmptyResponder {
7817 fn respond(&self, req: &Request) -> ResponseTemplate {
7818 if is_stream(req) {
7819 ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
7820 } else {
7821 ResponseTemplate::new(200)
7822 .set_body_json(serde_json::json!({"message": {"content": ""}}))
7823 }
7824 }
7825 }
7826
7827 #[tokio::test]
7828 async fn fully_empty_response_yields_diagnostic_message() {
7829 let server = MockServer::start().await;
7830 Mock::given(method("POST"))
7831 .and(path("/api/chat"))
7832 .respond_with(AllEmptyResponder)
7833 .mount(&server)
7834 .await;
7835
7836 let messages = msgs();
7837 let caveats = Caveats::top();
7838 let (reply, streamed, _, _) =
7839 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7840 .await
7841 .expect("chat_complete should succeed");
7842
7843 assert!(
7844 reply.contains("model returned an empty response"),
7845 "got: {reply}"
7846 );
7847 assert!(reply.contains("newt doctor"), "points at diagnostics");
7848 assert!(!streamed);
7849 }
7850
7851 struct SuspiciousEmptyThenRecover {
7852 probes: Arc<AtomicUsize>,
7853 saw_nudge: Arc<AtomicBool>,
7854 }
7855 impl Respond for SuspiciousEmptyThenRecover {
7856 fn respond(&self, req: &Request) -> ResponseTemplate {
7857 if is_stream(req) {
7858 if self.probes.load(Ordering::SeqCst) <= 1 {
7859 ndjson(&[serde_json::json!({
7860 "message": {"content": ""},
7861 "done": true,
7862 "prompt_eval_count": 9,
7863 "eval_count": 4
7864 })])
7865 } else {
7866 ndjson(&[
7867 serde_json::json!({"message": {"content": "recovered "}, "done": false}),
7868 serde_json::json!({
7869 "message": {"content": "after empty retry"},
7870 "done": true,
7871 "prompt_eval_count": 5,
7872 "eval_count": 3
7873 }),
7874 ])
7875 }
7876 } else {
7877 let body = body_json(req);
7878 if body["messages"].as_array().into_iter().flatten().any(|m| {
7879 m["content"]
7880 .as_str()
7881 .unwrap_or("")
7882 .contains("no assistant-visible content")
7883 }) {
7884 self.saw_nudge.store(true, Ordering::SeqCst);
7885 }
7886 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
7887 if n == 1 {
7888 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7889 "message": {
7890 "content": "",
7891 "thinking": "I know what to do but did not emit final text."
7892 },
7893 "prompt_eval_count": 10,
7894 "eval_count": 2559,
7895 }))
7896 } else {
7897 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7898 "message": {"content": "recovered after empty retry"},
7899 "prompt_eval_count": 5,
7900 "eval_count": 3,
7901 }))
7902 }
7903 }
7904 }
7905 }
7906
7907 #[tokio::test]
7908 async fn suspicious_empty_generated_output_retries_with_nudge() {
7909 let server = MockServer::start().await;
7910 let probes = Arc::new(AtomicUsize::new(0));
7911 let saw_nudge = Arc::new(AtomicBool::new(false));
7912 Mock::given(method("POST"))
7913 .and(path("/api/chat"))
7914 .respond_with(SuspiciousEmptyThenRecover {
7915 probes: probes.clone(),
7916 saw_nudge: saw_nudge.clone(),
7917 })
7918 .mount(&server)
7919 .await;
7920
7921 let messages = msgs();
7922 let caveats = Caveats::top();
7923 let (reply, streamed, usage, _) =
7924 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7925 .await
7926 .expect("chat_complete should succeed");
7927
7928 assert_eq!(reply, "recovered after empty retry");
7929 assert!(streamed);
7930 assert_eq!(probes.load(Ordering::SeqCst), 2);
7931 assert!(saw_nudge.load(Ordering::SeqCst));
7932 assert!(
7933 usage
7934 .expect("usage survives suspicious retry")
7935 .output_tokens
7936 >= 2566,
7937 "usage from the suspicious empty round must be preserved"
7938 );
7939 }
7940
7941 struct SuspiciousEmptyTwiceThenRecover {
7942 probes: Arc<AtomicUsize>,
7943 saw_strong_nudge: Arc<AtomicBool>,
7944 }
7945 impl Respond for SuspiciousEmptyTwiceThenRecover {
7946 fn respond(&self, req: &Request) -> ResponseTemplate {
7947 if is_stream(req) {
7948 if self.probes.load(Ordering::SeqCst) <= 2 {
7949 ndjson(&[serde_json::json!({
7950 "message": {"content": ""},
7951 "done": true,
7952 "prompt_eval_count": 9,
7953 "eval_count": 4
7954 })])
7955 } else {
7956 ndjson(&[serde_json::json!({
7957 "message": {"content": "recovered after strong hidden-only nudge"},
7958 "done": true,
7959 "prompt_eval_count": 5,
7960 "eval_count": 3
7961 })])
7962 }
7963 } else {
7964 let body = body_json(req);
7965 if body["messages"].as_array().into_iter().flatten().any(|m| {
7966 m["content"]
7967 .as_str()
7968 .unwrap_or("")
7969 .contains("Hidden thinking is not an action")
7970 }) {
7971 self.saw_strong_nudge.store(true, Ordering::SeqCst);
7972 }
7973 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
7974 if n <= 2 {
7975 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7976 "message": {
7977 "content": "",
7978 "thinking": "I know the next action but did not emit it."
7979 },
7980 "prompt_eval_count": 10,
7981 "eval_count": 2559,
7982 }))
7983 } else {
7984 ResponseTemplate::new(200).set_body_json(serde_json::json!({
7985 "message": {"content": "recovered after strong hidden-only nudge"},
7986 "prompt_eval_count": 5,
7987 "eval_count": 3,
7988 }))
7989 }
7990 }
7991 }
7992 }
7993
7994 #[tokio::test]
7995 async fn repeated_thinking_only_gets_stronger_second_nudge() {
7996 let server = MockServer::start().await;
7997 let probes = Arc::new(AtomicUsize::new(0));
7998 let saw_strong_nudge = Arc::new(AtomicBool::new(false));
7999 Mock::given(method("POST"))
8000 .and(path("/api/chat"))
8001 .respond_with(SuspiciousEmptyTwiceThenRecover {
8002 probes: probes.clone(),
8003 saw_strong_nudge: saw_strong_nudge.clone(),
8004 })
8005 .mount(&server)
8006 .await;
8007
8008 let messages = msgs();
8009 let caveats = Caveats::top();
8010 let (reply, streamed, _, _) =
8011 chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8012 .await
8013 .expect("second hidden-only nudge should recover the turn");
8014
8015 assert_eq!(reply, "recovered after strong hidden-only nudge");
8016 assert!(streamed);
8017 assert_eq!(probes.load(Ordering::SeqCst), 3);
8018 assert!(saw_strong_nudge.load(Ordering::SeqCst));
8019 }
8020
8021 struct SuspiciousEmptyStaysEmpty;
8022 impl Respond for SuspiciousEmptyStaysEmpty {
8023 fn respond(&self, req: &Request) -> ResponseTemplate {
8024 if is_stream(req) {
8025 ndjson(&[serde_json::json!({
8026 "message": {"content": ""},
8027 "done": true,
8028 "prompt_eval_count": 9,
8029 "eval_count": 4
8030 })])
8031 } else {
8032 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8033 "message": {
8034 "content": "",
8035 "reasoning_content": "internal-only response"
8036 },
8037 "prompt_eval_count": 10,
8038 "eval_count": 12,
8039 }))
8040 }
8041 }
8042 }
8043
8044 #[tokio::test]
8045 async fn suspicious_empty_generated_output_reports_targeted_diagnostic() {
8046 let server = MockServer::start().await;
8047 Mock::given(method("POST"))
8048 .and(path("/api/chat"))
8049 .respond_with(SuspiciousEmptyStaysEmpty)
8050 .mount(&server)
8051 .await;
8052
8053 let messages = msgs();
8054 let caveats = Caveats::top();
8055 let uri = server.uri();
8056 let mut c = ctx(&uri, &messages, &caveats);
8057 c.trace = true;
8058 let (reply, streamed, _, _) = chat_complete(c, &mut NoMcp)
8059 .await
8060 .expect("chat_complete should succeed");
8061
8062 assert!(reply.contains("generated output tokens"), "got: {reply}");
8063 assert!(
8064 reply.contains("reasoning_content"),
8065 "diagnostic should name the non-content field: {reply}"
8066 );
8067 assert!(reply.contains("--trace"), "points at trace diagnostics");
8068 assert!(!streamed);
8069 }
8070
8071 struct OverflowThenRecover {
8075 probes: Arc<AtomicUsize>,
8076 }
8077 impl Respond for OverflowThenRecover {
8078 fn respond(&self, req: &Request) -> ResponseTemplate {
8079 if is_stream(req) {
8080 if self.probes.load(Ordering::SeqCst) <= 1 {
8082 ndjson(&[serde_json::json!({
8083 "message": {"content": ""}, "done": true,
8084 "prompt_eval_count": 90, "eval_count": 1
8085 })])
8086 } else {
8087 ndjson(&[
8088 serde_json::json!({"message": {"content": "recovered "}, "done": false}),
8089 serde_json::json!({
8090 "message": {"content": "after trim"}, "done": true,
8091 "prompt_eval_count": 12, "eval_count": 4
8092 }),
8093 ])
8094 }
8095 } else {
8096 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8097 if n == 1 {
8098 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8099 "message": {"content": ""},
8100 "prompt_eval_count": 90, "eval_count": 1,
8101 }))
8102 } else {
8103 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8104 "message": {"content": "recovered after trim"},
8105 "prompt_eval_count": 12, "eval_count": 4,
8106 }))
8107 }
8108 }
8109 }
8110 }
8111
8112 #[tokio::test]
8113 async fn context_overflow_trims_and_retries_then_recovers() {
8114 let server = MockServer::start().await;
8115 let probes = Arc::new(AtomicUsize::new(0));
8116 Mock::given(method("POST"))
8117 .and(path("/api/chat"))
8118 .respond_with(OverflowThenRecover {
8119 probes: probes.clone(),
8120 })
8121 .mount(&server)
8122 .await;
8123
8124 let messages = msgs();
8125 let caveats = Caveats::top();
8126 let uri = server.uri();
8127 let mut c = ctx(&uri, &messages, &caveats);
8128 c.safe_context = Some(100);
8134 let (reply, streamed, usage, _) = chat_complete(c, &mut NoMcp)
8135 .await
8136 .expect("chat_complete should succeed");
8137
8138 assert_eq!(
8139 probes.load(Ordering::SeqCst),
8140 2,
8141 "overflow must trigger exactly one trim-and-retry probe"
8142 );
8143 assert_eq!(reply, "recovered after trim");
8144 assert!(streamed);
8145 assert_eq!(
8146 usage
8147 .expect("accumulated usage survives the retry")
8148 .input_tokens,
8149 90,
8150 "largest single prompt across the overflowed + recovered rounds"
8151 );
8152 }
8153
8154 struct TrimObservingResponder {
8159 marker_seen: Arc<AtomicBool>,
8160 old_placeholder_seen: Arc<AtomicBool>,
8161 }
8162 impl Respond for TrimObservingResponder {
8163 fn respond(&self, req: &Request) -> ResponseTemplate {
8164 let body = body_json(req);
8165 let contains = |needle: &str| {
8166 body["messages"]
8167 .as_array()
8168 .map(|m| {
8169 m.iter().any(|msg| {
8170 msg["content"]
8171 .as_str()
8172 .map(|c| c.contains(needle))
8173 .unwrap_or(false)
8174 })
8175 })
8176 .unwrap_or(false)
8177 };
8178 if contains(SUMMARY_PREFIX) && contains("Summary generation was unavailable.") {
8179 self.marker_seen.store(true, Ordering::SeqCst);
8180 }
8181 if contains("earlier tool-call messages omitted") {
8182 self.old_placeholder_seen.store(true, Ordering::SeqCst);
8183 }
8184 if body.get("tools").is_some() {
8185 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8186 "message": {"content": "", "tool_calls": [{
8187 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8188 }]}
8189 }))
8190 } else {
8191 ResponseTemplate::new(200)
8192 .set_body_json(serde_json::json!({"message": {"content": "final after trim"}}))
8193 }
8194 }
8195 }
8196
8197 #[tokio::test]
8198 async fn mid_loop_compression_fires_when_message_list_grows() {
8199 let server = MockServer::start().await;
8200 let marker_seen = Arc::new(AtomicBool::new(false));
8201 let old_placeholder_seen = Arc::new(AtomicBool::new(false));
8202 Mock::given(method("POST"))
8203 .and(path("/api/chat"))
8204 .respond_with(TrimObservingResponder {
8205 marker_seen: marker_seen.clone(),
8206 old_placeholder_seen: old_placeholder_seen.clone(),
8207 })
8208 .mount(&server)
8209 .await;
8210
8211 let messages = msgs();
8212 let caveats = Caveats::top();
8213 let uri = server.uri();
8214 let mut c = ctx(&uri, &messages, &caveats);
8215 c.max_tool_rounds = 3;
8216 c.mid_loop_trim_threshold = 4;
8217 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8218 .await
8219 .expect("chat_complete should succeed");
8220
8221 assert!(
8222 marker_seen.load(Ordering::SeqCst),
8223 "the static compaction marker must have reached the model mid-loop"
8224 );
8225 assert!(
8226 !old_placeholder_seen.load(Ordering::SeqCst),
8227 "the pre-18.4 amputation placeholder must never be emitted"
8228 );
8229 assert_eq!(reply, "final after trim");
8230 }
8231
8232 struct EmptyFinalSummary;
8235 impl Respond for EmptyFinalSummary {
8236 fn respond(&self, req: &Request) -> ResponseTemplate {
8237 if body_json(req).get("tools").is_some() {
8238 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8239 "message": {"content": "", "tool_calls": [{
8240 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8241 }]}
8242 }))
8243 } else {
8244 ResponseTemplate::new(200)
8245 .set_body_json(serde_json::json!({"message": {"content": ""}}))
8246 }
8247 }
8248 }
8249
8250 #[tokio::test]
8251 async fn empty_final_summary_yields_cap_fallback() {
8252 let server = MockServer::start().await;
8253 Mock::given(method("POST"))
8254 .and(path("/api/chat"))
8255 .respond_with(EmptyFinalSummary)
8256 .mount(&server)
8257 .await;
8258
8259 let messages = msgs();
8260 let caveats = Caveats::top();
8261 let uri = server.uri();
8262 let mut c = ctx(&uri, &messages, &caveats);
8263 c.max_tool_rounds = 2;
8264 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8265 .await
8266 .expect("chat_complete should succeed");
8267
8268 assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
8269 assert!(reply.contains("max_tool_rounds"), "names the knob");
8270 }
8271
8272 struct HallucinatingFinalSummary;
8278 impl Respond for HallucinatingFinalSummary {
8279 fn respond(&self, req: &Request) -> ResponseTemplate {
8280 if body_json(req).get("tools").is_some() {
8281 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8282 "message": {"content": "", "tool_calls": [{
8283 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8284 }]}
8285 }))
8286 } else {
8287 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8288 "message": {"content":
8289 "The /end command is defined in newt-tui/src/commands.rs \
8290 (lines 38-40) as enum variants."}
8291 }))
8292 }
8293 }
8294 }
8295
8296 #[tokio::test]
8297 async fn cap_exit_hallucinated_path_gets_claim_check_refutation() {
8298 let server = MockServer::start().await;
8299 Mock::given(method("POST"))
8300 .and(path("/api/chat"))
8301 .respond_with(HallucinatingFinalSummary)
8302 .mount(&server)
8303 .await;
8304
8305 let messages = msgs();
8306 let caveats = Caveats::top();
8307 let uri = server.uri();
8308 let mut c = ctx(&uri, &messages, &caveats);
8309 c.max_tool_rounds = 2;
8310 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8313 .await
8314 .expect("chat_complete should succeed");
8315
8316 assert!(
8317 reply.contains("newt-tui/src/commands.rs (lines 38-40)"),
8318 "the model's prose is preserved verbatim: {reply}"
8319 );
8320 assert!(reply.contains("⚠ claim check (#867)"), "got: {reply}");
8321 assert!(
8322 reply.contains("`newt-tui/src/commands.rs`"),
8323 "the fabricated path is named in the refutation: {reply}"
8324 );
8325 }
8326
8327 #[tokio::test]
8332 async fn chat_complete_dispatches_openai_kind_and_returns_first_round_answer() {
8333 let server = MockServer::start().await;
8334 Mock::given(method("POST"))
8335 .and(path("/v1/chat/completions"))
8336 .and(header("authorization", "Bearer sk-test"))
8337 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8338 "choices": [{"message": {"content": "openai says hi"}}],
8339 "usage": {"prompt_tokens": 10, "completion_tokens": 4},
8340 })))
8341 .mount(&server)
8342 .await;
8343
8344 let messages = msgs();
8345 let caveats = Caveats::top();
8346 let uri = server.uri();
8347 let mut c = ctx(&uri, &messages, &caveats);
8348 c.kind = BackendKind::Openai;
8349 c.api_key = Some("sk-test");
8350 let (reply, streamed, usage, hallu) = chat_complete(c, &mut NoMcp)
8352 .await
8353 .expect("openai dispatch should succeed");
8354
8355 assert_eq!(reply, "openai says hi");
8356 assert!(!streamed, "openai path is non-streaming");
8357 let u = usage.unwrap();
8358 assert_eq!((u.input_tokens, u.output_tokens), (10, 4));
8359 assert_eq!(hallu, 0);
8360 }
8361
8362 #[tokio::test]
8363 async fn openai_strips_inline_think_and_never_returns_reasoning_content() {
8364 let server = MockServer::start().await;
8369 Mock::given(method("POST"))
8370 .and(path("/v1/chat/completions"))
8371 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8372 "choices": [{"message": {
8373 "content": "<think>secret chain of thought</think>The final answer.",
8374 "reasoning_content": "separate-channel reasoning"
8375 }}]
8376 })))
8377 .mount(&server)
8378 .await;
8379
8380 let messages = msgs();
8381 let caveats = Caveats::top();
8382 let uri = server.uri();
8383 let mut c = ctx(&uri, &messages, &caveats);
8384 c.kind = BackendKind::Openai;
8385 let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
8386 .await
8387 .expect("openai dispatch should succeed");
8388
8389 assert_eq!(reply, "The final answer.", "answer is the stripped content");
8390 assert!(!reply.contains("<think>"), "no think markers: {reply}");
8391 assert!(
8392 !reply.contains("secret chain of thought"),
8393 "inline CoT must not leak: {reply}"
8394 );
8395 assert!(
8396 !reply.contains("separate-channel reasoning"),
8397 "reasoning_content must not leak into the reply: {reply}"
8398 );
8399 }
8400
8401 #[tokio::test]
8402 async fn openai_empty_content_yields_diagnostic_message() {
8403 let server = MockServer::start().await;
8404 Mock::given(method("POST"))
8405 .and(path("/v1/chat/completions"))
8406 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8407 "choices": [{"message": {"content": ""}}]
8408 })))
8409 .mount(&server)
8410 .await;
8411
8412 let messages = msgs();
8413 let caveats = Caveats::top();
8414 let uri = server.uri();
8415 let mut c = ctx(&uri, &messages, &caveats);
8416 c.kind = BackendKind::Openai;
8417 let (reply, _, _, _) = chat_complete(c, &mut NoMcp).await.expect("should succeed");
8418 assert!(
8419 reply.contains("model returned an empty response"),
8420 "got: {reply}"
8421 );
8422 }
8423
8424 struct OneToolMcp {
8426 name: &'static str,
8427 result: &'static str,
8428 }
8429 #[async_trait::async_trait]
8430 impl McpTools for OneToolMcp {
8431 fn handles(&self, name: &str) -> bool {
8432 name == self.name
8433 }
8434 fn tool_defs(&self) -> Vec<serde_json::Value> {
8435 Vec::new()
8436 }
8437 async fn call(&mut self, _name: &str, _args: &serde_json::Value) -> String {
8438 self.result.to_string()
8439 }
8440 }
8441
8442 #[tokio::test]
8448 async fn openai_anthropic_native_tool_calls_route_correctly() {
8449 let server = MockServer::start().await;
8450 let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8453 let cc = call_count.clone();
8454 Mock::given(method("POST"))
8455 .and(path("/v1/chat/completions"))
8456 .respond_with(move |_req: &Request| {
8457 let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8458 if n == 0 {
8459 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8460 "choices": [{"message": {
8461 "content": null,
8462 "tool_calls": [{
8463 "type": "tool_use",
8464 "id": "toolu_01ABC",
8465 "name": "my_server__my_tool",
8466 "input": {"key": "value"}
8467 }]
8468 }}],
8469 "usage": {"prompt_tokens": 50, "completion_tokens": 10}
8470 }))
8471 } else {
8472 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8473 "choices": [{"message": {"content": "done after anthropic-native tool"}}],
8474 "usage": {"prompt_tokens": 60, "completion_tokens": 8}
8475 }))
8476 }
8477 })
8478 .mount(&server)
8479 .await;
8480
8481 let messages = msgs();
8482 let caveats = Caveats::top();
8483 let uri = server.uri();
8484 let mut c = ctx(&uri, &messages, &caveats);
8485 c.kind = BackendKind::Openai;
8486 let mut mcp = OneToolMcp {
8487 name: "my_server__my_tool",
8488 result: "tool-result-text",
8489 };
8490 let (reply, _, _, hallu) = chat_complete(c, &mut mcp)
8491 .await
8492 .expect("should succeed with anthropic-native tool format");
8493 assert_eq!(reply, "done after anthropic-native tool");
8494 assert_eq!(hallu, 0, "should not be counted as hallucination");
8495 assert_eq!(
8496 call_count.load(std::sync::atomic::Ordering::SeqCst),
8497 2,
8498 "must have done both rounds (tool call + final answer)"
8499 );
8500 }
8501
8502 #[tokio::test]
8506 async fn openai_hyphenated_server_name_routes_through_mcp() {
8507 let server = MockServer::start().await;
8508 let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8509 let cc = call_count.clone();
8510 Mock::given(method("POST"))
8511 .and(path("/v1/chat/completions"))
8512 .respond_with(move |_req: &Request| {
8513 let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8514 if n == 0 {
8515 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8518 "choices": [{"message": {
8519 "content": "",
8520 "tool_calls": [{
8521 "index": 0,
8522 "function": {
8523 "arguments": "{}",
8524 "name": "acme_server__probe_tool"
8525 },
8526 "id": "call_probe_01",
8527 "type": "function"
8528 }]
8529 }}],
8530 "usage": {"prompt_tokens": 599, "completion_tokens": 30}
8531 }))
8532 } else {
8533 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8534 "choices": [{"message": {"content": "outlook routed correctly"}}]
8535 }))
8536 }
8537 })
8538 .mount(&server)
8539 .await;
8540
8541 let messages = msgs();
8542 let caveats = Caveats::top();
8543 let uri = server.uri();
8544 let mut c = ctx(&uri, &messages, &caveats);
8545 c.kind = BackendKind::Openai;
8546 let mut mcp = OneToolMcp {
8548 name: "acme_server__probe_tool",
8549 result: "ok",
8550 };
8551 let (reply, _, _, _) = chat_complete(c, &mut mcp)
8552 .await
8553 .expect("should route hyphenated server name through mcp");
8554 assert_eq!(reply, "outlook routed correctly");
8555 assert_eq!(
8556 call_count.load(std::sync::atomic::Ordering::SeqCst),
8557 2,
8558 "must have completed both rounds"
8559 );
8560 }
8561
8562 struct OpenAiErrOnFinal;
8565 impl Respond for OpenAiErrOnFinal {
8566 fn respond(&self, req: &Request) -> ResponseTemplate {
8567 if body_json(req).get("tools").is_some() {
8568 ResponseTemplate::new(200).set_body_json(serde_json::json!({
8569 "choices": [{"message": {
8570 "content": null,
8571 "tool_calls": [{
8572 "id": "call_1",
8573 "type": "function",
8574 "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
8575 }]
8576 }}]
8577 }))
8578 } else {
8579 ResponseTemplate::new(400).set_body_string("bad request")
8580 }
8581 }
8582 }
8583
8584 #[tokio::test]
8585 async fn openai_cap_exit_fallback_when_final_summary_errors() {
8586 let server = MockServer::start().await;
8587 Mock::given(method("POST"))
8588 .and(path("/v1/chat/completions"))
8589 .respond_with(OpenAiErrOnFinal)
8590 .mount(&server)
8591 .await;
8592
8593 let messages = msgs();
8594 let caveats = Caveats::top();
8595 let uri = server.uri();
8596 let mut c = ctx(&uri, &messages, &caveats);
8597 c.kind = BackendKind::Openai;
8598 c.max_tool_rounds = 2;
8599 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8600 .await
8601 .expect("must succeed even when the summary errors");
8602 assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
8603 assert!(reply.contains("max_tool_rounds"));
8604 }
8605
8606 struct ScriptedOpenAi {
8611 round: Arc<AtomicUsize>,
8612 script: Vec<serde_json::Value>,
8613 }
8614 impl Respond for ScriptedOpenAi {
8615 fn respond(&self, _req: &Request) -> ResponseTemplate {
8616 let i = self.round.fetch_add(1, Ordering::SeqCst);
8617 let msg = self
8618 .script
8619 .get(i)
8620 .or_else(|| self.script.last())
8621 .cloned()
8622 .unwrap_or_else(|| serde_json::json!({ "content": "final." }));
8623 ResponseTemplate::new(200)
8624 .set_body_json(serde_json::json!({ "choices": [{ "message": msg }] }))
8625 }
8626 }
8627
8628 async fn run_openai_script_with_ledger(
8630 script: Vec<serde_json::Value>,
8631 step_ledger: Option<&dyn StepLedger>,
8632 ) -> (String, usize) {
8633 let server = MockServer::start().await;
8634 let round = Arc::new(AtomicUsize::new(0));
8635 Mock::given(method("POST"))
8636 .and(path("/v1/chat/completions"))
8637 .respond_with(ScriptedOpenAi {
8638 round: round.clone(),
8639 script,
8640 })
8641 .mount(&server)
8642 .await;
8643 let messages = msgs();
8644 let caveats = Caveats::top();
8645 let uri = server.uri();
8646 let mut c = ctx(&uri, &messages, &caveats);
8647 c.kind = BackendKind::Openai;
8648 c.step_ledger = step_ledger;
8649 let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
8650 (reply, round.load(Ordering::SeqCst))
8651 }
8652
8653 async fn run_openai_script(script: Vec<serde_json::Value>) -> (String, usize) {
8654 run_openai_script_with_ledger(script, None).await
8655 }
8656
8657 #[tokio::test]
8658 async fn narrated_intent_with_no_tool_call_nudges_and_continues() {
8659 let (reply, rounds) = run_openai_script(vec![
8663 serde_json::json!({ "content": "Let me edit the file now." }),
8664 serde_json::json!({ "content": "All done — the edit is complete." }),
8665 ])
8666 .await;
8667 assert_eq!(rounds, 2, "must run a second round after the nudge");
8668 assert!(
8669 reply.contains("complete"),
8670 "returns the post-nudge answer: {reply}"
8671 );
8672 assert!(
8673 !reply.contains("Let me edit"),
8674 "must not return the narration: {reply}"
8675 );
8676 }
8677
8678 #[tokio::test]
8679 async fn narration_auto_continue_is_bounded_by_the_cap() {
8680 let (reply, rounds) = run_openai_script(vec![
8683 serde_json::json!({ "content": "Let me keep editing now." }),
8684 serde_json::json!({ "content": "Let me keep editing now." }),
8685 serde_json::json!({ "content": "Let me keep editing now." }),
8686 ])
8687 .await;
8688 assert_eq!(
8689 rounds, 2,
8690 "exactly one nudge (cap=1), then accept, got {rounds}"
8691 );
8692 assert!(
8693 reply.contains("editing"),
8694 "narration accepted as final: {reply}"
8695 );
8696 }
8697
8698 #[tokio::test]
8699 async fn genuine_final_answer_is_not_nudged() {
8700 let (reply, rounds) = run_openai_script(vec![
8703 serde_json::json!({ "content": "The capital of France is Paris." }),
8704 ])
8705 .await;
8706 assert_eq!(
8707 rounds, 1,
8708 "a plain final answer is not nudged, got {rounds}"
8709 );
8710 assert!(reply.contains("Paris"), "returns the answer: {reply}");
8711 }
8712
8713 #[tokio::test]
8714 async fn final_answer_after_a_tool_call_is_not_nudged() {
8715 let (reply, rounds) = run_openai_script(vec![
8719 serde_json::json!({
8720 "content": null,
8721 "tool_calls": [{
8722 "id": "c1", "type": "function",
8723 "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
8724 }]
8725 }),
8726 serde_json::json!({ "content": "The files were examined; everything checks out." }),
8727 ])
8728 .await;
8729 assert_eq!(
8730 rounds, 2,
8731 "tool call (r0) then final answer (r1) — no extra round, got {rounds}"
8732 );
8733 assert!(
8734 reply.contains("checks out"),
8735 "returns the final answer as-is: {reply}"
8736 );
8737 }
8738
8739 #[tokio::test]
8740 async fn observed_fix_intent_after_a_tool_call_nudges_and_continues() {
8741 let (reply, rounds) = run_openai_script(vec![
8744 serde_json::json!({
8745 "content": null,
8746 "tool_calls": [{
8747 "id": "c1", "type": "function",
8748 "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
8749 }]
8750 }),
8751 serde_json::json!({
8752 "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."
8753 }),
8754 serde_json::json!({ "content": "The stray brace is removed and the compile error is fixed." }),
8755 ])
8756 .await;
8757 assert_eq!(
8758 rounds, 3,
8759 "tool call, narrated edit intent, then post-nudge answer; got {rounds}"
8760 );
8761 assert!(
8762 reply.contains("compile error is fixed"),
8763 "returns the post-nudge answer: {reply}"
8764 );
8765 assert!(
8766 !reply.contains("I need to remove"),
8767 "must not stop on the narrated edit intent: {reply}"
8768 );
8769 }
8770
8771 #[tokio::test]
8772 async fn pending_plan_final_answer_nudges_before_handoff() {
8773 let ledger = SessionStepLedger::default();
8774 ledger.restore(&PlanSnapshot {
8775 steps: vec![
8776 Step {
8777 description: "convert help sections".to_string(),
8778 status: StepStatus::Done,
8779 },
8780 Step {
8781 description: "fix format_command_list and update lib.rs".to_string(),
8782 status: StepStatus::Active,
8783 },
8784 Step {
8785 description: "add tests".to_string(),
8786 status: StepStatus::Todo,
8787 },
8788 ],
8789 });
8790 let (reply, rounds) = run_openai_script_with_ledger(
8791 vec![
8792 serde_json::json!({
8793 "content": "I need to finish Step 2, then Steps 3-5."
8794 }),
8795 serde_json::json!({
8796 "content": "Plan updated; continuing with the active step."
8797 }),
8798 serde_json::json!({
8799 "content": "The active step is now complete."
8800 }),
8801 ],
8802 Some(&ledger as &dyn StepLedger),
8803 )
8804 .await;
8805 assert_eq!(
8806 rounds, 3,
8807 "open plan should force a completion-gate round and action-nudge follow-on narration"
8808 );
8809 assert!(
8810 reply.contains("complete"),
8811 "returns the post-nudge answer: {reply}"
8812 );
8813 assert!(
8814 !reply.contains("I need to finish"),
8815 "must not accept a plain handoff while plan is open: {reply}"
8816 );
8817 }
8818
8819 #[tokio::test]
8820 async fn findings_summary_with_stale_plan_nudges_update_plan_then_continues() {
8821 let ledger = SessionStepLedger::default();
8822 ledger.restore(&PlanSnapshot {
8823 steps: vec![
8824 Step {
8825 description: "convert help sections".to_string(),
8826 status: StepStatus::Done,
8827 },
8828 Step {
8829 description: "wire progressive dispatch in lib.rs".to_string(),
8830 status: StepStatus::Active,
8831 },
8832 Step {
8833 description: "add tests".to_string(),
8834 status: StepStatus::Todo,
8835 },
8836 ],
8837 });
8838 let findings = "\
8839Summary of Findings
8840
8841Across the tool calls, I observed two issues in newt-tui/src/help_sections.rs:
88421. Duplicate function definitions
88432. Stray closing brace
8844
8845Current Status
8846
8847The 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.
8848
8849Next Steps Required
8850
8851To 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.
8852
8853However, I've reached the tool-call limit and cannot make these edits now.";
8854 let (reply, rounds) = run_openai_script_with_ledger(
8855 vec![
8856 serde_json::json!({ "content": findings }),
8857 serde_json::json!({
8858 "content": null,
8859 "tool_calls": [{
8860 "id": "plan_1",
8861 "type": "function",
8862 "function": {
8863 "name": "update_plan",
8864 "arguments": serde_json::json!({
8865 "plan": [
8866 {"step": "fix duplicate help rollup functions and stray brace", "status": "in_progress"},
8867 {"step": "wire progressive dispatch in lib.rs", "status": "pending"},
8868 {"step": "add rollup tests", "status": "pending"}
8869 ]
8870 }).to_string()
8871 }
8872 }]
8873 }),
8874 serde_json::json!({
8875 "content": null,
8876 "tool_calls": [{
8877 "id": "edit_1",
8878 "type": "function",
8879 "function": {
8880 "name": "definitely_not_a_real_tool",
8881 "arguments": "{}"
8882 }
8883 }]
8884 }),
8885 serde_json::json!({ "content": "Done." }),
8886 ],
8887 Some(&ledger as &dyn StepLedger),
8888 )
8889 .await;
8890 assert_eq!(
8891 rounds, 4,
8892 "findings summary should be nudged into update_plan, then a concrete tool"
8893 );
8894 assert_eq!(reply, "Done.");
8895 assert!(
8896 !reply.contains("tool-call limit"),
8897 "must not accept the handoff summary: {reply}"
8898 );
8899 let snap = ledger.snapshot();
8900 assert_eq!(
8901 snap.steps[0].description,
8902 "fix duplicate help rollup functions and stray brace"
8903 );
8904 assert_eq!(snap.steps[0].status, StepStatus::Active);
8905 }
8906
8907 #[tokio::test]
8908 async fn completed_plan_final_answer_is_accepted() {
8909 let ledger = SessionStepLedger::default();
8910 ledger.restore(&PlanSnapshot {
8911 steps: vec![Step {
8912 description: "done".to_string(),
8913 status: StepStatus::Done,
8914 }],
8915 });
8916 let (reply, rounds) = run_openai_script_with_ledger(
8917 vec![serde_json::json!({
8918 "content": "All plan steps are complete."
8919 })],
8920 Some(&ledger as &dyn StepLedger),
8921 )
8922 .await;
8923 assert_eq!(rounds, 1, "completed plan must not be nudged");
8924 assert!(reply.contains("complete"), "returns final answer: {reply}");
8925 }
8926
8927 #[tokio::test]
8928 async fn continuing_with_active_step_after_plan_nudge_gets_action_nudge() {
8929 let ledger = SessionStepLedger::default();
8930 ledger.restore(&PlanSnapshot {
8931 steps: vec![
8932 Step {
8933 description: "convert help sections".to_string(),
8934 status: StepStatus::Done,
8935 },
8936 Step {
8937 description: "insert progressive dispatch".to_string(),
8938 status: StepStatus::Active,
8939 },
8940 Step {
8941 description: "add tests".to_string(),
8942 status: StepStatus::Todo,
8943 },
8944 ],
8945 });
8946 let (reply, rounds) = run_openai_script_with_ledger(
8947 vec![
8948 serde_json::json!({
8949 "content": "I need to finish Step 2, then Steps 3-5."
8950 }),
8951 serde_json::json!({
8952 "content": "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
8953 }),
8954 serde_json::json!({
8955 "content": "The edit is now complete."
8956 }),
8957 ],
8958 Some(&ledger as &dyn StepLedger),
8959 )
8960 .await;
8961 assert_eq!(
8962 rounds, 3,
8963 "plan nudge should be followed by an action nudge for continuing-with narration"
8964 );
8965 assert!(
8966 reply.contains("complete"),
8967 "returns the post-action-nudge answer: {reply}"
8968 );
8969 assert!(
8970 !reply.contains("Continuing with step 2"),
8971 "must not stop on the continuing-with narration: {reply}"
8972 );
8973 }
8974
8975 #[tokio::test]
8976 async fn stale_file_blocker_nudges_ground_truth_check_and_continues() {
8977 let blocker = "\
8978Summary
8979
8980What happened: The lib.rs file I was editing grew from ~9400 to ~16808 lines \
8981between reads — likely modified concurrently by another agent or tool. This \
8982means my old edit contexts are stale.
8983
8984Why I'm blocked: I cannot safely use edit_file on lib.rs because the file has \
8985been modified out from under me. My old line references and context are invalid \
8986for an 8400-line larger file.
8987
8988Final Answer / Recommendation
8989
8990The operator should restore lib.rs to a known-good state (e.g., git checkout \
8991newt-tui/src/lib.rs).";
8992 let (reply, rounds) = run_openai_script(vec![
8993 serde_json::json!({ "content": blocker }),
8994 serde_json::json!({ "content": "Ground truth checked; lib.rs is clean, so I am continuing." }),
8995 ])
8996 .await;
8997 assert_eq!(
8998 rounds, 2,
8999 "stale-file blocker should get one verification nudge"
9000 );
9001 assert!(
9002 reply.contains("lib.rs is clean"),
9003 "returns the post-nudge answer: {reply}"
9004 );
9005 assert!(
9006 !reply.contains("git checkout"),
9007 "must not accept the unverified revert recommendation: {reply}"
9008 );
9009 }
9010
9011 #[test]
9012 fn looks_like_intent_to_act_separates_narration_from_final_answers() {
9013 assert!(looks_like_intent_to_act(
9015 "Now I have everything I need. Let me make both edits now."
9016 ));
9017 assert!(looks_like_intent_to_act(
9018 "Now I'll add the --home flag to the Cli struct."
9019 ));
9020 assert!(looks_like_intent_to_act("Let me keep editing now."));
9021 assert!(looks_like_intent_to_act(
9022 "I'm going to edit the config file."
9023 ));
9024 assert!(looks_like_intent_to_act(
9025 "Let me understand what was already done on this branch and compare it with the issue requirements."
9026 ));
9027 assert!(looks_like_intent_to_act(
9028 "Let me check the current implementation and identify any gaps."
9029 ));
9030 assert!(looks_like_intent_to_act(
9031 "The help section logic itself has no tests yet.\n\nLet me commit this first step, then move on:"
9032 ));
9033 assert!(looks_like_intent_to_act(
9034 "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
9035 ));
9036 assert!(looks_like_intent_to_act(
9037 "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."
9038 ));
9039 assert!(!looks_like_intent_to_act("The capital of France is Paris."));
9041 assert!(!looks_like_intent_to_act(
9042 "I have finished editing the file and the tests pass."
9043 ));
9044 assert!(!looks_like_intent_to_act(
9045 "Here is a summary of what I found across the tool calls."
9046 ));
9047 assert!(!looks_like_intent_to_act(
9049 "Done. Let me know if you want any further changes."
9050 ));
9051 let multibyte = format!("{}let me edit", "…".repeat(200));
9055 assert!(looks_like_intent_to_act(&multibyte));
9056 }
9057
9058 #[test]
9059 fn looks_like_unverified_stale_file_blocker_requires_file_stale_and_blocker_cues() {
9060 assert!(looks_like_unverified_stale_file_blocker(
9061 "The lib.rs file I was editing grew from ~9400 to ~16808 lines between reads. \
9062 Why I'm blocked: I cannot safely use edit_file because the file has been \
9063 modified out from under me. The operator should restore lib.rs."
9064 ));
9065 assert!(looks_like_unverified_stale_file_blocker(
9066 "My old line references are invalid and the context is stale. Any edit could \
9067 land in the wrong place and corrupt the code; recommendation: restore the file."
9068 ));
9069 assert!(!looks_like_unverified_stale_file_blocker(
9070 "The cache entry is stale, so I refreshed it and continued."
9071 ));
9072 assert!(!looks_like_unverified_stale_file_blocker(
9073 "I checked git diff and the file is clean, so I can continue from the verified contents."
9074 ));
9075 }
9076
9077 #[test]
9078 fn stale_file_ground_truth_nudge_names_read_only_checks_and_revert_guard() {
9079 let nudge = stale_file_ground_truth_nudge();
9080 assert!(nudge.contains("git status --short"), "{nudge}");
9081 assert!(nudge.contains("git diff -- <file>"), "{nudge}");
9082 assert!(nudge.contains("wc -l <file>"), "{nudge}");
9083 assert!(nudge.contains("re-read the exact target range"), "{nudge}");
9084 assert!(
9085 nudge.contains("Never recommend git checkout/revert"),
9086 "{nudge}"
9087 );
9088 }
9089}
9090
9091#[cfg(test)]
9103mod save_note_loop_tests {
9104 use super::note_sink::tests::MockSink;
9105 use super::*;
9106 use crate::caveats::Caveats;
9107 use crate::{BackendKind, MemMessage};
9108 use std::sync::atomic::{AtomicBool, Ordering};
9109 use std::sync::Arc;
9110 use wiremock::matchers::{method, path};
9111 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
9112
9113 fn msgs() -> Vec<MemMessage> {
9114 vec![
9115 MemMessage::system("you are a test"),
9116 MemMessage::user("do the thing"),
9117 ]
9118 }
9119
9120 fn ctx<'a>(
9121 server_uri: &'a str,
9122 messages: &'a [MemMessage],
9123 caveats: &'a Caveats,
9124 ) -> ChatCtx<'a> {
9125 ChatCtx {
9126 url: server_uri,
9127 model: "test-model",
9128 kind: BackendKind::Ollama,
9129 api_key: None,
9130 messages,
9131 task: "do the thing",
9132 workspace: ".",
9133 color: false,
9134 markdown: false,
9135 tool_offload: false,
9136 spill_store: None,
9137 compaction_store: None,
9138 scratchpad: false,
9139 scratchpad_store: None,
9140 code_search: None,
9141 experience_store: None,
9142 step_ledger: None,
9143 caveats,
9144 max_tool_rounds: 6,
9145 workflow_grace_rounds: 0,
9146 tool_output_lines: 20,
9147 debug: false,
9148 trace: false,
9149 num_ctx: None,
9150 connect_timeout_secs: 5,
9151 inference_timeout_secs: 30,
9152 mid_loop_trim_threshold: 40,
9153 mid_loop_trim_tokens: None,
9154 max_ok_input: None,
9155 build_check_cmd: None,
9156 safe_context: None,
9157 recover_cw_400: None,
9158 note_sink: None,
9159 note_nudge: None,
9160 recall_source: None,
9161 memory_source: None,
9162 summarizer: None,
9163 compress_state: None,
9164 tool_events: None,
9165 phantom_reaches: None,
9166 permission_gate: None,
9167 on_round_usage: None,
9168 estimate_ratio: None,
9169 estimation: crate::tokens::TokenEstimation::default(),
9170 summary_input_cap_floor_chars: 8_192,
9171 exec_floor: None,
9172 write_ledger: None,
9173 cancel: None,
9174 git_tool: None,
9175 crew_runner: None,
9176 }
9177 }
9178
9179 fn body_json(req: &Request) -> serde_json::Value {
9180 serde_json::from_slice(&req.body).unwrap_or_default()
9181 }
9182
9183 fn advertised_tool_names(body: &serde_json::Value) -> Vec<String> {
9184 body["tools"]
9185 .as_array()
9186 .map(|a| {
9187 a.iter()
9188 .filter_map(|d| d["function"]["name"].as_str())
9189 .map(String::from)
9190 .collect()
9191 })
9192 .unwrap_or_default()
9193 }
9194
9195 fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
9196 body["messages"]
9197 .as_array()
9198 .map(|msgs| {
9199 msgs.iter().any(|m| {
9200 m["content"]
9201 .as_str()
9202 .map(|c| c.contains(needle))
9203 .unwrap_or(false)
9204 })
9205 })
9206 .unwrap_or(false)
9207 }
9208
9209 struct SaveNoteResponder {
9214 save_note_advertised: Arc<AtomicBool>,
9215 nudge_seen: Arc<AtomicBool>,
9216 final_answer: String,
9217 }
9218
9219 impl Respond for SaveNoteResponder {
9220 fn respond(&self, req: &Request) -> ResponseTemplate {
9221 let body = body_json(req);
9222 if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9223 self.save_note_advertised.store(true, Ordering::SeqCst);
9224 }
9225 if messages_contain(&body, "[system reminder:")
9226 && messages_contain(&body, "without a saved note")
9227 {
9228 self.nudge_seen.store(true, Ordering::SeqCst);
9229 }
9230 if messages_contain(&body, "note saved:") {
9231 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9233 "message": { "content": self.final_answer }
9234 }))
9235 } else if body.get("tools").is_some() {
9236 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9237 "message": {
9238 "content": "",
9239 "tool_calls": [{ "function": {
9240 "name": "save_note",
9241 "arguments": {
9242 "action": "add",
9243 "text": "user prefers vi keybindings"
9244 }
9245 }}]
9246 }
9247 }))
9248 } else {
9249 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9250 "message": { "content": "final summary" }
9251 }))
9252 }
9253 }
9254 }
9255
9256 #[tokio::test]
9257 async fn ollama_save_note_routes_to_sink_and_result_feeds_back() {
9258 let server = MockServer::start().await;
9259 let advertised = Arc::new(AtomicBool::new(false));
9260 let nudge_seen = Arc::new(AtomicBool::new(false));
9261 Mock::given(method("POST"))
9262 .and(path("/api/chat"))
9263 .respond_with(SaveNoteResponder {
9264 save_note_advertised: advertised.clone(),
9265 nudge_seen: nudge_seen.clone(),
9266 final_answer: "noted, moving on".into(),
9267 })
9268 .mount(&server)
9269 .await;
9270
9271 let messages = msgs();
9272 let caveats = Caveats::top();
9273 let uri = server.uri();
9274 let mut sink = MockSink::default();
9275 let mut c = ctx(&uri, &messages, &caveats);
9276 c.note_sink = Some(&mut sink);
9277 let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
9278 .await
9279 .expect("chat_complete should succeed");
9280
9281 assert!(
9282 advertised.load(Ordering::SeqCst),
9283 "save_note must be advertised when a sink is present"
9284 );
9285 assert_eq!(
9286 sink.calls,
9287 vec!["add:user prefers vi keybindings"],
9288 "the tool call must route through the sink"
9289 );
9290 assert_eq!(reply, "noted, moving on");
9291 assert_eq!(hallu, 0, "save_note is a real tool, not a hallucination");
9292 assert!(
9293 !nudge_seen.load(Ordering::SeqCst),
9294 "no nudge configured — none may be injected"
9295 );
9296 }
9297
9298 struct NoSinkObserver {
9301 save_note_advertised: Arc<AtomicBool>,
9302 nudge_seen: Arc<AtomicBool>,
9303 }
9304
9305 impl Respond for NoSinkObserver {
9306 fn respond(&self, req: &Request) -> ResponseTemplate {
9307 let body = body_json(req);
9308 if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9309 self.save_note_advertised.store(true, Ordering::SeqCst);
9310 }
9311 if messages_contain(&body, "[system reminder:") {
9312 self.nudge_seen.store(true, Ordering::SeqCst);
9313 }
9314 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9315 "message": { "content": "plain answer" }
9316 }))
9317 }
9318 }
9319
9320 #[tokio::test]
9321 async fn without_sink_no_tool_and_no_nudge_even_when_due() {
9322 let server = MockServer::start().await;
9323 let advertised = Arc::new(AtomicBool::new(false));
9324 let nudge_seen = Arc::new(AtomicBool::new(false));
9325 Mock::given(method("POST"))
9326 .and(path("/api/chat"))
9327 .respond_with(NoSinkObserver {
9328 save_note_advertised: advertised.clone(),
9329 nudge_seen: nudge_seen.clone(),
9330 })
9331 .mount(&server)
9332 .await;
9333
9334 let messages = msgs();
9335 let caveats = Caveats::top();
9336 let uri = server.uri();
9337 let mut nudge = NoteNudge::new(1);
9339 let _ = nudge.begin_turn();
9340 let mut c = ctx(&uri, &messages, &caveats);
9341 c.note_nudge = Some(&mut nudge);
9343 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9344 .await
9345 .expect("chat_complete should succeed");
9346
9347 assert_eq!(reply, "plain answer");
9348 assert!(
9349 !advertised.load(Ordering::SeqCst),
9350 "save_note advertised without a sink"
9351 );
9352 assert!(
9353 !nudge_seen.load(Ordering::SeqCst),
9354 "nudge injected without a sink"
9355 );
9356 }
9357
9358 #[tokio::test]
9359 async fn nudge_appended_to_user_message_when_due() {
9360 let server = MockServer::start().await;
9361 let advertised = Arc::new(AtomicBool::new(false));
9362 let nudge_seen = Arc::new(AtomicBool::new(false));
9363 Mock::given(method("POST"))
9364 .and(path("/api/chat"))
9365 .respond_with(NoSinkObserver {
9366 save_note_advertised: advertised.clone(),
9367 nudge_seen: nudge_seen.clone(),
9368 })
9369 .mount(&server)
9370 .await;
9371
9372 let messages = msgs();
9373 let caveats = Caveats::top();
9374 let uri = server.uri();
9375 let mut sink = MockSink::default();
9376 let mut nudge = NoteNudge::new(1);
9378 let _ = nudge.begin_turn();
9379 let mut c = ctx(&uri, &messages, &caveats);
9380 c.note_sink = Some(&mut sink);
9381 c.note_nudge = Some(&mut nudge);
9382 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9383 .await
9384 .expect("chat_complete should succeed");
9385
9386 assert_eq!(reply, "plain answer");
9387 assert!(
9388 nudge_seen.load(Ordering::SeqCst),
9389 "the reminder line must reach the model on the due turn"
9390 );
9391 }
9392
9393 #[tokio::test]
9394 async fn organic_save_resets_the_nudge_counter() {
9395 let server = MockServer::start().await;
9396 let advertised = Arc::new(AtomicBool::new(false));
9397 let nudge_seen = Arc::new(AtomicBool::new(false));
9398 Mock::given(method("POST"))
9399 .and(path("/api/chat"))
9400 .respond_with(SaveNoteResponder {
9401 save_note_advertised: advertised.clone(),
9402 nudge_seen: nudge_seen.clone(),
9403 final_answer: "done".into(),
9404 })
9405 .mount(&server)
9406 .await;
9407
9408 let messages = msgs();
9409 let caveats = Caveats::top();
9410 let uri = server.uri();
9411 let mut sink = MockSink::default();
9412 let mut nudge = NoteNudge::new(1);
9413 let mut c = ctx(&uri, &messages, &caveats);
9414 c.note_sink = Some(&mut sink);
9415 c.note_nudge = Some(&mut nudge);
9416 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9417 .await
9418 .expect("chat_complete should succeed");
9419 assert_eq!(reply, "done");
9420 assert_eq!(sink.calls.len(), 1, "the model saved organically");
9421
9422 assert!(
9426 nudge.begin_turn().is_none(),
9427 "organic save_note use must reset the nudge counter"
9428 );
9429 }
9430
9431 struct OpenAiSaveNoteResponder {
9433 save_note_advertised: Arc<AtomicBool>,
9434 nudge_seen: Arc<AtomicBool>,
9435 }
9436
9437 impl Respond for OpenAiSaveNoteResponder {
9438 fn respond(&self, req: &Request) -> ResponseTemplate {
9439 let body = body_json(req);
9440 if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9441 self.save_note_advertised.store(true, Ordering::SeqCst);
9442 }
9443 if messages_contain(&body, "[system reminder:") {
9444 self.nudge_seen.store(true, Ordering::SeqCst);
9445 }
9446 if messages_contain(&body, "note saved:") {
9447 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9448 "choices": [{ "message": { "content": "openai noted" } }]
9449 }))
9450 } else {
9451 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9452 "choices": [{ "message": {
9453 "content": null,
9454 "tool_calls": [{
9455 "id": "call_1",
9456 "type": "function",
9457 "function": {
9458 "name": "save_note",
9459 "arguments": "{\"action\":\"add\",\"text\":\"CI gate is just check\"}"
9460 }
9461 }]
9462 }}]
9463 }))
9464 }
9465 }
9466 }
9467
9468 #[tokio::test]
9469 async fn openai_save_note_routes_and_nudge_appends() {
9470 let server = MockServer::start().await;
9471 let advertised = Arc::new(AtomicBool::new(false));
9472 let nudge_seen = Arc::new(AtomicBool::new(false));
9473 Mock::given(method("POST"))
9474 .and(path("/v1/chat/completions"))
9475 .respond_with(OpenAiSaveNoteResponder {
9476 save_note_advertised: advertised.clone(),
9477 nudge_seen: nudge_seen.clone(),
9478 })
9479 .mount(&server)
9480 .await;
9481
9482 let messages = msgs();
9483 let caveats = Caveats::top();
9484 let uri = server.uri();
9485 let mut sink = MockSink::default();
9486 let mut nudge = NoteNudge::new(1);
9487 let _ = nudge.begin_turn(); let mut c = ctx(&uri, &messages, &caveats);
9489 c.kind = BackendKind::Openai;
9490 c.note_sink = Some(&mut sink);
9491 c.note_nudge = Some(&mut nudge);
9492 let (reply, _, _, hallu) = chat_complete(c, &mut NoMcp)
9493 .await
9494 .expect("openai loop should succeed");
9495
9496 assert_eq!(reply, "openai noted");
9497 assert_eq!(sink.calls, vec!["add:CI gate is just check"]);
9498 assert!(advertised.load(Ordering::SeqCst));
9499 assert!(nudge_seen.load(Ordering::SeqCst));
9500 assert_eq!(hallu, 0);
9501 }
9502
9503 struct ErrorEchoResponder {
9507 error_seen_by_model: Arc<AtomicBool>,
9508 }
9509
9510 impl Respond for ErrorEchoResponder {
9511 fn respond(&self, req: &Request) -> ResponseTemplate {
9512 let body = body_json(req);
9513 if messages_contain(&body, "Replace or remove existing entries first")
9514 && messages_contain(&body, "1. an existing entry")
9515 {
9516 self.error_seen_by_model.store(true, Ordering::SeqCst);
9517 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9518 "message": { "content": "I will curate first" }
9519 }))
9520 } else if body.get("tools").is_some() {
9521 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9522 "message": {
9523 "content": "",
9524 "tool_calls": [{ "function": {
9525 "name": "save_note",
9526 "arguments": { "action": "add", "text": "too big" }
9527 }}]
9528 }
9529 }))
9530 } else {
9531 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9532 "message": { "content": "final summary" }
9533 }))
9534 }
9535 }
9536 }
9537
9538 #[tokio::test]
9539 async fn over_budget_error_round_trips_to_the_model() {
9540 let server = MockServer::start().await;
9541 let error_seen = Arc::new(AtomicBool::new(false));
9542 Mock::given(method("POST"))
9543 .and(path("/api/chat"))
9544 .respond_with(ErrorEchoResponder {
9545 error_seen_by_model: error_seen.clone(),
9546 })
9547 .mount(&server)
9548 .await;
9549
9550 let messages = msgs();
9551 let caveats = Caveats::top();
9552 let uri = server.uri();
9553 let mut sink = MockSink {
9554 fail_with: Some(
9555 "NOTES.md is full: this write needs 99/50 chars. \
9556 Replace or remove existing entries first.\nCurrent entries:\n 1. an existing entry"
9557 .into(),
9558 ),
9559 ..Default::default()
9560 };
9561 let mut c = ctx(&uri, &messages, &caveats);
9562 c.note_sink = Some(&mut sink);
9563 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9564 .await
9565 .expect("chat_complete should succeed");
9566
9567 assert_eq!(reply, "I will curate first");
9568 assert!(
9569 error_seen.load(Ordering::SeqCst),
9570 "the curator error (full entry list + instruction) must reach the model verbatim"
9571 );
9572 }
9573}
9574
9575#[cfg(test)]
9586mod compression_loop_tests {
9587 use super::*;
9588 use crate::caveats::Caveats;
9589 use crate::{BackendKind, MemMessage};
9590 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9591 use std::sync::{Arc, Mutex};
9592 use wiremock::matchers::{method, path};
9593 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
9594
9595 const TASK: &str =
9596 "ACTIVE TASK GAUNTLET-7f3d9c: read big.txt until told to stop, then restate this marker";
9597 const CANNED_SUMMARY: &str =
9598 "## Active Task\nACTIVE TASK GAUNTLET-7f3d9c (canned summary)\n## Completed Actions\n1. read big.txt";
9599
9600 fn msgs() -> Vec<MemMessage> {
9601 vec![MemMessage::system("you are a test"), MemMessage::user(TASK)]
9602 }
9603
9604 fn ctx<'a>(
9605 server_uri: &'a str,
9606 messages: &'a [MemMessage],
9607 caveats: &'a Caveats,
9608 workspace: &'a str,
9609 ) -> ChatCtx<'a> {
9610 ChatCtx {
9611 url: server_uri,
9612 model: "test-model",
9613 kind: BackendKind::Ollama,
9614 api_key: None,
9615 messages,
9616 task: TASK,
9617 workspace,
9618 color: false,
9619 markdown: false,
9620 tool_offload: false,
9621 spill_store: None,
9622 compaction_store: None,
9623 scratchpad: false,
9624 scratchpad_store: None,
9625 code_search: None,
9626 experience_store: None,
9627 step_ledger: None,
9628 caveats,
9629 max_tool_rounds: 12,
9630 workflow_grace_rounds: 0,
9631 tool_output_lines: 2,
9632 debug: false,
9633 trace: false,
9634 num_ctx: None,
9635 connect_timeout_secs: 5,
9636 inference_timeout_secs: 30,
9637 mid_loop_trim_threshold: 40,
9638 mid_loop_trim_tokens: Some(5_000),
9641 max_ok_input: None,
9642 build_check_cmd: None,
9643 safe_context: None,
9644 recover_cw_400: None,
9645 note_sink: None,
9646 note_nudge: None,
9647 recall_source: None,
9648 memory_source: None,
9649 summarizer: None,
9650 compress_state: None,
9651 tool_events: None,
9652 phantom_reaches: None,
9653 permission_gate: None,
9654 on_round_usage: None,
9655 estimate_ratio: None,
9656 estimation: crate::tokens::TokenEstimation::default(),
9657 summary_input_cap_floor_chars: 8_192,
9658 exec_floor: None,
9659 write_ledger: None,
9660 cancel: None,
9661 git_tool: None,
9662 crew_runner: None,
9663 }
9664 }
9665
9666 fn gauntlet_workspace() -> tempfile::TempDir {
9668 let ws = tempfile::TempDir::new().unwrap();
9669 let line = "the quick brown newt compresses context without discarding it\n";
9670 std::fs::write(ws.path().join("big.txt"), line.repeat(64)).unwrap();
9671 ws
9672 }
9673
9674 fn body_json(req: &Request) -> serde_json::Value {
9675 serde_json::from_slice(&req.body).unwrap_or_default()
9676 }
9677
9678 fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
9679 body["messages"]
9680 .as_array()
9681 .map(|msgs| {
9682 msgs.iter().any(|m| {
9683 m["content"]
9684 .as_str()
9685 .map(|c| c.contains(needle))
9686 .unwrap_or(false)
9687 })
9688 })
9689 .unwrap_or(false)
9690 }
9691
9692 fn body_message_tokens(body: &serde_json::Value) -> usize {
9695 body["messages"]
9696 .as_array()
9697 .map(|msgs| {
9698 msgs.iter()
9699 .map(|m| {
9700 crate::tokens::TokenEstimation::default()
9701 .tokens_for_chars(m.to_string().chars().count())
9702 })
9703 .sum()
9704 })
9705 .unwrap_or(0)
9706 }
9707
9708 fn canned_summarizer(prompts: Arc<Mutex<Vec<String>>>) -> Summarizer {
9711 Box::new(move |prompt: String| {
9712 let prompts = prompts.clone();
9713 Box::pin(async move {
9714 prompts.lock().unwrap().push(prompt);
9715 Ok(CANNED_SUMMARY.to_string())
9716 })
9717 })
9718 }
9719
9720 struct GauntletResponder {
9724 final_answer: String,
9725 log: Arc<Mutex<Vec<(bool, usize)>>>,
9727 task_in_marker_request: Arc<AtomicBool>,
9728 summary_in_marker_request: Arc<AtomicBool>,
9729 old_placeholder_seen: Arc<AtomicBool>,
9730 static_marker_instead: bool,
9731 }
9732
9733 impl Respond for GauntletResponder {
9734 fn respond(&self, req: &Request) -> ResponseTemplate {
9735 let body = body_json(req);
9736 let has_marker = messages_contain(&body, SUMMARY_PREFIX);
9737 if !body["stream"].as_bool().unwrap_or(false) {
9738 self.log
9739 .lock()
9740 .unwrap()
9741 .push((has_marker, body_message_tokens(&body)));
9742 }
9743 if messages_contain(&body, "earlier tool-call messages omitted") {
9744 self.old_placeholder_seen.store(true, Ordering::SeqCst);
9745 }
9746 if has_marker {
9747 if messages_contain(&body, TASK) {
9748 self.task_in_marker_request.store(true, Ordering::SeqCst);
9749 }
9750 let summary_needle = if self.static_marker_instead {
9751 "Summary generation was unavailable."
9752 } else {
9753 CANNED_SUMMARY
9754 };
9755 if messages_contain(&body, summary_needle) {
9756 self.summary_in_marker_request.store(true, Ordering::SeqCst);
9757 }
9758 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
9759 "message": { "content": self.final_answer }
9760 }));
9761 }
9762 if body.get("tools").is_some() {
9763 ResponseTemplate::new(200).set_body_json(serde_json::json!({
9764 "message": { "content": "", "tool_calls": [{
9765 "function": { "name": "read_file", "arguments": { "path": "big.txt" } }
9766 }]}
9767 }))
9768 } else {
9769 ResponseTemplate::new(200)
9770 .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
9771 }
9772 }
9773 }
9774
9775 #[tokio::test]
9779 async fn active_task_survives_compression() {
9780 let server = MockServer::start().await;
9781 let log = Arc::new(Mutex::new(Vec::new()));
9782 let task_in_marker = Arc::new(AtomicBool::new(false));
9783 let summary_in_marker = Arc::new(AtomicBool::new(false));
9784 let old_placeholder = Arc::new(AtomicBool::new(false));
9785 Mock::given(method("POST"))
9786 .and(path("/api/chat"))
9787 .respond_with(GauntletResponder {
9788 final_answer: "the marker is GAUNTLET-7f3d9c".into(),
9789 log: log.clone(),
9790 task_in_marker_request: task_in_marker.clone(),
9791 summary_in_marker_request: summary_in_marker.clone(),
9792 old_placeholder_seen: old_placeholder.clone(),
9793 static_marker_instead: false,
9794 })
9795 .mount(&server)
9796 .await;
9797
9798 let ws = gauntlet_workspace();
9799 let workspace = ws.path().to_string_lossy().to_string();
9800 let messages = msgs();
9801 let caveats = Caveats::top();
9802 let uri = server.uri();
9803 let prompts = Arc::new(Mutex::new(Vec::new()));
9804 let summarizer = canned_summarizer(prompts.clone());
9805 let mut compress_state = CompressState::new();
9806 let mut c = ctx(&uri, &messages, &caveats, &workspace);
9807 c.summarizer = Some(&*summarizer);
9808 c.compress_state = Some(&mut compress_state);
9809 let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
9810 .await
9811 .expect("chat_complete should succeed");
9812
9813 assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
9815 assert_eq!(hallu, 0);
9816
9817 let prompts = prompts.lock().unwrap();
9820 assert_eq!(prompts.len(), 1, "one compression, one summary request");
9821 assert!(
9822 prompts[0].contains(TASK),
9823 "summary request must quote the task verbatim"
9824 );
9825
9826 assert!(task_in_marker.load(Ordering::SeqCst), "B5 property");
9829 assert!(summary_in_marker.load(Ordering::SeqCst));
9830 assert!(
9831 !old_placeholder.load(Ordering::SeqCst),
9832 "the old amputation placeholder must never be dispatched"
9833 );
9834
9835 let log = log.lock().unwrap();
9838 let before = log
9839 .iter()
9840 .filter(|(m, _)| !m)
9841 .map(|&(_, t)| t)
9842 .max()
9843 .expect("pre-compression requests were dispatched");
9844 let after = log
9845 .iter()
9846 .find(|(m, _)| *m)
9847 .map(|&(_, t)| t)
9848 .expect("a compressed request was dispatched");
9849 println!("e2e reclaim: ~{before} -> ~{after} est. message tokens");
9850 assert!(
9851 after < before * 6 / 10,
9852 "compression must reclaim >40% here (got {before} -> {after})"
9853 );
9854 }
9855
9856 #[tokio::test]
9864 async fn first_turn_over_num_ctx_ceiling_compresses_before_dispatch() {
9865 let server = MockServer::start().await;
9866 let log = Arc::new(Mutex::new(Vec::new()));
9867 let task_in_marker = Arc::new(AtomicBool::new(false));
9868 let summary_in_marker = Arc::new(AtomicBool::new(false));
9869 let old_placeholder = Arc::new(AtomicBool::new(false));
9870 Mock::given(method("POST"))
9871 .and(path("/api/chat"))
9872 .respond_with(GauntletResponder {
9873 final_answer: "the marker is GAUNTLET-7f3d9c".into(),
9874 log: log.clone(),
9875 task_in_marker_request: task_in_marker.clone(),
9876 summary_in_marker_request: summary_in_marker.clone(),
9877 old_placeholder_seen: old_placeholder.clone(),
9878 static_marker_instead: false,
9879 })
9880 .mount(&server)
9881 .await;
9882
9883 let filler = "the quick brown newt reads three fifty-kilobyte fixtures\n".repeat(50);
9892 let mut messages = vec![MemMessage::system("you are a test"), MemMessage::user(TASK)];
9893 for _ in 0..12 {
9894 messages.push(MemMessage::assistant(format!("file contents: {filler}")));
9895 messages.push(MemMessage::user("continue"));
9896 }
9897
9898 let ws = gauntlet_workspace();
9899 let workspace = ws.path().to_string_lossy().to_string();
9900 let caveats = Caveats::top();
9901 let uri = server.uri();
9902 let prompts = Arc::new(Mutex::new(Vec::new()));
9903 let summarizer = canned_summarizer(prompts.clone());
9904 let mut compress_state = CompressState::new();
9905 let mut c = ctx(&uri, &messages, &caveats, &workspace);
9906 c.max_ok_input = None;
9910 c.safe_context = None;
9911 c.mid_loop_trim_tokens = None;
9912 c.num_ctx = Some(4_096);
9913 c.summarizer = Some(&*summarizer);
9914 c.compress_state = Some(&mut compress_state);
9915 let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
9916 .await
9917 .expect("the first turn must complete");
9918
9919 assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
9922
9923 assert!(
9931 !prompts.lock().unwrap().is_empty(),
9932 "compression ran before the first dispatch (≥1 bounded summary request)"
9933 );
9934 let log = log.lock().unwrap();
9935 let (first_had_marker, first_tokens) =
9936 *log.first().expect("at least one request dispatched");
9937 assert!(
9938 first_had_marker,
9939 "B6: the first dispatched request must already be compressed — \
9940 pre-#282 it went out raw at ~9k tokens"
9941 );
9942
9943 assert!(
9946 first_tokens <= 3_276,
9947 "first dispatch must fit the num_ctx input ceiling \
9948 (got ~{first_tokens} est. message tokens > 3,276)"
9949 );
9950
9951 assert!(task_in_marker.load(Ordering::SeqCst), "task survives");
9953 assert!(summary_in_marker.load(Ordering::SeqCst), "summary present");
9954 assert!(!old_placeholder.load(Ordering::SeqCst));
9955 }
9956
9957 #[tokio::test]
9960 async fn summarizer_500_degrades_to_static_marker_and_turn_completes() {
9961 let server = MockServer::start().await;
9962 Mock::given(method("POST"))
9963 .and(path("/summarize"))
9964 .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
9965 .mount(&server)
9966 .await;
9967 let log = Arc::new(Mutex::new(Vec::new()));
9968 let task_in_marker = Arc::new(AtomicBool::new(false));
9969 let static_in_marker = Arc::new(AtomicBool::new(false));
9970 let old_placeholder = Arc::new(AtomicBool::new(false));
9971 Mock::given(method("POST"))
9972 .and(path("/api/chat"))
9973 .respond_with(GauntletResponder {
9974 final_answer: "completed despite summarizer outage".into(),
9975 log: log.clone(),
9976 task_in_marker_request: task_in_marker.clone(),
9977 summary_in_marker_request: static_in_marker.clone(),
9978 old_placeholder_seen: old_placeholder.clone(),
9979 static_marker_instead: true,
9980 })
9981 .mount(&server)
9982 .await;
9983
9984 let attempts = Arc::new(AtomicUsize::new(0));
9986 let summarize_url = format!("{}/summarize", server.uri());
9987 let attempts_in = attempts.clone();
9988 let summarizer: Summarizer = Box::new(move |prompt: String| {
9989 let url = summarize_url.clone();
9990 let attempts = attempts_in.clone();
9991 Box::pin(async move {
9992 attempts.fetch_add(1, Ordering::SeqCst);
9993 let resp = reqwest::Client::new()
9994 .post(&url)
9995 .body(prompt)
9996 .send()
9997 .await?;
9998 if !resp.status().is_success() {
9999 anyhow::bail!("summarizer endpoint {}", resp.status());
10000 }
10001 Ok(resp.text().await?)
10002 })
10003 });
10004
10005 let ws = gauntlet_workspace();
10006 let workspace = ws.path().to_string_lossy().to_string();
10007 let messages = msgs();
10008 let caveats = Caveats::top();
10009 let uri = server.uri();
10010 let mut compress_state = CompressState::new();
10011 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10012 c.summarizer = Some(&*summarizer);
10013 c.compress_state = Some(&mut compress_state);
10014 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10015 .await
10016 .expect("a summarizer failure must never abort the turn");
10017
10018 assert_eq!(reply, "completed despite summarizer outage");
10019 assert!(
10020 attempts.load(Ordering::SeqCst) >= 1,
10021 "the summarizer endpoint must have been attempted"
10022 );
10023 assert!(
10024 static_in_marker.load(Ordering::SeqCst),
10025 "the static fallback marker must reach the model"
10026 );
10027 assert!(task_in_marker.load(Ordering::SeqCst), "task still anchored");
10028 assert!(!old_placeholder.load(Ordering::SeqCst));
10029 }
10030
10031 struct OpenAiGauntletResponder {
10034 final_answer: String,
10035 task_in_marker_request: Arc<AtomicBool>,
10036 summary_in_marker_request: Arc<AtomicBool>,
10037 }
10038
10039 impl Respond for OpenAiGauntletResponder {
10040 fn respond(&self, req: &Request) -> ResponseTemplate {
10041 let body = body_json(req);
10042 if messages_contain(&body, SUMMARY_PREFIX) {
10043 if messages_contain(&body, TASK) {
10044 self.task_in_marker_request.store(true, Ordering::SeqCst);
10045 }
10046 if messages_contain(&body, CANNED_SUMMARY) {
10047 self.summary_in_marker_request.store(true, Ordering::SeqCst);
10048 }
10049 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10050 "choices": [{ "message": { "content": self.final_answer } }]
10051 }));
10052 }
10053 if body.get("tools").is_some() {
10054 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10055 "choices": [{ "message": {
10056 "content": null,
10057 "tool_calls": [{
10058 "id": "call_1",
10059 "type": "function",
10060 "function": { "name": "read_file", "arguments": "{\"path\":\"big.txt\"}" }
10061 }]
10062 }}]
10063 }))
10064 } else {
10065 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10066 "choices": [{ "message": { "content": "cap exit" } }]
10067 }))
10068 }
10069 }
10070 }
10071
10072 #[tokio::test]
10073 async fn openai_loop_compresses_with_the_same_pipeline() {
10074 let server = MockServer::start().await;
10075 let task_in_marker = Arc::new(AtomicBool::new(false));
10076 let summary_in_marker = Arc::new(AtomicBool::new(false));
10077 Mock::given(method("POST"))
10078 .and(path("/v1/chat/completions"))
10079 .respond_with(OpenAiGauntletResponder {
10080 final_answer: "openai: marker is GAUNTLET-7f3d9c".into(),
10081 task_in_marker_request: task_in_marker.clone(),
10082 summary_in_marker_request: summary_in_marker.clone(),
10083 })
10084 .mount(&server)
10085 .await;
10086
10087 let ws = gauntlet_workspace();
10088 let workspace = ws.path().to_string_lossy().to_string();
10089 let messages = msgs();
10090 let caveats = Caveats::top();
10091 let uri = server.uri();
10092 let prompts = Arc::new(Mutex::new(Vec::new()));
10093 let summarizer = canned_summarizer(prompts.clone());
10094 let mut compress_state = CompressState::new();
10095 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10096 c.kind = BackendKind::Openai;
10097 c.api_key = Some("sk-test");
10098 c.summarizer = Some(&*summarizer);
10099 c.compress_state = Some(&mut compress_state);
10100 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10101 .await
10102 .expect("openai loop should succeed");
10103
10104 assert_eq!(reply, "openai: marker is GAUNTLET-7f3d9c");
10105 assert!(!prompts.lock().unwrap().is_empty(), "summarizer engaged");
10106 assert!(task_in_marker.load(Ordering::SeqCst));
10107 assert!(summary_in_marker.load(Ordering::SeqCst));
10108 }
10109
10110 type HaulLog = Arc<Mutex<Vec<(usize, Option<usize>)>>>;
10119
10120 struct LongHaulResponder {
10130 path: &'static str,
10131 log: HaulLog,
10132 }
10133
10134 impl Respond for LongHaulResponder {
10135 fn respond(&self, req: &Request) -> ResponseTemplate {
10136 let body = body_json(req);
10137 if body.get("tools").is_some() {
10138 let empty = Vec::new();
10139 let msgs = body["messages"].as_array().unwrap_or(&empty);
10140 let last_tool_len = msgs
10141 .iter()
10142 .rev()
10143 .find(|m| m["role"].as_str() == Some("tool"))
10144 .and_then(|m| m["content"].as_str())
10145 .map(|c| c.chars().count());
10146 self.log.lock().unwrap().push((msgs.len(), last_tool_len));
10147 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10148 "message": { "content": "", "tool_calls": [
10149 { "function": { "name": "apply_patch", "arguments": {} } },
10150 { "function": { "name": "read_file", "arguments": { "path": self.path } } }
10151 ]}
10152 }))
10153 } else {
10154 ResponseTemplate::new(200).set_body_json(
10155 serde_json::json!({ "message": { "content": "long haul done" } }),
10156 )
10157 }
10158 }
10159 }
10160
10161 fn multi_turn_msgs() -> Vec<MemMessage> {
10164 vec![
10165 MemMessage::system("you are a test"),
10166 MemMessage::user("prior turn: inspect the workspace"),
10167 MemMessage::assistant("inspected — looks healthy"),
10168 MemMessage::user("prior turn: run the linters"),
10169 MemMessage::assistant("linters are green"),
10170 MemMessage::user("prior turn: sketch a fix"),
10171 MemMessage::assistant("sketched in my head"),
10172 MemMessage::user(TASK),
10173 ]
10174 }
10175
10176 async fn run_long_haul(
10179 mem_messages: Vec<MemMessage>,
10180 rounds: usize,
10181 threshold: usize,
10182 file: &'static str,
10183 content: &str,
10184 ) -> (Vec<(usize, Option<usize>)>, usize, String, bool) {
10185 let server = MockServer::start().await;
10186 let log = Arc::new(Mutex::new(Vec::new()));
10187 Mock::given(method("POST"))
10188 .and(path("/api/chat"))
10189 .respond_with(LongHaulResponder {
10190 path: file,
10191 log: log.clone(),
10192 })
10193 .mount(&server)
10194 .await;
10195 let ws = tempfile::TempDir::new().unwrap();
10196 std::fs::write(ws.path().join(file), content).unwrap();
10197 let workspace = ws.path().to_string_lossy().to_string();
10198 let caveats = Caveats::top();
10199 let uri = server.uri();
10200 let prompts = Arc::new(Mutex::new(Vec::new()));
10201 let summarizer = canned_summarizer(prompts.clone());
10202 let mut compress_state = CompressState::new();
10203 let mut c = ctx(&uri, &mem_messages, &caveats, &workspace);
10204 c.max_tool_rounds = rounds;
10205 c.mid_loop_trim_threshold = threshold;
10206 c.mid_loop_trim_tokens = None; c.summarizer = Some(&*summarizer);
10208 c.compress_state = Some(&mut compress_state);
10209 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10210 .await
10211 .expect("the long haul must complete");
10212 let log = log.lock().unwrap().clone();
10213 let calls = prompts.lock().unwrap().len();
10214 (log, calls, reply, compress_state.is_disabled())
10215 }
10216
10217 #[tokio::test]
10224 async fn forty_rounds_single_turn_stay_bounded_with_fresh_results_intact() {
10225 let line = "the quick brown newt compresses context without discarding it\n";
10226 let threshold = 15usize;
10227 let (log, summarizer_calls, reply, latched) =
10228 run_long_haul(msgs(), 40, threshold, "big.txt", &line.repeat(64)).await;
10229
10230 assert_eq!(reply, "long haul done");
10231 assert!(!latched, "count-only pressure must never latch anti-thrash");
10232 assert_eq!(log.len(), 40, "all 40 tool rounds dispatched");
10233 for (round, (len, last_tool)) in log.iter().enumerate() {
10234 assert!(
10235 *len <= threshold + 6,
10236 "round {round}: dispatched {len} messages — the count must stay \
10237 bounded after every compression (threshold {threshold} + slack)"
10238 );
10239 if let Some(n) = last_tool {
10240 assert!(
10241 *n > 1_000,
10242 "round {round}: the fresh tool result was destroyed before \
10243 dispatch ({n} chars — a one-liner)"
10244 );
10245 }
10246 }
10247 assert!(summarizer_calls >= 2, "the long haul compresses repeatedly");
10248 assert!(
10249 summarizer_calls <= 16,
10250 "summarizer invocations must be bounded, not per-round \
10251 (got {summarizer_calls} in 40 rounds)"
10252 );
10253 let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
10254 println!(
10255 "forty-round trace: max dispatched len {max_len}, \
10256 summarizer calls {summarizer_calls}"
10257 );
10258 }
10259
10260 #[tokio::test]
10266 async fn thirty_rounds_multi_turn_stay_bounded_with_fresh_results_intact() {
10267 let line = "the quick brown newt compresses context without discarding it\n";
10268 let threshold = 15usize;
10269 let (log, summarizer_calls, reply, latched) = run_long_haul(
10270 multi_turn_msgs(),
10271 30,
10272 threshold,
10273 "big.txt",
10274 &line.repeat(64),
10275 )
10276 .await;
10277
10278 assert_eq!(reply, "long haul done");
10279 assert!(!latched, "count-only pressure must never latch anti-thrash");
10280 assert_eq!(log.len(), 30, "all 30 tool rounds dispatched");
10281 for (round, (len, last_tool)) in log.iter().enumerate() {
10282 assert!(
10283 *len <= threshold + 6,
10284 "round {round}: dispatched {len} messages — bounded"
10285 );
10286 if let Some(n) = last_tool {
10287 assert!(
10288 *n > 1_000,
10289 "round {round}: fresh tool result destroyed pre-dispatch ({n} chars)"
10290 );
10291 }
10292 }
10293 assert!(summarizer_calls >= 2);
10294 assert!(
10295 summarizer_calls <= 14,
10296 "summarizer invocations must be bounded, not per-round \
10297 (got {summarizer_calls} in 30 rounds)"
10298 );
10299 let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
10300 println!(
10301 "thirty-round multi-turn trace: max dispatched len {max_len}, \
10302 summarizer calls {summarizer_calls}"
10303 );
10304 }
10305
10306 #[tokio::test]
10313 async fn count_only_low_reclaim_never_latches_or_bails() {
10314 let (log, _calls, reply, latched) =
10315 run_long_haul(multi_turn_msgs(), 30, 15, "small.txt", &"x".repeat(600)).await;
10316 assert_eq!(reply, "long haul done", "the turn must complete — no bail");
10317 assert!(
10318 !latched,
10319 "count-only passes must never latch the disable switch"
10320 );
10321 assert_eq!(log.len(), 30, "all 30 rounds ran (no Refused early-exit)");
10322 }
10323
10324 type NudgedLog = Arc<Mutex<Vec<(usize, bool, Vec<usize>)>>>;
10333
10334 struct NudgedHaulResponder {
10343 log: NudgedLog,
10344 }
10345
10346 impl Respond for NudgedHaulResponder {
10347 fn respond(&self, req: &Request) -> ResponseTemplate {
10348 let body = body_json(req);
10349 if body.get("tools").is_some() {
10350 let empty = Vec::new();
10351 let msgs = body["messages"].as_array().unwrap_or(&empty);
10352 let group_start = msgs
10353 .iter()
10354 .rposition(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()))
10355 .map(|i| i + 1)
10356 .unwrap_or(msgs.len());
10357 let group_lens: Vec<usize> = msgs[group_start..]
10358 .iter()
10359 .filter(|m| m["role"].as_str() == Some("tool"))
10360 .filter_map(|m| m["content"].as_str())
10361 .map(|c| c.chars().count())
10362 .collect();
10363 let nudged = messages_contain(&body, "read-only rounds so far");
10364 self.log
10365 .lock()
10366 .unwrap()
10367 .push((msgs.len(), nudged, group_lens));
10368 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10371 "message": { "role": "assistant", "content": "", "tool_calls": [
10372 { "function": { "name": "read_file", "arguments": { "path": "big1.txt" } } },
10373 { "function": { "name": "read_file", "arguments": { "path": "big2.txt" } } },
10374 { "function": { "name": "read_file", "arguments": { "path": "small.txt" } } }
10375 ]}
10376 }))
10377 } else {
10378 ResponseTemplate::new(200).set_body_json(
10379 serde_json::json!({ "message": { "content": "nudged haul done" } }),
10380 )
10381 }
10382 }
10383 }
10384
10385 #[tokio::test]
10397 async fn nudged_long_haul_keeps_fresh_group_results_intact() {
10398 let server = MockServer::start().await;
10399 let log: NudgedLog = Arc::new(Mutex::new(Vec::new()));
10400 Mock::given(method("POST"))
10401 .and(path("/api/chat"))
10402 .respond_with(NudgedHaulResponder { log: log.clone() })
10403 .mount(&server)
10404 .await;
10405 let ws = tempfile::TempDir::new().unwrap();
10408 std::fs::write(
10409 ws.path().join("big1.txt"),
10410 "the first big fixture keeps unseen results intact\n".repeat(200),
10411 )
10412 .unwrap();
10413 std::fs::write(
10414 ws.path().join("big2.txt"),
10415 "the second big fixture must survive every nudge round\n".repeat(200),
10416 )
10417 .unwrap();
10418 std::fs::write(
10419 ws.path().join("small.txt"),
10420 "the small newest fixture stays whole\n".repeat(5),
10421 )
10422 .unwrap();
10423 let workspace = ws.path().to_string_lossy().to_string();
10424 let messages = msgs();
10425 let caveats = Caveats::top();
10426 let uri = server.uri();
10427 let prompts = Arc::new(Mutex::new(Vec::new()));
10428 let summarizer = canned_summarizer(prompts.clone());
10429 let mut compress_state = CompressState::new();
10430 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10431 c.max_tool_rounds = 12;
10432 c.mid_loop_trim_tokens = Some(4_000); c.summarizer = Some(&*summarizer);
10434 c.compress_state = Some(&mut compress_state);
10435 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10436 .await
10437 .expect("the nudged haul must complete");
10438
10439 assert_eq!(reply, "nudged haul done");
10440 assert!(
10441 !compress_state.is_disabled(),
10442 "real reclaims every round must never latch anti-thrash"
10443 );
10444 let log = log.lock().unwrap();
10445 for (round, entry) in log.iter().enumerate() {
10446 println!("nudged haul round {round}: {entry:?}");
10447 }
10448 assert_eq!(log.len(), 12, "all 12 tool rounds dispatched");
10449 let nudged_rounds = log.iter().filter(|(_, n, _)| *n).count();
10450 assert!(
10451 nudged_rounds >= 2,
10452 "the read-only nudge regime must actually be active \
10453 (got {nudged_rounds} nudged requests)"
10454 );
10455 for (round, (len, nudged, group_lens)) in log.iter().enumerate() {
10456 if group_lens.is_empty() {
10457 continue; }
10459 assert_eq!(group_lens.len(), 3, "round {round}: pairing intact");
10460 assert!(
10462 group_lens[2] > 150,
10463 "round {round} (nudged={nudged}): the newest fresh result \
10464 was destroyed pre-dispatch ({} chars)",
10465 group_lens[2]
10466 );
10467 assert!(
10471 group_lens[1] > 1_000,
10472 "round {round} (nudged={nudged}, {len} msgs): the middle \
10473 fresh result was destroyed pre-dispatch ({} chars)",
10474 group_lens[1]
10475 );
10476 }
10477 let max_tokens = log.iter().map(|(l, _, _)| *l).max().unwrap();
10478 println!(
10479 "nudged haul trace: {nudged_rounds} nudged rounds, \
10480 max dispatched len {max_tokens}, group lens e.g. {:?}",
10481 log.last().unwrap().2
10482 );
10483 }
10484
10485 type OversizedLog = Arc<Mutex<Vec<(usize, bool, bool, bool, bool)>>>;
10488
10489 struct OversizedRoundResponder {
10492 log: OversizedLog,
10493 }
10494
10495 impl Respond for OversizedRoundResponder {
10496 fn respond(&self, req: &Request) -> ResponseTemplate {
10497 let body = body_json(req);
10498 let a_onelined = messages_contain(&body, "[read_file] read 'a.txt'");
10499 if !body["stream"].as_bool().unwrap_or(false) {
10500 self.log.lock().unwrap().push((
10501 body_message_tokens(&body),
10502 a_onelined,
10503 messages_contain(&body, "[read_file] read 'b.txt'"),
10504 messages_contain(&body, "NEWEST-PAYLOAD-C-INTACT"),
10505 messages_contain(&body, TASK),
10506 ));
10507 }
10508 if a_onelined {
10509 return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10510 "message": { "content": "the three files are summarized" }
10511 }));
10512 }
10513 if body.get("tools").is_some() {
10514 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10517 "message": { "role": "assistant", "content": "", "tool_calls": [
10518 { "function": { "name": "read_file", "arguments": { "path": "a.txt" } } },
10519 { "function": { "name": "read_file", "arguments": { "path": "b.txt" } } },
10520 { "function": { "name": "read_file", "arguments": { "path": "c.txt" } } }
10521 ]}
10522 }))
10523 } else {
10524 ResponseTemplate::new(200)
10525 .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
10526 }
10527 }
10528 }
10529
10530 #[tokio::test]
10540 async fn oversized_single_round_dispatches_within_the_window() {
10541 let server = MockServer::start().await;
10542 let log: OversizedLog = Arc::new(Mutex::new(Vec::new()));
10543 Mock::given(method("POST"))
10544 .and(path("/api/chat"))
10545 .respond_with(OversizedRoundResponder { log: log.clone() })
10546 .mount(&server)
10547 .await;
10548 let ws = tempfile::TempDir::new().unwrap();
10554 std::fs::write(
10555 ws.path().join("a.txt"),
10556 "fixture a arrives first in the one giant trailing group\n".repeat(125),
10557 )
10558 .unwrap();
10559 std::fs::write(
10560 ws.path().join("b.txt"),
10561 "fixture b arrives second and is older than the newest\n".repeat(130),
10562 )
10563 .unwrap();
10564 std::fs::write(
10565 ws.path().join("c.txt"),
10566 format!(
10567 "{}NEWEST-PAYLOAD-C-INTACT\n",
10568 "fixture c arrives last and must reach the model whole\n".repeat(130)
10569 ),
10570 )
10571 .unwrap();
10572 let workspace = ws.path().to_string_lossy().to_string();
10573 let messages = msgs();
10574 let caveats = Caveats::top();
10575 let uri = server.uri();
10576 let prompts = Arc::new(Mutex::new(Vec::new()));
10577 let summarizer = canned_summarizer(prompts.clone());
10578 let mut compress_state = CompressState::new();
10579 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10580 c.max_ok_input = None;
10583 c.safe_context = None;
10584 c.mid_loop_trim_tokens = None;
10585 c.num_ctx = Some(4_096);
10586 c.summarizer = Some(&*summarizer);
10587 c.compress_state = Some(&mut compress_state);
10588 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10589 .await
10590 .expect("the oversized round must complete");
10591
10592 let log = log.lock().unwrap();
10593 for (i, entry) in log.iter().enumerate() {
10594 println!("oversized round request {i}: {entry:?}");
10595 }
10596 assert_eq!(reply, "the three files are summarized");
10598
10599 for (i, &(tokens, ..)) in log.iter().enumerate() {
10604 assert!(
10605 tokens <= 3_276,
10606 "request {i} dispatched over the window: ~{tokens} est. \
10607 message tokens > 3,276 (the pre-#285 B6 residual)"
10608 );
10609 }
10610
10611 let (tokens, _, b_onelined, c_intact, task_present) = *log
10612 .iter()
10613 .find(|(_, a, ..)| *a)
10614 .expect("a dispatch with a.txt one-lined must have happened");
10615 assert!(
10616 b_onelined,
10617 "older members one-lined in order: b.txt one-liner missing"
10618 );
10619 assert!(
10620 c_intact,
10621 "#285: the NEWEST result must reach the model whole"
10622 );
10623 assert!(task_present, "the task survives the within-group reclaim");
10624 assert!(
10627 tokens <= 3_276,
10628 "#285: the reclaimed dispatch must fit the window \
10629 (got ~{tokens} est. message tokens > 3,276)"
10630 );
10631 println!(
10632 "#285 e2e trace: reclaimed dispatch ~{tokens} est. tokens \
10633 (ceiling 3,276), a/b one-lined, c intact"
10634 );
10635 }
10636
10637 #[tokio::test]
10644 async fn hard_budget_thrash_latches_then_bails_with_named_error() {
10645 let server = MockServer::start().await;
10646 let log = Arc::new(Mutex::new(Vec::new()));
10647 Mock::given(method("POST"))
10648 .and(path("/api/chat"))
10649 .respond_with(LongHaulResponder {
10650 path: "tiny.txt",
10651 log: log.clone(),
10652 })
10653 .mount(&server)
10654 .await;
10655 let ws = tempfile::TempDir::new().unwrap();
10656 std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
10657 let workspace = ws.path().to_string_lossy().to_string();
10658 let messages = vec![
10661 MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
10662 MemMessage::user(TASK),
10663 ];
10664 let caveats = Caveats::top();
10665 let uri = server.uri();
10666 let mut compress_state = CompressState::new();
10667 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10668 c.mid_loop_trim_tokens = Some(50); c.compress_state = Some(&mut compress_state);
10670 let err = chat_complete(c, &mut NoMcp)
10671 .await
10672 .expect_err("the post-latch over-budget round must refuse the send");
10673 let msg = err.to_string();
10674 assert!(msg.contains("exceeds the model's input budget"), "{msg}");
10675 assert!(msg.contains("auto-compression is disabled"), "{msg}");
10676 assert!(compress_state.is_disabled(), "anti-thrash latched first");
10677 assert!(
10678 log.lock().unwrap().len() <= 3,
10679 "the refusal must stop the loop within a round or two"
10680 );
10681 }
10682
10683 #[tokio::test]
10692 async fn lone_hwm_budget_fails_open_and_does_not_bail() {
10693 let server = MockServer::start().await;
10694 let log = Arc::new(Mutex::new(Vec::new()));
10695 Mock::given(method("POST"))
10696 .and(path("/api/chat"))
10697 .respond_with(LongHaulResponder {
10698 path: "tiny.txt",
10699 log: log.clone(),
10700 })
10701 .mount(&server)
10702 .await;
10703 let ws = tempfile::TempDir::new().unwrap();
10704 std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
10705 let workspace = ws.path().to_string_lossy().to_string();
10706 let messages = vec![
10707 MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
10708 MemMessage::user(TASK),
10709 ];
10710 let caveats = Caveats::top();
10711 let uri = server.uri();
10712 let mut compress_state = CompressState::new();
10713 let mut c = ctx(&uri, &messages, &caveats, &workspace);
10714 c.mid_loop_trim_tokens = None;
10716 c.max_ok_input = Some(50); c.safe_context = None; c.num_ctx = None; c.compress_state = Some(&mut compress_state);
10720 let result = chat_complete(c, &mut NoMcp).await;
10721 if let Err(e) = &result {
10723 let msg = e.to_string();
10724 assert!(
10725 !msg.contains("exceeds the model's input budget"),
10726 "a lone-HWM budget must never refuse the send: {msg}"
10727 );
10728 }
10729 assert!(
10730 compress_state.is_disabled(),
10731 "anti-thrash still latches on the poor passes"
10732 );
10733 assert!(
10734 log.lock().unwrap().len() > 3,
10735 "the loop kept dispatching past the latch instead of bailing"
10736 );
10737 }
10738}
10739
10740#[cfg(test)]
10746mod observation_hook_tests {
10747 use super::*;
10748 use crate::caveats::Caveats;
10749 use crate::{BackendKind, MemMessage};
10750 use std::sync::atomic::{AtomicUsize, Ordering};
10751 use std::sync::Arc;
10752 use wiremock::matchers::{method, path};
10753 use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
10754
10755 fn ctx<'a>(
10756 server_uri: &'a str,
10757 messages: &'a [MemMessage],
10758 caveats: &'a Caveats,
10759 ) -> ChatCtx<'a> {
10760 ChatCtx {
10761 url: server_uri,
10762 model: "test-model",
10763 kind: BackendKind::Ollama,
10764 api_key: None,
10765 messages,
10766 task: "do the thing",
10767 workspace: ".",
10768 color: false,
10769 markdown: false,
10770 tool_offload: false,
10771 spill_store: None,
10772 compaction_store: None,
10773 scratchpad: false,
10774 scratchpad_store: None,
10775 code_search: None,
10776 experience_store: None,
10777 step_ledger: None,
10778 caveats,
10779 max_tool_rounds: 8,
10780 workflow_grace_rounds: 0,
10781 tool_output_lines: 20,
10782 debug: false,
10783 trace: false,
10784 num_ctx: None,
10785 connect_timeout_secs: 5,
10786 inference_timeout_secs: 30,
10787 mid_loop_trim_threshold: 40,
10788 mid_loop_trim_tokens: None,
10789 max_ok_input: None,
10790 build_check_cmd: None,
10791 safe_context: None,
10792 recover_cw_400: None,
10793 note_sink: None,
10794 note_nudge: None,
10795 recall_source: None,
10796 memory_source: None,
10797 summarizer: None,
10798 compress_state: None,
10799 tool_events: None,
10800 phantom_reaches: None,
10801 permission_gate: None,
10802 on_round_usage: None,
10803 estimate_ratio: None,
10804 estimation: crate::tokens::TokenEstimation::default(),
10805 summary_input_cap_floor_chars: 8_192,
10806 exec_floor: None,
10808 write_ledger: None,
10809 cancel: None,
10810 git_tool: None,
10811 crew_runner: None,
10812 }
10813 }
10814
10815 fn body_json(req: &Request) -> serde_json::Value {
10816 serde_json::from_slice(&req.body).unwrap_or_default()
10817 }
10818
10819 fn is_stream(req: &Request) -> bool {
10820 body_json(req)["stream"].as_bool().unwrap_or(false)
10821 }
10822
10823 fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
10824 let body: String = lines
10825 .iter()
10826 .map(|l| format!("{l}\n"))
10827 .collect::<Vec<_>>()
10828 .join("");
10829 ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
10830 }
10831
10832 struct AcceptsLargePrompts {
10835 tools_rounds: Arc<AtomicUsize>,
10836 }
10837 impl Respond for AcceptsLargePrompts {
10838 fn respond(&self, req: &Request) -> ResponseTemplate {
10839 if is_stream(req) {
10840 return ndjson(&[serde_json::json!({
10841 "message": {"content": "budget raised, here is the answer"},
10842 "done": true, "prompt_eval_count": 8_700, "eval_count": 12
10843 })]);
10844 }
10845 let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
10846 if n < 2 {
10847 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10848 "message": {"content": "", "tool_calls": [{
10849 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
10850 }]},
10851 "prompt_eval_count": 8_734, "eval_count": 10,
10852 }))
10853 } else {
10854 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10855 "message": {"content": "budget raised, here is the answer"},
10856 "prompt_eval_count": 8_700, "eval_count": 12,
10857 }))
10858 }
10859 }
10860 }
10861
10862 #[tokio::test]
10870 async fn poisoned_low_budget_recovers_via_accepted_observation_and_raise() {
10871 let server = MockServer::start().await;
10872 let tools_rounds = Arc::new(AtomicUsize::new(0));
10873 Mock::given(method("POST"))
10874 .and(path("/api/chat"))
10875 .respond_with(AcceptsLargePrompts {
10876 tools_rounds: tools_rounds.clone(),
10877 })
10878 .mount(&server)
10879 .await;
10880
10881 let big_task = "study the workspace and report. ".repeat(380);
10884 let messages = vec![
10885 MemMessage::system("you are a test"),
10886 MemMessage::user(&big_task),
10887 ];
10888 let caveats = Caveats::top();
10889 let uri = server.uri();
10890 let mut observations: Vec<RoundObservation> = Vec::new();
10891 let mut hook = |obs: RoundObservation| observations.push(obs);
10892 let mut c = ctx(&uri, &messages, &caveats);
10893 c.max_ok_input = Some(2_000); c.on_round_usage = Some(&mut hook);
10895 let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
10896 .await
10897 .expect("the turn must complete — no Refused bail after the raise");
10898
10899 assert_eq!(reply, "budget raised, here is the answer");
10900 assert!(
10901 observations.iter().any(|o| matches!(
10902 o,
10903 RoundObservation::Accepted {
10904 prompt_tokens: 8_734,
10905 ..
10906 }
10907 )),
10908 "the accepted 8,734-token prompt must reach the hook: {observations:?}"
10909 );
10910 for o in &observations {
10913 if let RoundObservation::Accepted {
10914 estimated_tokens, ..
10915 } = o
10916 {
10917 assert!(*estimated_tokens > 0, "estimate rides along: {o:?}");
10918 }
10919 }
10920 }
10921
10922 struct ToolCallsWithUsage;
10925 impl Respond for ToolCallsWithUsage {
10926 fn respond(&self, req: &Request) -> ResponseTemplate {
10927 if body_json(req).get("tools").is_some() {
10928 ResponseTemplate::new(200).set_body_json(serde_json::json!({
10929 "message": {"content": "", "tool_calls": [{
10930 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
10931 }]},
10932 "prompt_eval_count": 100, "eval_count": 5,
10933 }))
10934 } else {
10935 ResponseTemplate::new(200)
10936 .set_body_json(serde_json::json!({"message": {"content": "cap exit"}}))
10937 }
10938 }
10939 }
10940
10941 #[tokio::test]
10947 async fn err_turn_still_delivered_accepted_observations_first() {
10948 let server = MockServer::start().await;
10949 Mock::given(method("POST"))
10950 .and(path("/api/chat"))
10951 .respond_with(ToolCallsWithUsage)
10952 .mount(&server)
10953 .await;
10954
10955 let messages = vec![
10958 MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
10959 MemMessage::user("do the thing"),
10960 ];
10961 let caveats = Caveats::top();
10962 let uri = server.uri();
10963 let mut compress_state = CompressState::new();
10964 let mut observations: Vec<RoundObservation> = Vec::new();
10965 let mut hook = |obs: RoundObservation| observations.push(obs);
10966 let mut c = ctx(&uri, &messages, &caveats);
10967 c.mid_loop_trim_tokens = Some(50);
10968 c.compress_state = Some(&mut compress_state);
10969 c.on_round_usage = Some(&mut hook);
10970 let err = chat_complete(c, &mut NoMcp)
10971 .await
10972 .expect_err("the post-latch over-budget round must refuse the send");
10973
10974 let msg = err.to_string();
10975 assert!(msg.contains("exceeds the model's input budget"), "{msg}");
10976 assert!(
10977 msg.contains("newt tunings reset test-model"),
10978 "the bail must name the model's reset command: {msg}"
10979 );
10980 assert!(
10981 observations.iter().any(|o| matches!(
10982 o,
10983 RoundObservation::Accepted {
10984 prompt_tokens: 100,
10985 ..
10986 }
10987 )),
10988 "accepted rounds before the bail must have been reported: {observations:?}"
10989 );
10990 }
10991
10992 struct ThinkingOnlyThenRecover {
10996 probes: Arc<AtomicUsize>,
10997 }
10998 impl Respond for ThinkingOnlyThenRecover {
10999 fn respond(&self, req: &Request) -> ResponseTemplate {
11000 if is_stream(req) {
11001 if self.probes.load(Ordering::SeqCst) <= 1 {
11002 ndjson(&[serde_json::json!({
11003 "message": {"content": ""}, "done": true,
11004 "prompt_eval_count": 9, "eval_count": 4
11005 })])
11006 } else {
11007 ndjson(&[serde_json::json!({
11008 "message": {"content": "recovered after thinking-only"},
11009 "done": true, "prompt_eval_count": 12, "eval_count": 3
11010 })])
11011 }
11012 } else {
11013 let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
11014 if n == 1 {
11015 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11016 "message": {
11017 "content": "",
11018 "thinking": "all reasoning, no final text"
11019 },
11020 "prompt_eval_count": 10, "eval_count": 2559,
11021 }))
11022 } else {
11023 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11024 "message": {"content": "recovered after thinking-only"},
11025 "prompt_eval_count": 12, "eval_count": 3,
11026 }))
11027 }
11028 }
11029 }
11030 }
11031
11032 #[tokio::test]
11033 async fn thinking_only_response_emits_one_thinking_only_observation() {
11034 let server = MockServer::start().await;
11035 let probes = Arc::new(AtomicUsize::new(0));
11036 Mock::given(method("POST"))
11037 .and(path("/api/chat"))
11038 .respond_with(ThinkingOnlyThenRecover {
11039 probes: probes.clone(),
11040 })
11041 .mount(&server)
11042 .await;
11043
11044 let messages = vec![
11045 MemMessage::system("you are a test"),
11046 MemMessage::user("do the thing"),
11047 ];
11048 let caveats = Caveats::top();
11049 let uri = server.uri();
11050 let mut observations: Vec<RoundObservation> = Vec::new();
11051 let mut hook = |obs: RoundObservation| observations.push(obs);
11052 let mut c = ctx(&uri, &messages, &caveats);
11053 c.on_round_usage = Some(&mut hook);
11054 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11055 .await
11056 .expect("the corrective retry recovers the turn");
11057
11058 assert_eq!(reply, "recovered after thinking-only");
11059 let thinking = observations
11060 .iter()
11061 .filter(|o| matches!(o, RoundObservation::ThinkingOnly))
11062 .count();
11063 assert_eq!(thinking, 1, "exactly once per turn: {observations:?}");
11064 assert!(
11065 observations
11066 .iter()
11067 .any(|o| matches!(o, RoundObservation::Accepted { .. })),
11068 "the recovered round is usable output: {observations:?}"
11069 );
11070 }
11071
11072 struct TruncationSuspectResponder {
11077 tools_rounds: Arc<AtomicUsize>,
11078 }
11079 impl Respond for TruncationSuspectResponder {
11080 fn respond(&self, req: &Request) -> ResponseTemplate {
11081 if is_stream(req) {
11082 return ndjson(&[serde_json::json!({
11083 "message": {"content": "suspect answer"}, "done": true,
11084 "prompt_eval_count": 4_000, "eval_count": 5
11085 })]);
11086 }
11087 let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
11088 if n == 0 {
11089 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11090 "message": {"content": "", "tool_calls": [{
11091 "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11092 }]},
11093 "prompt_eval_count": 4_000, "eval_count": 5,
11095 }))
11096 } else {
11097 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11098 "message": {"content": "suspect answer"},
11099 "prompt_eval_count": 4_000, "eval_count": 5,
11100 }))
11101 }
11102 }
11103 }
11104
11105 #[tokio::test]
11106 async fn truncation_suspect_rounds_emit_nothing() {
11107 let server = MockServer::start().await;
11108 let tools_rounds = Arc::new(AtomicUsize::new(0));
11109 Mock::given(method("POST"))
11110 .and(path("/api/chat"))
11111 .respond_with(TruncationSuspectResponder {
11112 tools_rounds: tools_rounds.clone(),
11113 })
11114 .mount(&server)
11115 .await;
11116
11117 let messages = vec![
11118 MemMessage::system("you are a test"),
11119 MemMessage::user("do the thing"),
11120 ];
11121 let caveats = Caveats::top();
11122 let uri = server.uri();
11123 let mut observations: Vec<RoundObservation> = Vec::new();
11124 let mut hook = |obs: RoundObservation| observations.push(obs);
11125 let mut c = ctx(&uri, &messages, &caveats);
11126 c.num_ctx = Some(4_096);
11127 c.on_round_usage = Some(&mut hook);
11128 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11129 .await
11130 .expect("suspect rounds still complete the turn");
11131
11132 assert_eq!(reply, "suspect answer");
11133 assert!(
11134 observations.is_empty(),
11135 "a possibly head-truncated prompt is evidence of nothing: \
11136 {observations:?}"
11137 );
11138 }
11139
11140 struct OpenAiAcceptsResponder;
11144 impl Respond for OpenAiAcceptsResponder {
11145 fn respond(&self, req: &Request) -> ResponseTemplate {
11146 if body_json(req).get("tools").is_some()
11147 && !body_json(req)["messages"]
11148 .as_array()
11149 .map(|m| m.iter().any(|x| x["role"] == "tool"))
11150 .unwrap_or(false)
11151 {
11152 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11153 "choices": [{"message": {
11154 "content": null,
11155 "tool_calls": [{
11156 "id": "call_1",
11157 "type": "function",
11158 "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
11159 }]
11160 }}],
11161 "usage": {"prompt_tokens": 5_120, "completion_tokens": 9},
11162 }))
11163 } else {
11164 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11165 "choices": [{"message": {"content": "openai accepted"}}],
11166 "usage": {"prompt_tokens": 5_200, "completion_tokens": 11},
11167 }))
11168 }
11169 }
11170 }
11171
11172 #[tokio::test]
11173 async fn openai_loop_reports_accepted_rounds() {
11174 let server = MockServer::start().await;
11175 Mock::given(method("POST"))
11176 .and(path("/v1/chat/completions"))
11177 .respond_with(OpenAiAcceptsResponder)
11178 .mount(&server)
11179 .await;
11180
11181 let messages = vec![
11182 MemMessage::system("you are a test"),
11183 MemMessage::user("do the thing"),
11184 ];
11185 let caveats = Caveats::top();
11186 let uri = server.uri();
11187 let mut observations: Vec<RoundObservation> = Vec::new();
11188 let mut hook = |obs: RoundObservation| observations.push(obs);
11189 let mut c = ctx(&uri, &messages, &caveats);
11190 c.kind = BackendKind::Openai;
11191 c.api_key = Some("sk-test");
11192 c.on_round_usage = Some(&mut hook);
11193 let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11194 .await
11195 .expect("openai loop should succeed");
11196
11197 assert_eq!(reply, "openai accepted");
11198 let accepted: Vec<u32> = observations
11199 .iter()
11200 .filter_map(|o| match o {
11201 RoundObservation::Accepted { prompt_tokens, .. } => Some(*prompt_tokens),
11202 _ => None,
11203 })
11204 .collect();
11205 assert_eq!(
11206 accepted,
11207 vec![5_120, 5_200],
11208 "both usable rounds reported, in order: {observations:?}"
11209 );
11210 }
11211
11212 struct PersistentEmptyOverflow;
11222 impl Respond for PersistentEmptyOverflow {
11223 fn respond(&self, _req: &Request) -> ResponseTemplate {
11224 if is_stream(_req) {
11225 return ndjson(&[serde_json::json!({
11228 "message": {"content": ""}, "done": true,
11229 "prompt_eval_count": 8_734, "eval_count": 0
11230 })]);
11231 }
11232 ResponseTemplate::new(200).set_body_json(serde_json::json!({
11235 "message": {"content": ""},
11236 "prompt_eval_count": 8_734, "eval_count": 0,
11237 }))
11238 }
11239 }
11240
11241 #[tokio::test]
11242 async fn persistent_empty_over_safe_context_emits_suspected_overflow() {
11243 let server = MockServer::start().await;
11244 Mock::given(method("POST"))
11245 .and(path("/api/chat"))
11246 .respond_with(PersistentEmptyOverflow)
11247 .mount(&server)
11248 .await;
11249
11250 let messages = vec![
11251 MemMessage::system("you are a test"),
11252 MemMessage::user("do the thing"),
11253 ];
11254 let caveats = Caveats::top();
11255 let uri = server.uri();
11256 let mut observations: Vec<RoundObservation> = Vec::new();
11257 let mut hook = |obs: RoundObservation| observations.push(obs);
11258 let mut c = ctx(&uri, &messages, &caveats);
11259 c.safe_context = Some(4_000);
11261 c.on_round_usage = Some(&mut hook);
11262 let (_reply, streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
11263 .await
11264 .expect("persistent empties return the empty-response message, not Err");
11265
11266 assert!(
11268 !streamed,
11269 "the silent-overflow exit is not a streamed reply"
11270 );
11271 let overflow: Vec<u32> = observations
11274 .iter()
11275 .filter_map(|o| match o {
11276 RoundObservation::SuspectedOverflow { prompt_tokens } => Some(*prompt_tokens),
11277 _ => None,
11278 })
11279 .collect();
11280 assert_eq!(
11281 overflow,
11282 vec![8_734],
11283 "one SuspectedOverflow at the merged prompt size: {observations:?}"
11284 );
11285 assert!(
11288 !observations
11289 .iter()
11290 .any(|o| matches!(o, RoundObservation::Accepted { .. })),
11291 "empty rounds are not Accepted evidence: {observations:?}"
11292 );
11293 }
11294}