From 236ee39043dfa284cc62f74604ab11924dbd809b Mon Sep 17 00:00:00 2001 From: David Rauschenbach Date: Thu, 29 Oct 2026 10:31:18 -0700 Subject: [PATCH] Replace the proprietary "args" front-matter key with an MCP-compliant "inputSchema" key --- Cargo.lock | 1 + .../engine/src/execute/context-bound.rs | 8 +- .../src/execute/tests/suite/args_surface.rs | 16 +- .../execute/tests/suite/global_metatable.rs | 3 +- crates/runvil-internal/parser/Cargo.toml | 1 + crates/runvil-internal/parser/src/contract.rs | 25 +- .../parser/src/contract/args.rs | 278 ------------------ .../parser/src/contract/input_schema.rs | 174 +++++++++++ .../parser/src/contract/tests-input-schema.rs | 150 ++++++++++ .../parser/src/contract/tests.rs | 161 ++-------- .../runvil-internal/parser/src/frontmatter.rs | 26 +- crates/runvil-internal/parser/src/lib.rs | 4 +- crates/runvil/public-api.txt | 34 +-- crates/runvil/src/lib.rs | 4 - crates/runvil/tests/suite/prompt.rs | 12 +- .../server/src/routes/prompts-tests.rs | 45 ++- crates/workshop/server/src/routes/prompts.rs | 101 +++++-- crates/workshop/ui/src/parts/run/run-rows.ts | 3 + crates/workshop/ui/src/services/run-api.ts | 27 +- crates/workshop/ui/test/run-api.mjs | 9 +- 20 files changed, 519 insertions(+), 563 deletions(-) delete mode 100644 crates/runvil-internal/parser/src/contract/args.rs create mode 100644 crates/runvil-internal/parser/src/contract/input_schema.rs create mode 100644 crates/runvil-internal/parser/src/contract/tests-input-schema.rs diff --git a/Cargo.lock b/Cargo.lock index d2cce542c..c65a3f8b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5370,6 +5370,7 @@ dependencies = [ "runvil-lua", "runvil-types", "serde", + "serde_json", "serde_yaml_ng", "thiserror 2.0.19", ] diff --git a/crates/runvil-internal/engine/src/execute/context-bound.rs b/crates/runvil-internal/engine/src/execute/context-bound.rs index 1744e50e5..37b5dfc2f 100644 --- a/crates/runvil-internal/engine/src/execute/context-bound.rs +++ b/crates/runvil-internal/engine/src/execute/context-bound.rs @@ -82,13 +82,13 @@ pub(super) fn bound_model_set(prompt: &Prompt, bindings: &ModelBindings) -> Mode } /// Derives a section's `argv` from the args string under the prompt's -/// declaration: a default-declared prompt wraps the interface prose into -/// the default shape (`argv.prose`, with the empty string present, not -/// absent); a structured declaration parses the string as JSON, and a parse +/// `inputSchema`: a prompt without one wraps the interface prose into +/// `argv.prose` (with the empty string present, not absent); a prompt with +/// one parses the string as JSON, and a parse /// failure or a JSON `null` reads as nil (`if argv then` is the malformed /// check). The executor never hard-errors on shape. pub(super) fn derive_argv(prompt: &Prompt, args: &str) -> Option { - if prompt.frontmatter().args().is_default() { + if prompt.frontmatter().input_schema().is_none() { return Some(serde_json::json!({ "prose": args })); } match serde_json::from_str(args) { diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/args_surface.rs b/crates/runvil-internal/engine/src/execute/tests/suite/args_surface.rs index 8272dc0c4..ebb77e371 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/args_surface.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/args_surface.rs @@ -1,7 +1,7 @@ //! The args/argv surface: `args` is the exact passed string always; `argv` //! is the parsed JSON on success and nil otherwise (`if argv then` is the -//! malformed check); a default-declared prompt wraps interface prose into -//! `argv.prose`; `argv` is writable in H1 only and frozen when H1 completes, +//! malformed check); a prompt without an `inputSchema` wraps interface +//! prose into `argv.prose`; `argv` is writable in H1 only and frozen when H1 completes, //! so an H1 repair reaches every downstream section while an H2 assignment //! is an error; and `{{ argv }}` joins the prose substitution namespaces. @@ -9,9 +9,9 @@ use runvil_parser::test_support::{PromptSource, prompt}; use crate::test_support::scenario::Scenario; -/// One declared (required) `query` string. The declaration advertises and +/// An input schema with one `query` string. The schema advertises and /// documents; it never enforces - enforcement is the prompt's H1. -const QUERY: &str = "args: { query: { type: string } }"; +const QUERY: &str = "inputSchema: { type: object, properties: { query: { type: string } } }"; /// A prompt declaring [`QUERY`] whose one section `Only` runs `lua`. fn query_prompt(lua: &str) -> String { @@ -177,7 +177,7 @@ fn absent_is_not_the_empty_string() { // An optional field omitted is nil; passed empty it is "". The two are // distinguishable in Lua. let source = prompt( - "args: { prose: { type: string, optional: true } }", + "inputSchema: { type: object, properties: { prose: { type: string } } }", &[( "Only", " @@ -193,8 +193,8 @@ fn absent_is_not_the_empty_string() { } #[test] -fn a_default_declared_prompt_wraps_interface_prose() { - // No `args:` key: the default declaration wraps prose into argv.prose, +fn a_prompt_without_an_input_schema_wraps_interface_prose() { + // No `inputSchema:` key: the input text wraps into argv.prose, // and args still holds the exact passed string. let source = prompt( "", @@ -211,7 +211,7 @@ fn a_default_declared_prompt_wraps_interface_prose() { } #[test] -fn a_default_declared_prompt_wraps_empty_prose_as_present() { +fn a_prompt_without_an_input_schema_wraps_empty_prose_as_present() { // Interface prose is always present, so empty prose wraps as the // present empty string, distinguishable from an absent field. let source = prompt( diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/global_metatable.rs b/crates/runvil-internal/engine/src/execute/tests/suite/global_metatable.rs index 96cd3cfd6..367a3ed8a 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/global_metatable.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/global_metatable.rs @@ -10,7 +10,8 @@ use crate::test_support::scenario::Scenario; /// One declared `query` string, so `argv` is the parsed JSON or nil. fn query_source() -> PromptSource { - PromptSource::new().front("args: { query: { type: string } }") + PromptSource::new() + .front("inputSchema: { type: object, properties: { query: { type: string } } }") } #[test] diff --git a/crates/runvil-internal/parser/Cargo.toml b/crates/runvil-internal/parser/Cargo.toml index d50dabbf9..8483624ba 100644 --- a/crates/runvil-internal/parser/Cargo.toml +++ b/crates/runvil-internal/parser/Cargo.toml @@ -12,6 +12,7 @@ runvil-lua.workspace = true runvil-types.workspace = true pulldown-cmark.workspace = true serde.workspace = true +serde_json.workspace = true serde_yaml_ng.workspace = true thiserror.workspace = true diff --git a/crates/runvil-internal/parser/src/contract.rs b/crates/runvil-internal/parser/src/contract.rs index d12605d37..5346a2a84 100644 --- a/crates/runvil-internal/parser/src/contract.rs +++ b/crates/runvil-internal/parser/src/contract.rs @@ -1,16 +1,17 @@ -//! Parses the frontmatter contract keys: `uses`, `args`, and `models`. +//! Parses the frontmatter contract keys: `uses`, `inputSchema`, and +//! `models`. //! -//! The YAML is the whole contract: Packs install, models declare, args -//! type. Parsing validates the static shape - each `uses:` entry a bare -//! Pack id or vendor/name, no Pack id declared twice, the alias grammar -//! on map keys, the closed -//! model-keyword vocabulary, arg name and type sanity - and exposes the +//! The YAML is the whole contract: Packs install, models declare, the +//! input schema types. Parsing validates the static shape - each `uses:` +//! entry a bare Pack id or vendor/name, no Pack id declared twice, the +//! alias grammar on model labels, the closed model-keyword vocabulary, the +//! input schema's object shape - and exposes the //! FULL declaration on the parsed [`Prompt`](crate::Prompt); satisfying //! the declaration against the caller's environment is prepare's job, //! never the parser's. //! -//! `args` and `models` are defined in submodules; this root owns the -//! duplicate-Pack check and the map deserializer the two map keys share. +//! `inputSchema` and `models` are defined in submodules; this root owns +//! the duplicate-Pack check and the contract map deserializer. use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; @@ -21,19 +22,17 @@ use runvil_types::packs::{PackId, PackRef}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; -mod args; +pub(crate) mod input_schema; mod models; #[cfg(test)] mod tests; -pub use args::{ArgDecl, ArgType, ArgsDecl, ArgsDeclIter}; pub use models::{ModelKeyword, ModelRole, ModelRoles, ModelRolesIter}; /// The prompt-local alias grammar: `[A-Za-z][A-Za-z0-9_-]{0,63}`. /// -/// Model labels and args field names are prompt-local and never global -/// names. (The same rule sits in `runvil-lua`'s `alias` module, the +/// Model labels are prompt-local and never global names. (The same rule sits in `runvil-lua`'s `alias` module, the /// run-time counterpart to this parse-time check.) fn is_valid_alias(alias: &str) -> bool { let bytes = alias.as_bytes(); @@ -51,7 +50,7 @@ struct ContractKeys { what: &'static str, } -/// Deserializes a contract map (`models`, `args`): string keys +/// Deserializes a contract map (`models`): string keys /// validated against the alias grammar and `keys`, values deserialized as /// `T`, duplicates rejected. fn deserialize_contract_map<'de, D, T>( diff --git a/crates/runvil-internal/parser/src/contract/args.rs b/crates/runvil-internal/parser/src/contract/args.rs deleted file mode 100644 index c84b03d25..000000000 --- a/crates/runvil-internal/parser/src/contract/args.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Parses the `args` frontmatter key into typed arg declarations. -//! -//! The declaration advertises, documents, and derives the tool schema; it -//! does not enforce - enforcement belongs to the prompt's H1. There are no -//! freeform prompts: a prompt with no `args:` key gets the default -//! declaration of one optional string field named `prose`. - -use std::collections::{BTreeMap, btree_map}; - -use serde::Deserialize; - -use crate::contract::{self, ContractKeys}; - -/// The type of a declared prompt arg. -/// -/// The `type` key of an arg declaration accepts only `string`, `boolean`, -/// `integer`, and `number`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -#[non_exhaustive] -pub enum ArgType { - /// A string arg, declared `type: string`, whose default is a YAML string - /// such as `draft` or `'42'`. - String, - /// A boolean arg, declared `type: boolean`, whose default is the YAML - /// `true` or `false`. - Boolean, - /// An integer arg, declared `type: integer`, whose default is a YAML - /// integer such as `3` or `-1`. - Integer, - /// A numeric arg, declared `type: number`, whose default is any YAML - /// number, such as `3` or `1.5`. - Number, -} - -impl std::fmt::Display for ArgType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - ArgType::String => "string", - ArgType::Boolean => "boolean", - ArgType::Integer => "integer", - ArgType::Number => "number", - }) - } -} - -/// The declaration of one prompt arg: its type, whether it is optional, its -/// default, and its description. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct ArgDecl { - kind: ArgType, - optional: bool, - default: Option, - description: Option, -} - -impl ArgDecl { - /// Returns the declared type. - #[must_use] - pub fn kind(&self) -> ArgType { - self.kind - } - - /// Returns whether a call may omit this arg entirely. - /// - /// An omitted arg is absent from the call. An empty string is a supplied - /// value. - #[must_use] - pub fn is_optional(&self) -> bool { - self.optional - } - - /// Returns the declared default, when present. - #[must_use] - pub fn default(&self) -> Option<&serde_yaml_ng::Value> { - self.default.as_ref() - } - - /// Returns the human-readable description, when present. - #[must_use] - pub fn description(&self) -> Option<&str> { - self.description.as_deref() - } -} - -/// The map form of an arg declaration (`type` is a Rust keyword). -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct ArgDeclWire { - /// The declared type. - #[serde(rename = "type")] - kind: ArgType, - /// Whether a call may omit the field entirely. - #[serde(default)] - optional: bool, - /// The declared default value. - #[serde(default)] - default: Option, - /// The human-readable description. - #[serde(default)] - description: Option, -} - -/// Type sanity: a declared default must match the declared type. -fn default_matches(kind: ArgType, value: &serde_yaml_ng::Value) -> bool { - match (kind, value) { - (ArgType::String, serde_yaml_ng::Value::String(_)) - | (ArgType::Boolean, serde_yaml_ng::Value::Bool(_)) - | (ArgType::Number, serde_yaml_ng::Value::Number(_)) => true, - (ArgType::Integer, serde_yaml_ng::Value::Number(n)) => n.is_i64() || n.is_u64(), - _ => false, - } -} - -/// Names the YAML type of `value` in the vocabulary of [`ArgType`]. -fn yaml_type(value: &serde_yaml_ng::Value) -> &'static str { - match value { - serde_yaml_ng::Value::Null => "null", - serde_yaml_ng::Value::Bool(_) => "boolean", - serde_yaml_ng::Value::Number(n) if n.is_i64() || n.is_u64() => "integer", - serde_yaml_ng::Value::Number(_) => "number", - serde_yaml_ng::Value::String(_) => "string", - serde_yaml_ng::Value::Sequence(_) => "sequence", - serde_yaml_ng::Value::Mapping(_) => "mapping", - serde_yaml_ng::Value::Tagged(_) => "tagged value", - } -} - -impl<'de> Deserialize<'de> for ArgDecl { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - let wire = ArgDeclWire::deserialize(deserializer)?; - if let Some(default) = &wire.default - && !default_matches(wire.kind, default) - { - return Err(serde::de::Error::custom(format!( - "the default has type `{}`, which does not match the declared type `{}`", - yaml_type(default), - wire.kind - ))); - } - Ok(ArgDecl { - kind: wire.kind, - optional: wire.optional, - default: wire.default, - description: wire.description, - }) - } -} - -/// The args a prompt declares: a map from each arg name to its declaration. -/// -/// Every prompt has an args declaration. A prompt whose frontmatter omits -/// the `args` key gets the default declaration: one optional string arg -/// named `prose`. The input text of such a prompt arrives wrapped as -/// `argv = { prose = "" }`. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct ArgsDecl { - fields: BTreeMap, - /// True only when the declaration is the implicit default (the `args:` - /// key was absent). - /// - /// Interface prose then wraps into `argv.prose`. An explicit `args:` - /// key - even one identical to the default shape - is a structured - /// declaration and never wraps. - implicit: bool, -} - -impl ArgsDecl { - /// Returns whether this is the default declaration, which a prompt gets - /// when its frontmatter omits the `args` key. - /// - /// The input text of such a prompt arrives wrapped as `argv.prose`. This - /// returns `false` for every explicit `args` key, even one that declares - /// the same single `prose` arg. - #[must_use] - pub fn is_default(&self) -> bool { - self.implicit - } - - /// Returns the declaration of the arg named `name`, when present. - #[must_use] - pub fn get(&self, name: &str) -> Option<&ArgDecl> { - self.fields.get(name) - } - - /// Iterates the declared args as `(name, declaration)` pairs, in name order. - pub fn iter(&self) -> ArgsDeclIter<'_> { - ArgsDeclIter { - fields: self.fields.iter(), - } - } - - /// Returns the number of declared args. - #[must_use] - pub fn len(&self) -> usize { - self.fields.len() - } - - /// Returns whether zero args are declared. - #[must_use] - pub fn is_empty(&self) -> bool { - self.fields.is_empty() - } -} - -impl<'a> IntoIterator for &'a ArgsDecl { - type Item = (&'a str, &'a ArgDecl); - type IntoIter = ArgsDeclIter<'a>; - - fn into_iter(self) -> ArgsDeclIter<'a> { - self.iter() - } -} - -/// Yields the declared args of one [`ArgsDecl`] as `(name, declaration)` -/// pairs, in name order. -/// -/// [`ArgsDecl::iter`] returns it. -#[derive(Debug, Clone)] -#[must_use = "an iterator does nothing until it is consumed"] -pub struct ArgsDeclIter<'a> { - fields: btree_map::Iter<'a, String, ArgDecl>, -} - -impl<'a> Iterator for ArgsDeclIter<'a> { - type Item = (&'a str, &'a ArgDecl); - - fn next(&mut self) -> Option<(&'a str, &'a ArgDecl)> { - self.fields.next().map(|(name, decl)| (name.as_str(), decl)) - } - - fn size_hint(&self) -> (usize, Option) { - self.fields.size_hint() - } -} - -impl ExactSizeIterator for ArgsDeclIter<'_> {} - -impl Default for ArgsDecl { - /// The default declaration: one optional string field named `prose`. - fn default() -> Self { - let mut fields = BTreeMap::new(); - fields.insert( - "prose".to_owned(), - ArgDecl { - kind: ArgType::String, - optional: true, - default: None, - description: Some("Freeform input for this prompt".to_owned()), - }, - ); - ArgsDecl { - fields, - implicit: true, - } - } -} - -impl<'de> Deserialize<'de> for ArgsDecl { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - // Arg names are `argv` fields, never globals, so a reserved name - // such as the default declaration's `prose` is a valid arg name. - let fields = - contract::deserialize_contract_map(deserializer, ContractKeys { what: "arg name" })?; - Ok(ArgsDecl { - fields, - implicit: false, - }) - } -} diff --git a/crates/runvil-internal/parser/src/contract/input_schema.rs b/crates/runvil-internal/parser/src/contract/input_schema.rs new file mode 100644 index 000000000..1c5f53a1b --- /dev/null +++ b/crates/runvil-internal/parser/src/contract/input_schema.rs @@ -0,0 +1,174 @@ +//! Parses the `inputSchema` frontmatter key: the JSON Schema of a prompt's +//! input. +//! +//! The schema advertises, documents, and derives the tool schema; it does +//! not enforce - enforcement belongs to the prompt's H1. Parsing checks the +//! static shape alone: the value converts to JSON, its `type` is `object`, +//! a present `properties` maps each name to a schema, and a present +//! `required` lists distinct names that `properties` declares. An absent +//! or `null` key declares no +//! schema, and the input text then arrives wrapped as +//! `argv = { prose = "" }`. + +use serde::Deserialize; +use serde_json::{Map, Number, Value}; +use serde_yaml_ng::Value as Yaml; + +/// A prompt's input schema: a JSON Schema object whose `type` is `object`. +pub(crate) type InputSchema = Map; + +/// Deserializes the `inputSchema` key: `null` to `None`, and a mapping to +/// the checked schema. +pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let Some(yaml) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + to_json(yaml, "inputSchema") + .and_then(check) + .map(Some) + .map_err(serde::de::Error::custom) +} + +/// Converts YAML to JSON, refusing what JSON cannot hold: tags, non-string +/// keys, and non-finite numbers. `path` names the value in errors. +fn to_json(yaml: Yaml, path: &str) -> Result { + Ok(match yaml { + Yaml::Null => Value::Null, + Yaml::Bool(flag) => Value::Bool(flag), + Yaml::String(text) => Value::String(text), + Yaml::Number(number) => { + if let Some(int) = number.as_i64() { + Value::from(int) + } else if let Some(int) = number.as_u64() { + Value::from(int) + } else { + number + .as_f64() + .and_then(Number::from_f64) + .map(Value::Number) + .ok_or_else(|| format!("{path} is {number}; a JSON number must be finite"))? + } + } + Yaml::Sequence(items) => Value::Array( + items + .into_iter() + .enumerate() + .map(|(index, item)| to_json(item, &format!("{path}[{index}]"))) + .collect::>()?, + ), + Yaml::Mapping(entries) => { + let mut object = Map::new(); + for (key, value) in entries { + let Yaml::String(key) = key else { + return Err(format!( + "{path} has a key of type {}; a JSON object key must be a string, \ + so quote it", + yaml_type(&key) + )); + }; + let value = to_json(value, &format!("{path}.{key}"))?; + object.insert(key, value); + } + Value::Object(object) + } + Yaml::Tagged(tagged) => { + return Err(format!( + "{path} has the YAML tag {}; JSON has no tags", + tagged.tag + )); + } + }) +} + +/// Checks the static shape of a JSON Schema whose `type` is `object`. +fn check(value: Value) -> Result { + let Value::Object(schema) = value else { + return Err(format!( + "inputSchema must be null or a JSON Schema mapping with type: object, got {}", + json_type(&value) + )); + }; + match schema.get("type") { + Some(Value::String(kind)) if kind == "object" => {} + Some(other) => { + return Err(format!("inputSchema.type must be \"object\", got {other}")); + } + None => return Err("inputSchema.type must be \"object\", got none".to_owned()), + } + let properties = match schema.get("properties") { + None => None, + Some(Value::Object(properties)) => Some(properties), + Some(other) => { + return Err(format!( + "inputSchema.properties must be a mapping, got {}", + json_type(other) + )); + } + }; + if let Some(properties) = properties { + for (name, property) in properties { + if !matches!(property, Value::Object(_) | Value::Bool(_)) { + return Err(format!( + "inputSchema.properties.{name} must be a schema mapping or boolean, got {}", + json_type(property) + )); + } + } + } + if let Some(required) = schema.get("required") { + let Value::Array(names) = required else { + return Err(format!( + "inputSchema.required must be an array of strings, got {}", + json_type(required) + )); + }; + for (index, name) in names.iter().enumerate() { + let Value::String(name) = name else { + return Err(format!( + "inputSchema.required[{index}] must be a string, got {}", + json_type(name) + )); + }; + if names[..index].iter().any(|seen| seen == name) { + return Err(format!( + "inputSchema.required lists \"{name}\" more than once" + )); + } + if !properties.is_some_and(|properties| properties.contains_key(name)) { + return Err(format!( + "inputSchema.required lists \"{name}\", which inputSchema.properties \ + does not declare" + )); + } + } + } + Ok(schema) +} + +/// Names the JSON type of `value`. +fn json_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Names the YAML type of `value`. +fn yaml_type(value: &Yaml) -> &'static str { + match value { + Yaml::Null => "null", + Yaml::Bool(_) => "boolean", + Yaml::Number(_) => "number", + Yaml::String(_) => "string", + Yaml::Sequence(_) => "sequence", + Yaml::Mapping(_) => "mapping", + Yaml::Tagged(_) => "tagged value", + } +} diff --git a/crates/runvil-internal/parser/src/contract/tests-input-schema.rs b/crates/runvil-internal/parser/src/contract/tests-input-schema.rs new file mode 100644 index 000000000..ca404018c --- /dev/null +++ b/crates/runvil-internal/parser/src/contract/tests-input-schema.rs @@ -0,0 +1,150 @@ +//! Tests for the `inputSchema` key: an absent or null key, the accepted +//! object schemas, and the refused shapes and values. + +use super::*; + +#[test] +fn an_absent_or_null_input_schema_declares_none() { + for front in ["", "inputSchema: null", "inputSchema: ~"] { + let prompt = parse_ok(&prompt(front, &[])); + assert_eq!(prompt.frontmatter().input_schema(), None, "{front:?}"); + } +} + +#[test] +fn an_object_schema_parses_into_its_json_value() { + let prompt = parse_ok(&prompt( + "inputSchema: { type: object, properties: { query: { type: string }, \ + limit: { type: integer, default: 3, minimum: 1 }, tags: { type: array, \ + items: { type: string } }, any: true }, required: [query], \ + additionalProperties: false }", + &[], + )); + let schema = prompt.frontmatter().input_schema().expect("declared"); + assert_eq!( + serde_json::Value::Object(schema.clone()), + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer", "default": 3, "minimum": 1 }, + "tags": { "type": "array", "items": { "type": "string" } }, + "any": true, + }, + "required": ["query"], + "additionalProperties": false, + }) + ); +} + +#[test] +fn a_bare_object_schema_parses() { + let prompt = parse_ok(&prompt("inputSchema: { type: object }", &[])); + let schema = prompt.frontmatter().input_schema().expect("declared"); + assert_eq!(schema.len(), 1); + assert_eq!(schema["type"], "object"); +} + +#[test] +fn a_property_may_take_a_reserved_name_because_input_fields_are_argv_fields() { + let prompt = parse_ok(&prompt( + "inputSchema: { type: object, properties: { prose: {}, store: {}, end: {} } }", + &[], + )); + let schema = prompt.frontmatter().input_schema().expect("declared"); + assert_eq!( + schema["properties"].as_object().map(serde_json::Map::len), + Some(3) + ); +} + +#[test] +fn an_input_schema_that_is_not_an_object_schema_is_refused() { + for (front, message) in [ + ( + "inputSchema: [type, object]", + "inputSchema must be null or a JSON Schema mapping with type: object, got array", + ), + ( + "inputSchema: object", + "inputSchema must be null or a JSON Schema mapping with type: object, got string", + ), + ( + "inputSchema: { properties: {} }", + "inputSchema.type must be \"object\", got none", + ), + ( + "inputSchema: { type: string }", + "inputSchema.type must be \"object\", got \"string\"", + ), + ( + "inputSchema: { type: [object, 'null'] }", + "inputSchema.type must be \"object\", got [\"object\",\"null\"]", + ), + ] { + assert_parse_error(&prompt(front, &[]), ParseErrorKind::Frontmatter, message); + } +} + +#[test] +fn malformed_properties_and_required_are_refused() { + for (front, message) in [ + ( + "inputSchema: { type: object, properties: [query] }", + "inputSchema.properties must be a mapping, got array", + ), + ( + "inputSchema: { type: object, properties: { query: string } }", + "inputSchema.properties.query must be a schema mapping or boolean, got string", + ), + ( + "inputSchema: { type: object, required: query }", + "inputSchema.required must be an array of strings, got string", + ), + ( + "inputSchema: { type: object, properties: { query: {} }, required: [query, 1] }", + "inputSchema.required[1] must be a string, got number", + ), + ( + "inputSchema: { type: object, properties: { query: {} }, required: [query, query] }", + "inputSchema.required lists \"query\" more than once", + ), + ( + "inputSchema: { type: object, properties: { query: {} }, required: [query, limit] }", + "inputSchema.required lists \"limit\", which inputSchema.properties does not declare", + ), + ( + "inputSchema: { type: object, required: [query] }", + "inputSchema.required lists \"query\", which inputSchema.properties does not declare", + ), + ] { + assert_parse_error(&prompt(front, &[]), ParseErrorKind::Frontmatter, message); + } +} + +#[test] +fn yaml_that_json_cannot_hold_is_refused() { + for (front, message) in [ + ( + "inputSchema: { type: object, 1: x }", + "inputSchema has a key of type number; a JSON object key must be a string", + ), + ( + "inputSchema: { type: object, properties: { n: { maximum: .inf } } }", + "inputSchema.properties.n.maximum is .inf; a JSON number must be finite", + ), + ( + "inputSchema: { type: object, examples: [!thing x] }", + "inputSchema.examples[0] has the YAML tag !thing; JSON has no tags", + ), + ] { + assert_parse_error(&prompt(front, &[]), ParseErrorKind::Frontmatter, message); + } +} + +#[test] +fn the_retired_args_key_is_an_unknown_key() { + let error = parse_err(&prompt("args: { query: { type: string } }", &[])); + assert_eq!(error.kind(), ParseErrorKind::Frontmatter); + assert!(error.to_string().contains("args"), "{error}"); +} diff --git a/crates/runvil-internal/parser/src/contract/tests.rs b/crates/runvil-internal/parser/src/contract/tests.rs index 09b878d3f..68d51e85f 100644 --- a/crates/runvil-internal/parser/src/contract/tests.rs +++ b/crates/runvil-internal/parser/src/contract/tests.rs @@ -1,12 +1,16 @@ -//! Tests for the frontmatter contract keys: `uses`, `args`, and +//! Tests for the frontmatter contract keys: `uses`, `inputSchema`, and //! `models`. use std::num::NonZeroU32; -use super::{ArgType, ArgsDeclIter, ModelKeyword, ModelRolesIter}; +use serde_json::json; + +use super::{ModelKeyword, ModelRolesIter}; use crate::ParseErrorKind; use crate::test_support::{assert_parse_error, parse_err, parse_ok, prompt}; +#[path = "tests-input-schema.rs"] +mod input_schema; #[path = "tests-packs.rs"] mod packs; @@ -15,8 +19,8 @@ fn the_full_contract_declaration_parses_and_round_trips() { let prompt = parse_ok(&prompt( " uses: [web, mcp] - args: { use_mcp: { type: boolean, default: true, \ - description: Search MCP-connected private sources } } + inputSchema: { type: object, properties: { use_mcp: { type: boolean, default: true, \ + description: Search MCP-connected private sources } } } models: { analyst: { keywords: [frontier, thinking], min_context: 200000, \ description: deep reasoning }, \ triage: { keywords: [fast, small], description: quick triage of search results } } @@ -31,13 +35,14 @@ fn the_full_contract_declaration_parses_and_round_trips() { assert_eq!(packs[0].id().to_string(), "web"); assert_eq!(packs[1].id().to_string(), "mcp"); - let arg = fm.args().get("use_mcp").expect("the arg is declared"); - assert_eq!(arg.kind(), ArgType::Boolean); - assert!(!arg.is_optional()); - assert_eq!(arg.default(), Some(&serde_yaml_ng::Value::Bool(true))); + let schema = fm.input_schema().expect("the schema is declared"); assert_eq!( - arg.description(), - Some("Search MCP-connected private sources") + schema["properties"]["use_mcp"], + json!({ + "type": "boolean", + "default": true, + "description": "Search MCP-connected private sources", + }) ); let analyst = fm.models().get("analyst").expect("the role is declared"); @@ -63,28 +68,12 @@ fn a_prompt_without_contract_keys_behaves_exactly_as_today() { assert!(fm.models().is_empty()); } -#[test] -fn an_omitted_args_key_yields_the_default_prose_declaration() { - // There are no freeform prompts: an absent `args:` key is the default - // declaration of one optional string field named `prose`. - let prompt = parse_ok(&prompt("", &[])); - let args = prompt.frontmatter().args(); - assert_eq!(args.len(), 1); - let prose = args - .get("prose") - .expect("the default declaration names `prose`"); - assert_eq!(prose.kind(), ArgType::String); - assert!(prose.is_optional()); - assert_eq!(prose.default(), None); - assert_eq!(prose.description(), Some("Freeform input for this prompt")); -} - #[test] fn an_unknown_key_inside_a_contract_entry_is_rejected() { // `deny_unknown_fields` must hold inside each new key's entries too: a // typo'd field is an authoring error, not silently ignored. for front in [ - "args: { flag: { tipe: boolean } }", + "argz: { flag: { type: boolean } }", "models: { analyst: { keyword: [fast] } }", ] { let error = parse_err(&prompt(front, &[])); @@ -103,106 +92,6 @@ fn every_reserved_name_the_grammar_allows_parses_as_a_model_role_label() { } } -#[test] -fn an_arg_name_may_be_a_reserved_name_because_args_are_argv_fields() { - let front = - "args: { prose: { type: string }, store: { type: string }, end: { type: boolean } }"; - let prompt = parse_ok(&prompt(front, &[])); - let args = prompt.frontmatter().args(); - assert_eq!(args.len(), 3); - assert!(args.get("store").is_some() && args.get("end").is_some()); -} - -#[test] -fn args_declarations_round_trip() { - let prompt = parse_ok(&prompt( - "args: { use_mcp: { type: boolean, default: true, description: Search private sources }, \ - limit: { type: integer, optional: true }, query: { type: string } }", - &[], - )); - let args = prompt.frontmatter().args(); - assert_eq!(args.len(), 3); - let limit = args.get("limit").expect("declared"); - assert_eq!(limit.kind(), ArgType::Integer); - assert!(limit.is_optional()); - assert_eq!(limit.description(), None); - assert_eq!(args.get("query").expect("declared").kind(), ArgType::String); -} - -#[test] -fn an_arg_default_must_match_the_declared_type() { - for (kind, default, actual) in [ - ("boolean", "'true'", "string"), - ("integer", "1.5", "number"), - ("string", "42", "integer"), - ("number", "[1]", "sequence"), - ] { - let front = format!("args: {{ flag: {{ type: {kind}, default: {default} }} }}"); - assert_parse_error( - &prompt(&front, &[]), - ParseErrorKind::Frontmatter, - &format!( - "the default has type `{actual}`, which does not match the declared type `{kind}`" - ), - ); - } - // A matching default passes. - let prompt = parse_ok(&prompt( - "args: { limit: { type: integer, default: 3 } }", - &[], - )); - assert_eq!( - prompt - .frontmatter() - .args() - .get("limit") - .and_then(|a| a.default().cloned()), - Some(serde_yaml_ng::Value::Number(3.into())) - ); -} - -#[test] -fn an_arg_name_must_match_the_alias_grammar() { - let error = parse_err(&prompt("args: { '1bad': { type: string } }", &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); -} - -#[test] -fn an_absent_args_key_marks_the_default_declaration() { - // No `args:` key: the implicit default declaration, whose interface - // prose wraps into `argv.prose`. - assert!( - parse_ok(&prompt("", &[])).frontmatter().args().is_default(), - "an absent args key yields the default declaration" - ); - - // An explicit `args:` key is a structured declaration, which never - // wraps. - let explicit = parse_ok(&prompt("args: { query: { type: string } }", &[])); - assert!(!explicit.frontmatter().args().is_default()); - - // Even an explicit declaration identical to the default shape is - // structured: declared is not defaulted. - let shaped = parse_ok(&prompt( - "args: { prose: { type: string, optional: true, \ - description: Freeform input for this prompt } }", - &[], - )); - let args = shaped.frontmatter().args(); - assert!(!args.is_default()); - assert_ne!( - *args, - super::ArgsDecl::default(), - "an explicit declaration never equals the implicit default" - ); -} - -#[test] -fn an_unknown_arg_type_is_rejected() { - let error = parse_err(&prompt("args: { flag: { type: text } }", &[])); - assert_eq!(error.kind(), ParseErrorKind::Frontmatter); -} - #[test] fn model_roles_round_trip_with_defaults() { let prompt = parse_ok(&prompt( @@ -229,24 +118,10 @@ fn model_roles_round_trip_with_defaults() { } #[test] -fn args_and_model_roles_iterate_in_name_order_through_named_iterators() { - let prompt = parse_ok(&prompt( - "args: { query: { type: string }, limit: { type: integer } } - models: { triage: {}, analyst: {} }", - &[], - )); +fn model_roles_iterate_in_name_order_through_a_named_iterator() { + let prompt = parse_ok(&prompt("models: { triage: {}, analyst: {} }", &[])); let fm = prompt.frontmatter(); - let args: ArgsDeclIter<'_> = fm.args().iter(); - assert_eq!(args.len(), 2); - let names: Vec<&str> = args.map(|(name, _)| name).collect(); - assert_eq!(names, ["limit", "query"]); - let mut kinds = Vec::new(); - for (_, decl) in fm.args() { - kinds.push(decl.kind()); - } - assert_eq!(kinds, [ArgType::Integer, ArgType::String]); - let roles: ModelRolesIter<'_> = fm.models().iter(); assert_eq!(roles.len(), 2); let labels: Vec<&str> = roles.map(|(label, _)| label).collect(); diff --git a/crates/runvil-internal/parser/src/frontmatter.rs b/crates/runvil-internal/parser/src/frontmatter.rs index 4aedd9de4..24cfaf349 100644 --- a/crates/runvil-internal/parser/src/frontmatter.rs +++ b/crates/runvil-internal/parser/src/frontmatter.rs @@ -4,7 +4,8 @@ use std::num::NonZeroU32; use runvil_types::packs::PackRef; -use crate::{ArgsDecl, Error, ModelRoles, ParseErrorKind, Result}; +use crate::contract::input_schema::{self, InputSchema}; +use crate::{Error, ModelRoles, ParseErrorKind, Result}; /// A declared input or output file in a prompt's frontmatter. /// @@ -71,10 +72,14 @@ pub struct Frontmatter { /// section. #[serde(default)] uses: Vec, - /// The typed args declaration; an absent `args:` key yields the default - /// declaration (one optional string field named `prose`). - #[serde(default)] - args: ArgsDecl, + /// The JSON Schema of the prompt's input; an absent or `null` key + /// declares none, and the input text wraps into `argv.prose`. + #[serde( + default, + rename = "inputSchema", + deserialize_with = "input_schema::deserialize" + )] + input_schema: Option, /// Declared model roles: label to keywords, minimum, and description. #[serde(default)] models: ModelRoles, @@ -199,13 +204,14 @@ impl Frontmatter { &self.uses } - /// Returns the typed args declaration. + /// Returns the JSON Schema the `inputSchema:` key declares for the + /// prompt's input, an object whose `type` is `"object"`. /// - /// A prompt that omits the `args:` key yields the default declaration - /// (one optional string field named `prose`). + /// Returns `None` when the key is absent or `null`; the input text then + /// arrives wrapped as `argv = { prose = "" }`. #[must_use] - pub fn args(&self) -> &ArgsDecl { - &self.args + pub fn input_schema(&self) -> Option<&serde_json::Map> { + self.input_schema.as_ref() } /// Returns the declared model roles, which map each label to a role. diff --git a/crates/runvil-internal/parser/src/lib.rs b/crates/runvil-internal/parser/src/lib.rs index 4e958bad3..46fadd5f5 100644 --- a/crates/runvil-internal/parser/src/lib.rs +++ b/crates/runvil-internal/parser/src/lib.rs @@ -39,9 +39,7 @@ mod sections; pub mod test_support; pub use body_text::BodyText; -pub use contract::{ - ArgDecl, ArgType, ArgsDecl, ArgsDeclIter, ModelKeyword, ModelRole, ModelRoles, ModelRolesIter, -}; +pub use contract::{ModelKeyword, ModelRole, ModelRoles, ModelRolesIter}; use error::Result; pub use error::{Error, ParseError, ParseErrorKind}; #[cfg(test)] diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 438bb4f8f..f53a2cdd6 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -16,7 +16,6 @@ #[non_exhaustive] pub enum runvil::model::TemperatureError #[non_exhaustive] pub enum runvil::model::ThinkingMode #[non_exhaustive] pub enum runvil::packs::PackIdErrorKind -#[non_exhaustive] pub enum runvil::prompt::ArgType #[non_exhaustive] pub enum runvil::prompt::ModelKeyword #[non_exhaustive] pub enum runvil::tools::OutputTrust #[non_exhaustive] pub enum runvil::tools::ToolCatalogError @@ -77,8 +76,6 @@ #[non_exhaustive] pub struct runvil::packs::GlobalNameError { /* private fields */ } #[non_exhaustive] pub struct runvil::packs::PackId(/* private fields */) #[non_exhaustive] pub struct runvil::packs::PackIdError { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::ArgDecl { /* private fields */ } -#[non_exhaustive] pub struct runvil::prompt::ArgsDecl { /* private fields */ } #[non_exhaustive] pub struct runvil::prompt::FileDecl { /* private fields */ } #[non_exhaustive] pub struct runvil::prompt::Frontmatter { /* private fields */ } #[non_exhaustive] pub struct runvil::prompt::ModelRole { /* private fields */ } @@ -172,10 +169,6 @@ auto runvil::packs::PackIdError: Freeze, RefUnwindSafe, Send, Sync, Unpin, Unwin auto runvil::packs::PackIdErrorKind: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::packs::PackRef: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::packs::Prelude: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::prompt::ArgDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::prompt::ArgType: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::prompt::ArgsDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe -auto runvil::prompt::ArgsDeclIter: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::prompt::FileDecl: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::prompt::Frontmatter: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::prompt::ModelKeyword: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -299,10 +292,6 @@ derives runvil::packs::PackIdError: Clone, Debug, Eq, PartialEq derives runvil::packs::PackIdErrorKind: Clone, Copy, Debug, Eq, PartialEq derives runvil::packs::PackRef: Clone, Debug, Deserialize, Eq, PartialEq derives runvil::packs::Prelude: Clone, Debug, Eq, PartialEq -derives runvil::prompt::ArgDecl: Clone, Debug, Deserialize, Eq, PartialEq -derives runvil::prompt::ArgType: Clone, Copy, Debug, Deserialize, Eq, PartialEq -derives runvil::prompt::ArgsDecl: Clone, Debug, Default, Deserialize, Eq, PartialEq -derives runvil::prompt::ArgsDeclIter: Clone, Debug derives runvil::prompt::FileDecl: Clone, Debug, Deserialize, Eq, PartialEq derives runvil::prompt::Frontmatter: Clone, Debug, Deserialize, Eq, PartialEq derives runvil::prompt::ModelKeyword: Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd @@ -385,7 +374,6 @@ impl core::fmt::Display for runvil::packs::GlobalName impl core::fmt::Display for runvil::packs::GlobalNameError impl core::fmt::Display for runvil::packs::PackId impl core::fmt::Display for runvil::packs::PackIdError -impl core::fmt::Display for runvil::prompt::ArgType impl core::fmt::Display for runvil::timestamp::Timestamp impl core::fmt::Display for runvil::tools::ToolCatalogError impl core::fmt::Display for runvil::tools::ToolError @@ -398,7 +386,6 @@ impl core::fmt::Display for runvil::vfs::VfsPath impl core::fmt::Display for runvil::vfs::VfsPathBuf impl core::future::future::Future for runvil::cancel::Cancelled impl core::future::future::Future for runvil::cancel::Cancelled { type Output = () } -impl core::iter::traits::exact_size::ExactSizeIterator for runvil::prompt::ArgsDeclIter<'_> impl core::iter::traits::exact_size::ExactSizeIterator for runvil::prompt::ModelRolesIter<'_> impl core::ops::bit::BitOr for runvil::replay::Flags impl core::ops::bit::BitOr for runvil::replay::Flags { type Output = runvil::replay::Flags } @@ -423,16 +410,11 @@ impl runvil::vfs::Policy for runvil::vfs::ModePolicy impl runvil::vfs::Vfs for runvil::vfs::MemoryBackend impl runvil::vfs::Vfs for runvil::vfs::RealBackend impl runvil::vfs::Vfs for runvil::vfs::VfsRef -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ArgsDecl -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ArgsDecl { type IntoIter = runvil::prompt::ArgsDeclIter<'a> } -impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ArgsDecl { type Item = (&'a str, &'a runvil::prompt::ArgDecl) } impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ModelRoles impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ModelRoles { type IntoIter = runvil::prompt::ModelRolesIter<'a> } impl<'a> core::iter::traits::collect::IntoIterator for &'a runvil::prompt::ModelRoles { type Item = (&'a str, &'a runvil::prompt::ModelRole) } impl<'a> core::iter::traits::iterator::Iterator for runvil::model::ArgumentNames<'a> impl<'a> core::iter::traits::iterator::Iterator for runvil::model::ArgumentNames<'a> { type Item = &'a str } -impl<'a> core::iter::traits::iterator::Iterator for runvil::prompt::ArgsDeclIter<'a> -impl<'a> core::iter::traits::iterator::Iterator for runvil::prompt::ArgsDeclIter<'a> { type Item = (&'a str, &'a runvil::prompt::ArgDecl) } impl<'a> core::iter::traits::iterator::Iterator for runvil::prompt::ModelRolesIter<'a> impl<'a> core::iter::traits::iterator::Iterator for runvil::prompt::ModelRolesIter<'a> { type Item = (&'a str, &'a runvil::prompt::ModelRole) } impl<'a> runvil::vfs::OpEvent<'a> @@ -662,20 +644,11 @@ pub fn runvil::packs::PackRef::vendor(&self) -> &str pub fn runvil::packs::Prelude::new(pack: runvil::packs::PackId, source: impl core::convert::Into) -> runvil::packs::Prelude pub fn runvil::packs::Prelude::pack(&self) -> &runvil::packs::PackId pub fn runvil::packs::Prelude::source(&self) -> &str -pub fn runvil::prompt::ArgDecl::default(&self) -> core::option::Option<&serde_yaml_ng::value::Value> -pub fn runvil::prompt::ArgDecl::description(&self) -> core::option::Option<&str> -pub fn runvil::prompt::ArgDecl::is_optional(&self) -> bool -pub fn runvil::prompt::ArgDecl::kind(&self) -> runvil::prompt::ArgType -pub fn runvil::prompt::ArgsDecl::get(&self, name: &str) -> core::option::Option<&runvil::prompt::ArgDecl> -pub fn runvil::prompt::ArgsDecl::is_default(&self) -> bool -pub fn runvil::prompt::ArgsDecl::is_empty(&self) -> bool -pub fn runvil::prompt::ArgsDecl::iter(&self) -> runvil::prompt::ArgsDeclIter<'_> -pub fn runvil::prompt::ArgsDecl::len(&self) -> usize pub fn runvil::prompt::FileDecl::description(&self) -> &str pub fn runvil::prompt::FileDecl::path(&self) -> &str -pub fn runvil::prompt::Frontmatter::args(&self) -> &runvil::prompt::ArgsDecl pub fn runvil::prompt::Frontmatter::description(&self) -> &str pub fn runvil::prompt::Frontmatter::input(&self) -> core::option::Option<&runvil::prompt::FileDecl> +pub fn runvil::prompt::Frontmatter::input_schema(&self) -> core::option::Option<&serde_json::map::Map> pub fn runvil::prompt::Frontmatter::max_tool_iterations(&self) -> core::option::Option pub fn runvil::prompt::Frontmatter::models(&self) -> &runvil::prompt::ModelRoles pub fn runvil::prompt::Frontmatter::name(&self) -> &str @@ -1326,10 +1299,6 @@ pub runvil::packs::PackIdErrorKind::Charset pub runvil::packs::PackIdErrorKind::Control pub runvil::packs::PackIdErrorKind::Empty pub runvil::packs::PackIdErrorKind::SegmentCount -pub runvil::prompt::ArgType::Boolean -pub runvil::prompt::ArgType::Integer -pub runvil::prompt::ArgType::Number -pub runvil::prompt::ArgType::String pub runvil::prompt::ModelKeyword::Chat pub runvil::prompt::ModelKeyword::Creative pub runvil::prompt::ModelKeyword::Fast @@ -1512,7 +1481,6 @@ pub struct runvil::model::Temperature(/* private fields */) pub struct runvil::packs::GlobalName { /* private fields */ } pub struct runvil::packs::PackRef { /* private fields */ } pub struct runvil::packs::Prelude { /* private fields */ } -pub struct runvil::prompt::ArgsDeclIter<'a> { /* private fields */ } pub struct runvil::prompt::ModelRolesIter<'a> { /* private fields */ } pub struct runvil::replay::Flags(/* private fields */) pub struct runvil::timestamp::Timestamp(/* private fields */) diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index 19acb1fa0..b23054668 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -133,10 +133,6 @@ pub mod prompt { //! What a prompt's frontmatter declares: its files, Packs, //! arguments, and model roles. - pub use runvil_parser::ArgDecl; - pub use runvil_parser::ArgType; - pub use runvil_parser::ArgsDecl; - pub use runvil_parser::ArgsDeclIter; pub use runvil_parser::FileDecl; pub use runvil_parser::Frontmatter; pub use runvil_parser::ModelKeyword; diff --git a/crates/runvil/tests/suite/prompt.rs b/crates/runvil/tests/suite/prompt.rs index 5855798ca..191b5219e 100644 --- a/crates/runvil/tests/suite/prompt.rs +++ b/crates/runvil/tests/suite/prompt.rs @@ -10,7 +10,8 @@ const CONTRACT: &str = " input: { path: names.md, description: The names to greet } output: { path: note.md, description: The note the greeter leaves } uses: [text] - args: { times: { type: integer, optional: true, default: 1 }, name: { type: string } } + inputSchema: { type: object, properties: { times: { type: integer, default: 1 }, \ + name: { type: string } }, required: [name] } models: { writer: { keywords: [fast] } } "; @@ -53,12 +54,11 @@ fn the_frontmatter_lists_every_declaration_with_arguments_sorted_by_name() -> Te .collect(); assert_eq!(roles, [("writer".to_owned(), None)]); + let schema = frontmatter.input_schema().expect("the schema is declared"); assert!( - frontmatter - .args() - .iter() - .map(|(name, _)| name) - .eq(["name", "times"]) + schema["properties"] + .as_object() + .is_some_and(|properties| properties.keys().eq(["name", "times"])) ); let misspelled = source.replace("uses:", "use:"); diff --git a/crates/workshop/server/src/routes/prompts-tests.rs b/crates/workshop/server/src/routes/prompts-tests.rs index 13fbb0167..7bfcd91fc 100644 --- a/crates/workshop/server/src/routes/prompts-tests.rs +++ b/crates/workshop/server/src/routes/prompts-tests.rs @@ -44,14 +44,19 @@ uses: - web - fs - acme/poop -args: - topic: - type: string - description: the topic - count: - type: integer - optional: true - default: 3 +inputSchema: + type: object + properties: + topic: + type: string + description: the topic + count: + type: integer + default: 3 + tags: + type: array + items: { type: string } + required: [topic] models: writer: keywords: [frontier, thinking] @@ -99,6 +104,7 @@ async fn a_full_frontmatter_prompt_answers_every_contract_section() { "implicit": false, "fields": [ { "name": "count", "type": "integer", "optional": true, "default": 3, "description": null }, + { "name": "tags", "type": "json", "optional": true, "default": null, "description": null }, { "name": "topic", "type": "string", "optional": false, "default": null, "description": "the topic" }, ], }) @@ -126,8 +132,8 @@ async fn a_broken_yaml_key_answers_parse_frontmatter_with_the_line() { } #[tokio::test] -async fn a_prompt_without_args_yields_the_implicit_prose_field() { - let text = "---\nname: plain\ndescription: no args\n---\n\n# Plain\n\n## S\n\np\n"; +async fn a_prompt_without_an_input_schema_yields_the_implicit_prose_field() { + let text = "---\nname: plain\ndescription: no schema\n---\n\n# Plain\n\n## S\n\np\n"; let body = serde_json::json!({ "name": "plain", "text": text }); let (status, json) = post_contract(body).await; assert_eq!(status, StatusCode::OK); @@ -142,6 +148,25 @@ async fn a_prompt_without_args_yields_the_implicit_prose_field() { ); } +#[tokio::test] +async fn a_null_input_schema_is_implicit_and_a_bare_object_schema_has_no_fields() { + for (front, implicit, fields) in [ + ("inputSchema: null", true, 1), + ("inputSchema: { type: object }", false, 0), + ] { + let text = format!("---\nname: s\ndescription: d\n{front}\n---\n\n# S\n\n## S\n\np\n"); + let body = serde_json::json!({ "name": "s", "text": text }); + let (status, json) = post_contract(body).await; + assert_eq!(status, StatusCode::OK, "{front}"); + assert_eq!(json["args"]["implicit"], implicit, "{front}"); + assert_eq!( + json["args"]["fields"].as_array().map(Vec::len), + Some(fields), + "{front}" + ); + } +} + #[tokio::test] async fn a_lua_error_answers_parse_lua() { let text = diff --git a/crates/workshop/server/src/routes/prompts.rs b/crates/workshop/server/src/routes/prompts.rs index d9a633552..826751945 100644 --- a/crates/workshop/server/src/routes/prompts.rs +++ b/crates/workshop/server/src/routes/prompts.rs @@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize}; use runvil::Prompt; use runvil::packs::PackRef; -use runvil::prompt::{ArgDecl, ArgsDecl, FileDecl, Frontmatter, ModelKeyword, ModelRole}; +use runvil::prompt::{FileDecl, Frontmatter, ModelKeyword, ModelRole}; +use serde_json::{Map, Value}; use crate::error::AppError; @@ -53,7 +54,7 @@ struct ContractResponse { output: Option, /// The declared Packs, in declaration order. uses: Vec, - /// The typed args declaration. + /// The input fields, derived from the `inputSchema:` key. args: ArgsDto, /// The declared model roles, sorted by label. models: Vec, @@ -92,56 +93,94 @@ impl From<&PackRef> for PackDto { } } -/// The typed args declaration. +/// The input fields the Run window renders, derived from the prompt's +/// input schema. #[derive(Debug, Serialize)] struct ArgsDto { - /// True when the declaration is the implicit default (no `args:` key). + /// True when the prompt has no input schema, so its input is the + /// single prose field. implicit: bool, - /// The declared fields, sorted by name. + /// The fields, sorted by name. fields: Vec, } -impl From<&ArgsDecl> for ArgsDto { - fn from(decl: &ArgsDecl) -> Self { +impl ArgsDto { + /// Derives the fields from `schema`: one per top-level property, or + /// the single optional `prose` field when there is no schema. + fn new(schema: Option<&Map>) -> Self { + let Some(schema) = schema else { + return Self { + implicit: true, + fields: vec![ArgDto { + name: "prose".to_owned(), + kind: "string", + optional: true, + default: None, + description: Some("Freeform input for this prompt".to_owned()), + }], + }; + }; + let required: Vec<&str> = schema + .get("required") + .and_then(Value::as_array) + .map(|names| names.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let fields = schema + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .map(|(name, property)| { + ArgDto::new(name, property, required.contains(&name.as_str())) + }) + .collect() + }) + .unwrap_or_default(); Self { - implicit: decl.is_default(), - fields: decl - .iter() - .map(|(name, arg)| ArgDto::new(name, arg)) - .collect(), + implicit: false, + fields, } } } -/// One declared arg. +/// One input field. #[derive(Debug, Serialize)] struct ArgDto { - /// The arg name. + /// The property name. name: String, - /// The declared type (`string`, `boolean`, `integer`, `number`). + /// The property's scalar type (`string`, `boolean`, `integer`, + /// `number`), or `json` for any other schema. #[serde(rename = "type")] - kind: String, - /// Whether a call may omit the field. + kind: &'static str, + /// Whether a call may omit the field, which holds when the schema's + /// `required` does not list it. optional: bool, - /// The declared default; `null` when absent. - default: Option, - /// The human-readable description; `null` when absent. + /// The property's `default`; `null` when absent. + default: Option, + /// The property's `description`; `null` when absent. description: Option, } impl ArgDto { - /// Builds the DTO for the arg declared under `name`. - fn new(name: &str, decl: &ArgDecl) -> Self { + /// Builds the field for the property `name` with the schema `property`. + fn new(name: &str, property: &Value, required: bool) -> Self { + let kind = match property.get("type").and_then(Value::as_str) { + Some("string") => "string", + Some("boolean") => "boolean", + Some("integer") => "integer", + Some("number") => "number", + _ => "json", + }; Self { name: name.to_owned(), - kind: decl.kind().to_string(), - optional: decl.is_optional(), - // A declared default is validated against the declared type - // at parse, so it is always a JSON-representable scalar. - default: decl - .default() - .and_then(|value| serde_json::to_value(value).ok()), - description: decl.description().map(str::to_owned), + kind, + optional: !required, + default: property.get("default").cloned(), + description: property + .get("description") + .and_then(Value::as_str) + .map(str::to_owned), } } } @@ -198,7 +237,7 @@ impl From<&Frontmatter> for ContractResponse { input: frontmatter.input().map(FileDto::from), output: frontmatter.output().map(FileDto::from), uses: frontmatter.uses().iter().map(PackDto::from).collect(), - args: ArgsDto::from(frontmatter.args()), + args: ArgsDto::new(frontmatter.input_schema()), models: frontmatter .models() .iter() diff --git a/crates/workshop/ui/src/parts/run/run-rows.ts b/crates/workshop/ui/src/parts/run/run-rows.ts index eba98aeb7..788bf40ee 100644 --- a/crates/workshop/ui/src/parts/run/run-rows.ts +++ b/crates/workshop/ui/src/parts/run/run-rows.ts @@ -126,6 +126,9 @@ function argControl(arg: RunContractArg): HTMLElement { } return field; } + if (arg.type === "json") { + return textField(arg.default === null ? "" : JSON.stringify(arg.default)); + } return textField(typeof arg.default === "string" ? arg.default : ""); } diff --git a/crates/workshop/ui/src/services/run-api.ts b/crates/workshop/ui/src/services/run-api.ts index 3dad76326..7fb259ec2 100644 --- a/crates/workshop/ui/src/services/run-api.ts +++ b/crates/workshop/ui/src/services/run-api.ts @@ -22,21 +22,24 @@ export interface RunContractPack { readonly id: string; } -/** The declared arg types the wire format allows. */ -export type RunContractArgType = "string" | "boolean" | "integer" | "number"; +/** + * The input field types the wire format allows: a schema property's scalar + * type, or `json` for any other property schema. + */ +export type RunContractArgType = "string" | "boolean" | "integer" | "number" | "json"; -/** One declared arg. */ +/** One input field, derived from one input schema property. */ export interface RunContractArg { readonly name: string; readonly type: RunContractArgType; readonly optional: boolean; - /** The declared default; null when absent. */ - readonly default: string | number | boolean | null; + /** The property's default, any JSON value; null when absent. */ + readonly default: unknown; /** The human-readable description; null when absent. */ readonly description: string | null; } -/** The typed args declaration; implicit names the single prose field. */ +/** The input fields; implicit names the single prose field of a prompt without an input schema. */ export interface RunContractArgs { readonly implicit: boolean; readonly fields: readonly RunContractArg[]; @@ -88,7 +91,7 @@ function parsePack(value: unknown): RunContractPack | null { return { id }; } -const ARG_TYPES: readonly string[] = ["string", "boolean", "integer", "number"]; +const ARG_TYPES: readonly string[] = ["string", "boolean", "integer", "number", "json"]; function parseArg(value: unknown): RunContractArg | null { if (!isRecord(value)) { @@ -101,15 +104,7 @@ function parseArg(value: unknown): RunContractArg | null { if (typeof optional !== "boolean") { return null; } - const defaultValue = value.default; - if ( - defaultValue !== null && - typeof defaultValue !== "string" && - typeof defaultValue !== "number" && - typeof defaultValue !== "boolean" - ) { - return null; - } + const defaultValue: unknown = value.default ?? null; const description = value.description; if (description !== null && typeof description !== "string") { return null; diff --git a/crates/workshop/ui/test/run-api.mjs b/crates/workshop/ui/test/run-api.mjs index f5072c1ac..b266ea297 100644 --- a/crates/workshop/ui/test/run-api.mjs +++ b/crates/workshop/ui/test/run-api.mjs @@ -45,6 +45,7 @@ const CONTRACT = { { name: "topic", type: "string", optional: false, default: null, description: "the topic" }, { name: "deep", type: "boolean", optional: true, default: false, description: null }, { name: "limit", type: "integer", optional: true, default: 5, description: null }, + { name: "tags", type: "json", optional: true, default: ["a"], description: null }, ], }, models: [ @@ -101,13 +102,15 @@ const json = (body, status = 200) => ({ check( "arg fields narrow with defaults and descriptions", contract.args.implicit === false && - contract.args.fields.length === 3 && + contract.args.fields.length === 4 && contract.args.fields[0].name === "topic" && contract.args.fields[0].type === "string" && contract.args.fields[0].optional === false && contract.args.fields[1].default === false && contract.args.fields[2].type === "integer" && - contract.args.fields[2].default === 5, + contract.args.fields[2].default === 5 && + contract.args.fields[3].type === "json" && + JSON.stringify(contract.args.fields[3].default) === '["a"]', ); // The Run window shows a described arg's text as a second line under its // label and draws no line for an undescribed one, so the narrowed @@ -158,7 +161,7 @@ const json = (body, status = 200) => ({ contract.output === null, ); check( - "the implicit args declaration names the single prose field", + "the implicit args of a prompt without an input schema name the single prose field", contract.args.implicit === true && contract.args.fields.length === 1 && contract.args.fields[0].name === "prose" &&