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        // `codex exec` never asks for approval: without this, Codex rejects every call to the
664        // request's servers ("requires approval"). The caller chose these servers for this run.
665        command.arg("-c").arg(format!(
666            "mcp_servers.{}.default_tools_approval_mode=\"approve\"",
667            server.name
668        ));
669    }
670}
671
672/// A TOML basic string. JSON string escaping is a subset of TOML's, except that TOML also
673/// forbids a raw DEL character.
674fn toml_string(value: &str) -> String {
675    Value::String(value.to_owned())
676        .to_string()
677        .replace('\u{7f}', "\\u007F")
678}
679
680fn split_effort<'a>(model: &'a str, valid: &[&str]) -> Result<(&'a str, Option<&'a str>), Error> {
681    if model.trim().is_empty() {
682        return Err(Error::InvalidRequest("model is empty".into()));
683    }
684    if let Some((name, effort)) = model.rsplit_once('@') {
685        if name.is_empty() || !valid.contains(&effort) {
686            return Err(Error::InvalidRequest(format!(
687                "unsupported model effort: {model}"
688            )));
689        }
690        Ok((name, Some(effort)))
691    } else {
692        Ok((model, None))
693    }
694}
695
696fn configure(command: &mut Command, request: &RunRequest, stdin: bool) {
697    command
698        .current_dir(&request.cwd)
699        .stdin(if stdin { Stdio::piped() } else { Stdio::null() })
700        .stdout(Stdio::piped())
701        .stderr(Stdio::piped())
702        .kill_on_drop(true);
703    for name in &request.env_remove {
704        command.env_remove(name);
705    }
706    command.envs(request.env.iter().map(|(k, v)| (k, v)));
707}
708
709/// Called with each stdout line as it arrives (without the newline).
710type LineHandler<'a> = Option<&'a (dyn Fn(&str) + Send + Sync)>;
711
712async fn capture(
713    mut command: Command,
714    prompt: Option<&str>,
715    timeout: Duration,
716    on_line: LineHandler<'_>,
717) -> Result<(Output, Duration), Error> {
718    let started = Instant::now();
719    let mut child = command.spawn()?;
720    let mut stdin = child.stdin.take();
721    let stdout = child.stdout.take().expect("piped stdout");
722    let mut stderr = child.stderr.take().expect("piped stderr");
723    let work = async {
724        let write = async {
725            if let (Some(prompt), Some(mut stdin)) = (prompt, stdin.take()) {
726                stdin.write_all(prompt.as_bytes()).await?;
727                stdin.shutdown().await?;
728            }
729            Ok::<(), std::io::Error>(())
730        };
731        let read_out = async {
732            let mut bytes = Vec::new();
733            let mut reader = BufReader::new(stdout);
734            loop {
735                let start = bytes.len();
736                if reader.read_until(b'\n', &mut bytes).await? == 0 {
737                    break;
738                }
739                if let Some(on_line) = on_line {
740                    on_line(String::from_utf8_lossy(&bytes[start..]).trim_end());
741                }
742            }
743            Ok::<_, std::io::Error>(bytes)
744        };
745        let read_err = async {
746            let mut bytes = Vec::new();
747            stderr.read_to_end(&mut bytes).await?;
748            Ok::<_, std::io::Error>(bytes)
749        };
750        let (written, out, err, status) = tokio::join!(write, read_out, read_err, child.wait());
751        let output = Output {
752            status: status?,
753            stdout: out?,
754            stderr: err?,
755        };
756        if let Err(error) = written
757            && output.status.success()
758        {
759            return Err(Error::Io(error));
760        }
761        Ok((output, started.elapsed()))
762    };
763    match tokio::time::timeout(timeout, work).await {
764        Ok(value) => value,
765        Err(_) => {
766            let _ = child.start_kill();
767            let _ = child.wait().await;
768            Err(Error::Timeout(timeout))
769        }
770    }
771}
772
773fn check_status(output: &Output, stderr_tail: &str) -> Result<(), Error> {
774    if output.status.success() {
775        Ok(())
776    } else {
777        Err(Error::Exit {
778            status: output.status.code(),
779            stderr_tail: stderr_tail.into(),
780        })
781    }
782}
783
784fn result(
785    text: String,
786    session_id: Option<&str>,
787    usage: Option<TokenUsage>,
788    duration: Duration,
789    stderr_tail: String,
790) -> Result<RunResult, Error> {
791    let text = text.trim().to_string();
792    if text.is_empty() {
793        return Err(Error::EmptyResponse);
794    }
795    Ok(RunResult {
796        text,
797        session_id: session_id.map(str::to_string),
798        usage,
799        duration,
800        stderr_tail,
801    })
802}
803
804fn stderr_tail(bytes: &[u8]) -> String {
805    let bytes = &bytes[bytes.len().saturating_sub(4096)..];
806    String::from_utf8_lossy(bytes).to_string()
807}
808
809fn strip_schema_uri(value: &mut Value) {
810    match value {
811        Value::Object(map) => {
812            map.remove("$schema");
813            for item in map.values_mut() {
814                strip_schema_uri(item);
815            }
816        }
817        Value::Array(items) => {
818            for item in items {
819                strip_schema_uri(item);
820            }
821        }
822        _ => {}
823    }
824}
825
826fn normalize_codex_schema(value: &mut Value) {
827    let map = match value {
828        Value::Object(map) => map,
829        Value::Array(items) => {
830            for item in items {
831                normalize_codex_schema(item);
832            }
833            return;
834        }
835        _ => return,
836    };
837    map.remove("$schema");
838    if map.contains_key("$ref") && map.len() > 1 {
839        let reference = map.remove("$ref").expect("reference exists");
840        map.clear();
841        map.insert("$ref".into(), reference);
842    }
843    if let Some(Value::Array(variants)) = map.remove("oneOf") {
844        let constants: Option<Vec<Value>> =
845            variants.iter().map(|v| v.get("const").cloned()).collect();
846        if let Some(constants) = constants {
847            let mut types: Vec<&str> = variants
848                .iter()
849                .filter_map(|v| v.get("type").and_then(Value::as_str))
850                .collect();
851            types.sort_unstable();
852            types.dedup();
853            if let [ty] = types.as_slice() {
854                map.insert("type".into(), Value::String((*ty).into()));
855            }
856            map.insert("enum".into(), Value::Array(constants));
857        } else {
858            map.insert("anyOf".into(), Value::Array(variants));
859        }
860    }
861    for child in map.values_mut() {
862        normalize_codex_schema(child);
863    }
864    if map.get("type").and_then(Value::as_str) == Some("object") || map.contains_key("properties") {
865        let required: std::collections::HashSet<String> = map
866            .get("required")
867            .and_then(Value::as_array)
868            .into_iter()
869            .flatten()
870            .filter_map(Value::as_str)
871            .map(str::to_string)
872            .collect();
873        if let Some(Value::Object(properties)) = map.get_mut("properties") {
874            let all = properties.keys().cloned().map(Value::String).collect();
875            for (name, spec) in properties.iter_mut() {
876                if !required.contains(name) && !nullable(spec) {
877                    let previous = spec.take();
878                    *spec = serde_json::json!({"anyOf":[previous,{"type":"null"}]});
879                }
880            }
881            map.insert("required".into(), Value::Array(all));
882        }
883        map.insert("additionalProperties".into(), Value::Bool(false));
884    }
885}
886
887fn nullable(value: &Value) -> bool {
888    value["type"] == "null"
889        || value["type"]
890            .as_array()
891            .is_some_and(|a| a.iter().any(|v| v == "null"))
892        || value["anyOf"]
893            .as_array()
894            .is_some_and(|a| a.iter().any(|v| v["type"] == "null"))
895}
896
897#[cfg(test)]
898mod tests {
899    use super::toml_string;
900
901    #[test]
902    fn toml_string_escapes_control_characters() {
903        assert_eq!(
904            toml_string("a\"b\\c\nd\u{1}e\u{7f}"),
905            r#""a\"b\\c\nd\u0001e\u007F""#
906        );
907    }
908}