Skip to main content

unified_agent_sdk/providers/codex/
executor.rs

1//! Codex adapter for the unified executor abstraction.
2
3use std::path::Path;
4
5use async_trait::async_trait;
6use chrono::Utc;
7use codex::{ApprovalMode, Codex, CodexOptions, ModelReasoningEffort, Thread, ThreadOptions};
8
9use crate::{
10    error::{ExecutorError, Result},
11    executor::{AgentCapabilities, AgentExecutor, AvailabilityStatus, SpawnConfig},
12    session::{AgentSession, SessionMetadata},
13    types::{ExecutorType, PermissionPolicy},
14};
15
16/// Adapter that maps [`codex::Codex`] to the unified [`AgentExecutor`] interface.
17///
18/// # Examples
19///
20/// ```rust,no_run
21/// use unified_agent_sdk::{AgentExecutor, CodexExecutor, executor::SpawnConfig};
22///
23/// # async fn run() -> unified_agent_sdk::Result<()> {
24/// let executor = CodexExecutor::default();
25/// let cwd = std::env::current_dir()?;
26/// let _session = executor
27///     .spawn(&cwd, "List the top 3 refactor opportunities.", &SpawnConfig::default())
28///     .await?;
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Debug, Clone, Default)]
33pub struct CodexExecutor {
34    options: CodexOptions,
35}
36
37impl CodexExecutor {
38    /// Creates a new executor with optional base Codex client options.
39    ///
40    /// Values from [`SpawnConfig`] are merged at runtime for each session.
41    pub fn new(options: Option<CodexOptions>) -> Self {
42        Self {
43            options: options.unwrap_or_default(),
44        }
45    }
46
47    fn build_client(&self, config: &SpawnConfig) -> Result<Codex> {
48        let mut options = self.options.clone();
49        let mut env = options.env.take().unwrap_or_default();
50
51        for (key, value) in &config.env {
52            env.insert(key.clone(), value.clone());
53        }
54        options.env = (!env.is_empty()).then_some(env);
55
56        Codex::new(Some(options)).map_err(|error| {
57            ExecutorError::spawn_failed("failed to initialize codex client", error)
58        })
59    }
60
61    fn build_thread_options(
62        &self,
63        working_dir: &Path,
64        config: &SpawnConfig,
65    ) -> Result<ThreadOptions> {
66        Ok(ThreadOptions {
67            model: config.model.clone(),
68            model_reasoning_effort: parse_reasoning_effort(config.reasoning.as_deref())?,
69            approval_policy: map_permission_policy(config.permission_policy),
70            working_directory: Some(working_dir.to_string_lossy().to_string()),
71            ..ThreadOptions::default()
72        })
73    }
74
75    fn wrap_thread(
76        thread: &Thread,
77        working_dir: &Path,
78        fallback_session_id: Option<&str>,
79        context_window_override_tokens: Option<u32>,
80    ) -> Result<AgentSession> {
81        let session_id = thread
82            .id()
83            .or_else(|| fallback_session_id.map(ToOwned::to_owned))
84            .ok_or_else(|| {
85                ExecutorError::execution_failed(
86                    "failed to resolve codex session id",
87                    "codex did not return a thread id after running prompt",
88                )
89            })?;
90
91        Ok(AgentSession::from_metadata_with_exit_status(
92            SessionMetadata {
93                session_id,
94                executor_type: ExecutorType::Codex,
95                working_dir: working_dir.to_path_buf(),
96                created_at: Utc::now(),
97                last_message_id: None,
98                context_window_override_tokens,
99            },
100            crate::types::ExitStatus {
101                code: None,
102                success: true,
103            },
104        ))
105    }
106}
107
108#[async_trait]
109impl AgentExecutor for CodexExecutor {
110    fn executor_type(&self) -> ExecutorType {
111        ExecutorType::Codex
112    }
113
114    async fn spawn(
115        &self,
116        working_dir: &Path,
117        prompt: &str,
118        config: &SpawnConfig,
119    ) -> Result<AgentSession> {
120        let codex = self.build_client(config)?;
121        let thread_options = self.build_thread_options(working_dir, config)?;
122        let thread = codex.start_thread(Some(thread_options));
123
124        thread.run(prompt, None).await.map_err(|error| {
125            ExecutorError::execution_failed("failed to execute prompt in codex session", error)
126        })?;
127
128        Self::wrap_thread(
129            &thread,
130            working_dir,
131            None,
132            config.context_window_override_tokens,
133        )
134    }
135
136    async fn resume(
137        &self,
138        working_dir: &Path,
139        prompt: &str,
140        session_id: &str,
141        reset_to: Option<&str>,
142        config: &SpawnConfig,
143    ) -> Result<AgentSession> {
144        if reset_to.is_some() {
145            return Err(ExecutorError::invalid_config(
146                "failed to resume codex session",
147                "codex adapter does not support reset_to",
148            ));
149        }
150
151        let codex = self.build_client(config)?;
152        let thread_options = self.build_thread_options(working_dir, config)?;
153        let thread = codex.resume_thread(session_id, Some(thread_options));
154
155        thread.run(prompt, None).await.map_err(|error| {
156            ExecutorError::execution_failed(
157                format!("failed to execute prompt in resumed codex session '{session_id}'"),
158                error,
159            )
160        })?;
161
162        Self::wrap_thread(
163            &thread,
164            working_dir,
165            Some(session_id),
166            config.context_window_override_tokens,
167        )
168    }
169
170    fn capabilities(&self) -> AgentCapabilities {
171        AgentCapabilities {
172            session_fork: false,
173            context_usage: true,
174            mcp_support: true,
175            structured_output: true,
176        }
177    }
178
179    fn availability(&self) -> AvailabilityStatus {
180        match Codex::new(Some(self.options.clone())) {
181            Ok(_) => AvailabilityStatus {
182                available: true,
183                reason: None,
184            },
185            Err(error) => AvailabilityStatus {
186                available: false,
187                reason: Some(error.to_string()),
188            },
189        }
190    }
191}
192
193fn parse_reasoning_effort(reasoning: Option<&str>) -> Result<Option<ModelReasoningEffort>> {
194    let Some(reasoning) = reasoning else {
195        return Ok(None);
196    };
197
198    let normalized = reasoning.trim().to_ascii_lowercase();
199    let effort = match normalized.as_str() {
200        "minimal" => ModelReasoningEffort::Minimal,
201        "low" => ModelReasoningEffort::Low,
202        "medium" => ModelReasoningEffort::Medium,
203        "high" => ModelReasoningEffort::High,
204        "xhigh" | "x-high" | "extra-high" | "extra_high" => ModelReasoningEffort::XHigh,
205        _ => {
206            return Err(ExecutorError::invalid_config(
207                "failed to parse codex reasoning level",
208                format!(
209                    "unsupported value '{reasoning}', expected one of: minimal, low, medium, high, xhigh"
210                ),
211            ));
212        }
213    };
214
215    Ok(Some(effort))
216}
217
218fn map_permission_policy(policy: Option<PermissionPolicy>) -> Option<ApprovalMode> {
219    policy.map(|policy| match policy {
220        PermissionPolicy::Bypass => ApprovalMode::Never,
221        PermissionPolicy::Prompt => ApprovalMode::OnRequest,
222        PermissionPolicy::Deny => ApprovalMode::Untrusted,
223    })
224}