Skip to main content

zag_agent/providers/
codex.rs

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