Skip to main content

objectiveai_sdk/agent/codex_sdk/
agent.rs

1//! Codex SDK Agent types and validation logic.
2
3use serde::{Deserialize, Serialize};
4use twox_hash::XxHash3_128;
5use schemars::JsonSchema;
6
7/// The base configuration for a Codex SDK Agent (without computed ID).
8#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, arbitrary::Arbitrary)]
9#[schemars(rename = "agent.codex_sdk.AgentBase")]
10pub struct AgentBase {
11    /// The upstream provider marker.
12    pub upstream: super::Upstream,
13
14    /// The upstream language model identifier (e.g. `gpt-5`).
15    pub model: String,
16
17    /// The output mode for vector completions. Ignored for agent completions.
18    pub output_mode: super::OutputMode,
19
20    /// Reasoning effort — maps to Codex's `model_reasoning_effort`.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    #[schemars(extend("omitempty" = true))]
23    pub effort: Option<super::Effort>,
24
25    /// Whether this agent may use the codex binary's web-search tool.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    #[schemars(extend("omitempty" = true))]
28    pub web_search_enabled: Option<bool>,
29
30    /// Rich content prepended to the user's prompt.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    #[schemars(extend("omitempty" = true))]
33    pub prefix_content: Option<super::super::completions::message::RichContent>,
34
35    /// Rich content appended after the user's prompt.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    #[schemars(extend("omitempty" = true))]
38    pub suffix_content: Option<super::super::completions::message::RichContent>,
39
40    /// MCP servers the agent can connect to.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    #[schemars(extend("omitempty" = true))]
43    pub mcp_servers: Option<super::super::McpServers>,
44}
45
46impl AgentBase {
47    /// Normalizes the configuration for deterministic ID computation.
48    pub fn prepare(&mut self) {
49        self.effort = match self.effort.take() {
50            Some(effort) => effort.prepare(),
51            None => None,
52        };
53        self.web_search_enabled = match self.web_search_enabled {
54            Some(false) => None,
55            other => other,
56        };
57        self.prefix_content = match self.prefix_content.take() {
58            Some(prefix_content) if prefix_content.is_empty() => None,
59            Some(mut prefix_content) => {
60                prefix_content.prepare();
61                if prefix_content.is_empty() { None } else { Some(prefix_content) }
62            }
63            None => None,
64        };
65        self.suffix_content = match self.suffix_content.take() {
66            Some(suffix_content) if suffix_content.is_empty() => None,
67            Some(mut suffix_content) => {
68                suffix_content.prepare();
69                if suffix_content.is_empty() { None } else { Some(suffix_content) }
70            }
71            None => None,
72        };
73        self.mcp_servers = match self.mcp_servers.take() {
74            Some(mcp_servers) => super::super::mcp::mcp_servers::prepare(mcp_servers),
75            None => None,
76        };
77    }
78
79    /// Validates the configuration.
80    pub fn validate(&self) -> Result<(), String> {
81        if self.model.is_empty() {
82            return Err("`model` string cannot be empty".to_string());
83        }
84        if let Some(effort) = &self.effort {
85            effort.validate()?;
86        }
87        if let Some(prefix_content) = &self.prefix_content {
88            prefix_content
89                .validate_text_or_image_only()
90                .map_err(|e| format!("`prefix_content`: {e}"))?;
91        }
92        if let Some(suffix_content) = &self.suffix_content {
93            suffix_content
94                .validate_text_or_image_only()
95                .map_err(|e| format!("`suffix_content`: {e}"))?;
96        }
97        if let Some(mcp_servers) = &self.mcp_servers {
98            super::super::mcp::mcp_servers::validate(mcp_servers)?;
99        }
100        Ok(())
101    }
102
103    /// Returns prefix content (if set) as a user message, then the provided
104    /// messages, then suffix content (if set) as a user message. Codex has
105    /// no native system role; system-prompt-style instructions belong on
106    /// the user message itself or in the calling layer's input rendering.
107    pub fn merged_messages(
108        &self,
109        messages: Vec<super::super::completions::message::Message>,
110    ) -> Vec<super::super::completions::message::Message> {
111        use super::super::completions::message::{Message, UserMessage};
112        let prefix_len = if self.prefix_content.is_some() { 1 } else { 0 };
113        let suffix_len = if self.suffix_content.is_some() { 1 } else { 0 };
114        let mut merged = Vec::with_capacity(prefix_len + messages.len() + suffix_len);
115        let mut prefix_inserted = self.prefix_content.is_none();
116        for msg in messages {
117            if !prefix_inserted {
118                if !matches!(msg, Message::System(_) | Message::Developer(_)) {
119                    merged.push(Message::User(UserMessage {
120                        content: self.prefix_content.clone().unwrap(),
121                        name: None,
122                    }));
123                    prefix_inserted = true;
124                }
125            }
126            merged.push(msg);
127        }
128        if !prefix_inserted {
129            merged.push(Message::User(UserMessage {
130                content: self.prefix_content.clone().unwrap(),
131                name: None,
132            }));
133        }
134        if let Some(suffix_content) = &self.suffix_content {
135            merged.push(Message::User(UserMessage {
136                content: suffix_content.clone(),
137                name: None,
138            }));
139        }
140        merged
141    }
142
143    /// Computes the deterministic content-addressed ID.
144    pub fn id(&self) -> String {
145        let mut hasher = XxHash3_128::with_seed(0);
146        hasher.write(serde_json::to_string(self).unwrap().as_bytes());
147        format!("{:0>22}", base62::encode(hasher.finish_128()))
148    }
149}
150
151/// A validated Codex SDK Agent with its computed content-addressed ID.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
153#[schemars(rename = "agent.codex_sdk.Agent")]
154pub struct Agent {
155    /// The deterministic content-addressed ID (22-character base62 string).
156    pub id: String,
157    /// The normalized configuration.
158    #[serde(flatten)]
159    pub base: AgentBase,
160}
161
162impl TryFrom<AgentBase> for Agent {
163    type Error = String;
164    fn try_from(mut base: AgentBase) -> Result<Self, Self::Error> {
165        base.prepare();
166        base.validate()?;
167        let id = base.id();
168        Ok(Agent { id, base })
169    }
170}