feat: real LLM call via wasi-http (Ollama adapter) — v0.2.0

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-04-29 21:27:07 +02:00
parent 3bb8fc9e70
commit 7d07937913
6 changed files with 568 additions and 79 deletions

View file

@ -5,7 +5,7 @@
[package]
name = "orchestrator_llm"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"]
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"

View file

@ -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: <llm-endpoint>` 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"

View file

@ -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, &params) {
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<String> {
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<String, crate::llm::LlmError> {
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()))
}
}

316
src/llm.rs Normal file
View file

@ -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<String, LlmError>;
}
#[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 @<semver-req>.";
/// 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<OllamaMessage<'a>>,
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": "<plan-json>"}, ...}`
pub fn extract_ollama_content(body: &str) -> Result<String, LlmError> {
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<C: LlmClient>(
client: &C,
p: &OllamaParams,
) -> Result<String, OrchestratorError> {
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<Vec<Result<String, LlmError>>>,
last_body: RefCell<Option<String>>,
last_url: RefCell<Option<String>>,
last_api_key: RefCell<Option<String>>,
}
impl MockClient {
fn new(responses: Vec<Result<String, LlmError>>) -> 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<String, LlmError> {
*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")))
));
}
}

View file

@ -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<PlanStep>,
}
#[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<String, String>,
},
InstallModule {
name: String,
#[serde(default)]
version: Option<String>,
},
}
/// 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<Plan, PlanError> {
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();
}
}

View file

@ -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()