Ghostlight/crates/ghostlight/src/tool_schema.rs
Meta Vens 108b691015 Move the world module to crates/ghostlight
Pure `git mv` of the thirteen files under
`crates/ghostlight-dungeon/src/world/` into `crates/ghostlight/src/`, with
`mod.rs` becoming `lib.rs`. No byte of any file changes; all thirteen are
detected as renames so history follows.

This commit does not build. The crate graph has no `ghostlight` package yet
and `ghostlight-dungeon` still declares `mod world;`. The next commit adds
the manifest, the public boundary, and the consumer rewrite; the two are
pushed together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:32:21 +02:00

196 lines
7.4 KiB
Rust

//! The one emitter for every model-facing tool schema in the tree.
//!
//! Two catalogs sit above it and share nothing else: the action catalog
//! (`controllers::catalog_tools`) projects a world-state snapshot filtered by
//! grant, and the patch catalog (`patch::patch_tools`) projects the reducer's
//! own vocabulary. Both spell a property the same way because both spell it
//! here. Nothing in this module reads world state, a snapshot, or a config.
use codex_connector::CodexToolDefinition;
use serde_json::{Value, json};
pub(super) fn tool(name: &str, description: &str, schema: Value) -> CodexToolDefinition {
CodexToolDefinition {
name: name.into(),
description: description.into(),
parameters_json: serde_json::to_string(&schema)
.expect("static tool schemas must serialize"),
}
}
pub(super) fn empty_schema() -> Value {
json!({"type":"object","additionalProperties":false,"properties":{}})
}
/// Every generated object is closed and total: `additionalProperties` is false
/// and every property is required. An optional argument is expressed as a
/// nullable property, never as an absent one, so the decoder and the schema
/// agree without a second rule about omission.
pub(super) fn object(properties: Vec<(String, Value)>) -> Value {
let required: Vec<Value> = properties
.iter()
.map(|(name, _)| Value::String(name.clone()))
.collect();
json!({
"type": "object",
"additionalProperties": false,
"required": required,
"properties": properties.into_iter().collect::<serde_json::Map<_, _>>(),
})
}
pub(super) fn canonical_string(description: &str) -> Value {
json!({"type": "string", "description": description})
}
pub(super) fn bounded_integer(min: u64, max: u64) -> Value {
json!({"type": "integer", "minimum": min, "maximum": max})
}
/// The same schema with the text the model reads beside it. Meaning that lives
/// only in a prompt paragraph is lost by the time the model fills the field.
pub(super) fn described(mut schema: Value, description: &str) -> Value {
if let Value::Object(fields) = &mut schema {
fields.insert("description".into(), Value::String(description.into()));
}
schema
}
pub(super) fn name_enum(values: &[&str]) -> Value {
json!({"type": "string", "enum": values})
}
/// A closed sum spelled as a tagged object. `tag` is the discriminator field
/// name; each variant carries its own closed object of content.
pub(super) fn variant(tag: &str, variants: Vec<(&str, Vec<(String, Value)>)>) -> Value {
let branches: Vec<Value> = variants
.into_iter()
.map(|(name, content)| {
let mut properties = vec![(tag.to_owned(), json!({"type": "string", "const": name}))];
properties.extend(content);
object(properties)
})
.collect();
json!({ "anyOf": branches })
}
pub(super) fn nullable(inner: Value) -> Value {
json!({ "anyOf": [inner, {"type": "null"}] })
}
pub(super) fn list(inner: Value) -> Value {
json!({"type": "array", "items": inner})
}
/// `{"ref": "draft"|"existing", "value": <handle text|canonical uuid>}` —
/// Invariant 1 rendered as a schema, and the only reference shape either
/// catalog emits.
pub(super) fn reference(referent: &str) -> Value {
json!({
"type": "object",
"additionalProperties": false,
"required": ["ref", "value"],
"properties": {
"ref": {"type": "string", "enum": ["draft", "existing"]},
"value": {
"type": "string",
"description": format!(
"a draft handle declared in this same patch, or the canonical id of an existing {referent}"
),
},
},
})
}
/// An adjacently tagged sum: `{"<tag>": "<variant>", "<content>": <payload>}`.
/// A variant with no payload (`None`) omits the content field entirely, which
/// is what serde emits for a unit variant in an adjacently tagged enum.
pub(super) fn variant_content(
tag: &str,
content: &str,
variants: Vec<(&str, Option<Value>)>,
) -> Value {
let branches: Vec<Value> = variants
.into_iter()
.map(|(name, payload)| {
let mut properties = vec![(tag.to_owned(), json!({"type": "string", "const": name}))];
if let Some(inner) = payload {
properties.push((content.to_owned(), inner));
}
object(properties)
})
.collect();
json!({ "anyOf": branches })
}
/// An externally tagged sum: a bare string for a unit variant, or a
/// single-key object for one that carries a payload.
pub(super) fn external_variant(variants: Vec<(&str, Option<Value>)>) -> Value {
let branches: Vec<Value> = variants
.into_iter()
.map(|(name, payload)| match payload {
None => json!({"type": "string", "const": name}),
Some(inner) => object(vec![(name.to_owned(), inner)]),
})
.collect();
json!({ "anyOf": branches })
}
/// The provider's strict function-schema rules, enforced offline so the
/// catalog cannot be refused on the road one keyword at a time: every
/// node names a `type` or is an `anyOf` of nodes that do; every object is
/// closed and lists every property as required; `oneOf`, `allOf`, and
/// `not` never appear. The first live seeded run failed on exactly these.
#[cfg(test)]
pub(super) fn assert_strict(schema: &Value, at: &str) {
for forbidden in ["oneOf", "allOf", "not"] {
assert!(
schema.get(forbidden).is_none(),
"{at}: `{forbidden}` is not permitted in a strict schema"
);
}
if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) {
assert!(!branches.is_empty(), "{at}: empty anyOf");
for (index, branch) in branches.iter().enumerate() {
assert_strict(branch, &format!("{at}/anyOf/{index}"));
}
return;
}
let kind = schema
.get("type")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("{at}: schema must have a `type` key: {schema}"));
match kind {
"object" => {
assert_eq!(
schema.get("additionalProperties"),
Some(&Value::Bool(false)),
"{at}: object must be closed"
);
let properties = schema
.get("properties")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("{at}: object without properties"));
let mut names: Vec<&str> = properties.keys().map(String::as_str).collect();
names.sort_unstable();
let mut required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|list| list.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
required.sort_unstable();
assert_eq!(names, required, "{at}: every property must be required");
for (name, property) in properties {
assert_strict(property, &format!("{at}/{name}"));
}
}
"array" => {
let items = schema
.get("items")
.unwrap_or_else(|| panic!("{at}: array without items"));
assert_strict(items, &format!("{at}/items"));
}
"string" | "integer" | "number" | "boolean" | "null" => {}
other => panic!("{at}: unexpected type {other}"),
}
}