feat: deterministic stub orchestrator (v0.1.0)
The orchestrator-llm module turns a goal text into a Plan JSON that the F∆I hub can apply. v0.1.0 is a deterministic stub — it always emits a three-step plan (explain, explain-stub-notice, save_flow with debug.echo) regardless of the goal. Why ship a stub: it validates the module → plan → apply pipeline end-to-end before adding LLM integration. The output is a structurally valid Plan that round-trips cleanly through fai_hub::plan::Plan deserialization. v0.2.0 will replace the stub body with an actual LLM call once the F∆I platform exposes outbound HTTP to permitted modules via wasi-http. Files: - module.yaml — capability orchestrator.plan@0.1.0, no permissions - wit/world.wit — copy of fai:platform@0.1.0 - src/lib.rs — wit_bindgen Guest impl that delegates to plan.rs - src/plan.rs — pure plan-builder (host + WASM compatible) - tests/plan_compatibility.rs — plan JSON shape assertions - rust-toolchain.toml — pin to Rust 1.86 with wasm32-wasip2 target - README.md — usage and status Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
8b2678f3e8
commit
3bb8fc9e70
8 changed files with 430 additions and 1 deletions
34
Cargo.toml
Normal file
34
Cargo.toml
Normal file
|
|
@ -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 <platform@flemming.ai>"]
|
||||||
|
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.
|
||||||
78
README.md
78
README.md
|
|
@ -1,3 +1,79 @@
|
||||||
# orchestrator-llm
|
# orchestrator-llm
|
||||||
|
|
||||||
F∆I orchestrator module — turns natural-language goals into structured execution plans (Phase 0.5, version 0.8.0)
|
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: <llm-endpoint>` 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`.
|
||||||
|
|
|
||||||
31
module.yaml
Normal file
31
module.yaml
Normal file
|
|
@ -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: <llm-endpoint>` once the platform exposes
|
||||||
|
# wasi-http to permitted modules.
|
||||||
|
permissions: []
|
||||||
4
rust-toolchain.toml
Normal file
4
rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.86"
|
||||||
|
targets = ["wasm32-wasip2"]
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
63
src/lib.rs
Normal file
63
src/lib.rs
Normal file
|
|
@ -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);
|
||||||
101
src/plan.rs
Normal file
101
src/plan.rs
Normal file
|
|
@ -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<PlanStep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
tests/plan_compatibility.rs
Normal file
47
tests/plan_compatibility.rs
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
73
wit/world.wit
Normal file
73
wit/world.wit
Normal file
|
|
@ -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<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