Skip to main content

zag_agent/providers/
codex.rs

1// provider-updated: 2026-04-04
2use crate::agent::{Agent, ModelSize};
3use crate::output::AgentOutput;
4use crate::sandbox::SandboxConfig;
5use crate::session_log::{
6    BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
7    LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter, ToolKind,
8};
9use anyhow::{Context, Result};
10use log::debug;
11
12/// Classify a Codex tool name into a normalized ToolKind.
13fn tool_kind_from_name(name: &str) -> ToolKind {
14    match name {
15        "shell" | "bash" => ToolKind::Shell,
16        "read_file" | "view" => ToolKind::FileRead,
17        "write_file" => ToolKind::FileWrite,
18        "apply_patch" | "edit_file" => ToolKind::FileEdit,
19        "grep" | "find" | "search" => ToolKind::Search,
20        _ => ToolKind::Other,
21    }
22}
23use async_trait::async_trait;
24use log::info;
25use std::io::BufRead;
26use std::path::Path;
27use std::process::Stdio;
28use tokio::fs;
29use tokio::process::Command;
30
31/// Return the Codex history file path: `~/.codex/history.jsonl`.
32pub fn history_path() -> std::path::PathBuf {
33    dirs::home_dir()
34        .unwrap_or_else(|| std::path::PathBuf::from("."))
35        .join(".codex/history.jsonl")
36}
37
38/// Return the Codex TUI log path: `~/.codex/log/codex-tui.log`.
39pub fn tui_log_path() -> std::path::PathBuf {
40    dirs::home_dir()
41        .unwrap_or_else(|| std::path::PathBuf::from("."))
42        .join(".codex/log/codex-tui.log")
43}
44
45pub const DEFAULT_MODEL: &str = "gpt-5.4";
46
47pub const AVAILABLE_MODELS: &[&str] = &[
48    "gpt-5.4",
49    "gpt-5.4-mini",
50    "gpt-5.3-codex-spark",
51    "gpt-5.3-codex",
52    "gpt-5-codex",
53    "gpt-5.2-codex",
54    "gpt-5.2",
55    "o4-mini",
56    "gpt-5.1-codex-max",
57    "gpt-5.1-codex-mini",
58];
59
60pub struct Codex {
61    system_prompt: String,
62    model: String,
63    root: Option<String>,
64    skip_permissions: bool,
65    output_format: Option<String>,
66    add_dirs: Vec<String>,
67    capture_output: bool,
68    sandbox: Option<SandboxConfig>,
69    max_turns: Option<u32>,
70    ephemeral: bool,
71}
72
73pub struct CodexLiveLogAdapter {
74    _ctx: LiveLogContext,
75    tui_offset: u64,
76    history_offset: u64,
77    thread_id: Option<String>,
78    pending_history: Vec<(String, String)>,
79}
80
81pub struct CodexHistoricalLogAdapter;
82
83impl Codex {
84    pub fn new() -> Self {
85        Self {
86            system_prompt: String::new(),
87            model: DEFAULT_MODEL.to_string(),
88            root: None,
89            skip_permissions: false,
90            output_format: None,
91            add_dirs: Vec::new(),
92            capture_output: false,
93            sandbox: None,
94            max_turns: None,
95            ephemeral: false,
96        }
97    }
98
99    pub fn set_ephemeral(&mut self, ephemeral: bool) {
100        self.ephemeral = ephemeral;
101    }
102
103    fn get_base_path(&self) -> &Path {
104        self.root.as_ref().map(Path::new).unwrap_or(Path::new("."))
105    }
106
107    async fn write_agents_file(&self) -> Result<()> {
108        let base = self.get_base_path();
109        let codex_dir = base.join(".codex");
110        fs::create_dir_all(&codex_dir).await?;
111        fs::write(codex_dir.join("AGENTS.md"), &self.system_prompt).await?;
112        Ok(())
113    }
114
115    pub async fn review(
116        &self,
117        uncommitted: bool,
118        base: Option<&str>,
119        commit: Option<&str>,
120        title: Option<&str>,
121    ) -> Result<()> {
122        let mut cmd = Command::new("codex");
123        cmd.arg("review");
124
125        if uncommitted {
126            cmd.arg("--uncommitted");
127        }
128
129        if let Some(b) = base {
130            cmd.args(["--base", b]);
131        }
132
133        if let Some(c) = commit {
134            cmd.args(["--commit", c]);
135        }
136
137        if let Some(t) = title {
138            cmd.args(["--title", t]);
139        }
140
141        if let Some(ref root) = self.root {
142            cmd.args(["--cd", root]);
143        }
144
145        cmd.args(["--model", &self.model]);
146
147        if self.skip_permissions {
148            cmd.arg("--full-auto");
149        }
150
151        cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
152
153        crate::process::run_with_captured_stderr(&mut cmd).await?;
154        Ok(())
155    }
156
157    /// Parse Codex NDJSON output to extract thread_id and agent message text.
158    ///
159    /// Codex's `--json` flag outputs streaming JSON events (NDJSON format).
160    /// The actual agent response is inside `item.completed` events where
161    /// `item.type == "agent_message"`. The thread_id is in the `thread.started` event.
162    fn parse_ndjson_output(raw: &str) -> (Option<String>, Option<String>) {
163        let mut thread_id = None;
164        let mut agent_text = String::new();
165
166        for line in raw.lines() {
167            let line = line.trim();
168            if line.is_empty() {
169                continue;
170            }
171
172            if let Ok(event) = serde_json::from_str::<serde_json::Value>(line) {
173                match event.get("type").and_then(|t| t.as_str()) {
174                    Some("thread.started") => {
175                        thread_id = event
176                            .get("thread_id")
177                            .and_then(|t| t.as_str())
178                            .map(String::from);
179                    }
180                    Some("item.completed") => {
181                        if let Some(item) = event.get("item")
182                            && item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
183                            && let Some(text) = item.get("text").and_then(|t| t.as_str())
184                        {
185                            if !agent_text.is_empty() {
186                                agent_text.push('\n');
187                            }
188                            agent_text.push_str(text);
189                        }
190                    }
191                    Some("turn.failed") => {
192                        let error_msg = event
193                            .get("error")
194                            .and_then(|e| e.as_str())
195                            .unwrap_or("unknown error");
196                        if !agent_text.is_empty() {
197                            agent_text.push('\n');
198                        }
199                        agent_text.push_str("[turn failed: ");
200                        agent_text.push_str(error_msg);
201                        agent_text.push(']');
202                    }
203                    _ => {}
204                }
205            }
206        }
207
208        let text = if agent_text.is_empty() {
209            None
210        } else {
211            Some(agent_text)
212        };
213        (thread_id, text)
214    }
215
216    /// Build an AgentOutput from raw codex output, parsing NDJSON if output_format is "json".
217    fn build_output(&self, raw: &str) -> AgentOutput {
218        if self.output_format.as_deref() == Some("json") {
219            let (thread_id, agent_text) = Self::parse_ndjson_output(raw);
220            let text = agent_text.unwrap_or_else(|| raw.to_string());
221            let mut output = AgentOutput::from_text("codex", &text);
222            if let Some(tid) = thread_id {
223                debug!("Codex thread_id for retries: {}", tid);
224                output.session_id = tid;
225            }
226            output
227        } else {
228            AgentOutput::from_text("codex", raw)
229        }
230    }
231
232    /// Build the argument list for a run/exec invocation.
233    fn build_run_args(&self, interactive: bool, prompt: Option<&str>) -> Vec<String> {
234        let mut args = Vec::new();
235        let in_sandbox = self.sandbox.is_some();
236
237        if !interactive {
238            args.extend(["exec", "--skip-git-repo-check"].map(String::from));
239            if let Some(ref format) = self.output_format
240                && format == "json"
241            {
242                args.push("--json".to_string());
243            }
244            if self.ephemeral {
245                args.push("--ephemeral".to_string());
246            }
247        }
248
249        // Skip --cd in sandbox (workspace handles root)
250        if !in_sandbox && let Some(ref root) = self.root {
251            args.extend(["--cd".to_string(), root.clone()]);
252        }
253
254        args.extend(["--model".to_string(), self.model.clone()]);
255
256        for dir in &self.add_dirs {
257            args.extend(["--add-dir".to_string(), dir.clone()]);
258        }
259
260        if self.skip_permissions {
261            args.push("--full-auto".to_string());
262        }
263
264        if let Some(turns) = self.max_turns {
265            args.extend(["--max-turns".to_string(), turns.to_string()]);
266        }
267
268        if let Some(p) = prompt {
269            args.push(p.to_string());
270        }
271
272        args
273    }
274
275    /// Create a `Command` either directly or wrapped in sandbox.
276    fn make_command(&self, agent_args: Vec<String>) -> Command {
277        if let Some(ref sb) = self.sandbox {
278            let std_cmd = crate::sandbox::build_sandbox_command(sb, agent_args);
279            Command::from(std_cmd)
280        } else {
281            let mut cmd = Command::new("codex");
282            cmd.args(&agent_args);
283            cmd
284        }
285    }
286
287    async fn execute(
288        &self,
289        interactive: bool,
290        prompt: Option<&str>,
291    ) -> Result<Option<AgentOutput>> {
292        if !self.system_prompt.is_empty() {
293            log::debug!(
294                "Codex system prompt (written to AGENTS.md): {}",
295                self.system_prompt
296            );
297            self.write_agents_file().await?;
298        }
299
300        let agent_args = self.build_run_args(interactive, prompt);
301        log::debug!("Codex command: codex {}", agent_args.join(" "));
302        if let Some(p) = prompt {
303            log::debug!("Codex user prompt: {}", p);
304        }
305        let mut cmd = self.make_command(agent_args);
306
307        if interactive {
308            cmd.stdin(Stdio::inherit())
309                .stdout(Stdio::inherit())
310                .stderr(Stdio::inherit());
311            let status = cmd
312                .status()
313                .await
314                .context("Failed to execute 'codex' CLI. Is it installed and in PATH?")?;
315            if !status.success() {
316                anyhow::bail!("Codex command failed with status: {}", status);
317            }
318            Ok(None)
319        } else if self.capture_output {
320            let raw = crate::process::run_captured(&mut cmd, "Codex").await?;
321            log::debug!("Codex raw response ({} bytes): {}", raw.len(), raw);
322            Ok(Some(self.build_output(&raw)))
323        } else {
324            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
325            crate::process::run_with_captured_stderr(&mut cmd).await?;
326            Ok(None)
327        }
328    }
329}
330
331#[cfg(test)]
332#[path = "codex_tests.rs"]
333mod tests;
334
335impl Default for Codex {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341impl CodexLiveLogAdapter {
342    pub fn new(ctx: LiveLogContext) -> Self {
343        Self {
344            _ctx: ctx,
345            tui_offset: file_len(&codex_tui_log_path()).unwrap_or(0),
346            history_offset: file_len(&codex_history_path()).unwrap_or(0),
347            thread_id: None,
348            pending_history: Vec::new(),
349        }
350    }
351}
352
353#[async_trait]
354impl LiveLogAdapter for CodexLiveLogAdapter {
355    async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
356        self.poll_tui(writer)?;
357        self.poll_history(writer)?;
358        Ok(())
359    }
360}
361
362impl CodexLiveLogAdapter {
363    fn poll_tui(&mut self, writer: &SessionLogWriter) -> Result<()> {
364        let path = codex_tui_log_path();
365        if !path.exists() {
366            return Ok(());
367        }
368        let mut reader = open_reader_from_offset(&path, &mut self.tui_offset)?;
369        let mut line = String::new();
370        while reader.read_line(&mut line)? > 0 {
371            let current = line.trim().to_string();
372            self.tui_offset += line.len() as u64;
373            if self.thread_id.is_none() {
374                self.thread_id = extract_thread_id(&current);
375                if let Some(thread_id) = &self.thread_id {
376                    writer.set_provider_session_id(Some(thread_id.clone()))?;
377                    writer.add_source_path(path.to_string_lossy().to_string())?;
378                }
379            }
380            if let Some(thread_id) = &self.thread_id
381                && current.contains(thread_id)
382            {
383                if let Some(event) = parse_codex_tui_line(&current) {
384                    writer.emit(LogSourceKind::ProviderLog, event)?;
385                }
386            }
387            line.clear();
388        }
389        Ok(())
390    }
391
392    fn poll_history(&mut self, writer: &SessionLogWriter) -> Result<()> {
393        let path = codex_history_path();
394        if !path.exists() {
395            return Ok(());
396        }
397        let mut reader = open_reader_from_offset(&path, &mut self.history_offset)?;
398        let mut line = String::new();
399        while reader.read_line(&mut line)? > 0 {
400            self.history_offset += line.len() as u64;
401            let trimmed = line.trim();
402            if trimmed.is_empty() {
403                line.clear();
404                continue;
405            }
406            if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
407                && let (Some(session_id), Some(text)) = (
408                    value.get("session_id").and_then(|value| value.as_str()),
409                    value.get("text").and_then(|value| value.as_str()),
410                )
411            {
412                self.pending_history
413                    .push((session_id.to_string(), text.to_string()));
414            }
415            line.clear();
416        }
417
418        if let Some(thread_id) = &self.thread_id {
419            let mut still_pending = Vec::new();
420            for (session_id, text) in self.pending_history.drain(..) {
421                if &session_id == thread_id {
422                    writer.emit(
423                        LogSourceKind::ProviderLog,
424                        LogEventKind::UserMessage {
425                            role: "user".to_string(),
426                            content: text,
427                            message_id: None,
428                        },
429                    )?;
430                } else {
431                    still_pending.push((session_id, text));
432                }
433            }
434            self.pending_history = still_pending;
435            writer.add_source_path(path.to_string_lossy().to_string())?;
436        }
437
438        Ok(())
439    }
440}
441
442impl HistoricalLogAdapter for CodexHistoricalLogAdapter {
443    fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
444        let mut sessions = std::collections::HashMap::<String, BackfilledSession>::new();
445        let path = codex_history_path();
446        if path.exists() {
447            info!("Scanning Codex history: {}", path.display());
448            let file = std::fs::File::open(&path)?;
449            let reader = std::io::BufReader::new(file);
450            for line in reader.lines() {
451                let line = line?;
452                if line.trim().is_empty() {
453                    continue;
454                }
455                let value: serde_json::Value = match serde_json::from_str(&line) {
456                    Ok(value) => value,
457                    Err(_) => continue,
458                };
459                let Some(session_id) = value.get("session_id").and_then(|value| value.as_str())
460                else {
461                    continue;
462                };
463                let entry =
464                    sessions
465                        .entry(session_id.to_string())
466                        .or_insert_with(|| BackfilledSession {
467                            metadata: SessionLogMetadata {
468                                provider: "codex".to_string(),
469                                wrapper_session_id: session_id.to_string(),
470                                provider_session_id: Some(session_id.to_string()),
471                                workspace_path: None,
472                                command: "backfill".to_string(),
473                                model: None,
474                                resumed: false,
475                                backfilled: true,
476                            },
477                            completeness: LogCompleteness::Partial,
478                            source_paths: vec![path.to_string_lossy().to_string()],
479                            events: Vec::new(),
480                        });
481                if let Some(text) = value.get("text").and_then(|value| value.as_str()) {
482                    entry.events.push((
483                        LogSourceKind::Backfill,
484                        LogEventKind::UserMessage {
485                            role: "user".to_string(),
486                            content: text.to_string(),
487                            message_id: None,
488                        },
489                    ));
490                }
491            }
492        }
493
494        let tui_path = codex_tui_log_path();
495        if tui_path.exists() {
496            info!("Scanning Codex TUI log: {}", tui_path.display());
497            let file = std::fs::File::open(&tui_path)?;
498            let reader = std::io::BufReader::new(file);
499            for line in reader.lines() {
500                let line = line?;
501                let Some(thread_id) = extract_thread_id(&line) else {
502                    continue;
503                };
504                if let Some(session) = sessions.get_mut(&thread_id)
505                    && let Some(event) = parse_codex_tui_line(&line)
506                {
507                    session.events.push((LogSourceKind::Backfill, event));
508                    if !session
509                        .source_paths
510                        .contains(&tui_path.to_string_lossy().to_string())
511                    {
512                        session
513                            .source_paths
514                            .push(tui_path.to_string_lossy().to_string());
515                    }
516                }
517            }
518        }
519
520        Ok(sessions.into_values().collect())
521    }
522}
523
524fn parse_codex_tui_line(line: &str) -> Option<LogEventKind> {
525    if let Some(rest) = line.split("ToolCall: ").nth(1) {
526        let mut parts = rest.splitn(2, ' ');
527        let tool_name = parts.next()?.to_string();
528        let json_part = parts
529            .next()
530            .unwrap_or_default()
531            .split(" thread_id=")
532            .next()
533            .unwrap_or_default();
534        let input = serde_json::from_str(json_part).ok();
535        return Some(LogEventKind::ToolCall {
536            tool_kind: Some(tool_kind_from_name(&tool_name)),
537            tool_name,
538            tool_id: None,
539            input,
540        });
541    }
542
543    if line.contains("BackgroundEvent:") || line.contains("codex_core::client:") {
544        return Some(LogEventKind::ProviderStatus {
545            message: line.to_string(),
546            data: None,
547        });
548    }
549
550    None
551}
552
553fn extract_thread_id(line: &str) -> Option<String> {
554    let needle = "thread_id=";
555    let start = line.find(needle)? + needle.len();
556    let tail = &line[start..];
557    let end = tail.find([' ', '}', ':']).unwrap_or(tail.len());
558    Some(tail[..end].to_string())
559}
560
561fn codex_history_path() -> std::path::PathBuf {
562    history_path()
563}
564
565fn codex_tui_log_path() -> std::path::PathBuf {
566    tui_log_path()
567}
568
569fn file_len(path: &std::path::Path) -> Option<u64> {
570    std::fs::metadata(path).ok().map(|metadata| metadata.len())
571}
572
573fn open_reader_from_offset(
574    path: &std::path::Path,
575    offset: &mut u64,
576) -> Result<std::io::BufReader<std::fs::File>> {
577    let mut file = std::fs::File::open(path)?;
578    use std::io::Seek;
579    file.seek(std::io::SeekFrom::Start(*offset))?;
580    Ok(std::io::BufReader::new(file))
581}
582
583#[async_trait]
584impl Agent for Codex {
585    fn name(&self) -> &str {
586        "codex"
587    }
588
589    fn default_model() -> &'static str {
590        DEFAULT_MODEL
591    }
592
593    fn model_for_size(size: ModelSize) -> &'static str {
594        match size {
595            ModelSize::Small => "gpt-5.4-mini",
596            ModelSize::Medium => "gpt-5.3-codex",
597            ModelSize::Large => "gpt-5.4",
598        }
599    }
600
601    fn available_models() -> &'static [&'static str] {
602        AVAILABLE_MODELS
603    }
604
605    fn system_prompt(&self) -> &str {
606        &self.system_prompt
607    }
608
609    fn set_system_prompt(&mut self, prompt: String) {
610        self.system_prompt = prompt;
611    }
612
613    fn get_model(&self) -> &str {
614        &self.model
615    }
616
617    fn set_model(&mut self, model: String) {
618        self.model = model;
619    }
620
621    fn set_root(&mut self, root: String) {
622        self.root = Some(root);
623    }
624
625    fn set_skip_permissions(&mut self, skip: bool) {
626        self.skip_permissions = skip;
627    }
628
629    fn set_output_format(&mut self, format: Option<String>) {
630        self.output_format = format;
631    }
632
633    fn set_add_dirs(&mut self, dirs: Vec<String>) {
634        self.add_dirs = dirs;
635    }
636
637    fn set_capture_output(&mut self, capture: bool) {
638        self.capture_output = capture;
639    }
640
641    fn set_sandbox(&mut self, config: SandboxConfig) {
642        self.sandbox = Some(config);
643    }
644
645    fn set_max_turns(&mut self, turns: u32) {
646        self.max_turns = Some(turns);
647    }
648
649    fn as_any_ref(&self) -> &dyn std::any::Any {
650        self
651    }
652
653    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
654        self
655    }
656
657    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
658        self.execute(false, prompt).await
659    }
660
661    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
662        self.execute(true, prompt).await?;
663        Ok(())
664    }
665
666    async fn run_resume_with_prompt(
667        &self,
668        session_id: &str,
669        prompt: &str,
670    ) -> Result<Option<AgentOutput>> {
671        log::debug!(
672            "Codex resume with prompt: session={}, prompt={}",
673            session_id,
674            prompt
675        );
676        if !self.system_prompt.is_empty() {
677            self.write_agents_file().await?;
678        }
679
680        let in_sandbox = self.sandbox.is_some();
681        let mut args = vec!["exec".to_string(), "--skip-git-repo-check".to_string()];
682
683        if self.output_format.as_deref() == Some("json") {
684            args.push("--json".to_string());
685        }
686
687        if self.ephemeral {
688            args.push("--ephemeral".to_string());
689        }
690
691        if !in_sandbox && let Some(ref root) = self.root {
692            args.extend(["--cd".to_string(), root.clone()]);
693        }
694
695        args.extend(["--model".to_string(), self.model.clone()]);
696
697        for dir in &self.add_dirs {
698            args.extend(["--add-dir".to_string(), dir.clone()]);
699        }
700
701        if self.skip_permissions {
702            args.push("--full-auto".to_string());
703        }
704
705        if let Some(turns) = self.max_turns {
706            args.extend(["--max-turns".to_string(), turns.to_string()]);
707        }
708
709        args.extend(["--resume".to_string(), session_id.to_string()]);
710        args.push(prompt.to_string());
711
712        let mut cmd = self.make_command(args);
713        let raw = crate::process::run_captured(&mut cmd, "Codex").await?;
714        Ok(Some(self.build_output(&raw)))
715    }
716
717    async fn run_resume(&self, session_id: Option<&str>, last: bool) -> Result<()> {
718        let in_sandbox = self.sandbox.is_some();
719        let mut args = vec!["resume".to_string()];
720
721        if let Some(id) = session_id {
722            args.push(id.to_string());
723        } else if last {
724            args.push("--last".to_string());
725        }
726
727        if !in_sandbox && let Some(ref root) = self.root {
728            args.extend(["--cd".to_string(), root.clone()]);
729        }
730
731        args.extend(["--model".to_string(), self.model.clone()]);
732
733        for dir in &self.add_dirs {
734            args.extend(["--add-dir".to_string(), dir.clone()]);
735        }
736
737        if self.skip_permissions {
738            args.push("--full-auto".to_string());
739        }
740
741        let mut cmd = self.make_command(args);
742
743        cmd.stdin(Stdio::inherit())
744            .stdout(Stdio::inherit())
745            .stderr(Stdio::inherit());
746
747        let status = cmd
748            .status()
749            .await
750            .context("Failed to execute 'codex' CLI. Is it installed and in PATH?")?;
751        if !status.success() {
752            anyhow::bail!("Codex resume failed with status: {}", status);
753        }
754        Ok(())
755    }
756
757    async fn cleanup(&self) -> Result<()> {
758        log::debug!("Cleaning up Codex agent resources");
759        let base = self.get_base_path();
760        let codex_dir = base.join(".codex");
761        let agents_file = codex_dir.join("AGENTS.md");
762
763        if agents_file.exists() {
764            fs::remove_file(&agents_file).await?;
765        }
766
767        if codex_dir.exists()
768            && fs::read_dir(&codex_dir)
769                .await?
770                .next_entry()
771                .await?
772                .is_none()
773        {
774            fs::remove_dir(&codex_dir).await?;
775        }
776
777        Ok(())
778    }
779}