Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ repository = "https://github.com/cppalliance/runvil-workshop"
# in. All four name one commit, because two commits would be two copies
# of every type the crates share. `version` beside `git` keeps cargo-deny's
# wildcard check quiet.
runvil = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" }
harness-gateway-client = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" }
plugin-web = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" }
plugin-mcp = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" }
runvil = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "0d938885abc7fb293ae449a787c50e7c7eb60e92" }
harness-gateway-client = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "0d938885abc7fb293ae449a787c50e7c7eb60e92" }
plugin-web = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "0d938885abc7fb293ae449a787c50e7c7eb60e92" }
plugin-mcp = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "0d938885abc7fb293ae449a787c50e7c7eb60e92" }
# The safe layer over the C Plugin contract, version 0.4, from its own
# repository, pinned by commit. Only the library Plugin crates under
# `crates/workshop/` name it, for `export_plugin!`. The commit is the one
Expand Down
51 changes: 26 additions & 25 deletions crates/workshop/server/src/routes/prompts-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ name: full
description: does everything
runvil: 0
max_tool_iterations: 5
input:
path: paper.md
description: the paper
output:
path: out.md
description: the result
Expand All @@ -50,13 +47,16 @@ uses:
- fs
- acme/poop
args:
topic:
type: string
description: the topic
count:
type: integer
optional: true
default: 3
type: object
properties:
topic:
type: string
description: the topic
count:
type: integer
default: 3
required:
- topic
models:
strong:
min_context: 200000
Expand All @@ -78,10 +78,6 @@ async fn a_full_frontmatter_prompt_answers_every_contract_section() {
assert_eq!(json["description"], "does everything");
assert_eq!(json["runvil"], 0);
assert_eq!(json["max_tool_iterations"], 5);
assert_eq!(
json["input"],
serde_json::json!({ "path": "paper.md", "description": "the paper" })
);
assert_eq!(
json["output"],
serde_json::json!({ "path": "out.md", "description": "the result" })
Expand All @@ -96,14 +92,19 @@ async fn a_full_frontmatter_prompt_answers_every_contract_section() {
{ "id": "poop" },
])
);
// The declaration is sent as JSON Schema: every object node writes
// `additionalProperties`, and `required` lists the fields a caller
// must send.
assert_eq!(
json["args"],
serde_json::json!({
"implicit": false,
"fields": [
{ "name": "count", "type": "integer", "optional": true, "default": 3, "description": null },
{ "name": "topic", "type": "string", "optional": false, "default": null, "description": "the topic" },
],
"type": "object",
"properties": {
"count": { "type": "integer", "default": 3 },
"topic": { "type": "string", "description": "the topic" },
},
"required": ["topic"],
"additionalProperties": false,
})
);
assert_eq!(
Expand Down Expand Up @@ -147,18 +148,19 @@ fn an_internal_parse_failure_names_no_prompt_line() {
}

#[tokio::test]
async fn a_prompt_without_args_yields_the_implicit_prose_field() {
async fn a_prompt_without_args_yields_the_default_declaration() {
let text = "---\nname: plain\ndescription: no args\n---\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);
assert_eq!(
json["args"],
serde_json::json!({
"implicit": true,
"fields": [
{ "name": "prose", "type": "string", "optional": true, "default": null, "description": "Freeform input for this prompt" },
],
"type": "object",
"properties": {
"text": { "type": "string", "description": "Freeform text for this prompt" },
},
"additionalProperties": false,
})
);
assert_eq!(
Expand Down Expand Up @@ -195,7 +197,6 @@ async fn the_dto_serializes_absent_declarations_as_nulls() {
let (status, json) = post_contract(body).await;
assert_eq!(status, StatusCode::OK);
// Absent declarations serialize as explicit nulls, not missing keys.
assert!(json["input"].is_null());
assert!(json["output"].is_null());
assert!(json["max_tool_iterations"].is_null());
assert!(json["runvil"].is_null());
Expand Down
72 changes: 9 additions & 63 deletions crates/workshop/server/src/routes/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
//! posted markdown into the contract the Run window renders, or a `422`
//! envelope naming the parse failure kind and line. The parser's own
//! types are `Deserialize`-only, so the wire DTO is hand-written
//! `Serialize` structs built from [`Frontmatter`] accessors.
//! `Serialize` structs built from [`Frontmatter`] accessors. Its `args`
//! field is the JSON Schema that
//! [`ArgsSchema::as_json`](runvil::prompt::ArgsSchema::as_json) gives.

use axum::Json;
use axum::http::StatusCode;
Expand All @@ -16,7 +18,7 @@ use serde::{Deserialize, Serialize};
use runvil::Prompt;
use runvil::model::ModelRole;
use runvil::packs::PackRef;
use runvil::prompt::{ArgDecl, ArgsDecl, FileDecl, Frontmatter, RoleRequirements};
use runvil::prompt::{FileDecl, Frontmatter, RoleRequirements};

use crate::error::AppError;

Expand Down Expand Up @@ -48,19 +50,18 @@ struct ContractResponse {
runvil: Option<u32>,
/// The declared tool-loop cap; `null` for the runtime default.
max_tool_iterations: Option<u32>,
/// The declared input file; `null` when absent.
input: Option<FileDto>,
/// The declared output file; `null` when absent.
output: Option<FileDto>,
/// The declared Packs, in declaration order.
uses: Vec<PackDto>,
/// The typed args declaration.
args: ArgsDto,
/// The `args` declaration as JSON Schema. The root is an object node,
/// and its `properties` are the prompt's arguments by name.
args: serde_json::Value,
/// The declared model roles, sorted by label.
models: Vec<ModelDto>,
}

/// A declared input or output file.
/// The declared output file.
#[derive(Debug, Serialize)]
struct FileDto {
/// The store-internal path.
Expand Down Expand Up @@ -93,60 +94,6 @@ impl From<&PackRef> for PackDto {
}
}

/// The typed args declaration.
#[derive(Debug, Serialize)]
struct ArgsDto {
/// True when the declaration is the implicit default (no `args:` key).
implicit: bool,
/// The declared fields, sorted by name.
fields: Vec<ArgDto>,
}

impl From<&ArgsDecl> for ArgsDto {
fn from(decl: &ArgsDecl) -> Self {
Self {
implicit: decl.is_default(),
fields: decl
.iter()
.map(|(name, arg)| ArgDto::new(name, arg))
.collect(),
}
}
}

/// One declared arg.
#[derive(Debug, Serialize)]
struct ArgDto {
/// The arg name.
name: String,
/// The declared type (`string`, `boolean`, `integer`, `number`).
#[serde(rename = "type")]
kind: String,
/// Whether a call may omit the field.
optional: bool,
/// The declared default; `null` when absent.
default: Option<serde_json::Value>,
/// The human-readable description; `null` when absent.
description: Option<String>,
}

impl ArgDto {
/// Builds the DTO for the arg declared under `name`.
fn new(name: &str, decl: &ArgDecl) -> Self {
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),
}
}
}

/// The requirements a prompt declares for one model role.
#[derive(Debug, Serialize)]
struct ModelDto {
Expand Down Expand Up @@ -178,10 +125,9 @@ impl From<&Frontmatter> for ContractResponse {
max_tool_iterations: frontmatter
.max_tool_iterations()
.map(std::num::NonZeroU32::get),
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: frontmatter.args().as_json().clone(),
models: ModelRole::ALL
.into_iter()
.filter_map(|role| {
Expand Down
Loading
Loading