orchestrator-llm/src/plan.rs
flemming-it b407450e6b
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1m25s
style: cargo fmt fixes flagged by Forgejo CI
Signed-off-by: flemming-it <sf@flemming.it>
2026-04-30 09:23:33 +02:00

192 lines
5.9 KiB
Rust

//! Plan structure and pure helpers.
//!
//! Mirrors `fai_hub::plan::Plan` so the JSON we emit deserializes
//! cleanly on the hub side. Plan validation lives here so it can
//! be exercised on both host (tests) and WASM targets.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Plan {
pub schema_version: u32,
#[serde(default)]
pub goal: String,
pub steps: Vec<PlanStep>,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PlanStep {
Explain {
text: String,
},
SaveFlow {
name: String,
yaml: String,
},
RunFlow {
name: String,
#[serde(default)]
inputs: std::collections::HashMap<String, String>,
},
InstallModule {
name: String,
#[serde(default)]
version: Option<String>,
},
}
#[derive(Debug, thiserror::Error)]
pub enum PlanError {
#[error("plan json could not be parsed: {0}")]
Parse(String),
#[error("plan schema_version {0} is not supported (this module emits version 1)")]
UnsupportedSchemaVersion(u32),
#[error("plan must contain at least one step")]
Empty,
#[error("step {index}: {reason}")]
InvalidStep { index: usize, reason: String },
}
pub fn parse_and_validate(json: &str) -> Result<Plan, PlanError> {
let plan: Plan = serde_json::from_str(json).map_err(|e| PlanError::Parse(e.to_string()))?;
validate(&plan)?;
Ok(plan)
}
pub fn validate(plan: &Plan) -> Result<(), PlanError> {
if plan.schema_version != 1 {
return Err(PlanError::UnsupportedSchemaVersion(plan.schema_version));
}
if plan.steps.is_empty() {
return Err(PlanError::Empty);
}
for (i, step) in plan.steps.iter().enumerate() {
let bad = match step {
PlanStep::Explain { text } if text.is_empty() => Some("empty text"),
PlanStep::SaveFlow { name, yaml } if name.is_empty() || yaml.is_empty() => {
Some("empty name or yaml")
}
PlanStep::RunFlow { name, .. } if name.is_empty() => Some("empty name"),
PlanStep::InstallModule { name, .. } if name.is_empty() => Some("empty name"),
_ => None,
};
if let Some(reason) = bad {
return Err(PlanError::InvalidStep {
index: i,
reason: reason.to_string(),
});
}
}
Ok(())
}
/// Serialize a plan to JSON. Used by both the LLM path (after
/// validating the LLM-emitted plan) and the stub fallback.
pub fn to_json(plan: &Plan) -> String {
serde_json::to_string(plan).unwrap_or_else(|_| {
r#"{"schema_version":1,"goal":"","steps":[{"kind":"explain","text":"plan serialization failed"}]}"#.to_string()
})
}
/// Deterministic stub plan for the case where no LLM endpoint
/// was provided. Always emits a debug.echo flow.
pub fn build_stub_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: "orchestrator-llm called without llm_endpoint — \
emitting deterministic stub plan."
.to_string(),
},
PlanStep::SaveFlow {
name: "orchestrator-stub-greeting".to_string(),
yaml: stub_flow_yaml(goal),
},
],
};
to_json(&plan)
}
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 validates_minimal_plan() {
let plan: Plan = serde_json::from_str(
r#"{"schema_version":1,"goal":"x","steps":[{"kind":"explain","text":"hi"}]}"#,
)
.unwrap();
validate(&plan).unwrap();
}
#[test]
fn rejects_empty_steps() {
let plan: Plan =
serde_json::from_str(r#"{"schema_version":1,"goal":"x","steps":[]}"#).unwrap();
assert!(matches!(validate(&plan), Err(PlanError::Empty)));
}
#[test]
fn rejects_unsupported_schema_version() {
let plan: Plan = serde_json::from_str(
r#"{"schema_version":99,"goal":"x","steps":[{"kind":"explain","text":"hi"}]}"#,
)
.unwrap();
assert!(matches!(
validate(&plan),
Err(PlanError::UnsupportedSchemaVersion(99))
));
}
#[test]
fn rejects_save_flow_with_empty_name() {
let plan: Plan = serde_json::from_str(
r#"{"schema_version":1,"goal":"x","steps":[{"kind":"save_flow","name":"","yaml":"a"}]}"#,
)
.unwrap();
assert!(matches!(
validate(&plan),
Err(PlanError::InvalidStep { .. })
));
}
#[test]
fn round_trips_install_module_with_version() {
let json = r#"{"schema_version":1,"goal":"x","steps":[{"kind":"install_module","name":"text.extract","version":"^1"}]}"#;
let plan = parse_and_validate(json).unwrap();
match &plan.steps[0] {
PlanStep::InstallModule { name, version } => {
assert_eq!(name, "text.extract");
assert_eq!(version.as_deref(), Some("^1"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn stub_plan_validates() {
let json = build_stub_plan("test goal");
let plan: Plan = serde_json::from_str(&json).unwrap();
validate(&plan).unwrap();
}
}