Skip to main content

phi_ext/phi/
schema.rs

1//! Typed JSON Schema for tool parameters.
2//!
3//! Codex generates these via `schemars` from Rust argument structs. Authors
4//! build an equivalent schema with the builders below, serialized with
5//! `serde_json`; the wire still carries opaque JSON Schema bytes (same as
6//! Go's `Parameters map[string]any` after `json.Marshal`).
7
8use std::collections::BTreeMap;
9
10/// JSON Schema body for an LLM tool's parameters
11/// (`type` / `properties` / `required` / …).
12#[derive(Debug, Clone)]
13pub struct Schema {
14    inner: SchemaInner,
15}
16
17#[derive(Debug, Clone)]
18enum SchemaInner {
19    Built(Node),
20    /// Escape hatch for hand-written JSON Schema bytes.
21    Raw(Vec<u8>),
22}
23
24#[derive(Debug, Clone)]
25struct Node {
26    kind: Kind,
27    description: Option<String>,
28    properties: BTreeMap<String, Node>,
29    required: Vec<String>,
30    additional_properties: Option<bool>,
31    enum_values: Vec<String>,
32    items: Option<Box<Node>>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36enum Kind {
37    Object,
38    String,
39    Number,
40    Integer,
41    Boolean,
42    Array,
43}
44
45impl Default for Node {
46    fn default() -> Self {
47        Self {
48            kind: Kind::Object,
49            description: None,
50            properties: BTreeMap::new(),
51            required: Vec::new(),
52            additional_properties: None,
53            enum_values: Vec::new(),
54            items: None,
55        }
56    }
57}
58
59/// Serializes a schema node as JSON Schema. Keys are emitted in a fixed order
60/// (`type`, `description`, …) and `serde_json`'s `preserve_order` keeps that
61/// order on the wire, so the bytes are stable across runs.
62impl serde::Serialize for Node {
63    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
64        use serde::ser::SerializeMap;
65        let mut m = s.serialize_map(Some(3))?;
66        m.serialize_entry("type", kind_str(self.kind))?;
67        if let Some(d) = &self.description {
68            m.serialize_entry("description", d)?;
69        }
70        if self.kind == Kind::Object {
71            m.serialize_entry("properties", &self.properties)?;
72            if !self.required.is_empty() {
73                m.serialize_entry("required", &self.required)?;
74            }
75            if let Some(allow) = self.additional_properties {
76                m.serialize_entry("additionalProperties", &allow)?;
77            }
78        }
79        if self.kind == Kind::String && !self.enum_values.is_empty() {
80            m.serialize_entry("enum", &self.enum_values)?;
81        }
82        if self.kind == Kind::Array {
83            if let Some(items) = &self.items {
84                m.serialize_entry("items", items.as_ref())?;
85            }
86        }
87        m.end()
88    }
89}
90
91impl Schema {
92    fn from_node(node: Node) -> Self {
93        Self {
94            inner: SchemaInner::Built(node),
95        }
96    }
97
98    /// Object schema (`{"type":"object",…}`). Default for tool parameters.
99    pub fn object() -> Self {
100        Self::from_node(Node {
101            kind: Kind::Object,
102            ..Node::default()
103        })
104    }
105
106    pub fn string() -> Self {
107        Self::from_node(Node {
108            kind: Kind::String,
109            ..Node::default()
110        })
111    }
112
113    pub fn number() -> Self {
114        Self::from_node(Node {
115            kind: Kind::Number,
116            ..Node::default()
117        })
118    }
119
120    pub fn integer() -> Self {
121        Self::from_node(Node {
122            kind: Kind::Integer,
123            ..Node::default()
124        })
125    }
126
127    pub fn boolean() -> Self {
128        Self::from_node(Node {
129            kind: Kind::Boolean,
130            ..Node::default()
131        })
132    }
133
134    /// Array schema. `items` must be a builder schema (not [`Schema::raw`]).
135    pub fn array(items: Schema) -> Self {
136        let SchemaInner::Built(items_node) = items.inner else {
137            panic!("Schema::array requires a builder schema, not Schema::raw");
138        };
139        Self::from_node(Node {
140            kind: Kind::Array,
141            items: Some(Box::new(items_node)),
142            ..Node::default()
143        })
144    }
145
146    /// Opaque JSON Schema bytes (the previous `Vec<u8>` API).
147    pub fn raw(json: impl Into<Vec<u8>>) -> Self {
148        Self {
149            inner: SchemaInner::Raw(json.into()),
150        }
151    }
152
153    pub fn description(mut self, d: impl Into<String>) -> Self {
154        if let SchemaInner::Built(n) = &mut self.inner {
155            n.description = Some(d.into());
156        }
157        self
158    }
159
160    /// Add an object property. No-op on non-object / raw schemas.
161    pub fn property(mut self, name: impl Into<String>, schema: Schema) -> Self {
162        if let SchemaInner::Built(n) = &mut self.inner {
163            if n.kind == Kind::Object {
164                if let SchemaInner::Built(child) = schema.inner {
165                    n.properties.insert(name.into(), child);
166                }
167            }
168        }
169        self
170    }
171
172    /// Mark object property names as required.
173    pub fn required<I, S>(mut self, names: I) -> Self
174    where
175        I: IntoIterator<Item = S>,
176        S: Into<String>,
177    {
178        if let SchemaInner::Built(n) = &mut self.inner {
179            if n.kind == Kind::Object {
180                n.required.extend(names.into_iter().map(Into::into));
181            }
182        }
183        self
184    }
185
186    pub fn additional_properties(mut self, allow: bool) -> Self {
187        if let SchemaInner::Built(n) = &mut self.inner {
188            if n.kind == Kind::Object {
189                n.additional_properties = Some(allow);
190            }
191        }
192        self
193    }
194
195    /// Restrict a string schema to an enum (Codex-style compact enums).
196    pub fn enum_values<I, S>(mut self, values: I) -> Self
197    where
198        I: IntoIterator<Item = S>,
199        S: Into<String>,
200    {
201        if let SchemaInner::Built(n) = &mut self.inner {
202            if n.kind == Kind::String {
203                n.enum_values.extend(values.into_iter().map(Into::into));
204            }
205        }
206        self
207    }
208
209    /// Serialize to JSON Schema bytes for `RegisterTool`.
210    pub fn to_json_bytes(&self) -> Vec<u8> {
211        match &self.inner {
212            SchemaInner::Raw(b) => b.clone(),
213            SchemaInner::Built(n) => {
214                serde_json::to_vec(n).expect("schema serialization cannot fail")
215            }
216        }
217    }
218}
219
220impl From<Vec<u8>> for Schema {
221    fn from(json: Vec<u8>) -> Self {
222        Schema::raw(json)
223    }
224}
225
226impl From<&[u8]> for Schema {
227    fn from(json: &[u8]) -> Self {
228        Schema::raw(json.to_vec())
229    }
230}
231
232fn kind_str(k: Kind) -> &'static str {
233    match k {
234        Kind::Object => "object",
235        Kind::String => "string",
236        Kind::Number => "number",
237        Kind::Integer => "integer",
238        Kind::Boolean => "boolean",
239        Kind::Array => "array",
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn object_with_required_string_prop() {
249        let s = Schema::object()
250            .property("text", Schema::string().description("input text"))
251            .required(["text"]);
252        assert_eq!(
253            String::from_utf8(s.to_json_bytes()).unwrap(),
254            r#"{"type":"object","properties":{"text":{"type":"string","description":"input text"}},"required":["text"]}"#
255        );
256    }
257
258    #[test]
259    fn string_enum_and_additional_properties() {
260        let s = Schema::object()
261            .property(
262                "mode",
263                Schema::string().enum_values(["read-only", "workspace-write"]),
264            )
265            .additional_properties(false);
266        let json = String::from_utf8(s.to_json_bytes()).unwrap();
267        assert!(json.contains(r#""enum":["read-only","workspace-write"]"#));
268        assert!(json.contains(r#""additionalProperties":false"#));
269    }
270
271    #[test]
272    fn array_of_strings() {
273        let s = Schema::object().property("tags", Schema::array(Schema::string()));
274        assert_eq!(
275            String::from_utf8(s.to_json_bytes()).unwrap(),
276            r#"{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}}}}"#
277        );
278    }
279
280    #[test]
281    fn raw_passthrough() {
282        let raw = br#"{"type":"object"}"#;
283        assert_eq!(Schema::raw(raw.to_vec()).to_json_bytes(), raw);
284    }
285}