diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aed9250 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +# Standalone Cargo.toml — targets wasm32-wasip2, NOT a workspace member. +# +# Build with: +# cargo build --release --target wasm32-wasip2 + +[package] +name = "orchestrator_llm" +version = "0.1.0" +edition = "2024" +authors = ["Dr. Stefan Flemming "] +license = "Apache-2.0" +publish = false +description = "F∆I orchestrator module — turns natural-language goals into structured execution plans" +repository = "https://git.flemming.ws/fai-modules/orchestrator-llm" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +wit-bindgen = "0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +serde_yaml = "0.9" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = "symbols" + +[workspace] +# Empty workspace table so this package is NOT picked up by a parent. diff --git a/README.md b/README.md index 766c71d..daec4a7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,79 @@ # orchestrator-llm -F∆I orchestrator module — turns natural-language goals into structured execution plans (Phase 0.5, version 0.8.0) \ No newline at end of file +F∆I orchestrator module — turns natural-language goals into +structured execution plans (`fai_hub::plan::Plan` JSON). + +## Status + +`v0.1.0` — **deterministic stub**. Always returns a structurally +valid plan that creates a single `debug.echo` flow regardless of +the goal. Useful as a smoke test for the full module → plan → +`fai plan apply` path. + +`v0.2.0` (next) — replaces the stub with an actual LLM call to a +configured endpoint (Ollama or OpenAI-compatible) once the F∆I +platform exposes outbound HTTP to permitted modules via wasi-http. + +## Capability + +- `orchestrator.plan@0.1.0` + +## Inputs + +| Name | Type | Description | +|------|------|-------------| +| `goal` | text | Natural-language goal | +| `available_capabilities` | text (JSON list) | Optional. Capabilities currently installed in the hub. | +| `store_capabilities` | text (JSON list) | Optional. All capabilities the hub knows about. | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| `plan` | json | Plan JSON deserializable by `fai_hub::plan::Plan` | + +## Permissions + +None. The deterministic stub does not call out. v0.2.0 will +declare `net: ` once the platform supports it. + +## Build + +```bash +cargo build --release --target wasm32-wasip2 +``` + +The built WASM lands at +`target/wasm32-wasip2/release/orchestrator_llm.wasm`. + +## Test + +Host-side unit and integration tests exercise the pure +plan-building logic (no WASM build required for tests): + +```bash +cargo test +``` + +## Use from a flow + +```yaml +schema_version: 1 +name: plan-from-goal +inputs: + goal: text +steps: + - id: orchestrate + use: orchestrator.plan@^0 + with: + goal: $inputs.goal +outputs: + plan: $orchestrate.plan +``` + +The `plan` output is JSON — pipe it through `fai plan apply -` +to execute it. + +## License + +Apache-2.0. See `LICENSE`. diff --git a/module.yaml b/module.yaml new file mode 100644 index 0000000..87139c8 --- /dev/null +++ b/module.yaml @@ -0,0 +1,31 @@ +schema_version: 1 +name: orchestrator-llm +version: 0.1.0 + +# Capability provided by this module. +provides: + - capability: orchestrator.plan + version: 0.1.0 + +# Inputs the invoke function accepts. +inputs: + goal: text + # Optional JSON-encoded list of installed capabilities. If + # omitted or empty, the module plans without capability context. + available_capabilities: text + # Optional JSON-encoded list of all known store capabilities + # (installed + planned + alpha). Used when the orchestrator + # needs to suggest installations. + store_capabilities: text + +# Outputs produced. +outputs: + # JSON-encoded Plan compatible with `fai_hub::plan::Plan`. + plan: json + +# 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: [] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..32add0c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.86" +targets = ["wasm32-wasip2"] +components = ["rustfmt", "clippy"] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c4a2838 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,63 @@ +//! 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. + +mod plan; + +pub use plan::build_plan; + +#[cfg(target_arch = "wasm32")] +wit_bindgen::generate!({ + path: "wit", + world: "module", +}); + +#[cfg(target_arch = "wasm32")] +struct Orchestrator; + +#[cfg(target_arch = "wasm32")] +impl exports::fai::platform::invoke::Guest for Orchestrator { + fn invoke( + ctx: fai::platform::types::InvocationContext, + inputs: Vec<(String, fai::platform::types::Payload)>, + ) -> Result< + Vec<(String, fai::platform::types::Payload)>, + fai::platform::types::InvocationError, + > { + use fai::platform::types::{InvocationError, Payload}; + + fai::platform::host::log( + "debug", + &format!( + "orchestrator.invoke id={} cap={}.{}", + ctx.invocation_id, ctx.capability_namespace, ctx.capability_name, + ), + ); + + let goal = inputs + .iter() + .find(|(k, _)| k == "goal") + .and_then(|(_, v)| { + if let Payload::Text(s) = v { + Some(s.clone()) + } else { + None + } + }) + .ok_or_else(|| InvocationError::InvalidInput("missing 'goal' input".to_string()))?; + + let plan_json = crate::plan::build_plan(&goal); + Ok(vec![("plan".to_string(), Payload::Json(plan_json))]) + } +} + +#[cfg(target_arch = "wasm32")] +export!(Orchestrator); diff --git a/src/plan.rs b/src/plan.rs new file mode 100644 index 0000000..2e8a578 --- /dev/null +++ b/src/plan.rs @@ -0,0 +1,101 @@ +//! 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. + +use serde::Serialize; + +/// Top-level Plan structure mirroring `fai_hub::plan::Plan` so the +/// JSON we emit deserializes cleanly on the hub side. +#[derive(Serialize)] +pub struct Plan { + pub schema_version: u32, + pub goal: String, + pub steps: Vec, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PlanStep { + Explain { text: String }, + SaveFlow { name: String, yaml: 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 { + let plan = Plan { + schema_version: 1, + goal: goal.to_string(), + steps: vec![ + PlanStep::Explain { + text: format!("Goal received: {goal}"), + }, + PlanStep::Explain { + text: STUB_NOTICE.to_string(), + }, + PlanStep::SaveFlow { + name: "orchestrator-stub-greeting".to_string(), + yaml: stub_flow_yaml(goal), + }, + ], + }; + serde_json::to_string(&plan).unwrap_or_else(|_| { + r#"{"schema_version":1,"goal":"","steps":[]}"#.to_string() + }) +} + +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!( + "schema_version: 1\n\ + name: orchestrator-stub-greeting\n\ + inputs:\n who: text\n\ + steps:\n - id: greet\n use: debug.echo@^0\n \ + with:\n message: \"Goal: {safe_goal}; greeting from $inputs.who\"\n\ + outputs:\n greeting: $greet.echoed\n", + ) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + 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"); + } + + #[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\""#)); + } + + #[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); + } +} diff --git a/tests/plan_compatibility.rs b/tests/plan_compatibility.rs new file mode 100644 index 0000000..7cc540f --- /dev/null +++ b/tests/plan_compatibility.rs @@ -0,0 +1,47 @@ +//! Verify that plan JSON emitted by orchestrator-llm parses +//! cleanly as a `fai_hub::plan::Plan`. This protects against +//! schema drift between this module and the hub. +//! +//! Note: this test runs only on the host target. The WASM build +//! does not include test harnesses. +#![cfg(not(target_arch = "wasm32"))] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use orchestrator_llm::build_plan; +use serde_json::Value; + +#[test] +fn emitted_plan_has_required_top_level_fields() { + let json = build_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()); + assert!(parsed["steps"].is_array()); +} + +#[test] +fn each_step_has_kind_tag() { + let json = build_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}"); + } +} + +#[test] +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 parsed: Value = serde_json::from_str(&json).unwrap(); + let save_flow_step = parsed["steps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["kind"] == "save_flow") + .expect("expected a save_flow step"); + let yaml_text = save_flow_step["yaml"].as_str().unwrap(); + let flow: Value = serde_yaml::from_str(yaml_text).expect("embedded yaml must parse"); + assert_eq!(flow["name"], "orchestrator-stub-greeting"); + assert!(flow["steps"].is_array()); +} diff --git a/wit/world.wit b/wit/world.wit new file mode 100644 index 0000000..3b6b15a --- /dev/null +++ b/wit/world.wit @@ -0,0 +1,73 @@ +package fai:platform@0.1.0; + +/// Types that flow between the host (hub) and modules. +interface types { + /// A typed value passed between the host and a module. + variant payload { + /// Plain UTF-8 text. + text(string), + /// Arbitrary JSON encoded as a string. + json(string), + /// Raw bytes with a MIME type. + bytes(bytes-value), + /// A reference to a file, by URI. + file-ref(file-ref), + } + + /// Inline byte content. + record bytes-value { + mime-type: string, + data: list, + } + + /// File reference by URI. + record file-ref { + uri: string, + mime-type: string, + size-bytes: u64, + sha256: string, + } + + /// Context passed with every invocation. + record invocation-context { + invocation-id: string, + capability-namespace: string, + capability-name: string, + deadline-ms: u64, + } + + /// Structured error type returned by modules. + variant invocation-error { + invalid-input(string), + permission-denied(string), + resource-exhausted(string), + deadline-exceeded, + internal(string), + } +} + +/// The host-provided interface that modules can import to talk back. +interface host { + /// Log a structured message. Level is "trace", "debug", "info", "warn", "error". + log: func(level: string, message: string); +} + +/// The core interface every module exports. +interface invoke { + use types.{payload, invocation-context, invocation-error}; + + /// Invoke the module with inputs, receive outputs or an error. + invoke: func( + ctx: invocation-context, + inputs: list>, + ) -> result>, invocation-error>; +} + +/// The world a module component targets. +/// +/// Modules `export` the invoke interface to be callable by the hub. +/// Modules `import` the host interface to emit logs. +world module { + import host; + export invoke; +}