Skip to main content

a3s_code_core/tools/
presentation.rs

1//! Typed, deterministic model-facing Tool presentation.
2//!
3//! A presentation profile owns no [`Tool`](super::Tool) values and performs no
4//! execution. It can only select definitions from a caller-supplied governed
5//! source and, for the built-in code profile, rephrase the existing `program`
6//! definition. Names and parameter schemas remain byte-for-byte equivalent to
7//! their source definitions.
8
9use 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/// Closed presentation modes supported by the version-1 profile contract.
22///
23/// These modes change only the provider-facing definition list. They do not
24/// install Tools, alter permissions, or select an A3S Use generation.
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ToolPresentationModeV1 {
28    /// Preserve the historical prompt-sensitive Tool selector.
29    #[default]
30    Adaptive,
31    /// Present every Tool definition admitted by the run-owned permission
32    /// visibility boundary.
33    Direct,
34    /// Present the existing `program` Tool as a compact code gateway over the
35    /// same governed executor.
36    Code,
37    /// Present no Tools. Host-direct and other governed execution APIs remain
38    /// unchanged because this is a presentation choice, not authorization.
39    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/// Versioned, serializable Tool-presentation policy frozen by a Session and
54/// copied into each admitted Run.
55#[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    /// Reject a child mode that could expose a shape outside its parent.
102    ///
103    /// `direct` is the widest root mode. `adaptive` and `code` are intentionally
104    /// incomparable because each can expose a definition the other omits for a
105    /// given prompt. `disabled` is always a valid child.
106    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    /// Project the exact Tool definitions presented for one message context.
123    ///
124    /// Callers must first remove definitions hidden by run-owned permission
125    /// policy. The returned vector is canonical by Tool name and is validated
126    /// as a definition-only projection of that source.
127    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    /// Project definitions from a plain prompt without constructing a full
147    /// conversation history.
148    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        // Permission policy may hide the gateway. Failing closed to an empty
274        // presentation keeps that policy authoritative without blocking an
275        // otherwise tool-free model call.
276        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}