feat: migrate orchestrator-llm to fai-module-sdk (v0.3.0)
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1m34s

The module now uses the #[fai_module] macro from
fai-module-sdk v0.1.2. The behavior is unchanged from v0.2.1 —
deterministic stub when llm_endpoint is empty, real Ollama-shaped
HTTP otherwise. The migration drops:

  - The hand-written wit_bindgen::generate! invocation
  - The exports::fai::platform::invoke::Guest impl boilerplate
  - The unsafe_op_in_unsafe_fn allow workaround
  - The manual export!() macro call
  - The text_input helper that matched Payload variants by hand
  - The wit/ directory mirror of the platform's frozen WIT

In their place, the module body is one annotated function:

  #[fai_module]
  pub fn invoke(_ctx: Context, inputs: Inputs)
      -> Result<Outputs, ModuleError>
  { ... }

Compared with v0.2.1, lib.rs lost ~70 lines of WASM plumbing.
The SDK macro emits the WIT glue only on target_arch = wasm32
so the host build now compiles cleanly without a manual gate.
That removed two #[cfg(target_arch = wasm32)] blocks in the
process.

The Cargo.toml dep on wit-bindgen is replaced by a single git
dep on fai-module-sdk (branch=main, transitively pinning v0.1.2).
The macros crate comes through transitively. waki stays as a
target-gated dep — the SDK does not (yet) abstract HTTP.

Output payload extends the v0.2.1 schema additively: alongside
the existing 'plan' (JSON Plan), the module now emits
'model_endpoint' and 'model_name' so consumers (and audit logs)
can identify which LLM produced which plan. Existing flows that
only consume 'plan' are unaffected.

Wasm artifact verified to build with v1.0 imports. Platform-side
integration tests in fai/platform :: orchestrator_llm_path.rs
and orchestrator_module.rs pass against the migrated wasm with
no platform-side changes. Total platform suite: 151 green.

The wit/ directory is removed because the SDK ships the WIT
inline via include_str! at proc-macro compile time. Module repos
no longer maintain their own mirror copy.

Bumps the module to 0.3.0 (minor — output schema additively
extended).

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-05-02 11:49:10 +02:00
parent 62792a20aa
commit 7cba1ae57a
4 changed files with 92 additions and 162 deletions

View file

@ -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<Outputs, ModuleError> {
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<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 = 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, &params) {
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, &params)
.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<String> {
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<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
}
})
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<String, crate::llm::LlmError> {
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);

View file

@ -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}'")]