Skip to main content

agent_call/
other.rs

1//! Native one-shot adapters for the Claude, Codex, and Devin CLIs.
2
3use std::path::PathBuf;
4use std::process::{Output, Stdio};
5use std::time::{Duration, Instant};
6
7use serde_json::Value;
8use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
9use tokio::process::Command;
10
11use crate::{
12    AgentEvent, Error, EventHandler, McpServer, OpenCodeV2, RunRequest, RunResult, TokenUsage,
13    ToolPolicy,
14};
15
16/// An adapter error with the selected provider preserved for callers.
17#[derive(Debug, thiserror::Error)]
18#[error("{provider:?} CLI: {source}")]
19pub struct ProviderError {
20    pub provider: Provider,
21    #[source]
22    pub source: Error,
23}
24
25impl ProviderError {
26    /// Process exit code, when the CLI exited unsuccessfully.
27    pub fn exit_code(&self) -> Option<i32> {
28        match &self.source {
29            Error::Exit { status, .. } => *status,
30            _ => None,
31        }
32    }
33
34    /// Captured stderr from a failed CLI process, when available.
35    pub fn stderr_tail(&self) -> Option<&str> {
36        match &self.source {
37            Error::Exit { stderr_tail, .. } => Some(stderr_tail),
38            _ => None,
39        }
40    }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Provider {
45    Claude,
46    Codex,
47    Devin,
48    OpenCodeV2,
49}
50
51impl Provider {
52    /// Whether the CLI takes [`RunRequest::system_prompt`] as a real system prompt (Claude and
53    /// Codex). Other CLIs receive it in front of the user prompt instead.
54    pub fn native_system_prompt(self) -> bool {
55        matches!(self, Self::Claude | Self::Codex)
56    }
57
58    pub fn executable(self) -> &'static str {
59        match self {
60            Self::Claude => "claude",
61            Self::Codex => "codex",
62            Self::Devin => "devin",
63            Self::OpenCodeV2 => "opencode",
64        }
65    }
66}
67
68/// A local CLI adapter. Each `run` starts a fresh process and returns one result.
69#[derive(Debug, Clone)]
70pub struct AgentCli {
71    provider: Provider,
72    binary: PathBuf,
73}
74
75impl AgentCli {
76    /// Discover a CLI while preserving its provider in any error.
77    pub fn discover_with_context(provider: Provider) -> Result<Self, ProviderError> {
78        Self::discover(provider).map_err(|source| ProviderError { provider, source })
79    }
80
81    /// Use an explicit CLI path while preserving its provider in any error.
82    pub fn with_binary_with_context(
83        provider: Provider,
84        binary: impl Into<PathBuf>,
85    ) -> Result<Self, ProviderError> {
86        Self::with_binary(provider, binary).map_err(|source| ProviderError { provider, source })
87    }
88
89    pub fn discover(provider: Provider) -> Result<Self, Error> {
90        Self::with_binary(provider, provider.executable())
91    }
92
93    pub fn with_binary(provider: Provider, binary: impl Into<PathBuf>) -> Result<Self, Error> {
94        let binary = binary.into();
95        if provider == Provider::OpenCodeV2 {
96            OpenCodeV2::with_binary(binary.clone())?;
97        } else {
98            let output = std::process::Command::new(&binary)
99                .arg("--version")
100                .output()
101                .map_err(|e| match e.kind() {
102                    std::io::ErrorKind::NotFound => Error::NotFound(binary.clone()),
103                    _ => Error::VersionProbe(e.to_string()),
104                })?;
105            if !output.status.success() {
106                return Err(Error::VersionProbe(format!(
107                    "{} --version exited {:?}",
108                    provider.executable(),
109                    output.status.code()
110                )));
111            }
112        }
113        Ok(Self { provider, binary })
114    }
115
116    pub fn provider(&self) -> Provider {
117        self.provider
118    }
119
120    /// Whether this CLI supports [`RunRequest::system_prompt`] natively; see
121    /// [`Provider::native_system_prompt`].
122    pub fn native_system_prompt(&self) -> bool {
123        self.provider.native_system_prompt()
124    }
125
126    /// Run a request while preserving provider, exit code, and stderr in errors.
127    pub async fn run_with_context(&self, request: RunRequest) -> Result<RunResult, ProviderError> {
128        self.run(request).await.map_err(|source| ProviderError {
129            provider: self.provider,
130            source,
131        })
132    }
133
134    pub async fn run(&self, request: RunRequest) -> Result<RunResult, Error> {
135        validate_request(&request)?;
136        match self.provider {
137            Provider::OpenCodeV2 => {
138                OpenCodeV2 {
139                    binary: self.binary.clone(),
140                }
141                .run(request)
142                .await
143            }
144            Provider::Claude => self.run_claude(request).await,
145            Provider::Codex => self.run_codex(request).await,
146            Provider::Devin => self.run_devin(request).await,
147        }
148    }
149
150    async fn run_claude(&self, request: RunRequest) -> Result<RunResult, Error> {
151        let streaming = request.on_event.is_some();
152        let mut command = Command::new(&self.binary);
153        command.args(["-p", "--output-format"]);
154        if streaming {
155            command.args(["stream-json", "--verbose", "--include-partial-messages"]);
156        } else {
157            command.arg("json");
158        }
159        command.args(["--setting-sources", "local"]);
160        match (&request.resume, request.persist_session) {
161            (Some(session), _) => {
162                command.arg("--resume").arg(session);
163            }
164            (None, false) => {
165                command.arg("--no-session-persistence");
166            }
167            (None, true) => {}
168        }
169        claude_tools(&mut command, &request);
170        if let Some(system) = request
171            .system_prompt
172            .as_deref()
173            .filter(|s| !s.trim().is_empty())
174        {
175            command.arg("--append-system-prompt").arg(system);
176        }
177        if let Some(model) = request.model.as_deref() {
178            let (name, effort) = split_effort(model, &["low", "medium", "high", "xhigh", "max"])?;
179            command.arg("--model").arg(name);
180            if let Some(effort) = effort {
181                command.arg("--effort").arg(effort);
182            }
183        }
184        if let Some(mut schema) = request.json_schema.clone() {
185            strip_schema_uri(&mut schema);
186            command.arg("--json-schema").arg(schema.to_string());
187        }
188        configure(&mut command, &request, true);
189        if request.tool_policy != ToolPolicy::Default {
190            // Claude Code inlines `@path` mentions from the prompt (any readable path, also
191            // outside `cwd`) before the model runs, independent of `--tools`. Set after the
192            // caller's environment so it cannot be overridden.
193            command.env("CLAUDE_CODE_DISABLE_ATTACHMENTS", "1");
194        }
195        let handler = request.on_event.clone();
196        let on_line = move |line: &str| {
197            if let Some(handler) = &handler {
198                claude_event(handler, line);
199            }
200        };
201        let (output, duration) = capture(
202            command,
203            Some(&request.prompt),
204            request.timeout,
205            streaming.then_some(&on_line as &(dyn Fn(&str) + Send + Sync)),
206        )
207        .await?;
208        let stderr_tail = stderr_tail(&output.stderr);
209        if let Some(session) = &request.resume
210            && !output.status.success()
211            && stderr_tail.contains("No conversation found")
212        {
213            return Err(Error::SessionNotFound(session.clone()));
214        }
215        let parsed = if streaming {
216            // The last `result` line carries the same envelope as `--output-format json`.
217            String::from_utf8_lossy(&output.stdout)
218                .lines()
219                .rev()
220                .filter_map(|l| serde_json::from_str::<Value>(l).ok())
221                .find(|v| v["type"] == "result")
222                .ok_or_else(|| serde_json::Error::io(std::io::Error::other("no result line")))
223        } else {
224            serde_json::from_slice(&output.stdout)
225        };
226        let envelope: Value = match parsed {
227            Ok(value) => value,
228            Err(error) => {
229                check_status(&output, &stderr_tail)?;
230                return Err(Error::Protocol(format!("Claude JSON envelope: {error}")));
231            }
232        };
233        let error = if envelope["is_error"].as_bool() == Some(true) {
234            let details = envelope["errors"]
235                .as_array()
236                .map(|items| {
237                    items
238                        .iter()
239                        .filter_map(Value::as_str)
240                        .collect::<Vec<_>>()
241                        .join("; ")
242                })
243                .filter(|s| !s.is_empty());
244            Some(
245                details
246                    .or_else(|| envelope["result"].as_str().map(str::to_string))
247                    .unwrap_or_else(|| "unknown Claude error".into()),
248            )
249        } else {
250            None
251        };
252        if let Some(error) = error {
253            return Err(Error::Agent(error));
254        }
255        check_status(&output, &stderr_tail)?;
256        let text = envelope
257            .get("structured_output")
258            .filter(|v| !v.is_null())
259            .map(Value::to_string)
260            .or_else(|| envelope["result"].as_str().map(str::to_string))
261            .unwrap_or_default();
262        let usage = envelope
263            .get("usage")
264            .filter(|v| v.is_object())
265            .map(|u| TokenUsage {
266                input: u["input_tokens"].as_u64().unwrap_or(0),
267                output: u["output_tokens"].as_u64().unwrap_or(0),
268                cache_read: u["cache_read_input_tokens"].as_u64().unwrap_or(0),
269                cache_write: u["cache_creation_input_tokens"].as_u64().unwrap_or(0),
270                ..Default::default()
271            });
272        result(
273            text,
274            envelope["session_id"].as_str(),
275            usage,
276            duration,
277            stderr_tail,
278        )
279    }
280
281    async fn run_codex(&self, request: RunRequest) -> Result<RunResult, Error> {
282        let out_file = tempfile::NamedTempFile::new()?;
283        let schema_file = if let Some(mut schema) = request.json_schema.clone() {
284            normalize_codex_schema(&mut schema);
285            let file = tempfile::NamedTempFile::new()?;
286            std::fs::write(file.path(), schema.to_string())?;
287            Some(file)
288        } else {
289            None
290        };
291        let mut command = Command::new(&self.binary);
292        command.arg("exec");
293        if request.resume.is_some() {
294            command.arg("resume");
295        }
296        // `-c sandbox_mode` works for `exec` and `exec resume` (which has no `-s`).
297        command.args(["--skip-git-repo-check", "-c", "sandbox_mode=\"read-only\""]);
298        if request.resume.is_none() {
299            command.args(["--color", "never"]);
300            if !request.persist_session {
301                command.arg("--ephemeral");
302            }
303        }
304        command
305            .args([
306                "--disable",
307                "hooks",
308                "-c",
309                "project_doc_max_bytes=0",
310                "--json",
311                "-o",
312            ])
313            .arg(out_file.path());
314        if let Some(model) = request.model.as_deref() {
315            let (name, effort) =
316                split_effort(model, &["low", "medium", "high", "xhigh", "max", "ultra"])?;
317            command.arg("-m").arg(name);
318            if let Some(effort) = effort {
319                command
320                    .arg("-c")
321                    .arg(format!("model_reasoning_effort=\"{effort}\""));
322            }
323        }
324        if let Some(file) = &schema_file {
325            command.arg("--output-schema").arg(file.path());
326        }
327        codex_tools(&mut command, &request);
328        if let Some(system) = request
329            .system_prompt
330            .as_deref()
331            .filter(|s| !s.trim().is_empty())
332        {
333            command
334                .arg("-c")
335                .arg(format!("developer_instructions={}", toml_string(system)));
336        }
337        if let Some(session) = &request.resume {
338            command.arg(session);
339        }
340        command.arg("-");
341        configure(&mut command, &request, true);
342        let handler = request.on_event.clone();
343        let on_line = move |line: &str| {
344            if let Some(handler) = &handler {
345                codex_event(handler, line);
346            }
347        };
348        let (output, duration) = capture(
349            command,
350            Some(&request.prompt),
351            request.timeout,
352            request
353                .on_event
354                .is_some()
355                .then_some(&on_line as &(dyn Fn(&str) + Send + Sync)),
356        )
357        .await?;
358        let stderr_tail = stderr_tail(&output.stderr);
359        // Only stderr: stdout carries the agent's own text, which may quote anything.
360        if let Some(session) = &request.resume
361            && !output.status.success()
362            && stderr_tail.contains("no rollout found")
363        {
364            return Err(Error::SessionNotFound(session.clone()));
365        }
366        check_status(&output, &stderr_tail)?;
367        let mut text = None;
368        let mut session_id = None;
369        let mut usage = None;
370        for line in String::from_utf8_lossy(&output.stdout).lines() {
371            let event: Value = serde_json::from_str(line)
372                .map_err(|e| Error::Protocol(format!("Codex JSONL: {e}")))?;
373            match event["type"].as_str() {
374                Some("thread.started") => {
375                    session_id = event["thread_id"].as_str().map(str::to_string)
376                }
377                Some("item.completed") if event["item"]["type"] == "agent_message" => {
378                    text = event["item"]["text"].as_str().map(str::to_string);
379                }
380                Some("turn.completed") => {
381                    let u = &event["usage"];
382                    usage = Some(TokenUsage {
383                        input: u["input_tokens"].as_u64().unwrap_or(0),
384                        output: u["output_tokens"].as_u64().unwrap_or(0),
385                        reasoning: u["reasoning_output_tokens"].as_u64().unwrap_or(0),
386                        ..Default::default()
387                    });
388                }
389                Some("turn.failed" | "error") => {
390                    return Err(Error::Agent(
391                        event["error"]["message"]
392                            .as_str()
393                            .or_else(|| event["message"].as_str())
394                            .unwrap_or("unknown Codex error")
395                            .to_string(),
396                    ));
397                }
398                _ => {}
399            }
400        }
401        let from_file = std::fs::read_to_string(out_file.path())?;
402        result(
403            if from_file.trim().is_empty() {
404                text.unwrap_or_default()
405            } else {
406                from_file
407            },
408            session_id.as_deref(),
409            usage,
410            duration,
411            stderr_tail,
412        )
413    }
414
415    async fn run_devin(&self, request: RunRequest) -> Result<RunResult, Error> {
416        request.require_default_tools("Devin")?;
417        if request.resume.is_some() {
418            return Err(Error::InvalidRequest("Devin cannot resume sessions".into()));
419        }
420        let prompt_file = tempfile::NamedTempFile::new()?;
421        std::fs::write(prompt_file.path(), request.prompt_with_system())?;
422        let mut command = Command::new(&self.binary);
423        command
424            .arg("-p")
425            .arg("--prompt-file")
426            .arg(prompt_file.path())
427            .args([
428                "--respect-workspace-trust",
429                "false",
430                "--permission-mode",
431                "auto",
432            ]);
433        if let Some(model) = &request.model {
434            command.arg("--model").arg(model);
435        }
436        configure(&mut command, &request, false);
437        let (output, duration) = capture(command, None, request.timeout, None).await?;
438        let stderr_tail = stderr_tail(&output.stderr);
439        check_status(&output, &stderr_tail)?;
440        result(
441            String::from_utf8_lossy(&output.stdout).to_string(),
442            None,
443            None,
444            duration,
445            stderr_tail,
446        )
447    }
448}
449
450/// Claude `stream-json` line → event.
451fn claude_event(handler: &EventHandler, line: &str) {
452    let Ok(event) = serde_json::from_str::<Value>(line) else {
453        return;
454    };
455    match event["type"].as_str() {
456        Some("stream_event") => {
457            let delta = &event["event"]["delta"];
458            if delta["type"] == "text_delta"
459                && let Some(text) = delta["text"].as_str()
460            {
461                handler.emit(AgentEvent::TextDelta(text.to_string()));
462            }
463        }
464        Some("assistant") => {
465            for block in event["message"]["content"].as_array().into_iter().flatten() {
466                if block["type"] == "tool_use"
467                    && let Some(name) = block["name"].as_str()
468                {
469                    handler.emit(AgentEvent::ToolUse {
470                        name: name.to_string(),
471                    });
472                }
473            }
474        }
475        _ => {}
476    }
477}
478
479/// Codex JSONL line → event.
480fn codex_event(handler: &EventHandler, line: &str) {
481    let Ok(event) = serde_json::from_str::<Value>(line) else {
482        return;
483    };
484    let item = &event["item"];
485    match (event["type"].as_str(), item["type"].as_str()) {
486        (Some("item.started"), Some(kind)) if kind != "agent_message" && kind != "reasoning" => {
487            let name = item["tool"]
488                .as_str()
489                .or_else(|| item["name"].as_str())
490                .unwrap_or(kind);
491            handler.emit(AgentEvent::ToolUse {
492                name: name.to_string(),
493            });
494        }
495        (Some("item.completed"), Some("agent_message")) => {
496            if let Some(text) = item["text"].as_str() {
497                handler.emit(AgentEvent::Text(text.to_string()));
498            }
499        }
500        _ => {}
501    }
502}
503
504pub(crate) fn validate_request(request: &RunRequest) -> Result<(), Error> {
505    if request.prompt.trim().is_empty() {
506        return Err(Error::InvalidRequest("prompt is empty".into()));
507    }
508    if !request.cwd.is_dir() {
509        return Err(Error::InvalidRequest(format!(
510            "working directory does not exist: {}",
511            request.cwd.display()
512        )));
513    }
514    if let Some(session) = &request.resume
515        && (!is_identifier(session) || session.starts_with('-'))
516    {
517        // Session ids end up as command-line arguments (a positional one for Codex).
518        return Err(Error::InvalidRequest(format!(
519            "session id must use ASCII letters, digits, `_` or `-`: {session:?}"
520        )));
521    }
522    let mut names = std::collections::HashSet::new();
523    for server in &request.mcp_servers {
524        validate_mcp_server(server)?;
525        if !names.insert(server.name.as_str()) {
526            return Err(Error::InvalidRequest(format!(
527                "duplicate MCP server name: {}",
528                server.name
529            )));
530        }
531    }
532    Ok(())
533}
534
535fn is_identifier(s: &str) -> bool {
536    !s.is_empty()
537        && s.bytes()
538            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
539}
540
541fn validate_mcp_server(server: &McpServer) -> Result<(), Error> {
542    if !is_identifier(&server.name) {
543        return Err(Error::InvalidRequest(format!(
544            "MCP server name must use ASCII letters, digits, `_` or `-`: {:?}",
545            server.name
546        )));
547    }
548    if !(server.url.starts_with("http://") || server.url.starts_with("https://"))
549        || server.url.chars().any(|c| c.is_whitespace() || c == '"')
550    {
551        return Err(Error::InvalidRequest(format!(
552            "MCP server URL must be http(s) without spaces or quotes: {}",
553            server.url
554        )));
555    }
556    if let Some(var) = &server.bearer_token_env
557        && !(is_identifier(var) && !var.contains('-'))
558    {
559        return Err(Error::InvalidRequest(format!(
560            "bearer token variable must be a plain environment variable name: {var:?}"
561        )));
562    }
563    Ok(())
564}
565
566/// Claude Code built-in tools for each restricted policy. `--tools` is a whitelist, so tools
567/// added in later Claude Code versions stay off.
568const CLAUDE_WEB_TOOLS: &str = "WebSearch,WebFetch";
569
570/// Applies the tool policy and MCP servers to a Claude Code command line.
571fn claude_tools(command: &mut Command, request: &RunRequest) {
572    let restricted = request.tool_policy != ToolPolicy::Default;
573    if !restricted {
574        command.args(["--permission-mode", "bypassPermissions"]);
575    }
576    if !request.mcp_servers.is_empty() {
577        let servers: serde_json::Map<String, Value> = request
578            .mcp_servers
579            .iter()
580            .map(|s| {
581                let mut server = serde_json::json!({"type": "http", "url": s.url});
582                if let Some(var) = &s.bearer_token_env {
583                    // Claude Code expands `${VAR}` from the child environment.
584                    server["headers"] =
585                        serde_json::json!({"Authorization": format!("Bearer ${{{var}}}")});
586                }
587                (s.name.clone(), server)
588            })
589            .collect();
590        command
591            .arg("--mcp-config")
592            .arg(serde_json::json!({"mcpServers": servers}).to_string());
593    }
594    if restricted {
595        // Never load the user's own MCP servers into a restricted run.
596        command.arg("--strict-mcp-config");
597        // Untrusted prompts must not invoke skills or custom commands with `/name`.
598        command.arg("--disable-slash-commands");
599        let builtin = match request.tool_policy {
600            ToolPolicy::Web => CLAUDE_WEB_TOOLS,
601            _ => "",
602        };
603        command.arg("--tools").arg(builtin);
604        let mut allowed: Vec<String> = request
605            .mcp_servers
606            .iter()
607            .map(|s| format!("mcp__{}", s.name))
608            .collect();
609        if request.tool_policy == ToolPolicy::Web {
610            allowed.extend(CLAUDE_WEB_TOOLS.split(',').map(str::to_string));
611        }
612        if !allowed.is_empty() {
613            command.arg("--allowedTools").arg(allowed.join(","));
614        }
615        // Anything not allowed above is denied instead of prompting.
616        command.args(["--permission-mode", "dontAsk"]);
617    }
618}
619
620/// Codex features that run code, read files or reach beyond MCP and web search. Turned off for
621/// every restricted policy; the read-only sandbox stays on as a second layer.
622const CODEX_RESTRICTED_FEATURES: [&str; 8] = [
623    "shell_tool",
624    "unified_exec",
625    "view_image",
626    "browser_use",
627    "computer_use",
628    "in_app_browser",
629    "apps",
630    // Plugins can bring their own MCP servers and skills from the user's configuration.
631    "plugins",
632];
633
634/// Applies the tool policy and MCP servers to a Codex command line.
635fn codex_tools(command: &mut Command, request: &RunRequest) {
636    let restricted = request.tool_policy != ToolPolicy::Default;
637    if restricted {
638        for feature in CODEX_RESTRICTED_FEATURES {
639            command.arg("--disable").arg(feature);
640        }
641        let search = if request.tool_policy == ToolPolicy::Web {
642            "live"
643        } else {
644            "disabled"
645        };
646        command.arg("-c").arg(format!("web_search=\"{search}\""));
647        // Drop the user's own MCP servers; the request's servers are added below.
648        command.arg("-c").arg("mcp_servers={}");
649    }
650    for server in &request.mcp_servers {
651        command.arg("-c").arg(format!(
652            "mcp_servers.{}.url={}",
653            server.name,
654            toml_string(&server.url)
655        ));
656        if let Some(var) = &server.bearer_token_env {
657            command.arg("-c").arg(format!(
658                "mcp_servers.{}.bearer_token_env_var={}",
659                server.name,
660                toml_string(var)
661            ));
662        }
663    }
664}
665
666/// A TOML basic string. JSON string escaping is a subset of TOML's, except that TOML also
667/// forbids a raw DEL character.
668fn toml_string(value: &str) -> String {
669    Value::String(value.to_owned())
670        .to_string()
671        .replace('\u{7f}', "\\u007F")
672}
673
674fn split_effort<'a>(model: &'a str, valid: &[&str]) -> Result<(&'a str, Option<&'a str>), Error> {
675    if model.trim().is_empty() {
676        return Err(Error::InvalidRequest("model is empty".into()));
677    }
678    if let Some((name, effort)) = model.rsplit_once('@') {
679        if name.is_empty() || !valid.contains(&effort) {
680            return Err(Error::InvalidRequest(format!(
681                "unsupported model effort: {model}"
682            )));
683        }
684        Ok((name, Some(effort)))
685    } else {
686        Ok((model, None))
687    }
688}
689
690fn configure(command: &mut Command, request: &RunRequest, stdin: bool) {
691    command
692        .current_dir(&request.cwd)
693        .stdin(if stdin { Stdio::piped() } else { Stdio::null() })
694        .stdout(Stdio::piped())
695        .stderr(Stdio::piped())
696        .kill_on_drop(true);
697    for name in &request.env_remove {
698        command.env_remove(name);
699    }
700    command.envs(request.env.iter().map(|(k, v)| (k, v)));
701}
702
703/// Called with each stdout line as it arrives (without the newline).
704type LineHandler<'a> = Option<&'a (dyn Fn(&str) + Send + Sync)>;
705
706async fn capture(
707    mut command: Command,
708    prompt: Option<&str>,
709    timeout: Duration,
710    on_line: LineHandler<'_>,
711) -> Result<(Output, Duration), Error> {
712    let started = Instant::now();
713    let mut child = command.spawn()?;
714    let mut stdin = child.stdin.take();
715    let stdout = child.stdout.take().expect("piped stdout");
716    let mut stderr = child.stderr.take().expect("piped stderr");
717    let work = async {
718        let write = async {
719            if let (Some(prompt), Some(mut stdin)) = (prompt, stdin.take()) {
720                stdin.write_all(prompt.as_bytes()).await?;
721                stdin.shutdown().await?;
722            }
723            Ok::<(), std::io::Error>(())
724        };
725        let read_out = async {
726            let mut bytes = Vec::new();
727            let mut reader = BufReader::new(stdout);
728            loop {
729                let start = bytes.len();
730                if reader.read_until(b'\n', &mut bytes).await? == 0 {
731                    break;
732                }
733                if let Some(on_line) = on_line {
734                    on_line(String::from_utf8_lossy(&bytes[start..]).trim_end());
735                }
736            }
737            Ok::<_, std::io::Error>(bytes)
738        };
739        let read_err = async {
740            let mut bytes = Vec::new();
741            stderr.read_to_end(&mut bytes).await?;
742            Ok::<_, std::io::Error>(bytes)
743        };
744        let (written, out, err, status) = tokio::join!(write, read_out, read_err, child.wait());
745        let output = Output {
746            status: status?,
747            stdout: out?,
748            stderr: err?,
749        };
750        if let Err(error) = written
751            && output.status.success()
752        {
753            return Err(Error::Io(error));
754        }
755        Ok((output, started.elapsed()))
756    };
757    match tokio::time::timeout(timeout, work).await {
758        Ok(value) => value,
759        Err(_) => {
760            let _ = child.start_kill();
761            let _ = child.wait().await;
762            Err(Error::Timeout(timeout))
763        }
764    }
765}
766
767fn check_status(output: &Output, stderr_tail: &str) -> Result<(), Error> {
768    if output.status.success() {
769        Ok(())
770    } else {
771        Err(Error::Exit {
772            status: output.status.code(),
773            stderr_tail: stderr_tail.into(),
774        })
775    }
776}
777
778fn result(
779    text: String,
780    session_id: Option<&str>,
781    usage: Option<TokenUsage>,
782    duration: Duration,
783    stderr_tail: String,
784) -> Result<RunResult, Error> {
785    let text = text.trim().to_string();
786    if text.is_empty() {
787        return Err(Error::EmptyResponse);
788    }
789    Ok(RunResult {
790        text,
791        session_id: session_id.map(str::to_string),
792        usage,
793        duration,
794        stderr_tail,
795    })
796}
797
798fn stderr_tail(bytes: &[u8]) -> String {
799    let bytes = &bytes[bytes.len().saturating_sub(4096)..];
800    String::from_utf8_lossy(bytes).to_string()
801}
802
803fn strip_schema_uri(value: &mut Value) {
804    match value {
805        Value::Object(map) => {
806            map.remove("$schema");
807            for item in map.values_mut() {
808                strip_schema_uri(item);
809            }
810        }
811        Value::Array(items) => {
812            for item in items {
813                strip_schema_uri(item);
814            }
815        }
816        _ => {}
817    }
818}
819
820fn normalize_codex_schema(value: &mut Value) {
821    let map = match value {
822        Value::Object(map) => map,
823        Value::Array(items) => {
824            for item in items {
825                normalize_codex_schema(item);
826            }
827            return;
828        }
829        _ => return,
830    };
831    map.remove("$schema");
832    if map.contains_key("$ref") && map.len() > 1 {
833        let reference = map.remove("$ref").expect("reference exists");
834        map.clear();
835        map.insert("$ref".into(), reference);
836    }
837    if let Some(Value::Array(variants)) = map.remove("oneOf") {
838        let constants: Option<Vec<Value>> =
839            variants.iter().map(|v| v.get("const").cloned()).collect();
840        if let Some(constants) = constants {
841            let mut types: Vec<&str> = variants
842                .iter()
843                .filter_map(|v| v.get("type").and_then(Value::as_str))
844                .collect();
845            types.sort_unstable();
846            types.dedup();
847            if let [ty] = types.as_slice() {
848                map.insert("type".into(), Value::String((*ty).into()));
849            }
850            map.insert("enum".into(), Value::Array(constants));
851        } else {
852            map.insert("anyOf".into(), Value::Array(variants));
853        }
854    }
855    for child in map.values_mut() {
856        normalize_codex_schema(child);
857    }
858    if map.get("type").and_then(Value::as_str) == Some("object") || map.contains_key("properties") {
859        let required: std::collections::HashSet<String> = map
860            .get("required")
861            .and_then(Value::as_array)
862            .into_iter()
863            .flatten()
864            .filter_map(Value::as_str)
865            .map(str::to_string)
866            .collect();
867        if let Some(Value::Object(properties)) = map.get_mut("properties") {
868            let all = properties.keys().cloned().map(Value::String).collect();
869            for (name, spec) in properties.iter_mut() {
870                if !required.contains(name) && !nullable(spec) {
871                    let previous = spec.take();
872                    *spec = serde_json::json!({"anyOf":[previous,{"type":"null"}]});
873                }
874            }
875            map.insert("required".into(), Value::Array(all));
876        }
877        map.insert("additionalProperties".into(), Value::Bool(false));
878    }
879}
880
881fn nullable(value: &Value) -> bool {
882    value["type"] == "null"
883        || value["type"]
884            .as_array()
885            .is_some_and(|a| a.iter().any(|v| v == "null"))
886        || value["anyOf"]
887            .as_array()
888            .is_some_and(|a| a.iter().any(|v| v["type"] == "null"))
889}
890
891#[cfg(test)]
892mod tests {
893    use super::toml_string;
894
895    #[test]
896    fn toml_string_escapes_control_characters() {
897        assert_eq!(
898            toml_string("a\"b\\c\nd\u{1}e\u{7f}"),
899            r#""a\"b\\c\nd\u0001e\u007F""#
900        );
901    }
902}