liminal-cli 0.3.30

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! `flow` (SDK-017 R4): steps fired in order, each a routed request fed by
//! the previous reply through `{"$prev": "<JSON pointer>"}` objects; the
//! first failure stops the flow by index.

/// The one substitution the seat's flow and a line's stages share (NET-031 R1).
pub use liminal_sdk::prev::fill;
use serde_json::{Value, json};

use super::Rail;
use super::call::{Outcome, request};
use crate::mcp::tools::ToolResult;

/// One step as the host wrote it.
struct Step<'a> {
    subject: &'a str,
    input: &'a Value,
    reply: bool,
    pass_for: Option<&'a str>,
    /// Whether its reply is answered (SRV-052 R3); it feeds `$prev` either way.
    keep: bool,
}

fn step(value: &Value, index: usize) -> Result<Step<'_>, String> {
    let subject = value
        .get("subject")
        .and_then(Value::as_str)
        .ok_or_else(|| format!("step {index} is missing subject"))?;
    let Some(input @ Value::Object(_)) = value.get("input") else {
        return Err(format!("step {index}'s input is missing or not an object"));
    };
    let reply = value.get("reply").and_then(Value::as_bool).unwrap_or(true);
    let pass_for = match value.get("pass_for") {
        None => None,
        Some(Value::String(audience)) => Some(audience.as_str()),
        Some(_) => return Err(format!("step {index}'s pass_for is not a string")),
    };
    let keep = match value.get("keep") {
        None => true,
        Some(Value::Bool(keep)) => *keep,
        Some(_) => return Err(format!("step {index}'s keep is not a boolean")),
    };
    Ok(Step {
        subject,
        input,
        reply,
        pass_for,
        keep,
    })
}

/// The `flow` tool over `steps`, answering every kept reply.
pub fn flow<R: Rail>(rail: &R, steps: &[Value]) -> ToolResult {
    flow_answering(rail, steps, false)
}

/// A step's entry in the answer: its reply when kept, else its size, so a
/// large intermediate reply feeds the next step without coming back
/// (SRV-052 R3).
fn entry(subject: &str, reply: &Value, kept: bool) -> Value {
    if kept {
        json!({ "subject": subject, "reply": reply })
    } else {
        let bytes = serde_json::to_vec(reply).map_or(0, |bytes| bytes.len());
        json!({ "subject": subject, "kept": false, "bytes": bytes })
    }
}

/// The `flow` tool over `steps`, at most the rail's configured count; with
/// `last`, only the last step's reply is answered, as `reply`.
pub fn flow_answering<R: Rail>(rail: &R, steps: &[Value], last: bool) -> ToolResult {
    let limit = rail.flow_steps();
    if steps.is_empty() {
        return ToolResult::failed("the flow has no steps", json!({ "steps": [] }));
    }
    if steps.len() > limit {
        return ToolResult::failed(
            &format!(
                "the flow has {} steps, over the limit of {limit}",
                steps.len()
            ),
            json!({ "steps": [] }),
        );
    }
    let mut answered: Vec<Value> = Vec::with_capacity(steps.len());
    let mut prev: Option<Value> = None;
    for (index, value) in steps.iter().enumerate() {
        let failed = |words: String, answered: &[Value]| {
            ToolResult::failed(
                &format!("step {index} stopped the flow: {words}"),
                json!({ "failed_step": index, "steps": answered }),
            )
        };
        let step = match step(value, index) {
            Ok(step) => step,
            Err(words) => return failed(words, &answered),
        };
        let input = match fill(step.input, prev.as_ref()) {
            Ok(input) => input,
            Err(words) => return failed(words, &answered),
        };
        match request(rail, step.subject, &input, step.reply, step.pass_for) {
            Outcome::Reply(reply) => {
                answered.push(entry(step.subject, &reply, step.keep && !last));
                prev = Some(reply);
            }
            Outcome::Sent => {
                answered.push(json!({ "subject": step.subject, "sent": true }));
                prev = Some(Value::Null);
            }
            Outcome::Refused(words) => {
                return failed(format!("{}: {words}", step.subject), &answered);
            }
        }
    }
    if last {
        return ToolResult::ok(json!({ "steps": answered, "reply": prev.unwrap_or(Value::Null) }));
    }
    ToolResult::ok(json!({ "steps": answered }))
}

#[cfg(test)]
#[path = "flow_tests.rs"]
mod tests;