mirror of
https://github.com/openai/codex.git
synced 2026-03-02 20:53:19 +00:00
Compare commits
4 Commits
fix/notify
...
owen/dynam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cd861224 | ||
|
|
86dc7d0ccb | ||
|
|
35d08c0fdc | ||
|
|
6412cfd024 |
@@ -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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user