unified_agent_sdk/providers/codex/
executor.rs1use 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#[derive(Debug, Clone, Default)]
33pub struct CodexExecutor {
34 options: CodexOptions,
35}
36
37impl CodexExecutor {
38 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}