feat: migrate orchestrator-llm to fai-module-sdk (v0.3.0)
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:
parent
62792a20aa
commit
90b65db242
4 changed files with 92 additions and 162 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "orchestrator_llm"
|
name = "orchestrator_llm"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"]
|
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"]
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
@ -17,7 +17,7 @@ repository = "https://git.flemming.ws/fai-modules/orchestrator-llm"
|
||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
|
||||||
166
src/lib.rs
166
src/lib.rs
|
|
@ -1,101 +1,110 @@
|
||||||
//! orchestrator-llm — turns natural-language goals into Plan JSON.
|
//! orchestrator-llm — turns natural-language goals into Plan JSON.
|
||||||
//!
|
//!
|
||||||
//! v0.2.0 calls a configured Ollama-compatible LLM endpoint over
|
//! v0.3.0 migrates from a hand-written wit_bindgen scaffold to the
|
||||||
//! wasi-http. When `llm_endpoint` is empty, the module falls back
|
//! `#[fai_module]` macro from `fai-module-sdk`. The behavior is
|
||||||
//! to the deterministic stub from v0.1.0 — useful for tests and
|
//! unchanged from v0.2.1: a configured Ollama-compatible endpoint
|
||||||
//! for environments where no LLM is reachable.
|
//! 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
|
// The LLM client logic. Only the wasm32 build has a real
|
||||||
// whose bodies call further unsafe fns without explicit unsafe
|
// transport (waki); the host build links a stub client used
|
||||||
// blocks — Rust 2024's `unsafe_op_in_unsafe_fn` lint flags
|
// by unit tests, so the module compiles on both targets.
|
||||||
// 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))]
|
|
||||||
mod llm;
|
mod llm;
|
||||||
mod plan;
|
mod plan;
|
||||||
|
|
||||||
pub use plan::{Plan, PlanStep, build_stub_plan, parse_and_validate, validate};
|
pub use plan::{Plan, PlanStep, build_stub_plan, parse_and_validate, validate};
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
use fai_module_sdk::prelude::*;
|
||||||
wit_bindgen::generate!({
|
|
||||||
path: "wit",
|
|
||||||
world: "module",
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[fai_module]
|
||||||
struct Orchestrator;
|
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")]
|
let plan_json = if llm_endpoint.is_empty() {
|
||||||
impl exports::fai::platform::invoke::Guest for Orchestrator {
|
build_stub_plan(goal)
|
||||||
fn invoke(
|
} else {
|
||||||
ctx: fai::platform::types::InvocationContext,
|
let client = make_client();
|
||||||
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 params = crate::llm::OllamaParams {
|
let params = crate::llm::OllamaParams {
|
||||||
endpoint: &llm_endpoint,
|
endpoint: &llm_endpoint,
|
||||||
model: &llm_model,
|
model: &llm_model,
|
||||||
api_key: &llm_api_key,
|
api_key: &llm_api_key,
|
||||||
goal: &goal,
|
goal,
|
||||||
available_capabilities: &available_capabilities,
|
available_capabilities: &available_capabilities,
|
||||||
store_capabilities: &store_capabilities,
|
store_capabilities: &store_capabilities,
|
||||||
};
|
};
|
||||||
match crate::llm::generate_plan(&client, ¶ms) {
|
crate::llm::generate_plan(&client, ¶ms)
|
||||||
Ok(plan_json) => Ok(vec![("plan".to_string(), Payload::Json(plan_json))]),
|
.map_err(|e| ModuleError::internal(e.to_string()))?
|
||||||
Err(e) => Err(InvocationError::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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
fn text_input(inputs: &[(String, fai::platform::types::Payload)], name: &str) -> Option<String> {
|
fn make_client() -> WakiClient {
|
||||||
use fai::platform::types::Payload;
|
WakiClient
|
||||||
inputs.iter().find(|(k, _)| k == name).and_then(|(_, v)| {
|
}
|
||||||
if let Payload::Text(s) = v {
|
|
||||||
Some(s.clone())
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
} else {
|
fn make_client() -> HostStubClient {
|
||||||
None
|
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
|
/// 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()))
|
String::from_utf8(bytes).map_err(|e| crate::llm::LlmError::Decode(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
export!(Orchestrator);
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
|
|
||||||
use serde::Serialize;
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum LlmError {
|
pub enum LlmError {
|
||||||
#[error("missing required input '{0}'")]
|
#[error("missing required input '{0}'")]
|
||||||
|
|
|
||||||
|
|
@ -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<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<tuple<string, payload>>,
|
|
||||||
) -> result<list<tuple<string, payload>>, 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;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue