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
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue