Files
codex/codex-rs/protocol/src/dynamic_tools.rs
T
sayan-oai 11faf9af94 Expose explicit dynamic tool namespaces in thread start (#27371)
Stacked on #27365.

## Stack note

[#27365](https://github.com/openai/codex/pull/27365) kept `thread/start`
unchanged and converted its input in `thread_processor`. This PR updates
`thread/start` to accept explicit functions and namespaces directly.

Legacy per-tool arrays are still accepted and converted while reading
the request. As a result, `thread_processor` can validate and pass the
tools through directly, which is why some code added in #27365 is
removed here.

## Why

`thread/start.dynamicTools` still repeats namespace data on each
function even though core now stores explicit namespace groups. The
request API should use the same shape so each namespace has one
description and one member list.

## What changed

- Accept top-level functions and explicit namespace objects in
`dynamicTools`.
- Continue accepting fully legacy flat arrays, including
`exposeToContext`.
- Reject arrays that mix legacy and canonical entries.
- Reuse the protocol types directly and remove the temporary app-server
adapter.
- Update validation, docs, the test client, and generated schemas.

## Test plan

- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server
dynamic_tool_call_round_trip_sends_text_content_items_to_model`
- `just test -p codex-app-server
thread_start_normalizes_legacy_dynamic_tools_into_model_request`
- `just test -p codex-app-server
thread_start_rejects_mixed_dynamic_tool_formats`
- `just test -p codex-app-server
thread_start_rejects_hidden_dynamic_tools_without_namespace`
2026-06-15 15:35:57 +00:00

174 lines
5.8 KiB
Rust

use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as _;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", export_to = "v2/")]
pub enum DynamicToolSpec {
Function(DynamicToolFunctionSpec),
Namespace(DynamicToolNamespaceSpec),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolFunctionSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub defer_loading: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolNamespaceSpec {
pub name: String,
pub description: String,
pub tools: Vec<DynamicToolNamespaceTool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", export_to = "v2/")]
pub enum DynamicToolNamespaceTool {
Function(DynamicToolFunctionSpec),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolCallRequest {
pub call_id: String,
pub turn_id: String,
#[serde(default)]
pub started_at_ms: i64,
#[serde(default)]
pub namespace: Option<String>,
pub tool: String,
pub arguments: JsonValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolResponse {
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 },
}
/// Former flat `SessionMeta` shape, including the old `exposeToContext` flag.
/// Kept so new builds can resume sessions written before explicit namespaces.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyDynamicToolSpec {
namespace: Option<String>,
name: String,
description: String,
input_schema: JsonValue,
defer_loading: Option<bool>,
expose_to_context: Option<bool>,
}
pub fn normalize_dynamic_tool_specs(
values: Vec<JsonValue>,
) -> Result<Vec<DynamicToolSpec>, serde_json::Error> {
let has_legacy_fields = |value: &JsonValue| {
value.get("namespace").is_some()
|| value.get("exposeToContext").is_some()
|| value.get("type").is_none()
};
let has_legacy_format = values.iter().any(|value| {
has_legacy_fields(value)
|| value
.get("tools")
.and_then(JsonValue::as_array)
.is_some_and(|tools| tools.iter().any(&has_legacy_fields))
});
let has_canonical_format = values.iter().any(|value| value.get("type").is_some());
if has_legacy_format && has_canonical_format {
return Err(serde_json::Error::custom(
"dynamic tools must use either canonical or legacy format consistently",
));
}
if !has_legacy_format {
return values.into_iter().map(serde_json::from_value).collect();
}
let tools = values
.into_iter()
.map(|value| {
let tool: LegacyDynamicToolSpec = serde_json::from_value(value)?;
let function = DynamicToolFunctionSpec {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
defer_loading: tool.defer_loading.unwrap_or_else(|| {
tool.expose_to_context
.map(|visible| !visible)
.unwrap_or(false)
}),
};
Ok((tool.namespace, function))
})
.collect::<Result<Vec<_>, serde_json::Error>>()?;
Ok(group_dynamic_tools_by_namespace(tools))
}
pub fn group_dynamic_tools_by_namespace(
tools: Vec<(Option<String>, DynamicToolFunctionSpec)>,
) -> Vec<DynamicToolSpec> {
let mut grouped_tools = Vec::with_capacity(tools.len());
let mut namespace_indices = HashMap::<String, usize>::new();
for (namespace, function) in tools {
let Some(namespace) = namespace else {
grouped_tools.push(DynamicToolSpec::Function(function));
continue;
};
let function = DynamicToolNamespaceTool::Function(function);
if let Some(index) = namespace_indices.get(&namespace).copied() {
let DynamicToolSpec::Namespace(namespace) = &mut grouped_tools[index] else {
unreachable!("namespace index must point to a namespace");
};
namespace.tools.push(function);
continue;
}
namespace_indices.insert(namespace.clone(), grouped_tools.len());
grouped_tools.push(DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: namespace,
description: String::new(),
tools: vec![function],
}));
}
grouped_tools
}
pub fn deserialize_dynamic_tool_specs<'de, D>(
deserializer: D,
) -> Result<Option<Vec<DynamicToolSpec>>, D::Error>
where
D: Deserializer<'de>,
{
let Some(values) = Option::<Vec<JsonValue>>::deserialize(deserializer)? else {
return Ok(None);
};
normalize_dynamic_tool_specs(values)
.map(Some)
.map_err(D::Error::custom)
}