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:
parent
3bb8fc9e70
commit
7d07937913
6 changed files with 568 additions and 79 deletions
|
|
@ -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"
|
||||
|
|
|
|||
27
module.yaml
27
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: <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"
|
||||
|
|
|
|||
109
src/lib.rs
109
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<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
316
src/llm.rs
Normal 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")))
|
||||
));
|
||||
}
|
||||
}
|
||||
180
src/plan.rs
180
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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue