fix(protocol): sync upstream tool outputs and model metadata

This commit is contained in:
James Peter
2026-02-05 12:22:11 +10:00
parent b0c5254a7b
commit f5b770b887
13 changed files with 351 additions and 71 deletions
+1
View File
@@ -67,6 +67,7 @@ pub enum Verbosity {
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Personality {
None,
Friendly,
Pragmatic,
}
+11 -2
View File
@@ -24,7 +24,16 @@ pub struct DynamicToolCallRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolResponse {
pub call_id: String,
pub output: String,
pub content_items: Vec<DynamicToolCallOutputContentItem>,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
pub enum DynamicToolCallOutputContentItem {
#[serde(rename_all = "camelCase")]
InputText { text: String },
#[serde(rename_all = "camelCase")]
InputImage { image_url: String },
}
+207 -23
View File
@@ -168,15 +168,12 @@ impl From<ResponseInputItem> for ResponseItem {
}
ResponseInputItem::McpToolCallOutput { call_id, result } => Self::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
success: Some(result.is_ok()),
content: result.map_or_else(
|tool_call_err| format!("err: {tool_call_err:?}"),
|result| {
serde_json::to_string(&result)
.unwrap_or_else(|e| format!("JSON serialization error: {e}"))
},
),
output: match result {
Ok(result) => FunctionCallOutputPayload::from(&result),
Err(tool_call_err) => FunctionCallOutputPayload {
content: format!("err: {tool_call_err:?}"),
success: Some(false),
},
},
},
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
@@ -305,28 +302,78 @@ pub struct ShellToolCallParams {
pub justification: Option<String>,
}
/// Responses API compatible content items that can be returned by a tool call.
/// This is a subset of ContentItem with the types we support as function call outputs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FunctionCallOutputContentItem {
// Do not rename, these are serialized and used directly in the responses API.
InputText { text: String },
// Do not rename, these are serialized and used directly in the responses API.
InputImage { image_url: String },
}
/// Converts structured function-call output content into plain text for
/// human-readable surfaces.
///
/// This conversion is intentionally lossy:
/// - only `input_text` items are included
/// - image items are ignored
pub fn function_call_output_content_items_to_text(
content_items: &[FunctionCallOutputContentItem],
) -> Option<String> {
let text_segments = content_items
.iter()
.filter_map(|item| match item {
FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
Some(text.as_str())
}
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::InputImage { .. } => None,
})
.collect::<Vec<_>>();
if text_segments.is_empty() {
None
} else {
Some(text_segments.join("\n"))
}
}
impl From<crate::dynamic_tools::DynamicToolCallOutputContentItem>
for FunctionCallOutputContentItem
{
fn from(item: crate::dynamic_tools::DynamicToolCallOutputContentItem) -> Self {
match item {
crate::dynamic_tools::DynamicToolCallOutputContentItem::InputText { text } => {
Self::InputText { text }
}
crate::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { image_url } => {
Self::InputImage { image_url }
}
}
}
}
#[derive(Debug, Clone, PartialEq, TS)]
pub struct FunctionCallOutputPayload {
pub content: String,
pub success: Option<bool>,
}
// The Responses API expects two *different* shapes depending on success vs failure:
// • success → output is a plain string (no nested object)
// • failure → output is an object { content, success:false }
// The upstream TypeScript CLI implements this by special‑casing the serialize path.
// We replicate that behavior with a manual Serialize impl.
// The Responses API accepts either a plain string or an array of structured
// content items for `function_call_output.output`. We choose the array form when
// `content` holds a serialized `FunctionCallOutputContentItem` list.
// The `success` flag remains internal metadata and is never serialized.
impl Serialize for FunctionCallOutputPayload {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// The upstream TypeScript CLI always serializes `output` as a *plain string* regardless
// of whether the function call succeeded or failed. The boolean is purely informational
// for local bookkeeping and is NOT sent to the OpenAI endpoint. Sending the nested object
// form `{ content, success:false }` triggers the 400 we are still seeing. Mirror the JS CLI
// exactly: always emit a bare string.
if let Some(content_items) = try_parse_content_items(&self.content) {
return content_items.serialize(serializer);
}
serializer.serialize_str(&self.content)
}
@@ -337,11 +384,95 @@ impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(FunctionCallOutputPayload {
content: s,
#[derive(Deserialize)]
#[serde(untagged)]
enum OutputWire {
Text(String),
Items(Vec<FunctionCallOutputContentItem>),
}
let payload = match OutputWire::deserialize(deserializer)? {
OutputWire::Text(content) => FunctionCallOutputPayload {
content,
success: None,
},
OutputWire::Items(items) => FunctionCallOutputPayload {
content: serde_json::to_string(&items).unwrap_or_default(),
success: None,
},
};
Ok(payload)
}
}
impl FunctionCallOutputPayload {
pub fn from_content_items(content_items: Vec<FunctionCallOutputContentItem>) -> Self {
let content = serde_json::to_string(&content_items).unwrap_or_default();
Self {
content,
success: None,
})
}
}
pub fn content_items(&self) -> Option<Vec<FunctionCallOutputContentItem>> {
try_parse_content_items(&self.content)
}
}
impl From<&CallToolResult> for FunctionCallOutputPayload {
fn from(call_tool_result: &CallToolResult) -> Self {
let CallToolResult {
content,
structured_content,
is_error,
..
} = call_tool_result;
let is_success = is_error != &Some(true);
if let Some(structured_content) = structured_content
&& !structured_content.is_null()
{
match serde_json::to_string(structured_content) {
Ok(serialized_structured_content) => {
return FunctionCallOutputPayload {
content: serialized_structured_content,
success: Some(is_success),
};
}
Err(err) => {
return FunctionCallOutputPayload {
content: err.to_string(),
success: Some(false),
};
}
}
}
let serialized_content = match serde_json::to_string(content) {
Ok(serialized_content) => serialized_content,
Err(err) => {
return FunctionCallOutputPayload {
content: err.to_string(),
success: Some(false),
};
}
};
let content_items = convert_mcp_content_to_items(content);
let payload = match content_items {
Some(content_items) => FunctionCallOutputPayload::from_content_items(content_items),
None => FunctionCallOutputPayload {
content: serialized_content,
success: Some(is_success),
},
};
FunctionCallOutputPayload {
success: Some(is_success),
..payload
}
}
}
@@ -362,6 +493,59 @@ impl std::ops::Deref for FunctionCallOutputPayload {
}
}
fn try_parse_content_items(content: &str) -> Option<Vec<FunctionCallOutputContentItem>> {
serde_json::from_str::<Vec<FunctionCallOutputContentItem>>(content).ok()
}
fn convert_mcp_content_to_items(
contents: &[mcp_types::ContentBlock],
) -> 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(contents.len());
for content in contents {
let value = match serde_json::to_value(content) {
Ok(value) => value,
Err(_) => continue,
};
let item = match serde_json::from_value::<McpContent>(value.clone()) {
Ok(McpContent::Text { text }) => FunctionCallOutputContentItem::InputText { text },
Ok(McpContent::Image { data, mime_type }) => {
saw_image = true;
let image_url = if data.starts_with("data:") {
data
} else {
let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
format!("data:{mime_type};base64,{data}")
};
FunctionCallOutputContentItem::InputImage { image_url }
}
Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
text: serde_json::to_string(&value).unwrap_or_else(|_| "<content>".to_string()),
},
};
items.push(item);
}
if saw_image { Some(items) } else { None }
}
// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
#[cfg(test)]
+37
View File
@@ -43,6 +43,35 @@ 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.
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 +117,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 +238,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 {
@@ -292,6 +327,7 @@ impl ModelInstructionsVariables {
pub fn get_personality_message(&self, personality: Option<Personality>) -> Option<String> {
if let Some(personality) = personality {
match personality {
Personality::None => Some(String::new()),
Personality::Friendly => self.personality_friendly.clone(),
Personality::Pragmatic => self.personality_pragmatic.clone(),
}
@@ -349,6 +385,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,
}
}
}
+6
View File
@@ -448,6 +448,12 @@ impl SandboxPolicy {
subpaths.push(top_level_git);
}
}
for subdir in [".agents", ".codex"] {
let top_level = writable_root.join(subdir);
if top_level.is_dir() {
subpaths.push(top_level);
}
}
WritableRoot {
root: writable_root,
read_only_subpaths: subpaths,