Compare commits

...

4 Commits

Author SHA1 Message Date
Aaron Levine
95cd861224 refactor 2026-02-02 16:30:18 -08:00
Aaron Levine
86dc7d0ccb Feedback: dynamic tool result enum 2026-02-02 15:58:30 -08:00
Aaron Levine
35d08c0fdc Feedback 2026-02-02 13:50:52 -08:00
Aaron Levine
6412cfd024 dynamic tool call response can include content_items 2026-02-02 10:38:19 -08:00
6 changed files with 386 additions and 16 deletions

View File

@@ -2670,10 +2670,46 @@ pub struct DynamicToolCallParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolCallResponse {
pub output: String,
#[serde(flatten)]
pub result: DynamicToolCallResult,
pub success: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(untagged, rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum DynamicToolCallResult {
/// Preferred structured tool output (for example text + images) that is
/// forwarded directly to the model as content items.
ContentItems {
#[serde(rename = "contentItems")]
content_items: Vec<DynamicToolCallOutputContentItem>,
},
/// Legacy plain-text tool output.
Output { output: String },
}
/// App-server-facing dynamic tool output items.
///
/// This is intentionally defined separately from
/// `codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem` and
/// `codex_protocol::models::FunctionCallOutputContentItem` so the app-server API
/// can evolve independently.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum DynamicToolCallOutputContentItem {
#[serde(alias = "input_text")]
InputText { text: String },
#[serde(alias = "input_image", rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
InputImage {
#[serde(alias = "image_url")]
image_url: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3033,4 +3069,46 @@ mod tests {
})
);
}
#[test]
fn dynamic_tool_response_serializes_content_items() {
let value = serde_json::to_value(DynamicToolCallResponse {
result: DynamicToolCallResult::ContentItems {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "dynamic-ok".to_string(),
}],
},
success: true,
})
.unwrap();
assert_eq!(
value,
json!({
"success": true,
"contentItems": [
{
"type": "inputText",
"text": "dynamic-ok"
}
]
})
);
}
#[test]
fn dynamic_tool_content_item_accepts_legacy_snake_case_payloads() {
let item = serde_json::from_value::<DynamicToolCallOutputContentItem>(json!({
"type": "input_image",
"image_url": "data:image/png;base64,AAA"
}))
.unwrap();
assert_eq!(
item,
DynamicToolCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
}
);
}
}

View File

@@ -89,6 +89,7 @@ use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolResult as CoreDynamicToolResult;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer;
@@ -352,7 +353,9 @@ pub(crate) async fn apply_bespoke_event_handling(
id: call_id.clone(),
response: CoreDynamicToolResponse {
call_id,
output: "dynamic tool calls require api v2".to_string(),
result: CoreDynamicToolResult::Output {
output: "dynamic tool calls require api v2".to_string(),
},
success: false,
},
})

View File

@@ -1,6 +1,9 @@
use codex_app_server_protocol::DynamicToolCallResponse;
use codex_app_server_protocol::DynamicToolCallResult;
use codex_core::CodexThread;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolResult as CoreDynamicToolResult;
use codex_protocol::protocol::Op;
use std::sync::Arc;
use tokio::sync::oneshot;
@@ -18,7 +21,9 @@ pub(crate) async fn on_call_response(
error!("request failed: {err:?}");
let fallback = CoreDynamicToolResponse {
call_id: call_id.clone(),
output: "dynamic tool request failed".to_string(),
result: CoreDynamicToolResult::Output {
output: "dynamic tool request failed".to_string(),
},
success: false,
};
if let Err(err) = conversation
@@ -37,13 +42,24 @@ pub(crate) async fn on_call_response(
let response = serde_json::from_value::<DynamicToolCallResponse>(value).unwrap_or_else(|err| {
error!("failed to deserialize DynamicToolCallResponse: {err}");
DynamicToolCallResponse {
output: "dynamic tool response was invalid".to_string(),
result: DynamicToolCallResult::Output {
output: "dynamic tool response was invalid".to_string(),
},
success: false,
}
});
let result = match response.result {
DynamicToolCallResult::ContentItems { content_items } => {
CoreDynamicToolResult::ContentItems {
content_items: content_items.into_iter().map(map_content_item).collect(),
}
}
DynamicToolCallResult::Output { output } => CoreDynamicToolResult::Output { output },
};
let response = CoreDynamicToolResponse {
call_id: call_id.clone(),
output: response.output,
result,
success: response.success,
};
if let Err(err) = conversation
@@ -56,3 +72,16 @@ pub(crate) async fn on_call_response(
error!("failed to submit DynamicToolResponse: {err}");
}
}
fn map_content_item(
item: codex_app_server_protocol::DynamicToolCallOutputContentItem,
) -> CoreDynamicToolCallOutputContentItem {
match item {
codex_app_server_protocol::DynamicToolCallOutputContentItem::InputText { text } => {
CoreDynamicToolCallOutputContentItem::InputText { text }
}
codex_app_server_protocol::DynamicToolCallOutputContentItem::InputImage { image_url } => {
CoreDynamicToolCallOutputContentItem::InputImage { image_url }
}
}
}

View File

@@ -4,8 +4,10 @@ use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
use codex_app_server_protocol::DynamicToolCallParams;
use codex_app_server_protocol::DynamicToolCallResponse;
use codex_app_server_protocol::DynamicToolCallResult;
use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
@@ -15,6 +17,8 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -200,7 +204,9 @@ async fn dynamic_tool_call_round_trip_sends_output_to_model() -> Result<()> {
// Respond to the tool call so the model receives a function_call_output.
let response = DynamicToolCallResponse {
output: "dynamic-ok".to_string(),
result: DynamicToolCallResult::Output {
output: "dynamic-ok".to_string(),
},
success: true,
};
mcp.send_response(request_id, serde_json::to_value(response)?)
@@ -213,11 +219,154 @@ async fn dynamic_tool_call_round_trip_sends_output_to_model() -> Result<()> {
.await??;
let bodies = responses_bodies(&server).await?;
let output = bodies
let payload = bodies
.iter()
.find_map(|body| function_call_output_text(body, call_id))
.find_map(|body| function_call_output_payload(body, call_id))
.context("expected function_call_output in follow-up request")?;
assert_eq!(output, "dynamic-ok");
let expected_payload = FunctionCallOutputPayload {
content: "dynamic-ok".to_string(),
content_items: None,
success: None,
};
assert_eq!(payload, expected_payload);
Ok(())
}
/// Ensures dynamic tool call responses can include structured content items.
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> {
let call_id = "dyn-call-items-1";
let tool_name = "demo_tool";
let tool_args = json!({ "city": "Paris" });
let tool_call_arguments = serde_json::to_string(&tool_args)?;
let responses = vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
responses::ev_completed("resp-1"),
]),
create_final_assistant_message_sse_response("Done")?,
];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
name: tool_name.to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
}),
};
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool]),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Run the tool".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let request = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let (request_id, params) = match request {
ServerRequest::DynamicToolCall { request_id, params } => (request_id, params),
other => panic!("expected DynamicToolCall request, got {other:?}"),
};
let expected = DynamicToolCallParams {
thread_id: thread.id,
turn_id: turn.id,
call_id: call_id.to_string(),
tool: tool_name.to_string(),
arguments: tool_args,
};
assert_eq!(params, expected);
let response_content_items = vec![
DynamicToolCallOutputContentItem::InputText {
text: "dynamic-ok".to_string(),
},
DynamicToolCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
},
];
let content_items = response_content_items
.clone()
.into_iter()
.map(|item| match item {
DynamicToolCallOutputContentItem::InputText { text } => {
FunctionCallOutputContentItem::InputText { text }
}
DynamicToolCallOutputContentItem::InputImage { image_url } => {
FunctionCallOutputContentItem::InputImage { image_url }
}
})
.collect::<Vec<FunctionCallOutputContentItem>>();
let response = DynamicToolCallResponse {
result: DynamicToolCallResult::ContentItems {
content_items: response_content_items,
},
success: true,
};
mcp.send_response(request_id, serde_json::to_value(response)?)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let bodies = responses_bodies(&server).await?;
let payload = bodies
.iter()
.find_map(|body| function_call_output_payload(body, call_id))
.context("expected function_call_output in follow-up request")?;
let expected_payload = FunctionCallOutputPayload {
// `FunctionCallOutputPayload` deserializes item arrays by also storing
// a JSON string representation in `content`.
content: serde_json::to_string(&content_items)?,
content_items: Some(content_items),
success: None,
};
assert_eq!(payload, expected_payload);
Ok(())
}
@@ -248,7 +397,7 @@ fn find_tool<'a>(body: &'a Value, name: &str) -> Option<&'a Value> {
})
}
fn function_call_output_text(body: &Value, call_id: &str) -> Option<String> {
fn function_call_output_payload(body: &Value, call_id: &str) -> Option<FunctionCallOutputPayload> {
body.get("input")
.and_then(Value::as_array)
.and_then(|items| {
@@ -258,8 +407,8 @@ fn function_call_output_text(body: &Value, call_id: &str) -> Option<String> {
})
})
.and_then(|item| item.get("output"))
.and_then(Value::as_str)
.map(str::to_string)
.cloned()
.and_then(|output| serde_json::from_value(output).ok())
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {

View File

@@ -8,8 +8,11 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use async_trait::async_trait;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolResult;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::protocol::EventMsg;
use serde_json::Value;
use tokio::sync::oneshot;
@@ -55,10 +58,21 @@ impl ToolHandler for DynamicToolHandler {
)
})?;
let DynamicToolResponse {
result, success, ..
} = response;
let (content, content_items) = match result {
DynamicToolResult::Output { output } => (output, None),
DynamicToolResult::ContentItems { content_items } => (
content_items_to_text(Some(&content_items)).unwrap_or_default(),
Some(content_items.into_iter().map(map_content_item).collect()),
),
};
Ok(ToolOutput::Function {
content: response.output,
content_items: None,
success: Some(response.success),
content,
content_items,
success: Some(success),
})
}
}
@@ -96,3 +110,73 @@ async fn request_dynamic_tool(
session.send_event(turn_context, event).await;
rx_response.await.ok()
}
fn content_items_to_text(
content_items: Option<&[DynamicToolCallOutputContentItem]>,
) -> Option<String> {
let mut text = Vec::new();
for item in content_items.unwrap_or_default() {
if let DynamicToolCallOutputContentItem::InputText { text: segment } = item
&& !segment.trim().is_empty()
{
text.push(segment.as_str());
}
}
if text.is_empty() {
None
} else {
Some(text.join("\n"))
}
}
fn map_content_item(item: DynamicToolCallOutputContentItem) -> FunctionCallOutputContentItem {
match item {
DynamicToolCallOutputContentItem::InputText { text } => {
FunctionCallOutputContentItem::InputText { text }
}
DynamicToolCallOutputContentItem::InputImage { image_url } => {
FunctionCallOutputContentItem::InputImage { image_url }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn content_items_to_text_uses_text_content_items() {
let content_items = vec![
DynamicToolCallOutputContentItem::InputText {
text: "line 1".to_string(),
},
DynamicToolCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
},
DynamicToolCallOutputContentItem::InputText {
text: "line 2".to_string(),
},
];
let output = content_items_to_text(Some(&content_items)).unwrap_or_default();
assert_eq!(output, "line 1\nline 2");
}
#[test]
fn content_items_to_text_ignores_empty_and_image_only_content_items() {
let content_items = vec![
DynamicToolCallOutputContentItem::InputText {
text: " ".to_string(),
},
DynamicToolCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
},
];
let output = content_items_to_text(Some(&content_items));
assert_eq!(output, None);
}
}

View File

@@ -25,6 +25,33 @@ pub struct DynamicToolCallRequest {
#[serde(rename_all = "camelCase")]
pub struct DynamicToolResponse {
pub call_id: String,
pub output: String,
#[serde(flatten)]
pub result: DynamicToolResult,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(untagged, rename_all = "camelCase")]
pub enum DynamicToolResult {
ContentItems {
#[serde(rename = "contentItems")]
content_items: Vec<DynamicToolCallOutputContentItem>,
},
Output {
output: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
pub enum DynamicToolCallOutputContentItem {
#[serde(alias = "input_text")]
InputText { text: String },
#[serde(alias = "input_image", rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
InputImage {
#[serde(alias = "image_url")]
image_url: String,
},
}