feat(app-server): add ThreadItem::DynamicToolCall (#12732)

Previously, clients would call `thread/start` with dynamic_tools set,
and when a model invokes a dynamic tool, it would just make the
server->client `item/tool/call` request and wait for the client's
response to complete the tool call. This works, but it doesn't have an
`item/started` or `item/completed` event.

Now we are doing this:
- [new] emit `item/started` with `DynamicToolCall` populated with the
call arguments
- send an `item/tool/call` server request
- [new] once the client responds, emit `item/completed` with
`DynamicToolCall` populated with the response.

Also, with `persistExtendedHistory: true`, dynamic tool calls are now
reconstructable in `thread/read` and `thread/resume` as
`ThreadItem::DynamicToolCall`.
This commit is contained in:
Owen Lin
2026-02-25 12:00:10 -08:00
committed by GitHub
parent 73eaebbd1c
commit a0fd94bde6
37 changed files with 2436 additions and 113 deletions

View File

@@ -1,3 +1,4 @@
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
use codex_app_server_protocol::DynamicToolCallResponse;
use codex_core::CodexThread;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
@@ -15,65 +16,23 @@ pub(crate) async fn on_call_response(
conversation: Arc<CodexThread>,
) {
let response = receiver.await;
let value = match response {
Ok(Ok(value)) => value,
let (response, _error) = match response {
Ok(Ok(value)) => decode_response(value),
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
let fallback = CoreDynamicToolResponse {
content_items: vec![CoreDynamicToolCallOutputContentItem::InputText {
text: "dynamic tool request failed".to_string(),
}],
success: false,
};
if let Err(err) = conversation
.submit(Op::DynamicToolResponse {
id: call_id.clone(),
response: fallback,
})
.await
{
error!("failed to submit DynamicToolResponse: {err}");
}
return;
fallback_response("dynamic tool request failed")
}
Err(err) => {
error!("request failed: {err:?}");
let fallback = CoreDynamicToolResponse {
content_items: vec![CoreDynamicToolCallOutputContentItem::InputText {
text: "dynamic tool request failed".to_string(),
}],
success: false,
};
if let Err(err) = conversation
.submit(Op::DynamicToolResponse {
id: call_id.clone(),
response: fallback,
})
.await
{
error!("failed to submit DynamicToolResponse: {err}");
}
return;
fallback_response("dynamic tool request failed")
}
};
let response = serde_json::from_value::<DynamicToolCallResponse>(value).unwrap_or_else(|err| {
error!("failed to deserialize DynamicToolCallResponse: {err}");
DynamicToolCallResponse {
content_items: vec![
codex_app_server_protocol::DynamicToolCallOutputContentItem::InputText {
text: "dynamic tool response was invalid".to_string(),
},
],
success: false,
}
});
let DynamicToolCallResponse {
content_items,
success,
} = response;
let response = CoreDynamicToolResponse {
} = response.clone();
let core_response = CoreDynamicToolResponse {
content_items: content_items
.into_iter()
.map(CoreDynamicToolCallOutputContentItem::from)
@@ -82,11 +41,33 @@ pub(crate) async fn on_call_response(
};
if let Err(err) = conversation
.submit(Op::DynamicToolResponse {
id: call_id,
response,
id: call_id.clone(),
response: core_response,
})
.await
{
error!("failed to submit DynamicToolResponse: {err}");
}
}
fn decode_response(value: serde_json::Value) -> (DynamicToolCallResponse, Option<String>) {
match serde_json::from_value::<DynamicToolCallResponse>(value) {
Ok(response) => (response, None),
Err(err) => {
error!("failed to deserialize DynamicToolCallResponse: {err}");
fallback_response("dynamic tool response was invalid")
}
}
}
fn fallback_response(message: &str) -> (DynamicToolCallResponse, Option<String>) {
(
DynamicToolCallResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: message.to_string(),
}],
success: false,
},
Some(message.to_string()),
)
}