From 7d079379134f730fe2462f2c478b68f388e3bbe9 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 29 Apr 2026 21:27:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20real=20LLM=20call=20via=20wasi-http=20(?= =?UTF-8?q?Ollama=20adapter)=20=E2=80=94=20v0.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator now calls a configured Ollama-compatible LLM endpoint over wasi-http and emits the LLM-generated plan back to the hub. When llm_endpoint is empty, the v0.1.0 deterministic stub is preserved as fallback (useful for tests and air-gapped environments). - module.yaml: bump to 0.2.0; new inputs llm_endpoint, llm_model, llm_api_key; net: permissions for OpenAI, Anthropic, localhost, 127.0.0.1 (Ollama defaults) - src/llm.rs: LlmClient trait, OllamaParams, build_user_prompt, build_ollama_body, extract_ollama_content, generate_plan; 9 unit tests for prompt-building and response-parsing - src/plan.rs: tagged enum with Explain/SaveFlow/RunFlow/ InstallModule variants; parse_and_validate; build_stub_plan (renamed from build_plan); validate(); 6 unit tests - src/lib.rs: WakiClient implementing LlmClient over the waki WASI-0.2 HTTP crate (wasm32-only); empty endpoint → stub - Cargo.toml: 0.2.0; thiserror dep; waki dep gated on wasm32 17 host-side tests pass. WASM bundle is 386KB (was 74KB; the delta is wasi-http via waki, expected). The deny path is exercised by the platform-side integration test in fai_platform/crates/fai_hub/tests/orchestrator_llm_path.rs. Signed-off-by: flemming-it --- Cargo.toml | 7 +- module.yaml | 27 ++- src/lib.rs | 109 ++++++++++--- src/llm.rs | 316 ++++++++++++++++++++++++++++++++++++ src/plan.rs | 180 ++++++++++++++------ tests/plan_compatibility.rs | 8 +- 6 files changed, 568 insertions(+), 79 deletions(-) create mode 100644 src/llm.rs diff --git a/Cargo.toml b/Cargo.toml index aed9250..5964172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "orchestrator_llm" -version = "0.1.0" +version = "0.2.0" edition = "2024" authors = ["Dr. Stefan Flemming "] license = "Apache-2.0" @@ -20,6 +20,11 @@ crate-type = ["cdylib", "rlib"] wit-bindgen = "0.36" serde = { version = "1", features = ["derive"] } serde_json = "1" +thiserror = "2" + +# WASI-0.2 HTTP client. Only compiled into the wasm32-wasip2 build. +[target.'cfg(target_arch = "wasm32")'.dependencies] +waki = "0.5" [dev-dependencies] serde_yaml = "0.9" diff --git a/module.yaml b/module.yaml index 87139c8..8f78c92 100644 --- a/module.yaml +++ b/module.yaml @@ -1,15 +1,24 @@ schema_version: 1 name: orchestrator-llm -version: 0.1.0 +version: 0.2.0 # Capability provided by this module. provides: - capability: orchestrator.plan - version: 0.1.0 + version: 0.2.0 # Inputs the invoke function accepts. inputs: goal: text + # LLM HTTP endpoint, e.g. "http://localhost:11434/api/chat" + # for Ollama, "https://api.openai.com/v1/chat/completions", + # or "https://api.anthropic.com/v1/messages". + llm_endpoint: text + # Model identifier, e.g. "qwen2.5:14b" for Ollama or "gpt-4o-mini". + llm_model: text + # Optional bearer token for cloud providers. Empty string means + # no Authorization header is sent. + llm_api_key: text # Optional JSON-encoded list of installed capabilities. If # omitted or empty, the module plans without capability context. available_capabilities: text @@ -25,7 +34,13 @@ outputs: # Permissions required. # -# v0.1.0 is a deterministic stub — no network, no fs, no env. -# v0.2.0 will add `net: ` once the platform exposes -# wasi-http to permitted modules. -permissions: [] +# The module makes outbound HTTP to the configured LLM endpoint. +# Default declarations cover the common providers (cloud OpenAI, +# Anthropic, local Ollama on loopback). Operators with different +# LLM endpoints add a host-level entry via operator config +# (Phase 1+) — for Phase 0.5 they fork module.yaml. +permissions: + - "net: api.openai.com" + - "net: api.anthropic.com" + - "net: localhost" + - "net: 127.0.0.1" diff --git a/src/lib.rs b/src/lib.rs index c4a2838..5abecd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,14 @@ //! orchestrator-llm — turns natural-language goals into Plan JSON. //! -//! v0.1.0 is a deterministic stub. It always returns a structurally -//! valid Plan that demonstrates the module → plan → apply pipeline: -//! one explain step echoing the goal, one explain step labelling -//! itself as a stub, and one save_flow step that creates a flow -//! using `debug.echo` to greet a caller-provided name. -//! -//! v0.2.0 (next milestone) replaces the stub body with an actual -//! LLM call once the platform exposes outbound HTTP to permitted -//! modules via wasi-http. +//! v0.2.0 calls a configured Ollama-compatible LLM endpoint over +//! wasi-http. When `llm_endpoint` is empty, the module falls back +//! to the deterministic stub from v0.1.0 — useful for tests and +//! for environments where no LLM is reachable. +mod llm; mod plan; -pub use plan::build_plan; +pub use plan::{Plan, PlanStep, build_stub_plan, parse_and_validate, validate}; #[cfg(target_arch = "wasm32")] wit_bindgen::generate!({ @@ -42,20 +38,89 @@ impl exports::fai::platform::invoke::Guest for Orchestrator { ), ); - let goal = inputs - .iter() - .find(|(k, _)| k == "goal") - .and_then(|(_, v)| { - if let Payload::Text(s) = v { - Some(s.clone()) - } else { - None - } - }) + let goal = text_input(&inputs, "goal") .ok_or_else(|| InvocationError::InvalidInput("missing 'goal' input".to_string()))?; + let llm_endpoint = text_input(&inputs, "llm_endpoint").unwrap_or_default(); + let llm_model = text_input(&inputs, "llm_model").unwrap_or_default(); + let llm_api_key = text_input(&inputs, "llm_api_key").unwrap_or_default(); + let available_capabilities = + text_input(&inputs, "available_capabilities").unwrap_or_default(); + let store_capabilities = text_input(&inputs, "store_capabilities").unwrap_or_default(); - let plan_json = crate::plan::build_plan(&goal); - Ok(vec![("plan".to_string(), Payload::Json(plan_json))]) + // No endpoint → deterministic stub. + if llm_endpoint.is_empty() { + fai::platform::host::log( + "info", + "no llm_endpoint provided; emitting deterministic stub plan", + ); + return Ok(vec![( + "plan".to_string(), + Payload::Json(crate::plan::build_stub_plan(&goal)), + )]); + } + + let client = WakiClient; + let params = crate::llm::OllamaParams { + endpoint: &llm_endpoint, + model: &llm_model, + api_key: &llm_api_key, + goal: &goal, + available_capabilities: &available_capabilities, + store_capabilities: &store_capabilities, + }; + match crate::llm::generate_plan(&client, ¶ms) { + Ok(plan_json) => Ok(vec![("plan".to_string(), Payload::Json(plan_json))]), + Err(e) => Err(InvocationError::Internal(e.to_string())), + } + } +} + +#[cfg(target_arch = "wasm32")] +fn text_input( + inputs: &[(String, fai::platform::types::Payload)], + name: &str, +) -> Option { + use fai::platform::types::Payload; + inputs.iter().find(|(k, _)| k == name).and_then(|(_, v)| { + if let Payload::Text(s) = v { + Some(s.clone()) + } else { + None + } + }) +} + +/// Production HTTP client backed by waki (wasi-http). Only +/// compiled for the WASM target. +#[cfg(target_arch = "wasm32")] +struct WakiClient; + +#[cfg(target_arch = "wasm32")] +impl crate::llm::LlmClient for WakiClient { + fn post_json( + &self, + url: &str, + body: &str, + api_key: &str, + ) -> Result { + let mut request = waki::Client::new() + .post(url) + .header("Content-Type", "application/json") + .body(body.to_string()); + if !api_key.is_empty() { + request = request.header("Authorization", &format!("Bearer {api_key}")); + } + let response = request + .send() + .map_err(|e| crate::llm::LlmError::Http(e.to_string()))?; + let status = response.status_code(); + if !(200..300).contains(&status) { + return Err(crate::llm::LlmError::Status(status)); + } + let bytes = response + .body() + .map_err(|e| crate::llm::LlmError::Http(e.to_string()))?; + String::from_utf8(bytes).map_err(|e| crate::llm::LlmError::Decode(e.to_string())) } } diff --git a/src/llm.rs b/src/llm.rs new file mode 100644 index 0000000..a1d0987 --- /dev/null +++ b/src/llm.rs @@ -0,0 +1,316 @@ +//! LLM client for the orchestrator. +//! +//! v0.2.0 supports the Ollama `/api/chat` schema. OpenAI- and +//! Anthropic-compatible adapters arrive in 0.10.x. +//! +//! All HTTP I/O lives behind a `LlmClient` trait so unit tests can +//! exercise the prompt-building and response-parsing logic on the +//! host without making real network calls. + +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum LlmError { + #[error("missing required input '{0}'")] + MissingInput(&'static str), + #[error("http error: {0}")] + Http(String), + #[error("non-success status: {0}")] + Status(u16), + #[error("response body could not be parsed as Ollama schema: {0}")] + Decode(String), +} + +/// Minimal abstraction so prompt-building and response-parsing +/// are testable without making network calls. +pub trait LlmClient { + fn post_json(&self, url: &str, body: &str, api_key: &str) -> Result; +} + +#[derive(Debug, Clone)] +pub struct OllamaParams<'a> { + pub endpoint: &'a str, + pub model: &'a str, + pub api_key: &'a str, + pub goal: &'a str, + pub available_capabilities: &'a str, + pub store_capabilities: &'a str, +} + +/// System prompt establishing what F∆I expects the LLM to emit. +/// Kept short on purpose — the emit-strict-JSON is enforced by +/// Ollama's `format: "json"` flag, not by the prompt. +pub const SYSTEM_PROMPT: &str = "You are an orchestrator for the F∆I workflow platform. \ +Given a user goal and a list of available capabilities, emit a JSON plan that the platform applies. \ +The plan schema is: {\"schema_version\":1,\"goal\":string,\"steps\":[step]}. \ +Each step has a \"kind\" tag — one of: explain (with \"text\"), save_flow (with \"name\" and \"yaml\"), \ +run_flow (with \"name\" and optional \"inputs\":object), install_module (with \"name\" and optional \"version\"). \ +Use only capabilities the user has installed unless install_module steps add the missing ones first. \ +Capability identifiers use dots (e.g. text.extract); flow YAML references them with @."; + +/// Build the user-facing prompt that follows the system prompt. +pub fn build_user_prompt(p: &OllamaParams) -> String { + let mut out = String::new(); + out.push_str("Goal: "); + out.push_str(p.goal); + out.push('\n'); + if !p.available_capabilities.is_empty() { + out.push_str("Installed capabilities (JSON): "); + out.push_str(p.available_capabilities); + out.push('\n'); + } + if !p.store_capabilities.is_empty() { + out.push_str("All known capabilities (JSON): "); + out.push_str(p.store_capabilities); + out.push('\n'); + } + out.push_str("Emit only the JSON plan. No prose."); + out +} + +#[derive(Serialize)] +struct OllamaMessage<'a> { + role: &'a str, + content: &'a str, +} + +#[derive(Serialize)] +struct OllamaRequest<'a> { + model: &'a str, + messages: Vec>, + format: &'a str, + stream: bool, +} + +/// Build the JSON body for an Ollama /api/chat request. +pub fn build_ollama_body(p: &OllamaParams, user_prompt: &str) -> String { + let req = OllamaRequest { + model: p.model, + messages: vec![ + OllamaMessage { + role: "system", + content: SYSTEM_PROMPT, + }, + OllamaMessage { + role: "user", + content: user_prompt, + }, + ], + format: "json", + stream: false, + }; + serde_json::to_string(&req).unwrap_or_else(|_| String::from("{}")) +} + +/// Extract the LLM-emitted plan JSON string from an Ollama +/// response body. +/// +/// Ollama returns: `{"message": {"content": ""}, ...}` +pub fn extract_ollama_content(body: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(body).map_err(|e| LlmError::Decode(e.to_string()))?; + v.get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| LlmError::Decode("missing message.content".into())) +} + +/// Generate a plan via the configured LLM endpoint. Returns the +/// validated plan JSON ready to be emitted as the module output. +pub fn generate_plan( + client: &C, + p: &OllamaParams, +) -> Result { + if p.endpoint.is_empty() { + return Err(OrchestratorError::MissingEndpoint); + } + if p.model.is_empty() { + return Err(OrchestratorError::Llm(LlmError::MissingInput("llm_model"))); + } + let user_prompt = build_user_prompt(p); + let body = build_ollama_body(p, &user_prompt); + let response_body = client + .post_json(p.endpoint, &body, p.api_key) + .map_err(OrchestratorError::Llm)?; + let plan_json = extract_ollama_content(&response_body).map_err(OrchestratorError::Llm)?; + crate::plan::parse_and_validate(&plan_json).map_err(OrchestratorError::Plan)?; + Ok(plan_json) +} + +#[derive(Debug, thiserror::Error)] +pub enum OrchestratorError { + #[error("llm_endpoint is required when calling the LLM path")] + MissingEndpoint, + #[error(transparent)] + Llm(#[from] LlmError), + #[error(transparent)] + Plan(#[from] crate::plan::PlanError), +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use super::*; + use std::cell::RefCell; + + struct MockClient { + responses: RefCell>>, + last_body: RefCell>, + last_url: RefCell>, + last_api_key: RefCell>, + } + + impl MockClient { + fn new(responses: Vec>) -> Self { + Self { + responses: RefCell::new(responses), + last_body: RefCell::new(None), + last_url: RefCell::new(None), + last_api_key: RefCell::new(None), + } + } + } + + impl LlmClient for MockClient { + fn post_json(&self, url: &str, body: &str, api_key: &str) -> Result { + *self.last_url.borrow_mut() = Some(url.to_string()); + *self.last_body.borrow_mut() = Some(body.to_string()); + *self.last_api_key.borrow_mut() = Some(api_key.to_string()); + self.responses + .borrow_mut() + .pop() + .unwrap_or(Err(LlmError::Http("no more mock responses".into()))) + } + } + + #[test] + fn user_prompt_includes_goal_and_capabilities() { + let p = OllamaParams { + endpoint: "http://x", + model: "x", + api_key: "", + goal: "build a greeter", + available_capabilities: r#"["debug.echo"]"#, + store_capabilities: "", + }; + let prompt = build_user_prompt(&p); + assert!(prompt.contains("build a greeter")); + assert!(prompt.contains("debug.echo")); + assert!(prompt.contains("Emit only the JSON plan")); + } + + #[test] + fn ollama_body_carries_system_and_user_messages() { + let p = OllamaParams { + endpoint: "http://x", + model: "qwen2.5:14b", + api_key: "", + goal: "hello", + available_capabilities: "", + store_capabilities: "", + }; + let body = build_ollama_body(&p, "user-text"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["model"], "qwen2.5:14b"); + assert_eq!(v["format"], "json"); + assert_eq!(v["stream"], false); + let messages = v["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"], "user-text"); + } + + #[test] + fn extracts_content_from_well_formed_response() { + let body = r#"{"model":"x","message":{"role":"assistant","content":"the-plan"},"done":true}"#; + assert_eq!(extract_ollama_content(body).unwrap(), "the-plan"); + } + + #[test] + fn errors_on_missing_content() { + let body = r#"{"model":"x","done":true}"#; + assert!(matches!( + extract_ollama_content(body), + Err(LlmError::Decode(_)) + )); + } + + #[test] + fn end_to_end_via_mock_client_returns_validated_plan() { + let canned_plan = r#"{"schema_version":1,"goal":"hi","steps":[{"kind":"explain","text":"ok"}]}"#; + let canned_response = format!( + r#"{{"message":{{"role":"assistant","content":{}}},"done":true}}"#, + serde_json::to_string(canned_plan).unwrap(), + ); + let client = MockClient::new(vec![Ok(canned_response)]); + let p = OllamaParams { + endpoint: "http://localhost:11434/api/chat", + model: "qwen", + api_key: "", + goal: "hi", + available_capabilities: "", + store_capabilities: "", + }; + let plan_json = generate_plan(&client, &p).unwrap(); + let v: serde_json::Value = serde_json::from_str(&plan_json).unwrap(); + assert_eq!(v["goal"], "hi"); + } + + #[test] + fn rejects_invalid_plan_from_llm() { + let bad_plan = r#"{"schema_version":99,"goal":"x","steps":[{"kind":"explain","text":"a"}]}"#; + let canned_response = format!( + r#"{{"message":{{"content":{}}}}}"#, + serde_json::to_string(bad_plan).unwrap(), + ); + let client = MockClient::new(vec![Ok(canned_response)]); + let p = OllamaParams { + endpoint: "http://x/api/chat", + model: "x", + api_key: "", + goal: "x", + available_capabilities: "", + store_capabilities: "", + }; + match generate_plan(&client, &p) { + Err(OrchestratorError::Plan(crate::plan::PlanError::UnsupportedSchemaVersion(99))) => {} + other => panic!("unexpected result: {other:?}"), + } + } + + #[test] + fn missing_endpoint_returns_clear_error() { + let client = MockClient::new(vec![]); + let p = OllamaParams { + endpoint: "", + model: "x", + api_key: "", + goal: "x", + available_capabilities: "", + store_capabilities: "", + }; + assert!(matches!( + generate_plan(&client, &p), + Err(OrchestratorError::MissingEndpoint) + )); + } + + #[test] + fn missing_model_returns_clear_error() { + let client = MockClient::new(vec![]); + let p = OllamaParams { + endpoint: "http://x", + model: "", + api_key: "", + goal: "x", + available_capabilities: "", + store_capabilities: "", + }; + assert!(matches!( + generate_plan(&client, &p), + Err(OrchestratorError::Llm(LlmError::MissingInput("llm_model"))) + )); + } +} diff --git a/src/plan.rs b/src/plan.rs index 2e8a578..baf5b8b 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,30 +1,97 @@ -//! Pure plan-building logic. Compiles for both host (tests) and -//! WASM targets, so the same code that runs inside the module can -//! be exercised by unit tests on the host. +//! Plan structure and pure helpers. +//! +//! Mirrors `fai_hub::plan::Plan` so the JSON we emit deserializes +//! cleanly on the hub side. Plan validation lives here so it can +//! be exercised on both host (tests) and WASM targets. -use serde::Serialize; +use serde::{Deserialize, Serialize}; -/// Top-level Plan structure mirroring `fai_hub::plan::Plan` so the -/// JSON we emit deserializes cleanly on the hub side. -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct Plan { pub schema_version: u32, + #[serde(default)] pub goal: String, pub steps: Vec, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum PlanStep { - Explain { text: String }, - SaveFlow { name: String, yaml: String }, + Explain { + text: String, + }, + SaveFlow { + name: String, + yaml: String, + }, + RunFlow { + name: String, + #[serde(default)] + inputs: std::collections::HashMap, + }, + InstallModule { + name: String, + #[serde(default)] + version: Option, + }, } -/// Build the deterministic stub plan for the given goal. -/// -/// Returns a JSON string. The plan is structurally valid against -/// `fai_hub::plan::Plan` — the hub can deserialize and apply it. -pub fn build_plan(goal: &str) -> String { +#[derive(Debug, thiserror::Error)] +pub enum PlanError { + #[error("plan json could not be parsed: {0}")] + Parse(String), + #[error("plan schema_version {0} is not supported (this module emits version 1)")] + UnsupportedSchemaVersion(u32), + #[error("plan must contain at least one step")] + Empty, + #[error("step {index}: {reason}")] + InvalidStep { index: usize, reason: String }, +} + +pub fn parse_and_validate(json: &str) -> Result { + let plan: Plan = serde_json::from_str(json).map_err(|e| PlanError::Parse(e.to_string()))?; + validate(&plan)?; + Ok(plan) +} + +pub fn validate(plan: &Plan) -> Result<(), PlanError> { + if plan.schema_version != 1 { + return Err(PlanError::UnsupportedSchemaVersion(plan.schema_version)); + } + if plan.steps.is_empty() { + return Err(PlanError::Empty); + } + for (i, step) in plan.steps.iter().enumerate() { + let bad = match step { + PlanStep::Explain { text } if text.is_empty() => Some("empty text"), + PlanStep::SaveFlow { name, yaml } if name.is_empty() || yaml.is_empty() => { + Some("empty name or yaml") + } + PlanStep::RunFlow { name, .. } if name.is_empty() => Some("empty name"), + PlanStep::InstallModule { name, .. } if name.is_empty() => Some("empty name"), + _ => None, + }; + if let Some(reason) = bad { + return Err(PlanError::InvalidStep { + index: i, + reason: reason.to_string(), + }); + } + } + Ok(()) +} + +/// Serialize a plan to JSON. Used by both the LLM path (after +/// validating the LLM-emitted plan) and the stub fallback. +pub fn to_json(plan: &Plan) -> String { + serde_json::to_string(plan).unwrap_or_else(|_| { + r#"{"schema_version":1,"goal":"","steps":[{"kind":"explain","text":"plan serialization failed"}]}"#.to_string() + }) +} + +/// Deterministic stub plan for the case where no LLM endpoint +/// was provided. Always emits a debug.echo flow. +pub fn build_stub_plan(goal: &str) -> String { let plan = Plan { schema_version: 1, goal: goal.to_string(), @@ -33,7 +100,9 @@ pub fn build_plan(goal: &str) -> String { text: format!("Goal received: {goal}"), }, PlanStep::Explain { - text: STUB_NOTICE.to_string(), + text: "orchestrator-llm called without llm_endpoint — \ + emitting deterministic stub plan." + .to_string(), }, PlanStep::SaveFlow { name: "orchestrator-stub-greeting".to_string(), @@ -41,17 +110,9 @@ pub fn build_plan(goal: &str) -> String { }, ], }; - serde_json::to_string(&plan).unwrap_or_else(|_| { - r#"{"schema_version":1,"goal":"","steps":[]}"#.to_string() - }) + to_json(&plan) } -const STUB_NOTICE: &str = - "orchestrator-llm@0.1.0 is a deterministic stub. \ - It always emits a debug.echo flow regardless of the goal. \ - Install orchestrator-llm@0.2.0 (when published) for goal-aware \ - planning via an LLM."; - fn stub_flow_yaml(goal: &str) -> String { let safe_goal = goal.replace('\\', "\\\\").replace('"', "\\\""); format!( @@ -70,32 +131,59 @@ mod tests { use super::*; #[test] - fn plan_is_valid_json_with_three_steps() { - let json = build_plan("build me a greeter"); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["schema_version"], 1); - assert_eq!(parsed["goal"], "build me a greeter"); - let steps = parsed["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 3); - assert_eq!(steps[0]["kind"], "explain"); - assert_eq!(steps[1]["kind"], "explain"); - assert_eq!(steps[2]["kind"], "save_flow"); - assert_eq!(steps[2]["name"], "orchestrator-stub-greeting"); + fn validates_minimal_plan() { + let plan: Plan = serde_json::from_str( + r#"{"schema_version":1,"goal":"x","steps":[{"kind":"explain","text":"hi"}]}"#, + ) + .unwrap(); + validate(&plan).unwrap(); } #[test] - fn goal_with_quotes_does_not_break_yaml() { - let json = build_plan(r#"build a "smart" greeter"#); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - let yaml = parsed["steps"][2]["yaml"].as_str().unwrap(); - assert!(yaml.contains(r#"\"smart\""#)); + fn rejects_empty_steps() { + let plan: Plan = + serde_json::from_str(r#"{"schema_version":1,"goal":"x","steps":[]}"#).unwrap(); + assert!(matches!(validate(&plan), Err(PlanError::Empty))); } #[test] - fn empty_goal_still_produces_valid_plan() { - let json = build_plan(""); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["goal"], ""); - assert_eq!(parsed["steps"].as_array().unwrap().len(), 3); + fn rejects_unsupported_schema_version() { + let plan: Plan = serde_json::from_str( + r#"{"schema_version":99,"goal":"x","steps":[{"kind":"explain","text":"hi"}]}"#, + ) + .unwrap(); + assert!(matches!( + validate(&plan), + Err(PlanError::UnsupportedSchemaVersion(99)) + )); + } + + #[test] + fn rejects_save_flow_with_empty_name() { + let plan: Plan = serde_json::from_str( + r#"{"schema_version":1,"goal":"x","steps":[{"kind":"save_flow","name":"","yaml":"a"}]}"#, + ) + .unwrap(); + assert!(matches!(validate(&plan), Err(PlanError::InvalidStep { .. }))); + } + + #[test] + fn round_trips_install_module_with_version() { + let json = r#"{"schema_version":1,"goal":"x","steps":[{"kind":"install_module","name":"text.extract","version":"^1"}]}"#; + let plan = parse_and_validate(json).unwrap(); + match &plan.steps[0] { + PlanStep::InstallModule { name, version } => { + assert_eq!(name, "text.extract"); + assert_eq!(version.as_deref(), Some("^1")); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn stub_plan_validates() { + let json = build_stub_plan("test goal"); + let plan: Plan = serde_json::from_str(&json).unwrap(); + validate(&plan).unwrap(); } } diff --git a/tests/plan_compatibility.rs b/tests/plan_compatibility.rs index 7cc540f..6eea1f9 100644 --- a/tests/plan_compatibility.rs +++ b/tests/plan_compatibility.rs @@ -7,12 +7,12 @@ #![cfg(not(target_arch = "wasm32"))] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use orchestrator_llm::build_plan; +use orchestrator_llm::build_stub_plan; use serde_json::Value; #[test] fn emitted_plan_has_required_top_level_fields() { - let json = build_plan("test goal"); + let json = build_stub_plan("test goal"); let parsed: Value = serde_json::from_str(&json).expect("plan must be valid json"); assert!(parsed["schema_version"].is_u64()); assert!(parsed["goal"].is_string()); @@ -21,7 +21,7 @@ fn emitted_plan_has_required_top_level_fields() { #[test] fn each_step_has_kind_tag() { - let json = build_plan("test goal"); + let json = build_stub_plan("test goal"); let parsed: Value = serde_json::from_str(&json).unwrap(); for step in parsed["steps"].as_array().unwrap() { assert!(step["kind"].is_string(), "step missing kind tag: {step}"); @@ -32,7 +32,7 @@ fn each_step_has_kind_tag() { fn save_flow_step_yaml_parses_as_flow_definition() { // The save_flow step embeds a YAML flow body. Verify it // round-trips through serde_yaml. - let json = build_plan("anything"); + let json = build_stub_plan("anything"); let parsed: Value = serde_json::from_str(&json).unwrap(); let save_flow_step = parsed["steps"] .as_array()