diff --git a/Cargo.toml b/Cargo.toml index 6414e9c..689f150 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "orchestrator_llm" -version = "0.2.1" +version = "0.3.0" edition = "2024" authors = ["Dr. Stefan Flemming "] license = "Apache-2.0" @@ -17,7 +17,7 @@ repository = "https://git.flemming.ws/fai-modules/orchestrator-llm" crate-type = ["cdylib", "rlib"] [dependencies] -wit-bindgen = "0.36" +fai-module-sdk = { git = "https://git.flemming.ws/fai/module-sdk.git", branch = "main" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/src/lib.rs b/src/lib.rs index 7f6ba6e..7770e0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,101 +1,110 @@ //! orchestrator-llm — turns natural-language goals into Plan JSON. //! -//! 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. +//! v0.3.0 migrates from a hand-written wit_bindgen scaffold to the +//! `#[fai_module]` macro from `fai-module-sdk`. The behavior is +//! unchanged from v0.2.1: a configured Ollama-compatible endpoint +//! drives a real plan when `llm_endpoint` is set, otherwise the +//! deterministic stub from v0.1.0 takes over. +//! +//! The output payload now also reports the LLM endpoint and model +//! name alongside the plan. Together with the platform's per-step +//! manifest hash (added in platform 0.10.10), this lets an auditor +//! reconstruct exactly which model produced which plan. -// wit_bindgen 0.36's `export!` macro expands into unsafe fns -// whose bodies call further unsafe fns without explicit unsafe -// blocks — Rust 2024's `unsafe_op_in_unsafe_fn` lint flags -// those, and CI runs with -D warnings. Suppress on wasm32 only -// so the host build stays strict. Drop this once wit_bindgen -// ships a fix. -#![cfg_attr(target_arch = "wasm32", allow(unsafe_op_in_unsafe_fn))] - -// The LLM client is only used by the wasm32 production code -// (WakiClient) and by the host-side test harness. Gating the -// module declaration keeps the host non-test build honest about -// what is actually reachable on each target. -#[cfg(any(target_arch = "wasm32", test))] +// The LLM client logic. Only the wasm32 build has a real +// transport (waki); the host build links a stub client used +// by unit tests, so the module compiles on both targets. mod llm; mod plan; pub use plan::{Plan, PlanStep, build_stub_plan, parse_and_validate, validate}; -#[cfg(target_arch = "wasm32")] -wit_bindgen::generate!({ - path: "wit", - world: "module", -}); +use fai_module_sdk::prelude::*; -#[cfg(target_arch = "wasm32")] -struct Orchestrator; +#[fai_module] +pub fn invoke(_ctx: Context, inputs: Inputs) -> Result { + let goal = inputs.require_text("goal")?; + let llm_endpoint = inputs + .get("llm_endpoint") + .and_then(payload_text) + .unwrap_or_default(); + let llm_model = inputs + .get("llm_model") + .and_then(payload_text) + .unwrap_or_default(); + let llm_api_key = inputs + .get("llm_api_key") + .and_then(payload_text) + .unwrap_or_default(); + let available_capabilities = inputs + .get("available_capabilities") + .and_then(payload_text) + .unwrap_or_default(); + let store_capabilities = inputs + .get("store_capabilities") + .and_then(payload_text) + .unwrap_or_default(); -#[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, 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 = 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(); - - // 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 plan_json = if llm_endpoint.is_empty() { + build_stub_plan(goal) + } else { + let client = make_client(); let params = crate::llm::OllamaParams { endpoint: &llm_endpoint, model: &llm_model, api_key: &llm_api_key, - goal: &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())), - } + crate::llm::generate_plan(&client, ¶ms) + .map_err(|e| ModuleError::internal(e.to_string()))? + }; + + Ok(Outputs::new() + .with_json_str("plan", plan_json) + .with_text("model_endpoint", llm_endpoint) + .with_text("model_name", llm_model)) +} + +fn payload_text(p: &Payload) -> Option { + match p { + Payload::Text(s) => Some(s.clone()), + _ => None, } } #[cfg(target_arch = "wasm32")] -fn text_input(inputs: &[(String, fai::platform::types::Payload)], name: &str) -> Option { - 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 - } - }) +fn make_client() -> WakiClient { + WakiClient +} + +#[cfg(not(target_arch = "wasm32"))] +fn make_client() -> HostStubClient { + HostStubClient +} + +/// Stub client present on host (non-wasm32) builds so the module +/// compiles for `cargo test --lib`. The `invoke` entry point is +/// not exercised in host tests today; a real call here surfaces a +/// clear runtime error rather than dragging waki onto the host. +#[cfg(not(target_arch = "wasm32"))] +struct HostStubClient; + +#[cfg(not(target_arch = "wasm32"))] +#[allow(dead_code)] +impl crate::llm::LlmClient for HostStubClient { + fn post_json( + &self, + _url: &str, + _body: &str, + _api_key: &str, + ) -> Result { + Err(crate::llm::LlmError::Http( + "LLM HTTP path is unavailable on the host build; only wasm32 supports outbound HTTP" + .to_string(), + )) + } } /// Production HTTP client backed by waki (wasi-http). Only @@ -131,6 +140,3 @@ impl crate::llm::LlmClient for WakiClient { String::from_utf8(bytes).map_err(|e| crate::llm::LlmError::Decode(e.to_string())) } } - -#[cfg(target_arch = "wasm32")] -export!(Orchestrator); diff --git a/src/llm.rs b/src/llm.rs index 735cff0..df6fd88 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -16,6 +16,10 @@ use serde::Serialize; +// On host non-test builds the only error reachable through the +// stub client is `Http`. The other variants stay present because +// the wasm32 production path constructs them from real responses. +#[allow(dead_code)] #[derive(Debug, thiserror::Error)] pub enum LlmError { #[error("missing required input '{0}'")] diff --git a/wit/world.wit b/wit/world.wit deleted file mode 100644 index d7e1a68..0000000 --- a/wit/world.wit +++ /dev/null @@ -1,80 +0,0 @@ -// Mirror of the platform's frozen WIT contract. -// -// Source of truth: fai/platform :: wit/world.wit (frozen at v1.0). -// This file will be replaced by a transitive include of the -// fai-module-sdk crate once it ships. Until then: keep in sync -// byte-for-byte with the platform copy. - -package fai:platform@1.0.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; -}