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
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue