mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Represent dynamic tools with explicit namespaces internally (#27365)
Follow-up to #27356. ## Stack note This PR changes Codex's internal dynamic-tool shape while leaving `thread/start` unchanged. App-server therefore converts the existing per-tool input into explicit functions and namespaces before passing it to core. [#27371](https://github.com/openai/codex/pull/27371) updates `thread/start` to use the same explicit shape and removes this temporary conversion. ## Why Dynamic tools repeat namespace metadata on every function. Core should keep one explicit namespace with its member tools so descriptions and membership stay consistent across sessions and runtime planning. ## What changed - Represent dynamic tools as top-level functions or explicit namespaces in protocol and session state. - Read old flat rollout metadata and write the canonical hierarchy. - Flatten namespace members only when registering callable tools. - Keep `thread/start.dynamicTools` flat for now and normalize it at the app-server boundary. New builds can read old rollout metadata. Older builds cannot read newly written hierarchical metadata. ## Test plan - `just test -p codex-app-server thread_start_normalizes_legacy_dynamic_tools_into_model_request` - `just test -p codex-protocol session_meta_normalizes_legacy_dynamic_tools` - `just test -p codex-core resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled` - `just test -p codex-core tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call` - `just test -p codex-core code_mode_can_call_hidden_dynamic_tools` - `just test -p codex-tools`
This commit is contained in:
committed by
GitHub
Unverified
parent
b3f6f70b68
commit
a292faae5a
@@ -2,21 +2,46 @@ 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, PartialEq, JsonSchema, 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")]
|
||||
pub struct DynamicToolSpec {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct DynamicToolFunctionSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: JsonValue,
|
||||
#[serde(default)]
|
||||
#[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 {
|
||||
@@ -47,9 +72,11 @@ pub enum DynamicToolCallOutputContentItem {
|
||||
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 DynamicToolSpecDe {
|
||||
struct LegacyDynamicToolSpec {
|
||||
namespace: Option<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
@@ -58,84 +85,84 @@ struct DynamicToolSpecDe {
|
||||
expose_to_context: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let DynamicToolSpecDe {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading,
|
||||
expose_to_context,
|
||||
} = DynamicToolSpecDe::deserialize(deserializer)?;
|
||||
|
||||
Ok(Self {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading: defer_loading
|
||||
.unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)),
|
||||
})
|
||||
pub fn normalize_dynamic_tool_specs(
|
||||
values: Vec<JsonValue>,
|
||||
) -> Result<Vec<DynamicToolSpec>, serde_json::Error> {
|
||||
let has_legacy_format = values.iter().any(|value| {
|
||||
value.get("namespace").is_some()
|
||||
|| value.get("exposeToContext").is_some()
|
||||
|| value.get("type").is_none()
|
||||
});
|
||||
let has_canonical_namespace = values
|
||||
.iter()
|
||||
.any(|value| value.get("type").and_then(JsonValue::as_str) == Some("namespace"));
|
||||
if has_legacy_format && has_canonical_namespace {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DynamicToolSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_deserializes_defer_loading() {
|
||||
let value = json!({
|
||||
"name": "lookup_ticket",
|
||||
"description": "Fetch a ticket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"deferLoading": true,
|
||||
});
|
||||
|
||||
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
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)
|
||||
}),
|
||||
defer_loading: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() {
|
||||
let value = json!({
|
||||
"name": "lookup_ticket",
|
||||
"description": "Fetch a ticket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"exposeToContext": false,
|
||||
});
|
||||
|
||||
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert!(actual.defer_loading);
|
||||
}
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user