1use super::selector;
10use crate::llm::{estimate_prompt_tokens, Message, ToolDefinition};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashSet};
13use thiserror::Error;
14
15pub const TOOL_PRESENTATION_PROFILE_V1_SCHEMA: &str = "a3s.code.tool-presentation-profile.v1";
16
17const MAX_PRESENTATION_TOOLS: usize = 4_096;
18const MAX_CODE_CATALOG_BYTES: usize = 64 * 1024;
19const MAX_CODE_CATALOG_DESCRIPTION_BYTES: usize = 192;
20
21#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ToolPresentationModeV1 {
28 #[default]
30 Adaptive,
31 Direct,
34 Code,
37 Disabled,
40}
41
42impl ToolPresentationModeV1 {
43 pub const fn as_str(self) -> &'static str {
44 match self {
45 Self::Adaptive => "adaptive",
46 Self::Direct => "direct",
47 Self::Code => "code",
48 Self::Disabled => "disabled",
49 }
50 }
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct ToolPresentationProfileV1 {
58 schema: String,
59 mode: ToolPresentationModeV1,
60}
61
62impl ToolPresentationProfileV1 {
63 pub fn adaptive() -> Self {
64 Self::new(ToolPresentationModeV1::Adaptive)
65 }
66
67 pub fn direct() -> Self {
68 Self::new(ToolPresentationModeV1::Direct)
69 }
70
71 pub fn code() -> Self {
72 Self::new(ToolPresentationModeV1::Code)
73 }
74
75 pub fn disabled() -> Self {
76 Self::new(ToolPresentationModeV1::Disabled)
77 }
78
79 fn new(mode: ToolPresentationModeV1) -> Self {
80 Self {
81 schema: TOOL_PRESENTATION_PROFILE_V1_SCHEMA.to_owned(),
82 mode,
83 }
84 }
85
86 pub fn schema(&self) -> &str {
87 &self.schema
88 }
89
90 pub const fn mode(&self) -> ToolPresentationModeV1 {
91 self.mode
92 }
93
94 pub fn validate(&self) -> Result<(), ToolPresentationError> {
95 if self.schema != TOOL_PRESENTATION_PROFILE_V1_SCHEMA {
96 return Err(ToolPresentationError::UnsupportedSchema);
97 }
98 Ok(())
99 }
100
101 pub fn ensure_within(&self, parent: &Self) -> Result<(), ToolPresentationError> {
107 self.validate()?;
108 parent.validate()?;
109 let within = self.mode == ToolPresentationModeV1::Disabled
110 || self.mode == parent.mode
111 || parent.mode == ToolPresentationModeV1::Direct;
112 if within {
113 Ok(())
114 } else {
115 Err(ToolPresentationError::ProfileExpansion {
116 parent: parent.mode.as_str(),
117 child: self.mode.as_str(),
118 })
119 }
120 }
121
122 pub fn present_for_messages(
128 &self,
129 source: &[ToolDefinition],
130 messages: &[Message],
131 ) -> Result<Vec<ToolDefinition>, ToolPresentationError> {
132 self.validate()?;
133 let source = canonical_source(source)?;
134 let projected = match self.mode {
135 ToolPresentationModeV1::Adaptive => {
136 selector::select_tools_for_messages(&source, messages)
137 }
138 ToolPresentationModeV1::Direct => source.clone(),
139 ToolPresentationModeV1::Code => code_projection(&source),
140 ToolPresentationModeV1::Disabled => Vec::new(),
141 };
142 validate_projection(&source, &projected)?;
143 Ok(projected)
144 }
145
146 pub fn present_for_prompt(
149 &self,
150 source: &[ToolDefinition],
151 prompt: &str,
152 ) -> Result<Vec<ToolDefinition>, ToolPresentationError> {
153 self.present_for_messages(source, &[Message::user(prompt)])
154 }
155}
156
157impl Default for ToolPresentationProfileV1 {
158 fn default() -> Self {
159 Self::adaptive()
160 }
161}
162
163#[derive(Clone, Debug, Eq, Error, PartialEq)]
164pub enum ToolPresentationError {
165 #[error("Tool presentation profile uses an unsupported schema")]
166 UnsupportedSchema,
167 #[error("Tool presentation source exceeds the {max} definition limit")]
168 ToolLimitExceeded { max: usize },
169 #[error("Tool presentation source contains duplicate name '{name}'")]
170 DuplicateToolName { name: String },
171 #[error("Tool presentation projected unknown Tool '{name}'")]
172 UnknownProjectedTool { name: String },
173 #[error("Tool presentation changed the parameter schema for Tool '{name}'")]
174 ParameterSchemaChanged { name: String },
175 #[error("Tool presentation submitted an unexpected description for Tool '{name}'")]
176 DescriptionChanged { name: String },
177 #[error("Tool presentation output is not in canonical Tool-name order")]
178 NonCanonicalOrder,
179 #[error("Tool presentation child mode '{child}' broadens parent mode '{parent}'")]
180 ProfileExpansion {
181 parent: &'static str,
182 child: &'static str,
183 },
184}
185
186pub(crate) fn estimated_definition_tokens(tools: &[ToolDefinition]) -> usize {
187 estimate_prompt_tokens(&[], None, tools)
188}
189
190pub(crate) fn canonical_source(
191 source: &[ToolDefinition],
192) -> Result<Vec<ToolDefinition>, ToolPresentationError> {
193 if source.len() > MAX_PRESENTATION_TOOLS {
194 return Err(ToolPresentationError::ToolLimitExceeded {
195 max: MAX_PRESENTATION_TOOLS,
196 });
197 }
198 let mut canonical = source.to_vec();
199 canonical.sort_by(|left, right| left.name.cmp(&right.name));
200 for pair in canonical.windows(2) {
201 if pair[0].name == pair[1].name {
202 return Err(ToolPresentationError::DuplicateToolName {
203 name: pair[0].name.clone(),
204 });
205 }
206 }
207 Ok(canonical)
208}
209
210pub(crate) fn is_definition_subset(
211 source: &[ToolDefinition],
212 projected: &[ToolDefinition],
213) -> Result<bool, ToolPresentationError> {
214 validate_projection(source, projected)?;
215 let source = source
216 .iter()
217 .map(|definition| (definition.name.as_str(), definition))
218 .collect::<BTreeMap<_, _>>();
219 for definition in projected {
220 let Some(original) = source.get(definition.name.as_str()) else {
221 return Err(ToolPresentationError::UnknownProjectedTool {
222 name: definition.name.clone(),
223 });
224 };
225 if definition.description != original.description {
226 return Err(ToolPresentationError::DescriptionChanged {
227 name: definition.name.clone(),
228 });
229 }
230 }
231 Ok(true)
232}
233
234fn validate_projection(
235 source: &[ToolDefinition],
236 projected: &[ToolDefinition],
237) -> Result<(), ToolPresentationError> {
238 let source = source
239 .iter()
240 .map(|definition| (definition.name.as_str(), definition))
241 .collect::<BTreeMap<_, _>>();
242 let mut prior_name: Option<&str> = None;
243 let mut seen = HashSet::with_capacity(projected.len());
244 for definition in projected {
245 if prior_name.is_some_and(|prior| prior >= definition.name.as_str()) {
246 return Err(ToolPresentationError::NonCanonicalOrder);
247 }
248 prior_name = Some(&definition.name);
249 if !seen.insert(definition.name.as_str()) {
250 return Err(ToolPresentationError::DuplicateToolName {
251 name: definition.name.clone(),
252 });
253 }
254 let Some(original) = source.get(definition.name.as_str()) else {
255 return Err(ToolPresentationError::UnknownProjectedTool {
256 name: definition.name.clone(),
257 });
258 };
259 if definition.parameters != original.parameters {
260 return Err(ToolPresentationError::ParameterSchemaChanged {
261 name: definition.name.clone(),
262 });
263 }
264 }
265 Ok(())
266}
267
268fn code_projection(source: &[ToolDefinition]) -> Vec<ToolDefinition> {
269 let Some(program) = source
270 .iter()
271 .find(|definition| definition.name == "program")
272 else {
273 return Vec::new();
277 };
278 let mut program = program.clone();
279 let catalog = compact_code_catalog(source);
280 program.description = format!(
281 "Run a sandboxed JavaScript program through the governed Tool executor. Define async function run(ctx, inputs), call await ctx.tool(name, args), and set allowed_tools to the smallest required subset. Every nested call keeps the Run permission, confirmation, cancellation, and audit boundaries. Available Tool signatures: {catalog}"
282 );
283 vec![program]
284}
285
286fn compact_code_catalog(source: &[ToolDefinition]) -> String {
287 let mut output = String::new();
288 let mut omitted = 0usize;
289 for definition in source
290 .iter()
291 .filter(|definition| definition.name != "program")
292 {
293 let signature = compact_signature(definition);
294 let separator = if output.is_empty() { "" } else { "; " };
295 if output
296 .len()
297 .saturating_add(separator.len())
298 .saturating_add(signature.len())
299 > MAX_CODE_CATALOG_BYTES
300 {
301 omitted = omitted.saturating_add(1);
302 continue;
303 }
304 output.push_str(separator);
305 output.push_str(&signature);
306 }
307 if omitted > 0 {
308 output.push_str(&format!("; ... {omitted} additional Tools omitted"));
309 }
310 if output.is_empty() {
311 "none".to_owned()
312 } else {
313 output
314 }
315}
316
317fn compact_signature(definition: &ToolDefinition) -> String {
318 let required = definition
319 .parameters
320 .get("required")
321 .and_then(serde_json::Value::as_array)
322 .into_iter()
323 .flatten()
324 .filter_map(serde_json::Value::as_str)
325 .collect::<Vec<_>>()
326 .join(",");
327 let mut description = definition.description.lines().next().unwrap_or("").trim();
328 if description.len() > MAX_CODE_CATALOG_DESCRIPTION_BYTES {
329 let mut boundary = MAX_CODE_CATALOG_DESCRIPTION_BYTES;
330 while !description.is_char_boundary(boundary) {
331 boundary -= 1;
332 }
333 description = &description[..boundary];
334 }
335 format!("{}({required}) {description}", definition.name)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use serde_json::json;
342
343 fn definition(name: &str, description: &str, required: &[&str]) -> ToolDefinition {
344 ToolDefinition {
345 name: name.to_owned(),
346 description: description.to_owned(),
347 parameters: json!({
348 "type": "object",
349 "properties": required
350 .iter()
351 .map(|name| ((*name).to_owned(), json!({"type": "string"})))
352 .collect::<serde_json::Map<_, _>>(),
353 "required": required,
354 }),
355 }
356 }
357
358 #[test]
359 fn profiles_are_closed_serializable_values() {
360 for (profile, mode) in [
361 (ToolPresentationProfileV1::adaptive(), "adaptive"),
362 (ToolPresentationProfileV1::direct(), "direct"),
363 (ToolPresentationProfileV1::code(), "code"),
364 (ToolPresentationProfileV1::disabled(), "disabled"),
365 ] {
366 profile.validate().unwrap();
367 let value = serde_json::to_value(&profile).unwrap();
368 assert_eq!(value["schema"], TOOL_PRESENTATION_PROFILE_V1_SCHEMA);
369 assert_eq!(value["mode"], mode);
370 assert_eq!(
371 serde_json::from_value::<ToolPresentationProfileV1>(value).unwrap(),
372 profile
373 );
374 }
375 }
376
377 #[test]
378 fn projection_is_canonical_and_can_only_rephrase_existing_definitions() {
379 let source = vec![
380 definition("write", "Write a file", &["file_path", "content"]),
381 definition("program", "Original program description", &["type"]),
382 definition("read", "Read a file", &["file_path"]),
383 ];
384 let projected = ToolPresentationProfileV1::code()
385 .present_for_prompt(&source, "change the file")
386 .unwrap();
387
388 assert_eq!(projected.len(), 1);
389 assert_eq!(projected[0].name, "program");
390 assert_eq!(projected[0].parameters, source[1].parameters);
391 assert_ne!(projected[0].description, source[1].description);
392 assert!(projected[0].description.contains("read(file_path)"));
393 assert!(projected[0]
394 .description
395 .contains("write(file_path,content)"));
396 }
397
398 #[test]
399 fn direct_and_adaptive_outputs_have_deterministic_order() {
400 let source = vec![
401 definition("write", "Write", &[]),
402 definition("bash", "Execute", &[]),
403 definition("read", "Read", &[]),
404 ];
405 for profile in [
406 ToolPresentationProfileV1::direct(),
407 ToolPresentationProfileV1::adaptive(),
408 ] {
409 let names = profile
410 .present_for_prompt(&source, "inspect and update the project")
411 .unwrap()
412 .into_iter()
413 .map(|definition| definition.name)
414 .collect::<Vec<_>>();
415 assert_eq!(names, vec!["bash", "read", "write"]);
416 }
417 }
418
419 #[test]
420 fn code_profile_reduces_large_direct_definition_cost() {
421 let mut source = vec![definition("program", "Program", &["type"])];
422 for index in 0..40 {
423 source.push(definition(
424 &format!("tool_{index:02}"),
425 &"long model-facing description ".repeat(20),
426 &["input", "path", "mode"],
427 ));
428 }
429 let direct = ToolPresentationProfileV1::direct()
430 .present_for_prompt(&source, "work")
431 .unwrap();
432 let code = ToolPresentationProfileV1::code()
433 .present_for_prompt(&source, "work")
434 .unwrap();
435
436 assert_eq!(code.len(), 1);
437 assert!(estimated_definition_tokens(&code) < estimated_definition_tokens(&direct));
438 }
439
440 #[test]
441 fn child_profile_partial_order_rejects_cross_mode_broadening() {
442 let direct = ToolPresentationProfileV1::direct();
443 let adaptive = ToolPresentationProfileV1::adaptive();
444 let code = ToolPresentationProfileV1::code();
445 let disabled = ToolPresentationProfileV1::disabled();
446
447 adaptive.ensure_within(&direct).unwrap();
448 code.ensure_within(&direct).unwrap();
449 disabled.ensure_within(&adaptive).unwrap();
450 adaptive.ensure_within(&adaptive).unwrap();
451 assert!(matches!(
452 code.ensure_within(&adaptive),
453 Err(ToolPresentationError::ProfileExpansion { .. })
454 ));
455 assert!(matches!(
456 adaptive.ensure_within(&code),
457 Err(ToolPresentationError::ProfileExpansion { .. })
458 ));
459 }
460}