1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
use std::collections::BTreeSet;
use super::components::ComponentRegistry;
use super::params::{
extract_params_from_json_schema, extract_params_from_vm_dict, ToolParamSchema,
};
use crate::value::VmValue;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub(crate) struct ToolSchema {
// `name` deliberately has no default: a catalog row without one is not a
// tool schema, and a consumer reading the sidecar back must fail on it
// rather than materialize a nameless tool.
pub(crate) name: String,
#[serde(default)]
pub(crate) description: String,
#[serde(default)]
pub(crate) params: Vec<ToolParamSchema>,
/// When true, every renderer serves [`tool_summary`](super::summary::tool_summary) of `description`
/// instead of the full text. The tool stays fully dispatchable and the
/// full description stays here and in the sidecar — only the copy the
/// model reads is shortened, and a host that offers a `tool_schema` tool
/// lets the model pull the rest on demand.
///
/// Named `summary_only` in Rust, `compact` on the wire. Harn spells four
/// unrelated things `compact` — the OpenAI Responses request option in
/// `llm_options`, the `agent_session_reanchor` option, the transcript
/// budget recovery action, and this — and that collision is why this
/// field sat collected but unread from its introduction until #7767. The
/// serialized key stays `compact` because tool registries are authored by
/// hosts and replayed from persisted sidecars; renaming it would strand
/// every existing declaration and every recorded run.
#[serde(default, rename = "compact")]
pub(crate) summary_only: bool,
}
impl ToolSchema {
/// The description this tool is served with. The one place a renderer
/// asks; reading `description` directly is what lets a surface drift into
/// serving text the sidecar says was never sent.
pub(crate) fn served_description(&self) -> String {
if self.summary_only {
super::summary::tool_summary(&self.description)
} else {
self.description.clone()
}
}
}
fn collect_vm_tool_schemas(
tools_val: Option<&VmValue>,
registry: &mut ComponentRegistry,
) -> Vec<ToolSchema> {
// Mirror the root registry as JSON so `$ref` can resolve against
// sibling `types` / `definitions` / `components.schemas`.
let root_json = match tools_val {
Some(value) => super::super::vm_value_to_json(value),
None => serde_json::Value::Null,
};
let entries: Vec<&VmValue> = match tools_val {
Some(VmValue::List(list)) => list.iter().collect(),
Some(VmValue::Dict(dict)) => {
if let Some(VmValue::List(tools)) = dict.get("tools") {
tools.iter().collect()
} else {
Vec::new()
}
}
_ => Vec::new(),
};
entries
.into_iter()
.filter_map(|value| match value {
VmValue::Dict(td) => {
if !crate::tool_registry::tool_entry_allows_audience(
td,
crate::tool_registry::ToolAudience::Agent,
)
.unwrap_or(false)
{
return None;
}
let name = td.get("name")?.display();
let description = td
.get("description")
.map(|value| value.display())
.unwrap_or_default();
let params = extract_params_from_vm_dict(td, &root_json, registry);
let summary_only = super::summary::entry_is_summary_only(td);
Some(ToolSchema {
name,
description,
params,
summary_only,
})
}
_ => None,
})
.collect()
}
fn collect_provider_declared_tool_schemas(
provider_tools: Option<&[serde_json::Value]>,
registry: &mut ComponentRegistry,
) -> Vec<ToolSchema> {
provider_tools
.unwrap_or(&[])
.iter()
.filter_map(|tool| {
let function = tool.get("function");
let name = function
.and_then(|value| value.get("name"))
.or_else(|| tool.get("name"))
.and_then(|value| value.as_str())?;
let description = function
.and_then(|value| value.get("description"))
.or_else(|| tool.get("description"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let provider_input_schema = function
.and_then(|value| value.get("parameters"))
.or_else(|| tool.get("input_schema"))
.cloned()
.unwrap_or_else(|| serde_json::json!({"type": "object"}));
// Resolve `$ref` against the tool wrapper itself (siblings
// such as `components.schemas` hang off there).
let root = tool.clone();
Some(ToolSchema {
name: name.to_string(),
description,
params: extract_params_from_json_schema(&provider_input_schema, &root, registry),
summary_only: false,
})
})
.collect()
}
/// Collect the full tool schema set AND the reusable type registry populated
/// by any `$ref` encounters during extraction. Callers that only need the
/// list of schemas can ignore the registry.
pub(crate) fn collect_tool_schemas_with_registry(
tools_val: Option<&VmValue>,
native_tools: Option<&[serde_json::Value]>,
) -> (Vec<ToolSchema>, ComponentRegistry) {
let mut registry = ComponentRegistry::default();
let mut merged = collect_vm_tool_schemas(tools_val, &mut registry);
let mut seen = merged
.iter()
.map(|schema| schema.name.clone())
.collect::<BTreeSet<_>>();
for schema in collect_provider_declared_tool_schemas(native_tools, &mut registry) {
if seen.insert(schema.name.clone()) {
merged.push(schema);
}
}
merged.sort_by(|a, b| a.name.cmp(&b.name));
(merged, registry)
}
pub(crate) fn collect_tool_schemas(
tools_val: Option<&VmValue>,
native_tools: Option<&[serde_json::Value]>,
) -> Vec<ToolSchema> {
collect_tool_schemas_with_registry(tools_val, native_tools).0
}
/// Validate that all required parameters (those without defaults) are present
/// in the tool call arguments. Returns `Ok(())` when valid, or an error string
/// listing the missing parameters.
pub(crate) fn validate_tool_args(
tool_name: &str,
args: &serde_json::Value,
schemas: &[ToolSchema],
) -> Result<(), String> {
let Some(schema) = schemas.iter().find(|schema| schema.name == tool_name) else {
return Ok(()); // Unknown tool — handled by the unknown-tool error path
};
let obj = args.as_object();
let missing: Vec<&str> = schema
.params
.iter()
.filter(|param| param.required && param.default.is_none())
.filter(|param| {
obj.is_none_or(|map| !map.contains_key(¶m.name) || map[¶m.name].is_null())
})
.map(|param| param.name.as_str())
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(format!(
"Tool '{}' is missing required parameter(s): {}. \
Provide all required parameters and try again.",
tool_name,
missing.join(", ")
))
}
}