1use std::collections::{BTreeMap, HashSet};
12use std::io::{IsTerminal, Write};
13use std::path::{Path, PathBuf};
14use std::sync::{atomic::AtomicBool, Arc};
15use std::time::{Duration, Instant};
16use std::{error::Error, fmt};
17
18use anyhow::{anyhow, Context, Result};
19use serde_json::{json, Value};
20
21use crate::color::Style;
22use crate::config::ModelConfig;
23use crate::interrupt;
24use crate::model_timeout::ModelTurnDeadline;
25use crate::tools;
26
27struct Spinner {
30 handle: Option<tokio::task::JoinHandle<()>>,
31}
32
33impl Spinner {
34 fn start(label: &str, color: Style) -> Spinner {
35 if !std::io::stderr().is_terminal() {
36 return Spinner { handle: None };
37 }
38 let label = label.to_string();
39 let handle = tokio::spawn(async move {
40 let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
41 let mut i = 0usize;
42 loop {
43 eprint!(
44 "\r{} {}",
45 color.dim(frames[i % frames.len()]),
46 color.dim(&label)
47 );
48 let _ = std::io::stderr().flush();
49 i += 1;
50 tokio::time::sleep(Duration::from_millis(80)).await;
51 }
52 });
53 Spinner {
54 handle: Some(handle),
55 }
56 }
57
58 fn stop(&mut self) {
59 if let Some(h) = self.handle.take() {
60 h.abort();
61 eprint!("\r\x1b[K");
62 let _ = std::io::stderr().flush();
63 }
64 }
65}
66
67impl Drop for Spinner {
68 fn drop(&mut self) {
69 self.stop();
70 }
71}
72
73fn system_message(system: &str, cache: bool) -> Value {
78 if cache {
79 json!({
80 "role": "system",
81 "content": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
82 })
83 } else {
84 json!({"role": "system", "content": system})
85 }
86}
87
88fn path_with_line_range(path: &str, v: &Value) -> String {
89 let start = v.get("start_line").and_then(|x| x.as_u64());
90 let end = v.get("end_line").and_then(|x| x.as_u64());
91 match (start, end) {
92 (None, None) => format!("{path}:1-EOF"),
93 (Some(start), None) => format!("{path}:{start}-EOF"),
94 (None, Some(end)) => format!("{path}:1-{end}"),
95 (Some(start), Some(end)) => format!("{path}:{start}-{end}"),
96 }
97}
98
99fn tool_call_label(name: &str, args: &str) -> String {
102 let hint = serde_json::from_str::<Value>(args)
103 .ok()
104 .and_then(|v| {
105 let scheduler = v.get("scheduler").and_then(|x| x.as_str());
106 if name == "read_file" {
107 if let Some(path) = v.get("path").and_then(|x| x.as_str()) {
108 return Some(path_with_line_range(path, &v));
109 }
110 }
111 if name == "read_scheduler_file" {
112 if let (Some(scheduler), Some(path)) =
113 (scheduler, v.get("path").and_then(|x| x.as_str()))
114 {
115 return Some(format!("{scheduler}/{}", path_with_line_range(path, &v)));
116 }
117 }
118 if name == "grep_schedulers" {
119 if let (Some(scheduler), Some(pattern)) =
120 (scheduler, v.get("pattern").and_then(|x| x.as_str()))
121 {
122 return Some(format!("{scheduler}:{pattern}"));
123 }
124 }
125 v.get("path")
126 .or_else(|| v.get("pattern"))
127 .or_else(|| v.get("url"))
128 .and_then(|x| x.as_str())
129 .map(|s| s.to_string())
130 })
131 .unwrap_or_else(|| preview(args, 80).replace('\n', " "));
132 format!("{name}({hint})")
133}
134
135pub struct ToolLoopConfig {
137 pub sandbox: PathBuf,
138 pub scheds_root: Option<PathBuf>,
139 pub allow_edit: bool,
140 pub max_iterations: u32,
141}
142
143#[derive(Debug)]
145pub struct ApiStatusError {
146 status: reqwest::StatusCode,
147 body: String,
148}
149
150impl fmt::Display for ApiStatusError {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 write!(f, "API error {}: {}", self.status, self.body)
153 }
154}
155
156impl Error for ApiStatusError {}
157
158pub fn is_api_error(err: &anyhow::Error) -> bool {
161 err.downcast_ref::<ApiStatusError>().is_some()
162 || err
163 .chain()
164 .any(|cause| cause.downcast_ref::<reqwest::Error>().is_some())
165}
166
167pub fn error_summary(err: &anyhow::Error) -> String {
169 let raw = err
170 .downcast_ref::<ApiStatusError>()
171 .map(|e| e.to_string())
172 .unwrap_or_else(|| err.to_string());
173 let one_line = raw.split_whitespace().collect::<Vec<_>>().join(" ");
174 const MAX: usize = 500;
175 if one_line.chars().count() > MAX {
176 format!("{}...", one_line.chars().take(MAX).collect::<String>())
177 } else {
178 one_line
179 }
180}
181
182fn apply_bearer(req: reqwest::RequestBuilder, api_key: &str) -> reqwest::RequestBuilder {
183 if api_key.is_empty() {
184 req
185 } else {
186 req.header("Authorization", format!("Bearer {api_key}"))
187 }
188}
189
190fn arguments_to_string(v: &Value) -> String {
192 match v {
193 Value::String(s) => s.clone(),
194 other => other.to_string(),
195 }
196}
197
198fn normalize_tool_call_arguments(tc: &mut Value) {
200 let Some(func) = tc.get_mut("function").and_then(|f| f.as_object_mut()) else {
201 return;
202 };
203 let args = func
204 .get("arguments")
205 .map(arguments_to_string)
206 .unwrap_or_else(|| "{}".to_string());
207 func.insert("arguments".to_string(), Value::String(args));
208}
209
210fn normalize_tool_calls(mut calls: Vec<Value>) -> Vec<Value> {
211 for tc in &mut calls {
212 normalize_tool_call_arguments(tc);
213 }
214 calls
215}
216
217fn sanitize_tool_calls_for_history(calls: &[Value]) -> Vec<Value> {
229 calls
230 .iter()
231 .map(|tc| {
232 let mut tc = tc.clone();
233 if let Some(func) = tc.get_mut("function").and_then(|f| f.as_object_mut()) {
234 let valid = func
235 .get("arguments")
236 .and_then(|a| a.as_str())
237 .is_some_and(|s| serde_json::from_str::<Value>(s).is_ok());
238 if !valid {
239 func.insert("arguments".to_string(), Value::String("{}".to_string()));
240 }
241 }
242 tc
243 })
244 .collect()
245}
246
247fn message_content_to_string(message: &Value) -> String {
248 match message.get("content") {
249 Some(Value::String(s)) => s.clone(),
250 Some(Value::Array(parts)) => parts
251 .iter()
252 .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
253 .collect::<Vec<_>>()
254 .join(""),
255 _ => String::new(),
256 }
257}
258
259const DUMP_MAX_CHARS: usize = 8000;
261const SLOW_TOOL_LOG_AFTER: Duration = Duration::from_millis(500);
262
263fn preview(s: &str, max: usize) -> String {
265 let n = s.chars().count();
266 if n > max {
267 let head: String = s.chars().take(max).collect();
268 format!("{head}\n [... {} more chars elided ...]", n - max)
269 } else {
270 s.to_string()
271 }
272}
273
274const TOOL_CALL_OPEN: &str = "<tool_call>";
275const TOOL_CALL_CLOSE: &str = "</tool_call>";
276
277fn parse_one_text_call(inner: &str, idx: usize) -> Option<Value> {
280 let v: Value = serde_json::from_str(inner.trim()).ok()?;
281 let name = v.get("name").and_then(|n| n.as_str())?;
282 let args = v
283 .get("arguments")
284 .or_else(|| v.get("parameters"))
285 .cloned()
286 .unwrap_or_else(|| json!({}));
287 Some(json!({
288 "id": format!("text_call_{idx}"),
289 "type": "function",
290 "function": {"name": name, "arguments": arguments_to_string(&args)},
291 }))
292}
293
294fn parse_text_tool_calls(content: &str) -> (Vec<Value>, String) {
299 let mut calls = Vec::new();
300 let mut stripped = String::new();
301 let mut rest = content;
302 let mut idx = 0usize;
303 while let Some(start) = rest.find(TOOL_CALL_OPEN) {
304 stripped.push_str(&rest[..start]);
305 let after = &rest[start + TOOL_CALL_OPEN.len()..];
306 let (inner, consumed) = match after.find(TOOL_CALL_CLOSE) {
307 Some(end) => (
308 &after[..end],
309 start + TOOL_CALL_OPEN.len() + end + TOOL_CALL_CLOSE.len(),
310 ),
311 None => (after, rest.len()),
312 };
313 if let Some(call) = parse_one_text_call(inner, idx) {
314 calls.push(call);
315 idx += 1;
316 }
317 rest = &rest[consumed..];
318 }
319 stripped.push_str(rest);
320 (calls, stripped.trim().to_string())
321}
322
323#[derive(Default)]
324struct PendingToolCall {
325 id: String,
326 ty: String,
327 name: String,
328 arguments: String,
329}
330
331#[derive(Default)]
332struct StreamedChat {
333 content: String,
334 tool_calls: BTreeMap<usize, PendingToolCall>,
335 usage: Option<crate::usage::Usage>,
336 raw_events: String,
337}
338
339impl StreamedChat {
340 fn into_message(self) -> Value {
341 let content = if self.content.is_empty() {
342 Value::Null
343 } else {
344 Value::String(self.content)
345 };
346 let mut message = json!({
347 "role": "assistant",
348 "content": content,
349 });
350 if !self.tool_calls.is_empty() {
351 let calls: Vec<Value> = self
352 .tool_calls
353 .into_iter()
354 .map(|(idx, call)| {
355 json!({
356 "id": if call.id.is_empty() { format!("stream_call_{idx}") } else { call.id },
357 "type": if call.ty.is_empty() { "function".to_string() } else { call.ty },
358 "function": {
359 "name": call.name,
360 "arguments": if call.arguments.is_empty() { "{}".to_string() } else { call.arguments },
361 },
362 })
363 })
364 .collect();
365 message.as_object_mut().unwrap().insert(
366 "tool_calls".to_string(),
367 Value::Array(normalize_tool_calls(calls)),
368 );
369 }
370 message
371 }
372}
373
374enum StreamPrintState {
375 Visible,
376 InToolCall,
377}
378
379struct StreamTextPrinter {
380 enabled: bool,
381 started: bool,
382 saw_tool_call: bool,
383 last_was_newline: bool,
384 state: StreamPrintState,
385 buf: String,
386}
387
388fn floor_char_boundary(s: &str, idx: usize) -> usize {
389 let mut idx = idx.min(s.len());
390 while idx > 0 && !s.is_char_boundary(idx) {
391 idx -= 1;
392 }
393 idx
394}
395
396impl StreamTextPrinter {
397 fn new(enabled: bool) -> Self {
398 Self {
399 enabled,
400 started: false,
401 saw_tool_call: false,
402 last_was_newline: false,
403 state: StreamPrintState::Visible,
404 buf: String::new(),
405 }
406 }
407
408 fn emit(&mut self, text: &str) -> Result<()> {
409 if text.is_empty() {
410 return Ok(());
411 }
412 self.started = true;
413 print!("{text}");
414 std::io::stdout()
415 .flush()
416 .context("flush streamed assistant text")?;
417 self.last_was_newline = text.ends_with('\n');
418 Ok(())
419 }
420
421 fn note_tool_call(&mut self) {
422 self.saw_tool_call = true;
423 }
424
425 fn emit_before_tool_call(&mut self, text: &str) -> Result<()> {
426 let visible = text.trim_end();
427 if !visible.is_empty() {
428 self.emit(visible)?;
429 }
430 Ok(())
431 }
432
433 fn push(&mut self, text: &str) -> Result<()> {
434 if !self.enabled {
435 return Ok(());
436 }
437 self.buf.push_str(text);
438 loop {
439 match self.state {
440 StreamPrintState::Visible => {
441 if let Some(pos) = self.buf.find(TOOL_CALL_OPEN) {
442 let visible = self.buf[..pos].to_string();
443 self.note_tool_call();
444 self.emit_before_tool_call(&visible)?;
445 self.buf.drain(..pos + TOOL_CALL_OPEN.len());
446 self.state = StreamPrintState::InToolCall;
447 } else {
448 let keep = TOOL_CALL_OPEN.len().saturating_sub(1);
449 let emit_len = self.buf.len().saturating_sub(keep);
450 if emit_len == 0 {
451 break;
452 }
453 let emit_len = floor_char_boundary(&self.buf, emit_len);
454 if emit_len == 0 {
455 break;
456 }
457 let visible = self.buf[..emit_len].to_string();
458 if self.started || !visible.trim().is_empty() {
459 self.emit(&visible)?;
460 }
461 self.buf.drain(..emit_len);
462 break;
463 }
464 }
465 StreamPrintState::InToolCall => {
466 if let Some(pos) = self.buf.find(TOOL_CALL_CLOSE) {
467 self.buf.drain(..pos + TOOL_CALL_CLOSE.len());
468 self.state = StreamPrintState::Visible;
469 } else {
470 let keep = TOOL_CALL_CLOSE.len().saturating_sub(1);
471 if self.buf.len() > keep {
472 let drop_len = self.buf.len() - keep;
473 let drop_len = floor_char_boundary(&self.buf, drop_len);
474 if drop_len > 0 {
475 self.buf.drain(..drop_len);
476 }
477 }
478 break;
479 }
480 }
481 }
482 }
483 Ok(())
484 }
485
486 fn finish(&mut self) -> Result<()> {
487 if !self.enabled {
488 return Ok(());
489 }
490 if matches!(self.state, StreamPrintState::Visible) && !self.buf.is_empty() {
491 let visible = std::mem::take(&mut self.buf);
492 if !visible.trim().is_empty() {
493 if self.saw_tool_call {
494 self.emit(visible.trim_end())?;
495 } else {
496 self.emit(&visible)?;
497 }
498 }
499 }
500 if self.started && !self.last_was_newline {
501 println!();
502 }
503 Ok(())
504 }
505}
506
507fn append_json_text(dst: &mut String, v: &Value) {
508 match v {
509 Value::String(s) => dst.push_str(s),
510 Value::Null => {}
511 other => dst.push_str(&other.to_string()),
512 }
513}
514
515fn apply_stream_delta(delta: &Value, streamed: &mut StreamedChat) -> Result<Option<String>> {
516 let mut visible = String::new();
517 if let Some(content) = delta.get("content") {
518 if let Some(s) = content.as_str() {
519 streamed.content.push_str(s);
520 visible.push_str(s);
521 }
522 }
523
524 if let Some(calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
525 for (fallback_idx, tc) in calls.iter().enumerate() {
526 let idx = tc
527 .get("index")
528 .and_then(|v| v.as_u64())
529 .and_then(|n| usize::try_from(n).ok())
530 .unwrap_or(fallback_idx);
531 let entry = streamed.tool_calls.entry(idx).or_default();
532 if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
533 if entry.id.is_empty() {
534 entry.id = id.to_string();
535 } else if entry.id != id {
536 entry.id.push_str(id);
537 }
538 }
539 if let Some(ty) = tc.get("type").and_then(|v| v.as_str()) {
540 if entry.ty.is_empty() {
541 entry.ty = ty.to_string();
542 } else if entry.ty != ty {
543 entry.ty.push_str(ty);
544 }
545 }
546 if let Some(func) = tc.get("function") {
547 if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
548 entry.name.push_str(name);
549 }
550 if let Some(args) = func.get("arguments") {
551 append_json_text(&mut entry.arguments, args);
552 }
553 }
554 }
555 }
556
557 if let Some(func) = delta.get("function_call") {
558 let entry = streamed.tool_calls.entry(0).or_default();
559 if entry.id.is_empty() {
560 entry.id = "stream_call_0".to_string();
561 }
562 if entry.ty.is_empty() {
563 entry.ty = "function".to_string();
564 }
565 if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
566 entry.name.push_str(name);
567 }
568 if let Some(args) = func.get("arguments") {
569 append_json_text(&mut entry.arguments, args);
570 }
571 }
572
573 Ok((!visible.is_empty()).then_some(visible))
574}
575
576fn find_sse_delimiter(buf: &[u8]) -> Option<(usize, usize)> {
577 let lf = buf.windows(2).position(|w| w == b"\n\n").map(|p| (p, 2));
578 let crlf = buf
579 .windows(4)
580 .position(|w| w == b"\r\n\r\n")
581 .map(|p| (p, 4));
582 match (lf, crlf) {
583 (Some(a), Some(b)) => Some(if a.0 <= b.0 { a } else { b }),
584 (Some(a), None) => Some(a),
585 (None, Some(b)) => Some(b),
586 (None, None) => None,
587 }
588}
589
590fn sse_data(event: &[u8]) -> Result<Option<String>> {
591 let text = std::str::from_utf8(event).context("parse streaming SSE as UTF-8")?;
592 let data = text
593 .lines()
594 .filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
595 .collect::<Vec<_>>();
596 if data.is_empty() {
597 Ok(None)
598 } else {
599 Ok(Some(data.join("\n")))
600 }
601}
602
603async fn read_streamed_chat(
604 mut resp: reqwest::Response,
605 stream_stdout: bool,
606 spinner: &mut Spinner,
607) -> Result<StreamedChat> {
608 let mut streamed = StreamedChat::default();
609 let mut printer = StreamTextPrinter::new(stream_stdout);
610 let mut buf = Vec::<u8>::new();
611
612 while let Some(chunk) = resp.chunk().await.context("read streaming chat chunk")? {
613 buf.extend_from_slice(&chunk);
614 while let Some((pos, delim_len)) = find_sse_delimiter(&buf) {
615 let event = buf.drain(..pos).collect::<Vec<_>>();
616 buf.drain(..delim_len);
617 let Some(data) = sse_data(&event)? else {
618 continue;
619 };
620 if data.trim() == "[DONE]" {
621 printer.finish()?;
622 return Ok(streamed);
623 }
624 streamed.raw_events.push_str(&data);
625 streamed.raw_events.push('\n');
626 let parsed: Value = serde_json::from_str(&data)
627 .with_context(|| format!("parse streaming chat JSON event: {data}"))?;
628 if let Some(u) = parsed.get("usage").filter(|u| !u.is_null()) {
629 streamed.usage = Some(crate::usage::Usage::from_openai(u));
630 }
631 let Some(delta) = parsed
632 .get("choices")
633 .and_then(|c| c.as_array())
634 .and_then(|a| a.first())
635 .and_then(|c| c.get("delta"))
636 else {
637 continue;
638 };
639 let has_tool_delta =
640 delta.get("tool_calls").is_some() || delta.get("function_call").is_some();
641 if delta.get("content").is_some() {
642 spinner.stop();
643 }
644 if has_tool_delta {
645 printer.note_tool_call();
646 }
647 if let Some(text) = apply_stream_delta(delta, &mut streamed)? {
648 printer.push(&text)?;
649 }
650 }
651 }
652
653 printer.finish()?;
654 Ok(streamed)
655}
656
657pub async fn chat(
661 client: &reqwest::Client,
662 model: &ModelConfig,
663 system: &str,
664 user: &str,
665 tool_loop: Option<&ToolLoopConfig>,
666 verbose: bool,
667 color: Style,
668 stream_stdout: bool,
669 usage: &mut crate::usage::Usage,
670 turn_timeout: Duration,
671 interrupted: Arc<AtomicBool>,
672) -> Result<String> {
673 let deadline = ModelTurnDeadline::new(turn_timeout);
674 let url = format!("{}/chat/completions", model.base_url.trim_end_matches('/'));
675 let mut use_cache = true;
680 let mut use_stream_options = true;
681 let mut messages: Vec<Value> = vec![
682 system_message(system, use_cache),
683 json!({"role": "user", "content": user}),
684 ];
685
686 if verbose {
687 eprintln!(
688 "\n{} {}",
689 color.dim("[api]"),
690 color.bold(format!(
691 "new turn (model={}, system prompt={} chars)",
692 model.model_id,
693 system.len()
694 ))
695 );
696 eprintln!(
697 "{} {}\n{}",
698 color.dim("[api]"),
699 color.blue("user:"),
700 preview(user, DUMP_MAX_CHARS)
701 );
702 }
703
704 let mut tool_iterations = 0u32;
705 let mut edits_applied = 0usize;
706 let mut disable_tools = false;
707 let mut force_edit = false;
708 let mut force_attempts = 0u32;
709 let mut request_n = 0u32;
710 let mut seen_tool_calls: HashSet<String> = HashSet::new();
715
716 loop {
717 deadline.check()?;
718 if interrupt::requested(&interrupted) {
719 return Err(interrupt::err());
720 }
721
722 const MAX_FORCE_ATTEMPTS: u32 = 3;
726 if force_edit {
727 force_attempts += 1;
728 if force_attempts > MAX_FORCE_ATTEMPTS {
729 force_edit = false;
730 disable_tools = true;
731 }
732 }
733
734 let mut body = serde_json::Map::new();
735 body.insert("model".into(), json!(model.model_id));
736 body.insert("messages".into(), json!(&messages));
737 body.insert("stream".into(), json!(true));
738 if use_stream_options {
739 body.insert(
740 "stream_options".into(),
741 json!({
742 "include_usage": true,
743 }),
744 );
745 }
746 if let Some(cfg) = tool_loop {
747 body.insert(
748 "tools".into(),
749 tools::openai_tools_json(cfg.allow_edit, cfg.scheds_root.is_some()),
750 );
751 let tool_choice = if force_edit && cfg.allow_edit {
754 json!({"type": "function", "function": {"name": "edit_file"}})
755 } else if disable_tools {
756 json!("none")
757 } else {
758 json!("auto")
759 };
760 body.insert("tool_choice".into(), tool_choice);
761 }
762 let body = Value::Object(body);
763
764 request_n += 1;
765 if verbose {
766 let tc = if tool_loop.is_none() {
767 ""
768 } else if force_edit {
769 " tool_choice=edit_file(forced)"
770 } else if disable_tools {
771 " tool_choice=none"
772 } else {
773 " tool_choice=auto"
774 };
775 eprintln!(
776 "{} {}",
777 color.dim("[api]"),
778 color.cyan(format!(
779 "POST {url} (request #{request_n}, {} messages{tc})",
780 messages.len()
781 ))
782 );
783 }
784
785 let mut spinner = Spinner::start("thinking...", color);
786 let resp = tokio::select! {
787 resp = apply_bearer(client.post(&url), &model.api_key).json(&body).send() => {
788 resp.context("POST chat/completions")?
789 }
790 _ = interrupt::wait(interrupted.clone()) => {
791 spinner.stop();
792 return Err(interrupt::err());
793 }
794 _ = tokio::time::sleep(deadline.remaining()) => {
795 spinner.stop();
796 return Err(deadline.timeout().into());
797 }
798 };
799 let status = resp.status();
800 if !status.is_success() {
801 spinner.stop();
802 let text = tokio::select! {
803 text = resp.text() => text.context("read chat/completions body")?,
804 _ = interrupt::wait(interrupted.clone()) => return Err(interrupt::err()),
805 _ = tokio::time::sleep(deadline.remaining()) => {
806 return Err(deadline.timeout().into());
807 }
808 };
809 if use_cache {
812 use_cache = false;
813 messages[0] = system_message(system, false);
814 if verbose {
815 eprintln!(
816 "{} {}",
817 color.dim("[api]"),
818 color.yellow(format!(
819 "request rejected ({status}); retrying without prompt-cache markers"
820 ))
821 );
822 }
823 continue;
824 }
825 if use_stream_options
826 && (text.contains("stream_options") || text.contains("include_usage"))
827 {
828 use_stream_options = false;
829 if verbose {
830 eprintln!(
831 "{} {}",
832 color.dim("[api]"),
833 color.yellow("stream usage option rejected; retrying without it")
834 );
835 }
836 continue;
837 }
838 return Err(ApiStatusError { status, body: text }.into());
839 }
840
841 let streamed = tokio::select! {
842 streamed = read_streamed_chat(resp, stream_stdout, &mut spinner) => streamed?,
843 _ = interrupt::wait(interrupted.clone()) => {
844 spinner.stop();
845 return Err(interrupt::err());
846 }
847 _ = tokio::time::sleep(deadline.remaining()) => {
848 spinner.stop();
849 return Err(deadline.timeout().into());
850 }
851 };
852 spinner.stop();
853 let raw_events = streamed.raw_events.clone();
854 let streamed_usage = streamed.usage;
855 let message = streamed.into_message();
856
857 if verbose {
858 eprintln!(
859 "{} {}\n{}",
860 color.dim("[api]"),
861 color.blue(format!(
862 "raw streamed response ({} bytes):",
863 raw_events.len()
864 )),
865 preview(&raw_events, 20000)
866 );
867 }
868
869 if verbose {
870 let content = message_content_to_string(&message);
871 if !content.trim().is_empty() {
872 eprintln!(
873 "{} {}\n{}",
874 color.dim("[api]"),
875 color.blue("assistant:"),
876 preview(&content, DUMP_MAX_CHARS)
877 );
878 }
879 }
880 if let Some(u) = streamed_usage {
881 usage.add(&u);
882 if verbose {
883 eprintln!(
884 "{} {}",
885 color.dim("[api]"),
886 color.dim(format!("usage: {}", u.footer_line()))
887 );
888 }
889 }
890
891 let tool_calls: Option<Vec<Value>> = message
892 .get("tool_calls")
893 .and_then(|tc| tc.as_array())
894 .filter(|a| !a.is_empty())
895 .cloned()
896 .map(normalize_tool_calls);
897
898 let (tool_calls, assistant_message) = if tool_calls.is_none() && tool_loop.is_some() {
903 let (parsed, stripped) = parse_text_tool_calls(&message_content_to_string(&message));
904 if parsed.is_empty() {
905 (tool_calls, message)
906 } else {
907 if verbose {
908 eprintln!(
909 "{} {}",
910 color.dim("[api]"),
911 color.yellow(format!(
912 "{} tool call(s) parsed from <tool_call> text",
913 parsed.len()
914 ))
915 );
916 }
917 let assistant = json!({
918 "role": "assistant",
919 "content": if stripped.is_empty() { Value::Null } else { Value::String(stripped) },
920 "tool_calls": sanitize_tool_calls_for_history(&parsed),
921 });
922 (Some(parsed), assistant)
923 }
924 } else {
925 let assistant = match &tool_calls {
926 Some(calls) => {
927 let mut assistant = message;
928 if let Some(obj) = assistant.as_object_mut() {
929 obj.insert(
930 "tool_calls".to_string(),
931 Value::Array(sanitize_tool_calls_for_history(calls)),
932 );
933 }
934 assistant
935 }
936 None => message,
937 };
938 (tool_calls, assistant)
939 };
940
941 if let (Some(arr), Some(cfg)) = (tool_calls, tool_loop) {
942 messages.push(assistant_message);
943
944 if disable_tools {
947 for tc in &arr {
948 if let Some(id) = tc.get("id").and_then(|x| x.as_str()) {
949 messages.push(json!({
950 "role": "tool",
951 "tool_call_id": id,
952 "content": "Tools are disabled now; reply with your one-line summary of the change you made."
953 }));
954 }
955 }
956 continue;
957 }
958
959 if tool_iterations >= cfg.max_iterations && !force_edit {
963 let nudge = if cfg.allow_edit && edits_applied == 0 {
964 force_edit = true;
965 "Exploration budget reached. You MUST make at least one edit_file change now."
966 } else {
967 disable_tools = true;
968 "Exploration budget reached; reply with your one-line summary of the change you made."
969 };
970 for tc in &arr {
971 if let Some(id) = tc.get("id").and_then(|x| x.as_str()) {
972 messages
973 .push(json!({"role": "tool", "tool_call_id": id, "content": nudge}));
974 }
975 }
976 continue;
977 }
978 tool_iterations += 1;
979
980 for tc in &arr {
981 let id = tc
982 .get("id")
983 .and_then(|x| x.as_str())
984 .ok_or_else(|| anyhow!("tool_calls[].id missing"))?;
985 let func = tc
986 .get("function")
987 .ok_or_else(|| anyhow!("tool_calls[].function missing"))?;
988 let name = func
989 .get("name")
990 .and_then(|x| x.as_str())
991 .ok_or_else(|| anyhow!("tool_calls[].function.name missing"))?;
992 let args = func
993 .get("arguments")
994 .map(arguments_to_string)
995 .unwrap_or_else(|| "{}".to_string());
996
997 eprintln!(
1000 "{} {} ({})",
1001 color.dim(" ->"),
1002 color.cyan(tool_call_label(name, &args)),
1003 color.dim(usage.footer_line())
1004 );
1005
1006 if !tools::is_write_tool(name)
1012 && !seen_tool_calls.insert(format!("{name}\u{0}{args}"))
1013 {
1014 eprintln!(
1015 "{} {} {}",
1016 color.dim(" <-"),
1017 color.cyan(tool_call_label(name, &args)),
1018 color.yellow("skipped: identical call already made this turn")
1019 );
1020 messages.push(json!({
1021 "role": "tool",
1022 "tool_call_id": id,
1023 "content": "Identical call already executed earlier in this turn; \
1024 its result has not changed and is shown above. Do not \
1025 repeat it - use that result, read a different location, \
1026 or make your edit_file change now.",
1027 }));
1028 continue;
1029 }
1030
1031 let tool_started = Instant::now();
1032 let out = match tokio::select! {
1033 out = tools::execute_tool_async(
1034 &cfg.sandbox,
1035 cfg.scheds_root.as_deref(),
1036 name,
1037 &args,
1038 cfg.allow_edit,
1039 ) => out,
1040 _ = interrupt::wait(interrupted.clone()) => return Err(interrupt::err()),
1041 _ = tokio::time::sleep(deadline.remaining()) => {
1042 return Err(deadline.timeout().into());
1043 }
1044 } {
1045 Ok(out) => {
1046 let elapsed = tool_started.elapsed();
1047 if verbose || elapsed >= SLOW_TOOL_LOG_AFTER {
1048 eprintln!(
1049 "{} {} done in {:.1}s ({} bytes)",
1050 color.dim(" <-"),
1051 color.cyan(tool_call_label(name, &args)),
1052 elapsed.as_secs_f64(),
1053 out.len()
1054 );
1055 }
1056 if tools::is_write_tool(name) {
1057 edits_applied += 1;
1058 }
1059 out
1060 }
1061 Err(e) => {
1066 let msg = format!("ERROR: {e:#}");
1069 let shown = format!("{e:#}");
1072 eprintln!(
1073 "{} {} failed after {:.1}s: {}",
1074 color.dim(" <-"),
1075 color.cyan(tool_call_label(name, &args)),
1076 tool_started.elapsed().as_secs_f64(),
1077 color.yellow(preview(shown.lines().next().unwrap_or(&shown), 300))
1078 );
1079 if verbose {
1080 eprintln!(
1081 " {}",
1082 color.yellow(preview(shown.lines().next().unwrap_or(&shown), 300))
1083 );
1084 }
1085 msg
1086 }
1087 };
1088 if verbose {
1089 eprintln!(
1090 "{} {}\n{}",
1091 color.dim("[api]"),
1092 color.blue(format!("tool result [{name}]:")),
1093 preview(&out, 2000)
1094 );
1095 }
1096 messages.push(json!({
1097 "role": "tool",
1098 "tool_call_id": id,
1099 "content": out,
1100 }));
1101 }
1102 if force_edit && edits_applied > 0 {
1107 force_edit = false;
1108 }
1109 continue;
1110 }
1111
1112 if let Some(cfg) = tool_loop {
1116 if cfg.allow_edit && edits_applied == 0 && !disable_tools {
1117 force_edit = true;
1118 messages.push(assistant_message);
1119 messages.push(json!({
1120 "role": "user",
1121 "content": "You have not made any edit yet. Make your edit_file change(s) now - actually call the tool, do not just describe the change."
1122 }));
1123 continue;
1124 }
1125 }
1126
1127 let content = message_content_to_string(&assistant_message);
1128 return Ok(content);
1129 }
1130}
1131
1132pub fn tool_loop(
1134 sandbox: &Path,
1135 scheds_root: Option<&Path>,
1136 allow_edit: bool,
1137 max_iterations: u32,
1138) -> ToolLoopConfig {
1139 ToolLoopConfig {
1140 sandbox: sandbox.to_path_buf(),
1141 scheds_root: scheds_root.map(Path::to_path_buf),
1142 allow_edit,
1143 max_iterations,
1144 }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149 use super::*;
1150
1151 #[test]
1152 fn system_message_cache_markers() {
1153 let cached = system_message("SYS", true);
1154 assert_eq!(cached["content"][0]["text"], "SYS");
1155 assert_eq!(cached["content"][0]["cache_control"]["type"], "ephemeral");
1156
1157 let plain = system_message("SYS", false);
1158 assert_eq!(plain["content"], "SYS");
1159 assert!(plain["content"].is_string());
1160 }
1161
1162 #[test]
1163 fn api_status_errors_are_classified() {
1164 let err: anyhow::Error = ApiStatusError {
1165 status: reqwest::StatusCode::BAD_REQUEST,
1166 body: "Already borrowed".to_string(),
1167 }
1168 .into();
1169
1170 assert!(is_api_error(&err));
1171 assert!(error_summary(&err).contains("Already borrowed"));
1172 }
1173
1174 #[test]
1175 fn tool_call_label_includes_scheduler_for_scheduler_file_reads() {
1176 let args = json!({
1177 "scheduler": "scx_rusty",
1178 "path": "src/bpf/main.bpf.c",
1179 "start_line": 10,
1180 "end_line": 40,
1181 })
1182 .to_string();
1183 assert_eq!(
1184 tool_call_label("read_scheduler_file", &args),
1185 "read_scheduler_file(scx_rusty/src/bpf/main.bpf.c:10-40)"
1186 );
1187 }
1188
1189 #[test]
1190 fn tool_call_label_includes_range_for_file_reads() {
1191 let bounded = json!({
1192 "path": "src/main.rs",
1193 "start_line": 20,
1194 "end_line": 80,
1195 })
1196 .to_string();
1197 assert_eq!(
1198 tool_call_label("read_file", &bounded),
1199 "read_file(src/main.rs:20-80)"
1200 );
1201
1202 let unbounded = json!({
1203 "path": "src/main.rs",
1204 })
1205 .to_string();
1206 assert_eq!(
1207 tool_call_label("read_file", &unbounded),
1208 "read_file(src/main.rs:1-EOF)"
1209 );
1210 }
1211
1212 #[test]
1213 fn tool_call_label_includes_scheduler_for_scheduler_grep() {
1214 let args = json!({
1215 "scheduler": "scx_lavd",
1216 "pattern": "vtime",
1217 })
1218 .to_string();
1219 assert_eq!(
1220 tool_call_label("grep_schedulers", &args),
1221 "grep_schedulers(scx_lavd:vtime)"
1222 );
1223 }
1224
1225 #[test]
1226 fn parses_single_text_tool_call() {
1227 let content = "I'll lower the slice.\n<tool_call>\n{\"name\": \"edit_file\", \"arguments\": {\"path\": \"src/bpf/main.bpf.c\", \"old_string\": \"a\", \"new_string\": \"b\"}}\n</tool_call>";
1228 let (calls, stripped) = parse_text_tool_calls(content);
1229 assert_eq!(calls.len(), 1);
1230 assert_eq!(calls[0]["function"]["name"], "edit_file");
1231 let args: Value =
1232 serde_json::from_str(calls[0]["function"]["arguments"].as_str().unwrap()).unwrap();
1233 assert_eq!(args["path"], "src/bpf/main.bpf.c");
1234 assert_eq!(calls[0]["type"], "function");
1235 assert!(calls[0]["id"].as_str().unwrap().starts_with("text_call_"));
1236 assert_eq!(stripped, "I'll lower the slice.");
1237 }
1238
1239 #[test]
1240 fn parses_multiple_calls_and_unique_ids() {
1241 let content = "<tool_call>{\"name\":\"grep\",\"arguments\":{\"pattern\":\"x\"}}</tool_call><tool_call>{\"name\":\"read_file\",\"arguments\":{\"path\":\"y\"}}</tool_call>";
1242 let (calls, stripped) = parse_text_tool_calls(content);
1243 assert_eq!(calls.len(), 2);
1244 assert_eq!(calls[0]["function"]["name"], "grep");
1245 assert_eq!(calls[1]["function"]["name"], "read_file");
1246 assert!(calls[0]["function"]["arguments"].is_string());
1247 assert!(calls[1]["function"]["arguments"].is_string());
1248 assert_ne!(calls[0]["id"], calls[1]["id"]);
1249 assert_eq!(stripped, "");
1250 }
1251
1252 #[test]
1253 fn accepts_parameters_key_and_missing_close_tag() {
1254 let content = "<tool_call>{\"name\":\"list_dir\",\"parameters\":{\"path\":\".\"}}";
1256 let (calls, _) = parse_text_tool_calls(content);
1257 assert_eq!(calls.len(), 1);
1258 assert_eq!(calls[0]["function"]["name"], "list_dir");
1259 let args: Value =
1260 serde_json::from_str(calls[0]["function"]["arguments"].as_str().unwrap()).unwrap();
1261 assert_eq!(args["path"], ".");
1262 }
1263
1264 #[test]
1265 fn normalizes_structured_tool_call_arguments_for_replay() {
1266 let calls = normalize_tool_calls(vec![json!({
1267 "id": "call_0",
1268 "type": "function",
1269 "function": {
1270 "name": "list_dir",
1271 "arguments": {"path": "."},
1272 },
1273 })]);
1274
1275 assert_eq!(calls.len(), 1);
1276 assert!(calls[0]["function"]["arguments"].is_string());
1277 let args: Value =
1278 serde_json::from_str(calls[0]["function"]["arguments"].as_str().unwrap()).unwrap();
1279 assert_eq!(args["path"], ".");
1280 }
1281
1282 #[test]
1283 fn normalizes_missing_tool_call_arguments_to_empty_object_string() {
1284 let calls = normalize_tool_calls(vec![json!({
1285 "id": "call_0",
1286 "type": "function",
1287 "function": {"name": "list_schedulers"},
1288 })]);
1289
1290 assert_eq!(calls[0]["function"]["arguments"], "{}");
1291 }
1292
1293 #[test]
1294 fn sanitize_replaces_malformed_tool_call_arguments_but_keeps_valid_ones() {
1295 let calls = vec![
1299 json!({
1300 "id": "bad",
1301 "type": "function",
1302 "function": {
1303 "name": "edit_file",
1304 "arguments": "{\"path\": \"src/main.rs\", \"old_string\": \"void f(",
1305 },
1306 }),
1307 json!({
1308 "id": "good",
1309 "type": "function",
1310 "function": {
1311 "name": "read_file",
1312 "arguments": "{\"path\": \"src/main.rs\"}",
1313 },
1314 }),
1315 ];
1316
1317 let sanitized = sanitize_tool_calls_for_history(&calls);
1318 assert_eq!(sanitized[0]["function"]["arguments"], "{}");
1319 assert_ne!(calls[0]["function"]["arguments"], "{}");
1321 assert_eq!(
1323 sanitized[1]["function"]["arguments"],
1324 "{\"path\": \"src/main.rs\"}"
1325 );
1326 }
1327
1328 #[test]
1329 fn assembles_streamed_tool_call_deltas() {
1330 let mut streamed = StreamedChat::default();
1331
1332 let visible = apply_stream_delta(
1333 &json!({
1334 "content": "I will inspect it.",
1335 "tool_calls": [{
1336 "index": 0,
1337 "id": "call_1",
1338 "type": "function",
1339 "function": {"name": "read_", "arguments": "{\"path\":\"src"}
1340 }]
1341 }),
1342 &mut streamed,
1343 )
1344 .unwrap();
1345 assert_eq!(visible.as_deref(), Some("I will inspect it."));
1346
1347 apply_stream_delta(
1348 &json!({
1349 "tool_calls": [{
1350 "index": 0,
1351 "function": {"name": "file", "arguments": "/bpf/main.bpf.c\"}"}
1352 }]
1353 }),
1354 &mut streamed,
1355 )
1356 .unwrap();
1357
1358 let message = streamed.into_message();
1359 assert_eq!(message["content"], "I will inspect it.");
1360 assert_eq!(message["tool_calls"][0]["id"], "call_1");
1361 assert_eq!(message["tool_calls"][0]["function"]["name"], "read_file");
1362 let args: Value = serde_json::from_str(
1363 message["tool_calls"][0]["function"]["arguments"]
1364 .as_str()
1365 .unwrap(),
1366 )
1367 .unwrap();
1368 assert_eq!(args["path"], "src/bpf/main.bpf.c");
1369 }
1370
1371 #[test]
1372 fn stream_printer_handles_multibyte_visible_prefix() {
1373 let mut printer = StreamTextPrinter::new(true);
1374 printer.push("或许是 **").unwrap();
1375 printer.finish().unwrap();
1376 }
1377
1378 #[test]
1379 fn stream_printer_handles_multibyte_hidden_tool_call_buffer() {
1380 let mut printer = StreamTextPrinter::new(true);
1381 printer.push("<tool_call>或许是 **").unwrap();
1382 printer.finish().unwrap();
1383 }
1384
1385 #[test]
1386 fn stream_printer_suppresses_whitespace_before_text_tool_call() {
1387 let mut printer = StreamTextPrinter::new(true);
1388 printer
1389 .push("\n\n<tool_call>{\"name\":\"list_dir\"}")
1390 .unwrap();
1391 printer.finish().unwrap();
1392
1393 assert!(!printer.started);
1394 }
1395
1396 #[test]
1397 fn stream_printer_suppresses_whitespace_only_structured_tool_call_text() {
1398 let mut printer = StreamTextPrinter::new(true);
1399 printer.push("\n\n").unwrap();
1400 printer.note_tool_call();
1401 printer.finish().unwrap();
1402
1403 assert!(!printer.started);
1404 }
1405
1406 #[test]
1407 fn stream_printer_trims_blank_lines_before_tool_call_after_text() {
1408 let mut printer = StreamTextPrinter::new(true);
1409 printer
1410 .push("Let me inspect:\n\n<tool_call>{\"name\":\"list_dir\"}")
1411 .unwrap();
1412 printer.finish().unwrap();
1413
1414 assert!(printer.started);
1415 assert!(!printer.last_was_newline);
1416 }
1417
1418 #[test]
1419 fn floor_char_boundary_does_not_split_utf8() {
1420 let text = "或许是 **";
1421 assert_eq!(floor_char_boundary(text, 0), 0);
1422 assert_eq!(floor_char_boundary(text, 1), 0);
1423 assert_eq!(floor_char_boundary(text, 2), 0);
1424 assert_eq!(floor_char_boundary(text, 3), 3);
1425 }
1426
1427 #[test]
1428 fn parses_sse_data_events() {
1429 let event = b"event: ignored\ndata: {\"a\":1}\n\n";
1430 assert_eq!(sse_data(event).unwrap().as_deref(), Some("{\"a\":1}"));
1431 let (pos, len) = find_sse_delimiter(event).unwrap();
1432 assert_eq!(&event[pos..pos + len], b"\n\n");
1433 }
1434
1435 #[test]
1436 fn no_tool_call_text_yields_no_calls() {
1437 let (calls, stripped) = parse_text_tool_calls("just a plain summary line");
1438 assert!(calls.is_empty());
1439 assert_eq!(stripped, "just a plain summary line");
1440 }
1441
1442 #[test]
1443 fn skips_malformed_block() {
1444 let (calls, _) = parse_text_tool_calls("<tool_call>not json</tool_call>");
1445 assert!(calls.is_empty());
1446 }
1447}