Skip to main content

start_command/
formal_ai_client.rs

1//! Minimal client of the OpenAI-compatible agent-mode endpoint of Formal AI
2//! (`POST /api/openai/v1/chat/completions`), used by `--use-formal-ai`
3//! (issue #255). Mirrors js/src/lib/formal-ai-client.js. It offers the
4//! `bash` tool and the structured `read` tool, plus `edit` and `write` when
5//! workspace changes are allowed (issue #258): Formal AI picks the call, start
6//! decides whether and how it runs.
7//!
8//! An HTTP error keeps a bounded, sanitized excerpt of its body (the error
9//! type, message and a preview) and the attempt count, so a failing runtime
10//! names its cause (issue #257).
11//!
12//! The runtime is always a loopback `http://` server, so a small blocking
13//! HTTP/1.1 client over `std::net` is enough; it polls for cancellation and
14//! the time budget while it waits.
15
16use std::io::{Read, Write};
17use std::net::{TcpStream, ToSocketAddrs};
18use std::time::{Duration, Instant};
19
20use regex::Regex;
21use serde_json::{json, Value};
22
23use crate::formal_ai_files::{describe_file_call, file_tool, normalize_file_args, FileArgs};
24use crate::formal_ai_policy::{EXIT_BUDGET, EXIT_PROTOCOL, EXIT_UNAVAILABLE};
25
26pub const API_PREFIX: &str = "/api/openai/v1";
27/// Names Formal AI may use for the bash tool.
28pub const BASH_NAMES: [&str; 3] = ["bash", "shell", "run_command"];
29/// Every tool name start accepts.
30pub const TOOL_NAMES: [&str; 6] = ["bash", "shell", "run_command", "read", "edit", "write"];
31const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
32const POLL: Duration = Duration::from_millis(100);
33
34/// The bash tool offered to Formal AI.
35pub fn bash_tool() -> Value {
36    json!({
37        "type": "function",
38        "function": {
39            "name": "bash",
40            "description": "Run one command in the working directory. Pipes, redirects, globs and variables are not supported.",
41            "parameters": {
42                "type": "object",
43                "properties": { "command": { "type": "string" } },
44                "required": ["command"],
45            },
46        },
47    })
48}
49
50/// Tools offered to Formal AI: the change tools only when the effect mode
51/// allows changes, so the advertised contract matches the policy.
52pub fn offered_tools(effects: &str) -> Vec<Value> {
53    let names: &[&str] = if effects == "workspace-write" {
54        &["read", "edit", "write"]
55    } else {
56        &["read"]
57    };
58    std::iter::once(bash_tool())
59        .chain(names.iter().map(|name| file_tool(name)))
60        .collect()
61}
62
63/// A failed exchange with Formal AI and the exit status it maps to.
64#[derive(Debug, Clone, PartialEq)]
65pub struct ClientError {
66    pub message: String,
67    pub exit_code: i32,
68    pub cancelled: bool,
69    /// Sanitized, bounded diagnostics (HTTP status, error type and message,
70    /// body preview, attempts) the evidence keeps (issue #257).
71    pub details: Option<Value>,
72}
73
74impl ClientError {
75    pub fn protocol(message: impl Into<String>) -> Self {
76        Self::with_code(message, EXIT_PROTOCOL)
77    }
78
79    pub fn with_code(message: impl Into<String>, exit_code: i32) -> Self {
80        Self {
81            message: message.into(),
82            exit_code,
83            cancelled: false,
84            details: None,
85        }
86    }
87
88    pub fn with_details(mut self, details: Value) -> Self {
89        self.details = Some(details);
90        self
91    }
92}
93
94const PREVIEW_CHARS: usize = 500;
95const MESSAGE_CHARS: usize = 300;
96const REDACTED: &str = "[REDACTED]";
97
98fn sanitizers() -> &'static [(Regex, &'static str); 4] {
99    static PATTERNS: std::sync::OnceLock<[(Regex, &'static str); 4]> = std::sync::OnceLock::new();
100    PATTERNS.get_or_init(|| {
101        let compile = |pattern: &str| Regex::new(pattern).expect("valid sanitizer pattern");
102        [
103            (
104                compile(r#"(?i)((?:proxy-)?authorization["']?\s*[:=]\s*["']?)[^"'\r\n,;}]*"#),
105                "${1}[REDACTED]",
106            ),
107            (
108                compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{4,}"),
109                "${1} [REDACTED]",
110            ),
111            (
112                compile(
113                    r#"(?i)((?:api[_-]?key|access[_-]?token|token|secret|password|passwd)["']?\s*[:=]\s*["']?)[^"'\s,;}]+"#,
114                ),
115                "${1}[REDACTED]",
116            ),
117            (compile(r"[\x00-\x1f\x7f]+"), " "),
118        ]
119    })
120}
121
122/// One line of untrusted diagnostic text, bounded and without credentials:
123/// known secrets, authorization headers, bearer or basic credentials and
124/// credential-named fields are masked, and control characters become spaces.
125pub fn sanitize_diagnostic(text: &str, limit: usize, secrets: &[&str]) -> String {
126    let mut clean = text.to_string();
127    for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
128        clean = clean.replace(secret, REDACTED);
129    }
130    for (pattern, replacement) in sanitizers() {
131        clean = pattern.replace_all(&clean, *replacement).to_string();
132    }
133    let clean = clean.trim();
134    let count = clean.chars().count();
135    if count > limit {
136        format!(
137            "{}… [{} more chars]",
138            clean.chars().take(limit).collect::<String>(),
139            count - limit
140        )
141    } else {
142        clean.to_string()
143    }
144}
145
146/// Structured diagnostics of an HTTP error response: httpStatus, errorType,
147/// errorMessage, bodyPreview and bodyChars (same keys as JavaScript).
148pub fn http_error_details(status: u16, text: &str, secrets: &[&str]) -> Value {
149    let body: Value = serde_json::from_str(text).unwrap_or(Value::Null);
150    let error = body.get("error").unwrap_or(&Value::Null);
151    let (kind, message) = if error.is_object() {
152        (
153            error.get("type").and_then(Value::as_str),
154            error.get("message").and_then(Value::as_str),
155        )
156    } else if let Some(text) = error.as_str() {
157        (None, Some(text))
158    } else {
159        (None, body.get("message").and_then(Value::as_str))
160    };
161    json!({
162        "httpStatus": status,
163        "errorType": kind.map(|kind| sanitize_diagnostic(kind, 100, secrets)),
164        "errorMessage": message.map(|message| sanitize_diagnostic(message, MESSAGE_CHARS, secrets)),
165        "bodyPreview": sanitize_diagnostic(text, PREVIEW_CHARS, secrets),
166        "bodyChars": text.encode_utf16().count(),
167    })
168}
169
170/// `Formal AI returned HTTP <status>: [<type>: ]<message or preview>`.
171pub fn http_error_message(details: &Value) -> String {
172    let text = |key: &str| details[key].as_str().filter(|text| !text.is_empty());
173    let cause = match (text("errorType"), text("errorMessage")) {
174        (Some(kind), Some(message)) => format!("{}: {}", kind, message),
175        (None, Some(message)) => message.to_string(),
176        _ => {
177            let preview: String = text("bodyPreview")
178                .unwrap_or("")
179                .chars()
180                .take(MESSAGE_CHARS)
181                .collect();
182            if preview.is_empty() {
183                "(empty body)".to_string()
184            } else {
185                preview
186            }
187        }
188    };
189    format!(
190        "Formal AI returned HTTP {}: {}",
191        details["httpStatus"], cause
192    )
193}
194
195/// Known structured gaps Formal AI answers instead of claiming success.
196pub const GAP_SCHEMAS: [(&str, &[&str]); 2] = [
197    (
198        "workspace-edit-capability-gap/v1",
199        &["EditToolUnavailable", "ReadToolUnavailable"],
200    ),
201    // SourcePreimageUnavailable: Formal AI could not bind the complete Read
202    // before its edit to the change (issue #261).
203    (
204        "workspace-change-verification-gap/v1",
205        &[
206            "VerificationRunToolUnavailable",
207            "SourcePreimageUnavailable",
208        ],
209    ),
210];
211const CAPABILITIES: [&str; 6] = ["read", "edit", "multi_edit", "write", "run", "bash"];
212
213/// Recognize a final answer that is exactly a known Formal AI capability
214/// gap: {schema, target, reason, missingCapabilities, complete, verified}.
215/// Only the runtime's own answer counts: text that a tool result in this run
216/// contained (file data echoed back) is never taken as a gap.
217pub fn recognize_capability_gap(content: &str, messages: &[Value]) -> Option<Value> {
218    let text = content.trim();
219    if !text.starts_with('{') || !text.ends_with('}') {
220        return None;
221    }
222    let gap: Value = serde_json::from_str(text).ok()?;
223    let schema = gap.get("schema").and_then(Value::as_str)?;
224    let (_, reasons) = GAP_SCHEMAS.iter().find(|(name, _)| *name == schema)?;
225    let reason = gap.get("reason").and_then(Value::as_str)?;
226    let target = gap.get("target").and_then(Value::as_str)?;
227    let missing = match gap.get("missingCapabilities") {
228        None => Vec::new(),
229        Some(value) => value.as_array()?.clone(),
230    };
231    let known = missing
232        .iter()
233        .all(|c| c.as_str().is_some_and(|c| CAPABILITIES.contains(&c)));
234    if !reasons.contains(&reason)
235        || gap.get("complete") != Some(&Value::Bool(false))
236        || gap.get("verified") != Some(&Value::Bool(false))
237        || target.trim().is_empty()
238        || !known
239    {
240        return None;
241    }
242    if crate::formal_ai_files::echoed_by_tool(text, messages) {
243        return None;
244    }
245    Some(json!({
246        "schema": schema,
247        "target": target,
248        "reason": reason,
249        "missingCapabilities": missing,
250        "complete": false,
251        "verified": false,
252    }))
253}
254
255/// One tool call Formal AI asked for.
256#[derive(Debug, Clone, PartialEq, Default)]
257pub struct ToolCall {
258    pub id: Option<String>,
259    /// Name Formal AI used.
260    pub name: String,
261    /// "bash", or the file tool "read", "edit" or "write".
262    pub tool: String,
263    /// The bash command, or a one-line rendering of a file call.
264    pub command: String,
265    /// Raw JSON arguments of a file call, sent back unchanged.
266    pub arguments: String,
267    /// Normalized arguments of a file call.
268    pub args: Option<FileArgs>,
269}
270
271/// One chat completion, normalized for the task loop.
272#[derive(Debug, Clone, PartialEq, Default)]
273pub struct Completion {
274    pub content: String,
275    pub finish_reason: Option<String>,
276    pub tool_calls: Vec<ToolCall>,
277    pub handler: Option<String>,
278}
279
280fn value_text(value: &Value) -> String {
281    match value {
282        Value::String(text) => text.clone(),
283        other => other.to_string(),
284    }
285}
286
287/// Normalize one chat completion into what the loop needs.
288pub fn parse_completion(body: &Value) -> Result<Completion, ClientError> {
289    let choice = body
290        .get("choices")
291        .and_then(Value::as_array)
292        .and_then(|choices| choices.first())
293        .filter(|choice| choice.get("message").is_some_and(Value::is_object))
294        .ok_or_else(|| ClientError::protocol("Formal AI response has no choices[0].message"))?;
295    let message = &choice["message"];
296    let handler = message
297        .get("thinking_steps")
298        .and_then(Value::as_array)
299        .and_then(|steps| {
300            steps
301                .iter()
302                .find(|s| s.get("step").and_then(Value::as_str) == Some("dispatch_handler"))
303        })
304        .map(|step| value_text(step.get("detail").unwrap_or(&Value::Null)));
305    let mut tool_calls = Vec::new();
306    if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
307        for call in calls {
308            let function = call.get("function");
309            let name = function
310                .and_then(|f| f.get("name"))
311                .and_then(Value::as_str)
312                .unwrap_or("");
313            if !TOOL_NAMES.contains(&name) {
314                let shown = if name.is_empty() { "undefined" } else { name };
315                return Err(ClientError::protocol(format!(
316                    "Formal AI called an unsupported tool \"{}\"",
317                    shown
318                )));
319            }
320            let raw = function
321                .and_then(|f| f.get("arguments"))
322                .and_then(Value::as_str)
323                .filter(|text| !text.is_empty())
324                .unwrap_or("{}");
325            let args: Value = serde_json::from_str(raw).map_err(|_| {
326                ClientError::protocol(format!("Formal AI sent non-JSON arguments for {}", name))
327            })?;
328            let id = call
329                .get("id")
330                .and_then(Value::as_str)
331                .filter(|id| !id.is_empty())
332                .map(str::to_string);
333            if !BASH_NAMES.contains(&name) {
334                let normalized = normalize_file_args(name, &args).map_err(|problem| {
335                    ClientError::protocol(format!("Formal AI called {}", problem))
336                })?;
337                tool_calls.push(ToolCall {
338                    id,
339                    name: name.to_string(),
340                    tool: name.to_string(),
341                    command: describe_file_call(name, &normalized),
342                    arguments: raw.to_string(),
343                    args: Some(normalized),
344                });
345                continue;
346            }
347            let command = args
348                .get("command")
349                .filter(|v| !v.is_null())
350                .or_else(|| args.get("cmd"))
351                .and_then(Value::as_str)
352                .filter(|text| !text.trim().is_empty())
353                .ok_or_else(|| {
354                    ClientError::protocol(format!("Formal AI called {} without a command", name))
355                })?;
356            tool_calls.push(ToolCall {
357                id,
358                name: name.to_string(),
359                tool: "bash".to_string(),
360                command: command.to_string(),
361                ..ToolCall::default()
362            });
363        }
364    }
365    Ok(Completion {
366        content: message
367            .get("content")
368            .and_then(Value::as_str)
369            .unwrap_or("")
370            .to_string(),
371        finish_reason: choice
372            .get("finish_reason")
373            .and_then(Value::as_str)
374            .filter(|reason| !reason.is_empty())
375            .map(str::to_string),
376        tool_calls,
377        handler,
378    })
379}
380
381/// Something that answers chat completions (HTTP, or a replay in tests).
382pub trait CompletionClient {
383    /// Send the conversation; `cancel` is polled while waiting.
384    fn complete(
385        &mut self,
386        messages: &[Value],
387        cancel: &dyn Fn() -> bool,
388    ) -> Result<Completion, ClientError>;
389    /// Requests sent so far (retries included).
390    fn requests(&self) -> u64;
391}
392
393/// Why an HTTP exchange failed.
394#[derive(Debug, Clone, PartialEq)]
395pub enum HttpError {
396    Timeout,
397    Cancelled,
398    Io(String),
399}
400
401/// Host, port and path prefix of an `http://host:port/prefix` URL.
402pub fn parse_http_url(url: &str) -> Result<(String, u16, String), String> {
403    let rest = url
404        .strip_prefix("http://")
405        .ok_or_else(|| format!("not an http:// URL: {}", url))?;
406    let (authority, prefix) = match rest.find('/') {
407        Some(index) => (&rest[..index], rest[index..].trim_end_matches('/')),
408        None => (rest, ""),
409    };
410    let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
411        let end = stripped
412            .find(']')
413            .ok_or_else(|| format!("invalid host in {}", url))?;
414        let port = stripped[end + 1..].strip_prefix(':').unwrap_or("80");
415        (stripped[..end].to_string(), port)
416    } else {
417        match authority.rsplit_once(':') {
418            Some((host, port)) => (host.to_string(), port),
419            None => (authority.to_string(), "80"),
420        }
421    };
422    if host.is_empty() {
423        return Err(format!("missing host in {}", url));
424    }
425    let port = port
426        .parse::<u16>()
427        .map_err(|_| format!("invalid port in {}", url))?;
428    Ok((host, port, prefix.to_string()))
429}
430
431fn header_end(buf: &[u8]) -> Option<usize> {
432    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)
433}
434
435/// Decode a chunked body; None while it is incomplete.
436fn decode_chunked(mut body: &[u8]) -> Option<Vec<u8>> {
437    let mut out = Vec::new();
438    loop {
439        let line_end = body.windows(2).position(|w| w == b"\r\n")?;
440        let size_text = String::from_utf8_lossy(&body[..line_end]);
441        let size_text = size_text.split(';').next().unwrap_or("").trim();
442        let size = usize::from_str_radix(size_text, 16).ok()?;
443        body = &body[line_end + 2..];
444        if size == 0 {
445            return Some(out);
446        }
447        if body.len() < size + 2 {
448            return None;
449        }
450        out.extend_from_slice(&body[..size]);
451        body = &body[size + 2..];
452    }
453}
454
455struct Head {
456    status: u16,
457    content_length: Option<usize>,
458    chunked: bool,
459}
460
461fn parse_head(head: &[u8]) -> Result<Head, HttpError> {
462    let text = String::from_utf8_lossy(head);
463    let mut lines = text.split("\r\n");
464    let status = lines
465        .next()
466        .and_then(|line| line.split_whitespace().nth(1))
467        .and_then(|code| code.parse::<u16>().ok())
468        .ok_or_else(|| HttpError::Io("malformed HTTP response".to_string()))?;
469    let mut content_length = None;
470    let mut chunked = false;
471    for line in lines {
472        if let Some((name, value)) = line.split_once(':') {
473            let name = name.trim().to_ascii_lowercase();
474            let value = value.trim();
475            if name == "content-length" {
476                content_length = value.parse::<usize>().ok();
477            } else if name == "transfer-encoding" && value.to_ascii_lowercase().contains("chunked")
478            {
479                chunked = true;
480            }
481        }
482    }
483    Ok(Head {
484        status,
485        content_length,
486        chunked,
487    })
488}
489
490/// Complete body of a buffered response, once it has fully arrived.
491fn complete_body(buf: &[u8], closed: bool) -> Result<Option<(u16, Vec<u8>)>, HttpError> {
492    let Some(end) = header_end(buf) else {
493        return if closed {
494            Err(HttpError::Io(
495                "connection closed before a response".to_string(),
496            ))
497        } else {
498            Ok(None)
499        };
500    };
501    let head = parse_head(&buf[..end])?;
502    let body = &buf[end..];
503    if head.chunked {
504        return match decode_chunked(body) {
505            Some(decoded) => Ok(Some((head.status, decoded))),
506            None if closed => Err(HttpError::Io("truncated chunked response".to_string())),
507            None => Ok(None),
508        };
509    }
510    match head.content_length {
511        Some(length) if body.len() >= length => Ok(Some((head.status, body[..length].to_vec()))),
512        Some(_) if closed => Err(HttpError::Io("truncated response".to_string())),
513        Some(_) => Ok(None),
514        None if closed => Ok(Some((head.status, body.to_vec()))),
515        None => Ok(None),
516    }
517}
518
519fn io_error(error: std::io::Error) -> HttpError {
520    HttpError::Io(error.to_string())
521}
522
523/// One blocking HTTP/1.1 exchange with `Connection: close`.
524pub fn http_request(
525    base_url: &str,
526    method: &str,
527    path: &str,
528    body: Option<&str>,
529    timeout: Duration,
530    cancel: &dyn Fn() -> bool,
531) -> Result<(u16, String), HttpError> {
532    let (host, port, prefix) = parse_http_url(base_url).map_err(HttpError::Io)?;
533    let deadline = Instant::now() + timeout;
534    let remaining = || deadline.saturating_duration_since(Instant::now());
535    let mut last = HttpError::Io(format!("cannot resolve {}", host));
536    let mut stream = None;
537    for addr in (host.as_str(), port).to_socket_addrs().map_err(io_error)? {
538        if remaining().is_zero() {
539            return Err(HttpError::Timeout);
540        }
541        match TcpStream::connect_timeout(&addr, remaining()) {
542            Ok(connected) => {
543                stream = Some(connected);
544                break;
545            }
546            Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
547                last = HttpError::Timeout;
548            }
549            Err(error) => last = io_error(error),
550        }
551    }
552    let mut stream = stream.ok_or(last)?;
553    let mut request = format!(
554        "{} {}{} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\nAccept: application/json\r\n",
555        method, prefix, path, host, port
556    );
557    if let Some(body) = body {
558        request.push_str(&format!(
559            "Content-Type: application/json\r\nContent-Length: {}\r\n",
560            body.len()
561        ));
562    }
563    request.push_str("\r\n");
564    request.push_str(body.unwrap_or(""));
565    stream
566        .set_write_timeout(Some(remaining().max(Duration::from_millis(1))))
567        .map_err(io_error)?;
568    stream.write_all(request.as_bytes()).map_err(io_error)?;
569    stream.set_read_timeout(Some(POLL)).map_err(io_error)?;
570    let mut buf = Vec::new();
571    let mut chunk = [0u8; 16 * 1024];
572    loop {
573        if cancel() {
574            return Err(HttpError::Cancelled);
575        }
576        if remaining().is_zero() {
577            return Err(HttpError::Timeout);
578        }
579        match stream.read(&mut chunk) {
580            Ok(0) => {
581                let (status, body) = complete_body(&buf, true)?.unwrap_or_default();
582                return Ok((status, String::from_utf8_lossy(&body).to_string()));
583            }
584            Ok(n) => {
585                buf.extend_from_slice(&chunk[..n]);
586                if buf.len() > MAX_RESPONSE_BYTES {
587                    return Err(HttpError::Io("response too large".to_string()));
588                }
589                if let Some((status, body)) = complete_body(&buf, false)? {
590                    return Ok((status, String::from_utf8_lossy(&body).to_string()));
591                }
592            }
593            Err(error)
594                if matches!(
595                    error.kind(),
596                    std::io::ErrorKind::WouldBlock
597                        | std::io::ErrorKind::TimedOut
598                        | std::io::ErrorKind::Interrupted
599                ) => {}
600            Err(error) => return Err(io_error(error)),
601        }
602    }
603}
604
605/// Client bound to one runtime over HTTP.
606pub struct HttpClient {
607    pub base_url: String,
608    pub max_retries: u64,
609    pub deadline: Instant,
610    pub requests: u64,
611    /// Tools offered with every request (offered_tools() of the effect mode).
612    pub tools: Vec<Value>,
613}
614
615impl HttpClient {
616    /// A client offering the read-only tools; see with_tools().
617    pub fn new(base_url: &str, max_retries: u64, deadline: Instant) -> Self {
618        Self {
619            base_url: base_url.to_string(),
620            max_retries,
621            deadline,
622            requests: 0,
623            tools: offered_tools("read-only"),
624        }
625    }
626
627    /// Offer these tools instead (e.g. offered_tools(&bounds.effects)).
628    pub fn with_tools(mut self, tools: Vec<Value>) -> Self {
629        self.tools = tools;
630        self
631    }
632}
633
634fn budget_error() -> ClientError {
635    ClientError::with_code(
636        "time budget exhausted while waiting for Formal AI",
637        EXIT_BUDGET,
638    )
639}
640
641impl CompletionClient for HttpClient {
642    fn complete(
643        &mut self,
644        messages: &[Value],
645        cancel: &dyn Fn() -> bool,
646    ) -> Result<Completion, ClientError> {
647        let body = json!({
648            "model": "formal-ai",
649            "messages": messages,
650            "tools": self.tools,
651        })
652        .to_string();
653        let mut last_error = ClientError::protocol("Formal AI was not asked");
654        let mut attempts: u64 = 0;
655        for _ in 0..=self.max_retries {
656            let remaining = self.deadline.saturating_duration_since(Instant::now());
657            if remaining.is_zero() {
658                return Err(budget_error());
659            }
660            self.requests += 1;
661            attempts += 1;
662            let path = "/chat/completions";
663            let full = format!("{}{}", API_PREFIX, path);
664            match http_request(
665                &self.base_url,
666                "POST",
667                &full,
668                Some(&body),
669                remaining,
670                cancel,
671            ) {
672                Ok((status, text)) => {
673                    if !(200..300).contains(&status) {
674                        let mut details = http_error_details(status, &text, &[]);
675                        details["attempts"] = json!(attempts);
676                        let error = ClientError::protocol(http_error_message(&details))
677                            .with_details(details);
678                        // A server error may be transient: retry it within
679                        // the bounds, keeping the latest diagnostics.
680                        if status >= 500 {
681                            last_error = error;
682                            continue;
683                        }
684                        return Err(error);
685                    }
686                    let parsed: Value = serde_json::from_str(&text).map_err(|_| {
687                        ClientError::protocol("Formal AI returned a non-JSON response")
688                    })?;
689                    return parse_completion(&parsed);
690                }
691                Err(HttpError::Cancelled) => {
692                    let mut error = ClientError::protocol("cancelled");
693                    error.cancelled = true;
694                    return Err(error);
695                }
696                Err(HttpError::Timeout) => return Err(budget_error()),
697                Err(HttpError::Io(message)) => {
698                    let cause = sanitize_diagnostic(&message, MESSAGE_CHARS, &[]);
699                    last_error = ClientError::with_code(
700                        format!("Formal AI request failed: {}", cause),
701                        EXIT_UNAVAILABLE,
702                    )
703                    .with_details(json!({ "cause": cause, "attempts": attempts }));
704                }
705            }
706        }
707        let retries = attempts.saturating_sub(1);
708        if retries > 0 {
709            let plural = if retries == 1 { "y" } else { "ies" };
710            last_error.message =
711                format!("{} (after {} retr{})", last_error.message, retries, plural);
712        }
713        if let Some(details) = last_error.details.as_mut() {
714            details["attempts"] = json!(attempts);
715        }
716        Err(last_error)
717    }
718
719    fn requests(&self) -> u64 {
720        self.requests
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn parses_tool_calls_and_handler() {
730        let body = json!({"choices": [{"finish_reason": "tool_calls", "message": {
731            "content": "",
732            "tool_calls": [{"id": "c1", "function": {"name": "bash", "arguments": "{\"command\":\"ls\"}"}}],
733            "thinking_steps": [{"step": "dispatch_handler", "detail": "list_files"}]
734        }}]});
735        let completion = parse_completion(&body).unwrap();
736        assert_eq!(completion.handler.as_deref(), Some("list_files"));
737        assert_eq!(completion.finish_reason.as_deref(), Some("tool_calls"));
738        assert_eq!(
739            completion.tool_calls,
740            vec![ToolCall {
741                id: Some("c1".to_string()),
742                name: "bash".to_string(),
743                tool: "bash".to_string(),
744                command: "ls".to_string(),
745                ..ToolCall::default()
746            }]
747        );
748    }
749
750    #[test]
751    fn rejects_malformed_completions() {
752        let cases = [
753            (json!({}), "Formal AI response has no choices[0].message"),
754            (
755                json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "python", "arguments": "{}"}}]}}]}),
756                "Formal AI called an unsupported tool \"python\"",
757            ),
758            (
759                json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "bash", "arguments": "ls"}}]}}]}),
760                "Formal AI sent non-JSON arguments for bash",
761            ),
762            (
763                json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "shell", "arguments": "{\"cmd\":\" \"}"}}]}}]}),
764                "Formal AI called shell without a command",
765            ),
766        ];
767        for (body, message) in cases {
768            let error = parse_completion(&body).unwrap_err();
769            assert_eq!(error.message, message);
770            assert_eq!(error.exit_code, EXIT_PROTOCOL);
771        }
772    }
773
774    #[test]
775    fn accepts_cmd_arguments() {
776        let body = json!({"choices": [{"message": {"content": "ok",
777            "tool_calls": [{"function": {"name": "run_command", "arguments": "{\"cmd\":\"pwd\"}"}}]}}]});
778        let completion = parse_completion(&body).unwrap();
779        assert_eq!(completion.tool_calls[0].command, "pwd");
780        assert_eq!(completion.tool_calls[0].id, None);
781        assert_eq!(completion.content, "ok");
782    }
783
784    #[test]
785    fn parses_http_urls() {
786        assert_eq!(
787            parse_http_url("http://127.0.0.1:8080").unwrap(),
788            ("127.0.0.1".to_string(), 8080, String::new())
789        );
790        assert_eq!(
791            parse_http_url("http://localhost/base/").unwrap(),
792            ("localhost".to_string(), 80, "/base".to_string())
793        );
794        assert_eq!(
795            parse_http_url("http://[::1]:9/").unwrap(),
796            ("::1".to_string(), 9, String::new())
797        );
798        assert!(parse_http_url("https://example.com").is_err());
799        assert!(parse_http_url("http://:80").is_err());
800    }
801
802    #[test]
803    fn decodes_content_length_and_chunked_bodies() {
804        let fixed = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nokEXTRA";
805        assert_eq!(
806            complete_body(fixed, false).unwrap(),
807            Some((200, b"ok".to_vec()))
808        );
809        let partial = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nok";
810        assert_eq!(complete_body(partial, false).unwrap(), None);
811        assert!(complete_body(partial, true).is_err());
812        let chunked = b"HTTP/1.1 503 Busy\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n";
813        assert_eq!(
814            complete_body(chunked, false).unwrap(),
815            Some((503, b"abcde".to_vec()))
816        );
817        let open = b"HTTP/1.1 200 OK\r\n\r\nbody";
818        assert_eq!(complete_body(open, false).unwrap(), None);
819        assert_eq!(
820            complete_body(open, true).unwrap(),
821            Some((200, b"body".to_vec()))
822        );
823    }
824
825    #[test]
826    fn unreachable_server_is_unavailable_after_retries() {
827        // Port 9 (discard) on loopback refuses connections on CI runners.
828        let mut client = HttpClient::new(
829            "http://127.0.0.1:9",
830            1,
831            Instant::now() + Duration::from_secs(10),
832        );
833        let error = client.complete(&[], &|| false).unwrap_err();
834        assert_eq!(client.requests(), 2);
835        if error.exit_code == EXIT_UNAVAILABLE {
836            assert!(error.message.starts_with("Formal AI request failed: "));
837            assert!(error.message.ends_with(" (after 1 retry)"));
838        } else {
839            assert_eq!(error.exit_code, EXIT_BUDGET);
840        }
841    }
842}
843
844#[cfg(test)]
845#[path = "formal_ai_client_cases.rs"]
846mod formal_ai_client_cases;