mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge branch 'main' into dev/aaronl/dynamic-tool-content-items
This commit is contained in:
@@ -19,7 +19,6 @@ codex-utils-image = { workspace = true }
|
||||
icu_decimal = { workspace = true }
|
||||
icu_locale_core = { workspace = true }
|
||||
icu_provider = { workspace = true, features = ["sync"] }
|
||||
mcp-types = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::mcp::RequestId;
|
||||
use crate::parse_command::ParsedCommand;
|
||||
use crate::protocol::FileChange;
|
||||
use mcp_types::RequestId;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -62,6 +62,7 @@ pub struct ExecApprovalRequestEvent {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ElicitationRequestEvent {
|
||||
pub server_name: String,
|
||||
#[ts(type = "string | number")]
|
||||
pub id: RequestId,
|
||||
pub message: String,
|
||||
// TODO: MCP servers can request we fill out a schema for the elicitation. We don't support
|
||||
|
||||
@@ -173,10 +173,23 @@ pub enum AltScreenMode {
|
||||
pub enum ModeKind {
|
||||
Plan,
|
||||
#[default]
|
||||
Code,
|
||||
#[serde(
|
||||
alias = "code",
|
||||
alias = "pair_programming",
|
||||
alias = "execute",
|
||||
alias = "custom"
|
||||
)]
|
||||
Default,
|
||||
#[doc(hidden)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
PairProgramming,
|
||||
#[doc(hidden)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
Execute,
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Collaboration mode for a Codex session.
|
||||
@@ -276,7 +289,7 @@ mod tests {
|
||||
#[test]
|
||||
fn apply_mask_can_clear_optional_fields() {
|
||||
let mode = CollaborationMode {
|
||||
mode: ModeKind::Code,
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffort::High),
|
||||
@@ -292,7 +305,7 @@ mod tests {
|
||||
};
|
||||
|
||||
let expected = CollaborationMode {
|
||||
mode: ModeKind::Code,
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
reasoning_effort: None,
|
||||
@@ -301,4 +314,13 @@ mod tests {
|
||||
};
|
||||
assert_eq!(expected, mode.apply_mask(&mask));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_kind_deserializes_alias_values_to_default() {
|
||||
for alias in ["code", "pair_programming", "execute", "custom"] {
|
||||
let json = format!("\"{alias}\"");
|
||||
let mode: ModeKind = serde_json::from_str(&json).expect("deserialize mode");
|
||||
assert_eq!(ModeKind::Default, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod config_types;
|
||||
pub mod custom_prompts;
|
||||
pub mod dynamic_tools;
|
||||
pub mod items;
|
||||
pub mod mcp;
|
||||
pub mod message_history;
|
||||
pub mod models;
|
||||
pub mod num_format;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Types used when representing Model Context Protocol (MCP) values inside the
|
||||
//! Codex protocol.
|
||||
//!
|
||||
//! We intentionally keep these types TS/JSON-schema friendly (via `ts-rs` and
|
||||
//! `schemars`) so they can be embedded in Codex's own protocol structures.
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// ID of a request, which can be either a string or an integer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
String(String),
|
||||
#[ts(type = "number")]
|
||||
Integer(i64),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RequestId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RequestId::String(s) => f.write_str(s),
|
||||
RequestId::Integer(i) => i.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Definition for a tool the client can call.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A known resource that the server is capable of reading.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Resource {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mime_type: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
#[ts(type = "number")]
|
||||
pub size: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A template description for resources available on the server.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceTemplate {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
pub uri_template: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
/// The server's response to a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolResult {
|
||||
pub content: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub structured_content: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub is_error: Option<bool>,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// === Adapter helpers ===
|
||||
//
|
||||
// These types and conversions intentionally live in `codex-protocol` so other crates can convert
|
||||
// “wire-shaped” MCP JSON (typically coming from rmcp model structs serialized with serde) into our
|
||||
// TS/JsonSchema-friendly protocol types without depending on `mcp-types`.
|
||||
|
||||
fn deserialize_lossy_opt_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
match Option::<serde_json::Number>::deserialize(deserializer)? {
|
||||
Some(number) => {
|
||||
if let Some(v) = number.as_i64() {
|
||||
Ok(Some(v))
|
||||
} else if let Some(v) = number.as_u64() {
|
||||
Ok(i64::try_from(v).ok())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ToolSerde {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default, rename = "inputSchema", alias = "input_schema")]
|
||||
input_schema: serde_json::Value,
|
||||
#[serde(default, rename = "outputSchema", alias = "output_schema")]
|
||||
output_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<ToolSerde> for Tool {
|
||||
fn from(value: ToolSerde) -> Self {
|
||||
let ToolSerde {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
input_schema,
|
||||
output_schema,
|
||||
annotations,
|
||||
icons,
|
||||
meta,
|
||||
} = value;
|
||||
Self {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
input_schema,
|
||||
output_schema,
|
||||
annotations,
|
||||
icons,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResourceSerde {
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "mimeType", alias = "mime_type", default)]
|
||||
mime_type: Option<String>,
|
||||
name: String,
|
||||
#[serde(default, deserialize_with = "deserialize_lossy_opt_i64")]
|
||||
size: Option<i64>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
icons: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<ResourceSerde> for Resource {
|
||||
fn from(value: ResourceSerde) -> Self {
|
||||
let ResourceSerde {
|
||||
annotations,
|
||||
description,
|
||||
mime_type,
|
||||
name,
|
||||
size,
|
||||
title,
|
||||
uri,
|
||||
icons,
|
||||
meta,
|
||||
} = value;
|
||||
Self {
|
||||
annotations,
|
||||
description,
|
||||
mime_type,
|
||||
name,
|
||||
size,
|
||||
title,
|
||||
uri,
|
||||
icons,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResourceTemplateSerde {
|
||||
#[serde(default)]
|
||||
annotations: Option<serde_json::Value>,
|
||||
#[serde(rename = "uriTemplate", alias = "uri_template")]
|
||||
uri_template: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "mimeType", alias = "mime_type", default)]
|
||||
mime_type: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ResourceTemplateSerde> for ResourceTemplate {
|
||||
fn from(value: ResourceTemplateSerde) -> Self {
|
||||
let ResourceTemplateSerde {
|
||||
annotations,
|
||||
uri_template,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
mime_type,
|
||||
} = value;
|
||||
Self {
|
||||
annotations,
|
||||
uri_template,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
mime_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ToolSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Resource {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ResourceSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceTemplate {
|
||||
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(serde_json::from_value::<ResourceTemplateSerde>(value)?.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_size_deserializes_without_narrowing() {
|
||||
let resource = serde_json::json!({
|
||||
"name": "big",
|
||||
"uri": "file:///tmp/big",
|
||||
"size": 5_000_000_000u64,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, Some(5_000_000_000));
|
||||
|
||||
let resource = serde_json::json!({
|
||||
"name": "negative",
|
||||
"uri": "file:///tmp/negative",
|
||||
"size": -1,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, Some(-1));
|
||||
|
||||
let resource = serde_json::json!({
|
||||
"name": "too_big_for_i64",
|
||||
"uri": "file:///tmp/too_big_for_i64",
|
||||
"size": 18446744073709551615u64,
|
||||
});
|
||||
|
||||
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
|
||||
assert_eq!(parsed.size, None);
|
||||
}
|
||||
}
|
||||
+232
-60
@@ -2,8 +2,6 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_utils_image::load_and_resize_to_fit;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ContentBlock;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
@@ -24,6 +22,8 @@ use codex_git::GhostCommit;
|
||||
use codex_utils_image::error::ImageProcessingError;
|
||||
use schemars::JsonSchema;
|
||||
|
||||
use crate::mcp::CallToolResult;
|
||||
|
||||
/// Controls whether a command should use the session sandbox or bypass it.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
|
||||
@@ -72,6 +72,13 @@ pub enum ContentItem {
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessagePhase {
|
||||
Commentary,
|
||||
FinalAnswer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseItem {
|
||||
@@ -85,6 +92,11 @@ pub enum ResponseItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
end_turn: Option<bool>,
|
||||
// Optional output-message phase (for example: "commentary", "final_answer").
|
||||
// Do not use directly; availability can vary by provider and model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
phase: Option<MessagePhase>,
|
||||
},
|
||||
Reasoning {
|
||||
#[serde(default, skip_serializing)]
|
||||
@@ -97,7 +109,7 @@ pub enum ResponseItem {
|
||||
encrypted_content: Option<String>,
|
||||
},
|
||||
LocalShellCall {
|
||||
/// Set when using the chat completions API.
|
||||
/// Legacy id field retained for compatibility with older payloads.
|
||||
#[serde(default, skip_serializing)]
|
||||
#[ts(skip)]
|
||||
id: Option<String>,
|
||||
@@ -113,8 +125,7 @@ pub enum ResponseItem {
|
||||
name: String,
|
||||
// The Responses API returns the function call arguments as a *string* that contains
|
||||
// JSON, not as an already‑parsed object. We keep it as a raw string here and let
|
||||
// Session::handle_function_call parse it into a Value. This exactly matches the
|
||||
// Chat Completions + Responses API behavior.
|
||||
// Session::handle_function_call parse it into a Value.
|
||||
arguments: String,
|
||||
call_id: String,
|
||||
},
|
||||
@@ -233,10 +244,13 @@ impl DeveloperInstructions {
|
||||
if !request_rule_enabled {
|
||||
APPROVAL_POLICY_ON_REQUEST.to_string()
|
||||
} else {
|
||||
let command_prefixes = format_allow_prefixes(exec_policy);
|
||||
let command_prefixes =
|
||||
format_allow_prefixes(exec_policy.get_allowed_prefixes());
|
||||
match command_prefixes {
|
||||
Some(prefixes) => {
|
||||
format!("{APPROVAL_POLICY_ON_REQUEST_RULE}\n{prefixes}")
|
||||
format!(
|
||||
"{APPROVAL_POLICY_ON_REQUEST_RULE}\nApproved command prefixes:\n{prefixes}"
|
||||
)
|
||||
}
|
||||
None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(),
|
||||
}
|
||||
@@ -371,20 +385,51 @@ impl DeveloperInstructions {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_command_prefix_list<I, P>(prefixes: I) -> Option<String>
|
||||
where
|
||||
I: IntoIterator<Item = P>,
|
||||
P: AsRef<[String]>,
|
||||
{
|
||||
let lines = prefixes
|
||||
.into_iter()
|
||||
.map(|prefix| format!("- {}", render_command_prefix(prefix.as_ref())))
|
||||
.collect::<Vec<_>>();
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
const MAX_RENDERED_PREFIXES: usize = 100;
|
||||
const MAX_ALLOW_PREFIX_TEXT_BYTES: usize = 5000;
|
||||
const TRUNCATED_MARKER: &str = "...\n[Some commands were truncated]";
|
||||
|
||||
pub fn format_allow_prefixes(prefixes: Vec<Vec<String>>) -> Option<String> {
|
||||
let mut truncated = false;
|
||||
if prefixes.len() > MAX_RENDERED_PREFIXES {
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
Some(lines.join("\n"))
|
||||
let mut prefixes = prefixes;
|
||||
prefixes.sort_by(|a, b| {
|
||||
a.len()
|
||||
.cmp(&b.len())
|
||||
.then_with(|| prefix_combined_str_len(a).cmp(&prefix_combined_str_len(b)))
|
||||
.then_with(|| a.cmp(b))
|
||||
});
|
||||
|
||||
let full_text = prefixes
|
||||
.into_iter()
|
||||
.take(MAX_RENDERED_PREFIXES)
|
||||
.map(|prefix| format!("- {}", render_command_prefix(&prefix)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// truncate to last UTF8 char
|
||||
let mut output = full_text;
|
||||
let byte_idx = output
|
||||
.char_indices()
|
||||
.nth(MAX_ALLOW_PREFIX_TEXT_BYTES)
|
||||
.map(|(i, _)| i);
|
||||
if let Some(byte_idx) = byte_idx {
|
||||
truncated = true;
|
||||
output = output[..byte_idx].to_string();
|
||||
}
|
||||
|
||||
if truncated {
|
||||
Some(format!("{output}{TRUNCATED_MARKER}"))
|
||||
} else {
|
||||
Some(output)
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_combined_str_len(prefix: &[String]) -> usize {
|
||||
prefix.iter().map(String::len).sum()
|
||||
}
|
||||
|
||||
fn render_command_prefix(prefix: &[String]) -> String {
|
||||
@@ -396,12 +441,6 @@ fn render_command_prefix(prefix: &[String]) -> String {
|
||||
format!("[{tokens}]")
|
||||
}
|
||||
|
||||
fn format_allow_prefixes(exec_policy: &Policy) -> Option<String> {
|
||||
let prefixes = exec_policy.get_allowed_prefixes();
|
||||
let lines = render_command_prefix_list(prefixes)?;
|
||||
Some(format!("Approved command prefixes:\n{lines}"))
|
||||
}
|
||||
|
||||
impl From<DeveloperInstructions> for ResponseItem {
|
||||
fn from(di: DeveloperInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
@@ -411,6 +450,7 @@ impl From<DeveloperInstructions> for ResponseItem {
|
||||
text: di.into_text(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +608,7 @@ impl From<ResponseInputItem> for ResponseItem {
|
||||
content,
|
||||
id: None,
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||||
Self::FunctionCallOutput { call_id, output }
|
||||
@@ -808,6 +849,7 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
|
||||
content,
|
||||
structured_content,
|
||||
is_error,
|
||||
meta: _,
|
||||
} = call_tool_result;
|
||||
|
||||
let is_success = is_error != &Some(true);
|
||||
@@ -844,7 +886,7 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
|
||||
}
|
||||
};
|
||||
|
||||
let content_items = convert_content_blocks_to_items(content);
|
||||
let content_items = convert_mcp_content_to_items(content);
|
||||
|
||||
FunctionCallOutputPayload {
|
||||
content: serialized_content,
|
||||
@@ -854,32 +896,45 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_content_blocks_to_items(
|
||||
blocks: &[ContentBlock],
|
||||
fn convert_mcp_content_to_items(
|
||||
contents: &[serde_json::Value],
|
||||
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum McpContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image")]
|
||||
Image {
|
||||
data: String,
|
||||
#[serde(rename = "mimeType", alias = "mime_type")]
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
let mut saw_image = false;
|
||||
let mut items = Vec::with_capacity(blocks.len());
|
||||
tracing::warn!("Blocks: {:?}", blocks);
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::TextContent(text) => {
|
||||
items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: text.text.clone(),
|
||||
});
|
||||
}
|
||||
ContentBlock::ImageContent(image) => {
|
||||
let mut items = Vec::with_capacity(contents.len());
|
||||
|
||||
for content in contents {
|
||||
let item = match serde_json::from_value::<McpContent>(content.clone()) {
|
||||
Ok(McpContent::Text { text }) => FunctionCallOutputContentItem::InputText { text },
|
||||
Ok(McpContent::Image { data, mime_type }) => {
|
||||
saw_image = true;
|
||||
// Just in case the content doesn't include a data URL, add it.
|
||||
let image_url = if image.data.starts_with("data:") {
|
||||
image.data.clone()
|
||||
let image_url = if data.starts_with("data:") {
|
||||
data
|
||||
} else {
|
||||
format!("data:{};base64,{}", image.mime_type, image.data)
|
||||
let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
|
||||
format!("data:{mime_type};base64,{data}")
|
||||
};
|
||||
items.push(FunctionCallOutputContentItem::InputImage { image_url });
|
||||
FunctionCallOutputContentItem::InputImage { image_url }
|
||||
}
|
||||
// TODO: render audio, resource, and embedded resource content to the model.
|
||||
_ => return None,
|
||||
}
|
||||
Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
|
||||
text: serde_json::to_string(content).unwrap_or_else(|_| "<content>".to_string()),
|
||||
},
|
||||
};
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
if saw_image { Some(items) } else { None }
|
||||
@@ -911,12 +966,54 @@ mod tests {
|
||||
use crate::protocol::AskForApproval;
|
||||
use anyhow::Result;
|
||||
use codex_execpolicy::Policy;
|
||||
use mcp_types::ImageContent;
|
||||
use mcp_types::TextContent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn convert_mcp_content_to_items_preserves_data_urls() {
|
||||
let contents = vec![serde_json::json!({
|
||||
"type": "image",
|
||||
"data": "data:image/png;base64,Zm9v",
|
||||
"mimeType": "image/png",
|
||||
})];
|
||||
|
||||
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,Zm9v".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
|
||||
let contents = vec![serde_json::json!({
|
||||
"type": "image",
|
||||
"data": "Zm9v",
|
||||
"mimeType": "image/png",
|
||||
})];
|
||||
|
||||
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,Zm9v".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_mcp_content_to_items_returns_none_without_images() {
|
||||
let contents = vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "hello",
|
||||
})];
|
||||
|
||||
assert_eq!(convert_mcp_content_to_items(&contents), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_sandbox_mode_into_developer_instructions() {
|
||||
let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
|
||||
@@ -1003,6 +1100,62 @@ mod tests {
|
||||
assert!(text.contains(r#"["git", "pull"]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() {
|
||||
let prefixes = vec![
|
||||
vec!["b".to_string(), "zz".to_string()],
|
||||
vec!["aa".to_string()],
|
||||
vec!["b".to_string()],
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()],
|
||||
vec!["a".to_string()],
|
||||
vec!["b".to_string(), "a".to_string()],
|
||||
];
|
||||
|
||||
let output = format_allow_prefixes(prefixes).expect("rendered list");
|
||||
assert_eq!(
|
||||
output,
|
||||
r#"- ["a"]
|
||||
- ["b"]
|
||||
- ["aa"]
|
||||
- ["b", "a"]
|
||||
- ["b", "zz"]
|
||||
- ["a", "b", "c"]"#
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_command_prefix_list_limits_output_to_max_prefixes() {
|
||||
let prefixes = (0..(MAX_RENDERED_PREFIXES + 5))
|
||||
.map(|i| vec![format!("{i:03}")])
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let output = format_allow_prefixes(prefixes).expect("rendered list");
|
||||
assert_eq!(output.ends_with(TRUNCATED_MARKER), true);
|
||||
eprintln!("output: {output}");
|
||||
assert_eq!(output.lines().count(), MAX_RENDERED_PREFIXES + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_allow_prefixes_limits_output() {
|
||||
let mut exec_policy = Policy::empty();
|
||||
for i in 0..200 {
|
||||
exec_policy
|
||||
.add_prefix_rule(
|
||||
&[format!("tool-{i:03}"), "x".repeat(500)],
|
||||
codex_execpolicy::Decision::Allow,
|
||||
)
|
||||
.expect("add rule");
|
||||
}
|
||||
|
||||
let output =
|
||||
format_allow_prefixes(exec_policy.get_allowed_prefixes()).expect("formatted prefixes");
|
||||
assert!(
|
||||
output.len() <= MAX_ALLOW_PREFIX_TEXT_BYTES + TRUNCATED_MARKER.len(),
|
||||
"output length exceeds expected limit: {output}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_success_as_plain_string() -> Result<()> {
|
||||
let item = ResponseInputItem::FunctionCallOutput {
|
||||
@@ -1043,20 +1196,12 @@ mod tests {
|
||||
fn serializes_image_outputs_as_array() -> Result<()> {
|
||||
let call_tool_result = CallToolResult {
|
||||
content: vec![
|
||||
ContentBlock::TextContent(TextContent {
|
||||
annotations: None,
|
||||
text: "caption".into(),
|
||||
r#type: "text".into(),
|
||||
}),
|
||||
ContentBlock::ImageContent(ImageContent {
|
||||
annotations: None,
|
||||
data: "BASE64".into(),
|
||||
mime_type: "image/png".into(),
|
||||
r#type: "image".into(),
|
||||
}),
|
||||
serde_json::json!({"type":"text","text":"caption"}),
|
||||
serde_json::json!({"type":"image","data":"BASE64","mimeType":"image/png"}),
|
||||
],
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let payload = FunctionCallOutputPayload::from(&call_tool_result);
|
||||
@@ -1088,6 +1233,33 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_existing_image_data_urls() -> Result<()> {
|
||||
let call_tool_result = CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "image",
|
||||
"data": "data:image/png;base64,BASE64",
|
||||
"mimeType": "image/png"
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let payload = FunctionCallOutputPayload::from(&call_tool_result);
|
||||
let Some(items) = payload.content_items else {
|
||||
panic!("expected content items");
|
||||
};
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,BASE64".into(),
|
||||
}]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_array_payload_into_items() -> Result<()> {
|
||||
let json = r#"[
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
//! Shared model metadata types exchanged between Codex services and clients.
|
||||
//!
|
||||
//! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults
|
||||
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -43,6 +48,38 @@ pub enum ReasoningEffort {
|
||||
XHigh,
|
||||
}
|
||||
|
||||
/// Canonical user-input modality tags advertised by a model.
|
||||
#[derive(
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Display,
|
||||
JsonSchema,
|
||||
TS,
|
||||
EnumIter,
|
||||
Hash,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum InputModality {
|
||||
/// Plain text turns and tool payloads.
|
||||
Text,
|
||||
/// Image attachments included in user turns.
|
||||
Image,
|
||||
}
|
||||
|
||||
/// Backward-compatible default when `input_modalities` is omitted on the wire.
|
||||
///
|
||||
/// Legacy payloads predate modality metadata, so we conservatively assume both text and images are
|
||||
/// accepted unless a preset explicitly narrows support.
|
||||
pub fn default_input_modalities() -> Vec<InputModality> {
|
||||
vec![InputModality::Text, InputModality::Image]
|
||||
}
|
||||
|
||||
/// A reasoning effort option that can be surfaced for a model.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
pub struct ReasoningEffortPreset {
|
||||
@@ -88,6 +125,9 @@ pub struct ModelPreset {
|
||||
pub show_in_picker: bool,
|
||||
/// whether this model is supported in the api
|
||||
pub supported_in_api: bool,
|
||||
/// Input modalities accepted when composing user turns for this preset.
|
||||
#[serde(default = "default_input_modalities")]
|
||||
pub input_modalities: Vec<InputModality>,
|
||||
}
|
||||
|
||||
/// Visibility of a model in the picker or APIs.
|
||||
@@ -206,6 +246,9 @@ pub struct ModelInfo {
|
||||
#[serde(default = "default_effective_context_window_percent")]
|
||||
pub effective_context_window_percent: i64,
|
||||
pub experimental_supported_tools: Vec<String>,
|
||||
/// Input modalities accepted by the backend for this model.
|
||||
#[serde(default = "default_input_modalities")]
|
||||
pub input_modalities: Vec<InputModality>,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
@@ -350,6 +393,7 @@ impl From<ModelInfo> for ModelPreset {
|
||||
}),
|
||||
show_in_picker: info.visibility == ModelVisibility::List,
|
||||
supported_in_api: info.supported_in_api,
|
||||
input_modalities: info.input_modalities,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,6 +504,7 @@ mod tests {
|
||||
auto_compact_token_limit: None,
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: vec![],
|
||||
input_modalities: default_input_modalities(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ use crate::dynamic_tools::DynamicToolCallRequest;
|
||||
use crate::dynamic_tools::DynamicToolResponse;
|
||||
use crate::dynamic_tools::DynamicToolSpec;
|
||||
use crate::items::TurnItem;
|
||||
use crate::mcp::CallToolResult;
|
||||
use crate::mcp::RequestId;
|
||||
use crate::mcp::Resource as McpResource;
|
||||
use crate::mcp::ResourceTemplate as McpResourceTemplate;
|
||||
use crate::mcp::Tool as McpTool;
|
||||
use crate::message_history::HistoryEntry;
|
||||
use crate::models::BaseInstructions;
|
||||
use crate::models::ContentItem;
|
||||
@@ -35,11 +40,6 @@ use crate::plan_tool::UpdatePlanArgs;
|
||||
use crate::request_user_input::RequestUserInputResponse;
|
||||
use crate::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::RequestId;
|
||||
use mcp_types::Resource as McpResource;
|
||||
use mcp_types::ResourceTemplate as McpResourceTemplate;
|
||||
use mcp_types::Tool as McpTool;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -589,13 +589,18 @@ impl SandboxPolicy {
|
||||
}
|
||||
subpaths.push(top_level_git);
|
||||
}
|
||||
#[allow(clippy::expect_used)]
|
||||
let top_level_codex = writable_root
|
||||
.join(".codex")
|
||||
.expect(".codex is a valid relative path");
|
||||
if top_level_codex.as_path().is_dir() {
|
||||
subpaths.push(top_level_codex);
|
||||
|
||||
// Make .agents/skills and .codex/config.toml and
|
||||
// related files read-only to the agent, by default.
|
||||
for subdir in &[".agents", ".codex"] {
|
||||
#[allow(clippy::expect_used)]
|
||||
let top_level_codex =
|
||||
writable_root.join(subdir).expect("valid relative path");
|
||||
if top_level_codex.as_path().is_dir() {
|
||||
subpaths.push(top_level_codex);
|
||||
}
|
||||
}
|
||||
|
||||
WritableRoot {
|
||||
root: writable_root,
|
||||
read_only_subpaths: subpaths,
|
||||
@@ -1691,6 +1696,7 @@ impl From<CompactedItem> for ResponseItem {
|
||||
text: value.message,
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user