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    (
202        "workspace-change-verification-gap/v1",
203        &["VerificationRunToolUnavailable"],
204    ),
205];
206const CAPABILITIES: [&str; 6] = ["read", "edit", "multi_edit", "write", "run", "bash"];
207
208/// Recognize a final answer that is exactly a known Formal AI capability
209/// gap: {schema, target, reason, missingCapabilities, complete, verified}.
210/// Only the runtime's own answer counts: text that a tool result in this run
211/// contained (file data echoed back) is never taken as a gap.
212pub fn recognize_capability_gap(content: &str, messages: &[Value]) -> Option<Value> {
213    let text = content.trim();
214    if !text.starts_with('{') || !text.ends_with('}') {
215        return None;
216    }
217    let gap: Value = serde_json::from_str(text).ok()?;
218    let schema = gap.get("schema").and_then(Value::as_str)?;
219    let (_, reasons) = GAP_SCHEMAS.iter().find(|(name, _)| *name == schema)?;
220    let reason = gap.get("reason").and_then(Value::as_str)?;
221    let target = gap.get("target").and_then(Value::as_str)?;
222    let missing = match gap.get("missingCapabilities") {
223        None => Vec::new(),
224        Some(value) => value.as_array()?.clone(),
225    };
226    let known = missing
227        .iter()
228        .all(|c| c.as_str().is_some_and(|c| CAPABILITIES.contains(&c)));
229    if !reasons.contains(&reason)
230        || gap.get("complete") != Some(&Value::Bool(false))
231        || gap.get("verified") != Some(&Value::Bool(false))
232        || target.trim().is_empty()
233        || !known
234    {
235        return None;
236    }
237    let echoed = messages.iter().any(|m| {
238        m["role"] == "tool"
239            && match &m["content"] {
240                Value::String(content) => content.contains(text),
241                Value::Null => false,
242                other => other.to_string().contains(text),
243            }
244    });
245    if echoed {
246        return None;
247    }
248    Some(json!({
249        "schema": schema,
250        "target": target,
251        "reason": reason,
252        "missingCapabilities": missing,
253        "complete": false,
254        "verified": false,
255    }))
256}
257
258/// One tool call Formal AI asked for.
259#[derive(Debug, Clone, PartialEq, Default)]
260pub struct ToolCall {
261    pub id: Option<String>,
262    /// Name Formal AI used.
263    pub name: String,
264    /// "bash", or the file tool "read", "edit" or "write".
265    pub tool: String,
266    /// The bash command, or a one-line rendering of a file call.
267    pub command: String,
268    /// Raw JSON arguments of a file call, sent back unchanged.
269    pub arguments: String,
270    /// Normalized arguments of a file call.
271    pub args: Option<FileArgs>,
272}
273
274/// One chat completion, normalized for the task loop.
275#[derive(Debug, Clone, PartialEq, Default)]
276pub struct Completion {
277    pub content: String,
278    pub finish_reason: Option<String>,
279    pub tool_calls: Vec<ToolCall>,
280    pub handler: Option<String>,
281}
282
283fn value_text(value: &Value) -> String {
284    match value {
285        Value::String(text) => text.clone(),
286        other => other.to_string(),
287    }
288}
289
290/// Normalize one chat completion into what the loop needs.
291pub fn parse_completion(body: &Value) -> Result<Completion, ClientError> {
292    let choice = body
293        .get("choices")
294        .and_then(Value::as_array)
295        .and_then(|choices| choices.first())
296        .filter(|choice| choice.get("message").is_some_and(Value::is_object))
297        .ok_or_else(|| ClientError::protocol("Formal AI response has no choices[0].message"))?;
298    let message = &choice["message"];
299    let handler = message
300        .get("thinking_steps")
301        .and_then(Value::as_array)
302        .and_then(|steps| {
303            steps
304                .iter()
305                .find(|s| s.get("step").and_then(Value::as_str) == Some("dispatch_handler"))
306        })
307        .map(|step| value_text(step.get("detail").unwrap_or(&Value::Null)));
308    let mut tool_calls = Vec::new();
309    if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
310        for call in calls {
311            let function = call.get("function");
312            let name = function
313                .and_then(|f| f.get("name"))
314                .and_then(Value::as_str)
315                .unwrap_or("");
316            if !TOOL_NAMES.contains(&name) {
317                let shown = if name.is_empty() { "undefined" } else { name };
318                return Err(ClientError::protocol(format!(
319                    "Formal AI called an unsupported tool \"{}\"",
320                    shown
321                )));
322            }
323            let raw = function
324                .and_then(|f| f.get("arguments"))
325                .and_then(Value::as_str)
326                .filter(|text| !text.is_empty())
327                .unwrap_or("{}");
328            let args: Value = serde_json::from_str(raw).map_err(|_| {
329                ClientError::protocol(format!("Formal AI sent non-JSON arguments for {}", name))
330            })?;
331            let id = call
332                .get("id")
333                .and_then(Value::as_str)
334                .filter(|id| !id.is_empty())
335                .map(str::to_string);
336            if !BASH_NAMES.contains(&name) {
337                let normalized = normalize_file_args(name, &args).map_err(|problem| {
338                    ClientError::protocol(format!("Formal AI called {}", problem))
339                })?;
340                tool_calls.push(ToolCall {
341                    id,
342                    name: name.to_string(),
343                    tool: name.to_string(),
344                    command: describe_file_call(name, &normalized),
345                    arguments: raw.to_string(),
346                    args: Some(normalized),
347                });
348                continue;
349            }
350            let command = args
351                .get("command")
352                .filter(|v| !v.is_null())
353                .or_else(|| args.get("cmd"))
354                .and_then(Value::as_str)
355                .filter(|text| !text.trim().is_empty())
356                .ok_or_else(|| {
357                    ClientError::protocol(format!("Formal AI called {} without a command", name))
358                })?;
359            tool_calls.push(ToolCall {
360                id,
361                name: name.to_string(),
362                tool: "bash".to_string(),
363                command: command.to_string(),
364                ..ToolCall::default()
365            });
366        }
367    }
368    Ok(Completion {
369        content: message
370            .get("content")
371            .and_then(Value::as_str)
372            .unwrap_or("")
373            .to_string(),
374        finish_reason: choice
375            .get("finish_reason")
376            .and_then(Value::as_str)
377            .filter(|reason| !reason.is_empty())
378            .map(str::to_string),
379        tool_calls,
380        handler,
381    })
382}
383
384/// Something that answers chat completions (HTTP, or a replay in tests).
385pub trait CompletionClient {
386    /// Send the conversation; `cancel` is polled while waiting.
387    fn complete(
388        &mut self,
389        messages: &[Value],
390        cancel: &dyn Fn() -> bool,
391    ) -> Result<Completion, ClientError>;
392    /// Requests sent so far (retries included).
393    fn requests(&self) -> u64;
394}
395
396/// Why an HTTP exchange failed.
397#[derive(Debug, Clone, PartialEq)]
398pub enum HttpError {
399    Timeout,
400    Cancelled,
401    Io(String),
402}
403
404/// Host, port and path prefix of an `http://host:port/prefix` URL.
405pub fn parse_http_url(url: &str) -> Result<(String, u16, String), String> {
406    let rest = url
407        .strip_prefix("http://")
408        .ok_or_else(|| format!("not an http:// URL: {}", url))?;
409    let (authority, prefix) = match rest.find('/') {
410        Some(index) => (&rest[..index], rest[index..].trim_end_matches('/')),
411        None => (rest, ""),
412    };
413    let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
414        let end = stripped
415            .find(']')
416            .ok_or_else(|| format!("invalid host in {}", url))?;
417        let port = stripped[end + 1..].strip_prefix(':').unwrap_or("80");
418        (stripped[..end].to_string(), port)
419    } else {
420        match authority.rsplit_once(':') {
421            Some((host, port)) => (host.to_string(), port),
422            None => (authority.to_string(), "80"),
423        }
424    };
425    if host.is_empty() {
426        return Err(format!("missing host in {}", url));
427    }
428    let port = port
429        .parse::<u16>()
430        .map_err(|_| format!("invalid port in {}", url))?;
431    Ok((host, port, prefix.to_string()))
432}
433
434fn header_end(buf: &[u8]) -> Option<usize> {
435    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)
436}
437
438/// Decode a chunked body; None while it is incomplete.
439fn decode_chunked(mut body: &[u8]) -> Option<Vec<u8>> {
440    let mut out = Vec::new();
441    loop {
442        let line_end = body.windows(2).position(|w| w == b"\r\n")?;
443        let size_text = String::from_utf8_lossy(&body[..line_end]);
444        let size_text = size_text.split(';').next().unwrap_or("").trim();
445        let size = usize::from_str_radix(size_text, 16).ok()?;
446        body = &body[line_end + 2..];
447        if size == 0 {
448            return Some(out);
449        }
450        if body.len() < size + 2 {
451            return None;
452        }
453        out.extend_from_slice(&body[..size]);
454        body = &body[size + 2..];
455    }
456}
457
458struct Head {
459    status: u16,
460    content_length: Option<usize>,
461    chunked: bool,
462}
463
464fn parse_head(head: &[u8]) -> Result<Head, HttpError> {
465    let text = String::from_utf8_lossy(head);
466    let mut lines = text.split("\r\n");
467    let status = lines
468        .next()
469        .and_then(|line| line.split_whitespace().nth(1))
470        .and_then(|code| code.parse::<u16>().ok())
471        .ok_or_else(|| HttpError::Io("malformed HTTP response".to_string()))?;
472    let mut content_length = None;
473    let mut chunked = false;
474    for line in lines {
475        if let Some((name, value)) = line.split_once(':') {
476            let name = name.trim().to_ascii_lowercase();
477            let value = value.trim();
478            if name == "content-length" {
479                content_length = value.parse::<usize>().ok();
480            } else if name == "transfer-encoding" && value.to_ascii_lowercase().contains("chunked")
481            {
482                chunked = true;
483            }
484        }
485    }
486    Ok(Head {
487        status,
488        content_length,
489        chunked,
490    })
491}
492
493/// Complete body of a buffered response, once it has fully arrived.
494fn complete_body(buf: &[u8], closed: bool) -> Result<Option<(u16, Vec<u8>)>, HttpError> {
495    let Some(end) = header_end(buf) else {
496        return if closed {
497            Err(HttpError::Io(
498                "connection closed before a response".to_string(),
499            ))
500        } else {
501            Ok(None)
502        };
503    };
504    let head = parse_head(&buf[..end])?;
505    let body = &buf[end..];
506    if head.chunked {
507        return match decode_chunked(body) {
508            Some(decoded) => Ok(Some((head.status, decoded))),
509            None if closed => Err(HttpError::Io("truncated chunked response".to_string())),
510            None => Ok(None),
511        };
512    }
513    match head.content_length {
514        Some(length) if body.len() >= length => Ok(Some((head.status, body[..length].to_vec()))),
515        Some(_) if closed => Err(HttpError::Io("truncated response".to_string())),
516        Some(_) => Ok(None),
517        None if closed => Ok(Some((head.status, body.to_vec()))),
518        None => Ok(None),
519    }
520}
521
522fn io_error(error: std::io::Error) -> HttpError {
523    HttpError::Io(error.to_string())
524}
525
526/// One blocking HTTP/1.1 exchange with `Connection: close`.
527pub fn http_request(
528    base_url: &str,
529    method: &str,
530    path: &str,
531    body: Option<&str>,
532    timeout: Duration,
533    cancel: &dyn Fn() -> bool,
534) -> Result<(u16, String), HttpError> {
535    let (host, port, prefix) = parse_http_url(base_url).map_err(HttpError::Io)?;
536    let deadline = Instant::now() + timeout;
537    let remaining = || deadline.saturating_duration_since(Instant::now());
538    let mut last = HttpError::Io(format!("cannot resolve {}", host));
539    let mut stream = None;
540    for addr in (host.as_str(), port).to_socket_addrs().map_err(io_error)? {
541        if remaining().is_zero() {
542            return Err(HttpError::Timeout);
543        }
544        match TcpStream::connect_timeout(&addr, remaining()) {
545            Ok(connected) => {
546                stream = Some(connected);
547                break;
548            }
549            Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
550                last = HttpError::Timeout;
551            }
552            Err(error) => last = io_error(error),
553        }
554    }
555    let mut stream = stream.ok_or(last)?;
556    let mut request = format!(
557        "{} {}{} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\nAccept: application/json\r\n",
558        method, prefix, path, host, port
559    );
560    if let Some(body) = body {
561        request.push_str(&format!(
562            "Content-Type: application/json\r\nContent-Length: {}\r\n",
563            body.len()
564        ));
565    }
566    request.push_str("\r\n");
567    request.push_str(body.unwrap_or(""));
568    stream
569        .set_write_timeout(Some(remaining().max(Duration::from_millis(1))))
570        .map_err(io_error)?;
571    stream.write_all(request.as_bytes()).map_err(io_error)?;
572    stream.set_read_timeout(Some(POLL)).map_err(io_error)?;
573    let mut buf = Vec::new();
574    let mut chunk = [0u8; 16 * 1024];
575    loop {
576        if cancel() {
577            return Err(HttpError::Cancelled);
578        }
579        if remaining().is_zero() {
580            return Err(HttpError::Timeout);
581        }
582        match stream.read(&mut chunk) {
583            Ok(0) => {
584                let (status, body) = complete_body(&buf, true)?.unwrap_or_default();
585                return Ok((status, String::from_utf8_lossy(&body).to_string()));
586            }
587            Ok(n) => {
588                buf.extend_from_slice(&chunk[..n]);
589                if buf.len() > MAX_RESPONSE_BYTES {
590                    return Err(HttpError::Io("response too large".to_string()));
591                }
592                if let Some((status, body)) = complete_body(&buf, false)? {
593                    return Ok((status, String::from_utf8_lossy(&body).to_string()));
594                }
595            }
596            Err(error)
597                if matches!(
598                    error.kind(),
599                    std::io::ErrorKind::WouldBlock
600                        | std::io::ErrorKind::TimedOut
601                        | std::io::ErrorKind::Interrupted
602                ) => {}
603            Err(error) => return Err(io_error(error)),
604        }
605    }
606}
607
608/// Client bound to one runtime over HTTP.
609pub struct HttpClient {
610    pub base_url: String,
611    pub max_retries: u64,
612    pub deadline: Instant,
613    pub requests: u64,
614    /// Tools offered with every request (offered_tools() of the effect mode).
615    pub tools: Vec<Value>,
616}
617
618impl HttpClient {
619    /// A client offering the read-only tools; see with_tools().
620    pub fn new(base_url: &str, max_retries: u64, deadline: Instant) -> Self {
621        Self {
622            base_url: base_url.to_string(),
623            max_retries,
624            deadline,
625            requests: 0,
626            tools: offered_tools("read-only"),
627        }
628    }
629
630    /// Offer these tools instead (e.g. offered_tools(&bounds.effects)).
631    pub fn with_tools(mut self, tools: Vec<Value>) -> Self {
632        self.tools = tools;
633        self
634    }
635}
636
637fn budget_error() -> ClientError {
638    ClientError::with_code(
639        "time budget exhausted while waiting for Formal AI",
640        EXIT_BUDGET,
641    )
642}
643
644impl CompletionClient for HttpClient {
645    fn complete(
646        &mut self,
647        messages: &[Value],
648        cancel: &dyn Fn() -> bool,
649    ) -> Result<Completion, ClientError> {
650        let body = json!({
651            "model": "formal-ai",
652            "messages": messages,
653            "tools": self.tools,
654        })
655        .to_string();
656        let mut last_error = ClientError::protocol("Formal AI was not asked");
657        let mut attempts: u64 = 0;
658        for _ in 0..=self.max_retries {
659            let remaining = self.deadline.saturating_duration_since(Instant::now());
660            if remaining.is_zero() {
661                return Err(budget_error());
662            }
663            self.requests += 1;
664            attempts += 1;
665            let path = "/chat/completions";
666            let full = format!("{}{}", API_PREFIX, path);
667            match http_request(
668                &self.base_url,
669                "POST",
670                &full,
671                Some(&body),
672                remaining,
673                cancel,
674            ) {
675                Ok((status, text)) => {
676                    if !(200..300).contains(&status) {
677                        let mut details = http_error_details(status, &text, &[]);
678                        details["attempts"] = json!(attempts);
679                        let error = ClientError::protocol(http_error_message(&details))
680                            .with_details(details);
681                        // A server error may be transient: retry it within
682                        // the bounds, keeping the latest diagnostics.
683                        if status >= 500 {
684                            last_error = error;
685                            continue;
686                        }
687                        return Err(error);
688                    }
689                    let parsed: Value = serde_json::from_str(&text).map_err(|_| {
690                        ClientError::protocol("Formal AI returned a non-JSON response")
691                    })?;
692                    return parse_completion(&parsed);
693                }
694                Err(HttpError::Cancelled) => {
695                    let mut error = ClientError::protocol("cancelled");
696                    error.cancelled = true;
697                    return Err(error);
698                }
699                Err(HttpError::Timeout) => return Err(budget_error()),
700                Err(HttpError::Io(message)) => {
701                    let cause = sanitize_diagnostic(&message, MESSAGE_CHARS, &[]);
702                    last_error = ClientError::with_code(
703                        format!("Formal AI request failed: {}", cause),
704                        EXIT_UNAVAILABLE,
705                    )
706                    .with_details(json!({ "cause": cause, "attempts": attempts }));
707                }
708            }
709        }
710        let retries = attempts.saturating_sub(1);
711        if retries > 0 {
712            let plural = if retries == 1 { "y" } else { "ies" };
713            last_error.message =
714                format!("{} (after {} retr{})", last_error.message, retries, plural);
715        }
716        if let Some(details) = last_error.details.as_mut() {
717            details["attempts"] = json!(attempts);
718        }
719        Err(last_error)
720    }
721
722    fn requests(&self) -> u64 {
723        self.requests
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn parses_tool_calls_and_handler() {
733        let body = json!({"choices": [{"finish_reason": "tool_calls", "message": {
734            "content": "",
735            "tool_calls": [{"id": "c1", "function": {"name": "bash", "arguments": "{\"command\":\"ls\"}"}}],
736            "thinking_steps": [{"step": "dispatch_handler", "detail": "list_files"}]
737        }}]});
738        let completion = parse_completion(&body).unwrap();
739        assert_eq!(completion.handler.as_deref(), Some("list_files"));
740        assert_eq!(completion.finish_reason.as_deref(), Some("tool_calls"));
741        assert_eq!(
742            completion.tool_calls,
743            vec![ToolCall {
744                id: Some("c1".to_string()),
745                name: "bash".to_string(),
746                tool: "bash".to_string(),
747                command: "ls".to_string(),
748                ..ToolCall::default()
749            }]
750        );
751    }
752
753    #[test]
754    fn rejects_malformed_completions() {
755        let cases = [
756            (json!({}), "Formal AI response has no choices[0].message"),
757            (
758                json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "python", "arguments": "{}"}}]}}]}),
759                "Formal AI called an unsupported tool \"python\"",
760            ),
761            (
762                json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "bash", "arguments": "ls"}}]}}]}),
763                "Formal AI sent non-JSON arguments for bash",
764            ),
765            (
766                json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "shell", "arguments": "{\"cmd\":\" \"}"}}]}}]}),
767                "Formal AI called shell without a command",
768            ),
769        ];
770        for (body, message) in cases {
771            let error = parse_completion(&body).unwrap_err();
772            assert_eq!(error.message, message);
773            assert_eq!(error.exit_code, EXIT_PROTOCOL);
774        }
775    }
776
777    #[test]
778    fn accepts_cmd_arguments() {
779        let body = json!({"choices": [{"message": {"content": "ok",
780            "tool_calls": [{"function": {"name": "run_command", "arguments": "{\"cmd\":\"pwd\"}"}}]}}]});
781        let completion = parse_completion(&body).unwrap();
782        assert_eq!(completion.tool_calls[0].command, "pwd");
783        assert_eq!(completion.tool_calls[0].id, None);
784        assert_eq!(completion.content, "ok");
785    }
786
787    #[test]
788    fn parses_http_urls() {
789        assert_eq!(
790            parse_http_url("http://127.0.0.1:8080").unwrap(),
791            ("127.0.0.1".to_string(), 8080, String::new())
792        );
793        assert_eq!(
794            parse_http_url("http://localhost/base/").unwrap(),
795            ("localhost".to_string(), 80, "/base".to_string())
796        );
797        assert_eq!(
798            parse_http_url("http://[::1]:9/").unwrap(),
799            ("::1".to_string(), 9, String::new())
800        );
801        assert!(parse_http_url("https://example.com").is_err());
802        assert!(parse_http_url("http://:80").is_err());
803    }
804
805    #[test]
806    fn decodes_content_length_and_chunked_bodies() {
807        let fixed = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nokEXTRA";
808        assert_eq!(
809            complete_body(fixed, false).unwrap(),
810            Some((200, b"ok".to_vec()))
811        );
812        let partial = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nok";
813        assert_eq!(complete_body(partial, false).unwrap(), None);
814        assert!(complete_body(partial, true).is_err());
815        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";
816        assert_eq!(
817            complete_body(chunked, false).unwrap(),
818            Some((503, b"abcde".to_vec()))
819        );
820        let open = b"HTTP/1.1 200 OK\r\n\r\nbody";
821        assert_eq!(complete_body(open, false).unwrap(), None);
822        assert_eq!(
823            complete_body(open, true).unwrap(),
824            Some((200, b"body".to_vec()))
825        );
826    }
827
828    #[test]
829    fn unreachable_server_is_unavailable_after_retries() {
830        // Port 9 (discard) on loopback refuses connections on CI runners.
831        let mut client = HttpClient::new(
832            "http://127.0.0.1:9",
833            1,
834            Instant::now() + Duration::from_secs(10),
835        );
836        let error = client.complete(&[], &|| false).unwrap_err();
837        assert_eq!(client.requests(), 2);
838        if error.exit_code == EXIT_UNAVAILABLE {
839            assert!(error.message.starts_with("Formal AI request failed: "));
840            assert!(error.message.ends_with(" (after 1 retry)"));
841        } else {
842            assert_eq!(error.exit_code, EXIT_BUDGET);
843        }
844    }
845}
846
847#[cfg(test)]
848#[path = "formal_ai_client_cases.rs"]
849mod formal_ai_client_cases;