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

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