Skip to main content

perspt_sdk/toolset/
entry.rs

1//! Catalog entries (PSP-9 system 5).
2//!
3//! Each entry binds a tool name to its argument schema, its `EffectKind`,
4//! its `RiskClass`, and its declarative scheduler footprint. The catalog is
5//! data, so a domain package — or an external tool server (system 13) — can
6//! extend it without forking the harness.
7
8use serde::{Deserialize, Serialize};
9
10use super::footprint::FootprintSpec;
11use crate::capability::{EffectKind, RiskClass};
12use crate::error::{Result, SdkError};
13use crate::model::ToolSpec;
14
15/// How validated arguments bind to a Paper III effect proposal. Bindings are
16/// locally trusted catalog data; tool descriptions and server annotations do
17/// not create them.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case", tag = "kind")]
20pub enum ProposalBinding {
21    Path {
22        field: String,
23    },
24    Command {
25        field: String,
26    },
27    Url {
28        field: String,
29    },
30    /// A bounded scalar array whose values bind to one proposal channel.
31    MultiValue {
32        field: String,
33        target: MultiValueTarget,
34    },
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum MultiValueTarget {
40    Path,
41    Command,
42    Url,
43}
44
45/// Where a tool entry came from.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ToolOrigin {
49    /// The SDK's base catalog.
50    Builtin,
51    /// Contributed by the active `AgentDomainPackage`.
52    Domain(String),
53    /// Registered by an external tool server (system 13). Registration never
54    /// mints authority: the session must already hold a capability for the
55    /// declared effect.
56    External(String),
57}
58
59/// Per-route presentation text for a tool (PSP-10 system 25). Presentation
60/// only: model-facing text never changes catalog effects, risks, or
61/// footprints.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct ToolDescriptionLibrary {
64    /// The route-neutral description used when no override matches.
65    pub base: String,
66    /// `(route_selector, description)` pairs; a selector is an adapter
67    /// kind, a family label, or `"adapter/family"`.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub overrides: Vec<(String, String)>,
70}
71
72/// One tool in the catalog.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct ToolEntry {
75    pub name: String,
76    pub description: String,
77    /// Trusted text `tool_search` ranks by, when non-empty (PSP-10
78    /// system 25). Separate from the model-facing description so untrusted
79    /// external descriptions never steer discovery.
80    #[serde(default, skip_serializing_if = "String::is_empty")]
81    pub discovery_summary: String,
82    /// Optional per-route presentation library; absent means the
83    /// route-neutral `description` serves every route.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub description_templates: Option<ToolDescriptionLibrary>,
86    pub effect: EffectKind,
87    pub risk: RiskClass,
88    /// JSON Schema for the tool's arguments.
89    pub schema: serde_json::Value,
90    /// Declarative selectors mapping validated arguments to resources.
91    pub footprint: FootprintSpec,
92    /// Deterministic argument-to-proposal bindings.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub proposal_bindings: Vec<ProposalBinding>,
95    /// Whether the tool mutates durable state (gates R5 bracketing).
96    pub durable: bool,
97    pub origin: ToolOrigin,
98    /// Part of the hot set: visible without discovery. The domain's common
99    /// operating loop (read, edit, test, build, format) stays hot; deferred
100    /// discovery is for rare tools and external servers, never the basic
101    /// loop. Context economy only — authority is still the kernel's.
102    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103    pub hot: bool,
104}
105
106impl ToolEntry {
107    /// Mark this entry hot (visible without discovery).
108    pub fn hot(mut self) -> Self {
109        self.hot = true;
110        self
111    }
112
113    /// Validate the entry at catalog-assembly time (Gates J and P).
114    ///
115    /// The schema must be an object schema; the footprint must reference
116    /// only declared arguments; and a mutating effect must declare a
117    /// footprint.
118    pub fn validate(&self) -> Result<()> {
119        if self.name.is_empty() {
120            return Err(SdkError::Domain("tool entry with empty name".into()));
121        }
122        if self.schema.get("type").and_then(|t| t.as_str()) != Some("object") {
123            return Err(SdkError::Domain(format!(
124                "tool {:?}: argument schema must be an object schema",
125                self.name
126            )));
127        }
128        validate_schema_profile(&self.name, &self.schema)?;
129        validate_proposal_bindings(&self.name, &self.schema, &self.proposal_bindings)?;
130        // `AskUser` is interactive, not mutating: it touches no workspace
131        // state, so it carries no footprint obligation.
132        let mutates = !self.effect.is_read_only() && self.effect != EffectKind::AskUser;
133        self.footprint
134            .validate(&self.schema, mutates)
135            .map_err(|e| SdkError::Domain(format!("tool {:?}: {e}", self.name)))
136    }
137
138    /// Validate provider-supplied arguments locally. Provider strict-schema
139    /// support is only an optimization; this is the harness trust boundary.
140    /// Catalog assembly guarantees the schema belongs to the profile this
141    /// validator implements completely.
142    pub fn validate_arguments(&self, arguments: &serde_json::Value) -> Result<()> {
143        validate_object_value(&self.name, "arguments", arguments, &self.schema)
144    }
145
146    /// The wire-neutral spec sent to a model.
147    pub fn to_spec(&self, strict: bool) -> ToolSpec {
148        ToolSpec {
149            name: self.name.clone(),
150            description: self.description.clone(),
151            schema: self.schema.clone(),
152            strict,
153        }
154    }
155
156    /// The spec compiled for one prompt route (PSP-10 system 25). With no
157    /// description library this equals [`ToolEntry::to_spec`] byte for
158    /// byte; with one, the first matching override (exact
159    /// `"adapter/family"`, then adapter, then family) replaces only the
160    /// description — never authority fields.
161    pub fn to_spec_for_route(&self, route: &crate::prompt::PromptRoute, strict: bool) -> ToolSpec {
162        let mut spec = self.to_spec(strict);
163        if let Some(library) = &self.description_templates {
164            let family = format!("{:?}", route.family);
165            let exact = format!("{}/{family}", route.adapter);
166            spec.description = library
167                .overrides
168                .iter()
169                .find(|(selector, _)| {
170                    *selector == exact || *selector == route.adapter || *selector == family
171                })
172                .map(|(_, description)| description.clone())
173                .unwrap_or_else(|| library.base.clone());
174        }
175        spec
176    }
177
178    /// The text `tool_search` ranks: the discovery summary when present,
179    /// else the route-neutral description.
180    pub fn discovery_text(&self) -> &str {
181        if self.discovery_summary.is_empty() {
182            &self.description
183        } else {
184            &self.discovery_summary
185        }
186    }
187}
188
189fn validate_schema_profile(tool: &str, schema: &serde_json::Value) -> Result<()> {
190    const ROOT_KEYS: &[&str] = &[
191        "$schema",
192        "title",
193        "description",
194        "type",
195        "properties",
196        "required",
197        "additionalProperties",
198    ];
199    let root = schema
200        .as_object()
201        .expect("object schema checked by ToolEntry::validate");
202    if let Some(key) = root.keys().find(|key| !ROOT_KEYS.contains(&key.as_str())) {
203        return Err(SdkError::Domain(format!(
204            "tool {tool:?}: unsupported argument-schema keyword {key:?}"
205        )));
206    }
207    let properties = root
208        .get("properties")
209        .and_then(serde_json::Value::as_object)
210        .ok_or_else(|| {
211            SdkError::Domain(format!(
212                "tool {tool:?}: schema must declare object properties"
213            ))
214        })?;
215    if let Some(additional) = root.get("additionalProperties") {
216        if !additional.is_boolean() {
217            return Err(SdkError::Domain(format!(
218                "tool {tool:?}: additionalProperties must be boolean"
219            )));
220        }
221    }
222    if let Some(required) = root.get("required") {
223        let required = required
224            .as_array()
225            .ok_or_else(|| SdkError::Domain(format!("tool {tool:?}: required must be an array")))?;
226        for field in required {
227            let field = field.as_str().ok_or_else(|| {
228                SdkError::Domain(format!("tool {tool:?}: required entries must be strings"))
229            })?;
230            if !properties.contains_key(field) {
231                return Err(SdkError::Domain(format!(
232                    "tool {tool:?}: required argument {field:?} is not a property"
233                )));
234            }
235        }
236    }
237    for (field, property) in properties {
238        validate_property_schema(tool, field, property, 0)?;
239    }
240    Ok(())
241}
242
243fn validate_property_schema(
244    tool: &str,
245    field: &str,
246    schema: &serde_json::Value,
247    object_depth: u8,
248) -> Result<()> {
249    const KEYS: &[&str] = &[
250        "title",
251        "description",
252        "type",
253        "enum",
254        "default",
255        "items",
256        "minItems",
257        "maxItems",
258        "properties",
259        "required",
260        "additionalProperties",
261    ];
262    let property = schema.as_object().ok_or_else(|| {
263        SdkError::Domain(format!(
264            "tool {tool:?}: property {field:?} must be a schema object"
265        ))
266    })?;
267    if let Some(key) = property.keys().find(|key| !KEYS.contains(&key.as_str())) {
268        return Err(SdkError::Domain(format!(
269            "tool {tool:?}: property {field:?} uses unsupported keyword {key:?}"
270        )));
271    }
272    let kind = property
273        .get("type")
274        .and_then(serde_json::Value::as_str)
275        .ok_or_else(|| {
276            SdkError::Domain(format!("tool {tool:?}: property {field:?} has no type"))
277        })?;
278    match kind {
279        "string" | "integer" | "number" | "boolean" | "null" => {
280            validate_enum(tool, field, property, kind)?;
281        }
282        "array" => validate_array_schema(tool, field, property, object_depth)?,
283        "object" if object_depth == 0 => {
284            validate_object_schema(tool, field, property, object_depth + 1)?;
285        }
286        "object" => {
287            return Err(SdkError::Domain(format!(
288                "tool {tool:?}: property {field:?} exceeds one nested object level"
289            )));
290        }
291        _ => {
292            return Err(SdkError::Domain(format!(
293                "tool {tool:?}: property {field:?} has unsupported type {kind:?}"
294            )));
295        }
296    }
297    Ok(())
298}
299
300fn validate_array_schema(
301    tool: &str,
302    field: &str,
303    property: &serde_json::Map<String, serde_json::Value>,
304    object_depth: u8,
305) -> Result<()> {
306    let maximum = property
307        .get("maxItems")
308        .and_then(serde_json::Value::as_u64)
309        .filter(|maximum| *maximum > 0)
310        .ok_or_else(|| {
311            SdkError::Domain(format!(
312                "tool {tool:?}: array property {field:?} requires a positive maxItems"
313            ))
314        })?;
315    if maximum > 256 {
316        return Err(SdkError::Domain(format!(
317            "tool {tool:?}: array property {field:?} maxItems exceeds 256"
318        )));
319    }
320    let items = property.get("items").ok_or_else(|| {
321        SdkError::Domain(format!(
322            "tool {tool:?}: array property {field:?} requires items"
323        ))
324    })?;
325    let item_kind = items.get("type").and_then(serde_json::Value::as_str);
326    if !matches!(
327        item_kind,
328        Some("string" | "integer" | "number" | "boolean" | "null")
329    ) {
330        return Err(SdkError::Domain(format!(
331            "tool {tool:?}: array property {field:?} must contain scalars"
332        )));
333    }
334    validate_property_schema(tool, &format!("{field}[]"), items, object_depth)
335}
336
337fn validate_object_schema(
338    tool: &str,
339    field: &str,
340    property: &serde_json::Map<String, serde_json::Value>,
341    depth: u8,
342) -> Result<()> {
343    let children = property
344        .get("properties")
345        .and_then(serde_json::Value::as_object)
346        .ok_or_else(|| {
347            SdkError::Domain(format!(
348                "tool {tool:?}: object property {field:?} requires properties"
349            ))
350        })?;
351    validate_required(tool, field, property, children)?;
352    for (child, schema) in children {
353        validate_property_schema(tool, &format!("{field}.{child}"), schema, depth)?;
354    }
355    Ok(())
356}
357
358fn validate_required(
359    tool: &str,
360    field: &str,
361    schema: &serde_json::Map<String, serde_json::Value>,
362    properties: &serde_json::Map<String, serde_json::Value>,
363) -> Result<()> {
364    let Some(required) = schema.get("required") else {
365        return Ok(());
366    };
367    let required = required.as_array().ok_or_else(|| {
368        SdkError::Domain(format!(
369            "tool {tool:?}: {field:?} required must be an array"
370        ))
371    })?;
372    for name in required {
373        let name = name.as_str().ok_or_else(|| {
374            SdkError::Domain(format!(
375                "tool {tool:?}: {field:?} required entries must be strings"
376            ))
377        })?;
378        if !properties.contains_key(name) {
379            return Err(SdkError::Domain(format!(
380                "tool {tool:?}: required property {field}.{name} is not declared"
381            )));
382        }
383    }
384    Ok(())
385}
386
387fn validate_enum(
388    tool: &str,
389    field: &str,
390    property: &serde_json::Map<String, serde_json::Value>,
391    kind: &str,
392) -> Result<()> {
393    if let Some(values) = property.get("enum") {
394        let values = values.as_array().ok_or_else(|| {
395            SdkError::Domain(format!(
396                "tool {tool:?}: property {field:?} enum must be an array"
397            ))
398        })?;
399        if values.iter().any(|value| !value_matches_type(value, kind)) {
400            return Err(SdkError::Domain(format!(
401                "tool {tool:?}: property {field:?} enum contains a value outside type {kind}"
402            )));
403        }
404    }
405    Ok(())
406}
407
408fn validate_object_value(
409    tool: &str,
410    field: &str,
411    value: &serde_json::Value,
412    schema: &serde_json::Value,
413) -> Result<()> {
414    let object = value.as_object().ok_or_else(|| {
415        SdkError::Domain(format!("tool {tool:?}: argument {field:?} must be object"))
416    })?;
417    let properties = schema
418        .get("properties")
419        .and_then(serde_json::Value::as_object)
420        .expect("catalog validation requires object properties");
421    if let Some(required) = schema.get("required").and_then(serde_json::Value::as_array) {
422        for required_field in required.iter().filter_map(serde_json::Value::as_str) {
423            if !object.contains_key(required_field) {
424                return Err(SdkError::Domain(format!(
425                    "tool {tool:?}: missing required argument {field}.{required_field}"
426                )));
427            }
428        }
429    }
430    if schema.get("additionalProperties") == Some(&serde_json::Value::Bool(false)) {
431        if let Some(unknown) = object.keys().find(|name| !properties.contains_key(*name)) {
432            return Err(SdkError::Domain(format!(
433                "tool {tool:?}: unknown argument {field}.{unknown}"
434            )));
435        }
436    }
437    for (name, child) in object {
438        if let Some(child_schema) = properties.get(name) {
439            validate_argument_value(tool, &format!("{field}.{name}"), child, child_schema)?;
440        }
441    }
442    Ok(())
443}
444
445fn validate_argument_value(
446    tool: &str,
447    field: &str,
448    value: &serde_json::Value,
449    schema: &serde_json::Value,
450) -> Result<()> {
451    let kind = schema
452        .get("type")
453        .and_then(serde_json::Value::as_str)
454        .expect("catalog validation requires property types");
455    if !value_matches_type(value, kind) {
456        return Err(SdkError::Domain(format!(
457            "tool {tool:?}: argument {field:?} must be {kind}"
458        )));
459    }
460    if let Some(allowed) = schema.get("enum").and_then(serde_json::Value::as_array) {
461        if !allowed.contains(value) {
462            return Err(SdkError::Domain(format!(
463                "tool {tool:?}: argument {field:?} is outside its enum"
464            )));
465        }
466    }
467    match kind {
468        "array" => {
469            let values = value.as_array().expect("array type checked");
470            let maximum = schema
471                .get("maxItems")
472                .and_then(serde_json::Value::as_u64)
473                .expect("array schema validated") as usize;
474            let minimum = schema
475                .get("minItems")
476                .and_then(serde_json::Value::as_u64)
477                .unwrap_or(0) as usize;
478            if values.len() < minimum || values.len() > maximum {
479                return Err(SdkError::Domain(format!(
480                    "tool {tool:?}: argument {field:?} has an invalid item count"
481                )));
482            }
483            let item_schema = schema.get("items").expect("array schema validated");
484            for (index, item) in values.iter().enumerate() {
485                validate_argument_value(tool, &format!("{field}[{index}]"), item, item_schema)?;
486            }
487        }
488        "object" => validate_object_value(tool, field, value, schema)?,
489        _ => {}
490    }
491    Ok(())
492}
493
494fn validate_proposal_bindings(
495    tool: &str,
496    schema: &serde_json::Value,
497    bindings: &[ProposalBinding],
498) -> Result<()> {
499    let properties = schema
500        .get("properties")
501        .and_then(serde_json::Value::as_object)
502        .expect("schema profile validates properties first");
503    for binding in bindings {
504        let (field, expected) = match binding {
505            ProposalBinding::Path { field }
506            | ProposalBinding::Command { field }
507            | ProposalBinding::Url { field } => (field, "string"),
508            ProposalBinding::MultiValue { field, .. } => (field, "array"),
509        };
510        let actual = properties
511            .get(field)
512            .and_then(|property| property.get("type"))
513            .and_then(serde_json::Value::as_str);
514        if actual != Some(expected) {
515            return Err(SdkError::Domain(format!(
516                "tool {tool:?}: proposal binding {field:?} requires {expected} arguments"
517            )));
518        }
519    }
520    Ok(())
521}
522
523fn value_matches_type(value: &serde_json::Value, kind: &str) -> bool {
524    match kind {
525        "string" => value.is_string(),
526        "integer" => value.is_i64() || value.is_u64(),
527        "number" => value.is_number(),
528        "boolean" => value.is_boolean(),
529        "array" => value.is_array(),
530        "object" => value.is_object(),
531        "null" => value.is_null(),
532        _ => false,
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::toolset::footprint::{AccessMode, ResourceSelector};
540
541    fn entry() -> ToolEntry {
542        ToolEntry {
543            name: "edit_file".into(),
544            description: "Exact-string replace".into(),
545            discovery_summary: String::new(),
546            description_templates: None,
547            effect: EffectKind::ApplyPatch,
548            risk: RiskClass::Medium,
549            schema: serde_json::json!({
550                "type": "object",
551                "properties": {"path": {"type": "string"}},
552                "required": ["path"],
553            }),
554            footprint: FootprintSpec::new(vec![ResourceSelector::PathArgument {
555                field: "path".into(),
556                access: AccessMode::Write,
557            }]),
558            proposal_bindings: vec![ProposalBinding::Path {
559                field: "path".into(),
560            }],
561            durable: false,
562            origin: ToolOrigin::Builtin,
563            hot: false,
564        }
565    }
566
567    #[test]
568    fn a_well_formed_entry_validates() {
569        assert!(entry().validate().is_ok());
570    }
571
572    #[test]
573    fn a_non_object_schema_is_rejected() {
574        let mut bad = entry();
575        bad.schema = serde_json::json!({"type": "string"});
576        assert!(bad.validate().is_err());
577    }
578
579    #[test]
580    fn a_mutating_entry_without_a_footprint_is_rejected_at_assembly() {
581        let mut bad = entry();
582        bad.footprint = FootprintSpec::default();
583        assert!(bad.validate().is_err());
584    }
585
586    #[test]
587    fn spec_carries_schema_and_strictness() {
588        let spec = entry().to_spec(true);
589        assert_eq!(spec.name, "edit_file");
590        assert!(spec.strict);
591    }
592
593    #[test]
594    fn runtime_arguments_are_checked_against_the_admitted_profile() {
595        let mut entry = entry();
596        entry.schema["additionalProperties"] = serde_json::Value::Bool(false);
597        assert!(entry
598            .validate_arguments(&serde_json::json!({"path": "src/lib.rs"}))
599            .is_ok());
600        assert!(entry
601            .validate_arguments(&serde_json::json!({"path": 7}))
602            .is_err());
603        assert!(entry
604            .validate_arguments(&serde_json::json!({"path": "src/lib.rs", "extra": true}))
605            .is_err());
606    }
607
608    #[test]
609    fn unsupported_schema_constructs_fail_at_catalog_assembly() {
610        let mut nested = entry();
611        nested.schema["properties"]["path"]["pattern"] = serde_json::json!("^src/");
612        assert!(nested.validate().is_err());
613
614        let mut array = entry();
615        array.schema["properties"]["path"]["type"] = serde_json::json!("array");
616        assert!(array.validate().is_err());
617    }
618
619    #[test]
620    fn bounded_scalar_arrays_and_one_nested_object_are_supported() {
621        let mut composite = entry();
622        composite.effect = EffectKind::SystemProbe;
623        composite.footprint = FootprintSpec::default();
624        composite.proposal_bindings.clear();
625        composite.schema = serde_json::json!({
626            "type": "object",
627            "properties": {
628                "names": {
629                    "type": "array",
630                    "items": {"type": "string"},
631                    "maxItems": 2
632                },
633                "options": {
634                    "type": "object",
635                    "properties": {"enabled": {"type": "boolean"}},
636                    "required": ["enabled"],
637                    "additionalProperties": false
638                }
639            },
640            "required": ["names", "options"],
641            "additionalProperties": false
642        });
643        assert!(composite.validate().is_ok());
644        assert!(composite
645            .validate_arguments(&serde_json::json!({
646                "names": ["one", "two"],
647                "options": {"enabled": true}
648            }))
649            .is_ok());
650        assert!(composite
651            .validate_arguments(&serde_json::json!({
652                "names": ["one", "two", "three"],
653                "options": {"enabled": true}
654            }))
655            .is_err());
656    }
657}