orchestral-runtime 0.2.0

Thread Runtime with concurrency, interruption, scheduling, LLM planners, actions, and API for Orchestral
Documentation
use std::fmt::Write;

use orchestral_core::action::ActionMeta;

pub(super) fn build_capability_catalog(
    actions: &[ActionMeta],
    skill_summaries: &[(&str, &str)],
) -> String {
    let mut out = String::new();

    let action_lines = build_action_lines(actions);
    let mcp_lines = build_mcp_lines(actions);

    if action_lines.is_empty() && mcp_lines.is_empty() && skill_summaries.is_empty() {
        return out;
    }

    out.push_str("Capability Catalog:\n");

    if !action_lines.is_empty() {
        out.push_str("Actions:\n");
        for line in action_lines {
            out.push_str(&line);
        }
    }

    if !mcp_lines.is_empty() {
        out.push_str("MCP Tools (call tool_lookup first for input schema, then call the tool directly by its exact name):\n");
        for line in mcp_lines {
            let _ = writeln!(out, "- {}", line);
        }
    }

    if !skill_summaries.is_empty() {
        out.push_str("Skills (use skill_activate to load full instructions before executing):\n");
        let mut sorted: Vec<_> = skill_summaries.to_vec();
        sorted.sort_by_key(|(name, _)| *name);
        for (name, description) in sorted {
            if description.is_empty() {
                let _ = writeln!(out, "- {}", name);
            } else {
                let _ = writeln!(out, "- {}: {}", name, description);
            }
        }
    }

    out
}

fn build_action_lines(actions: &[ActionMeta]) -> Vec<String> {
    let mut lines = actions
        .iter()
        .filter(|action| !is_mcp_action(action))
        .map(|action| {
            let prefix = action
                .category
                .as_deref()
                .map(|category| format!("{} [{}]", action.name, category))
                .unwrap_or_else(|| action.name.clone());
            let mut card = format!("- {}: {}", prefix, action.description);
            if !action.input_kinds.is_empty() {
                let _ = write!(card, "\n  consumes: {}", action.input_kinds.join(", "));
            }
            if !action.output_kinds.is_empty() {
                let _ = write!(card, "\n  produces: {}", action.output_kinds.join(", "));
            }
            let input_fields = summarize_schema_fields(&action.input_schema);
            if !input_fields.is_empty() {
                let _ = write!(card, "\n  input_fields: {}", input_fields.join(", "));
            }
            let output_fields = summarize_schema_fields(&action.output_schema);
            if !output_fields.is_empty() {
                let _ = write!(card, "\n  output_fields: {}", output_fields.join(", "));
            }
            card.push('\n');
            card
        })
        .collect::<Vec<_>>();
    lines.sort();
    lines
}

fn build_mcp_lines(actions: &[ActionMeta]) -> Vec<String> {
    let mut lines = actions
        .iter()
        .filter(|action| is_mcp_action(action))
        .map(|action| format!("{}: {}", action.name, action.description))
        .collect::<Vec<_>>();
    lines.sort();
    lines
}

fn is_mcp_action(action: &ActionMeta) -> bool {
    action.has_capability("mcp") || action.name.starts_with("mcp__")
}

fn summarize_schema_fields(schema: &serde_json::Value) -> Vec<String> {
    let properties = match schema.get("properties").and_then(|value| value.as_object()) {
        Some(properties) => properties,
        None => return Vec::new(),
    };
    let required = schema_required_fields(schema);
    let mut names = properties.keys().cloned().collect::<Vec<_>>();
    names.sort();
    names
        .into_iter()
        .map(|name| {
            summarize_schema_field(&name, &properties[&name], required.contains(name.as_str()))
        })
        .collect()
}

fn summarize_schema_field(name: &str, schema: &serde_json::Value, required: bool) -> String {
    let mut label = if required {
        format!("{} (required)", name)
    } else {
        name.to_string()
    };
    let mut hints = Vec::new();

    if let Some(values) = schema.get("enum").and_then(|value| value.as_array()) {
        let items = values
            .iter()
            .filter_map(|value| value.as_str())
            .collect::<Vec<_>>();
        if !items.is_empty() {
            hints.push(format!("enum: {}", items.join(" | ")));
        }
    }

    if let Some(shape) = summarize_object_shape(schema) {
        hints.push(format!("shape: {}", shape));
    }

    if let Some(description) = schema.get("description").and_then(|value| value.as_str()) {
        if description.to_ascii_lowercase().contains("utf-8") {
            hints.push("UTF-8 text".to_string());
        }
    }

    if !hints.is_empty() {
        label.push_str(&format!(" [{}]", hints.join("; ")));
    }
    label
}

fn summarize_object_shape(schema: &serde_json::Value) -> Option<String> {
    let properties = schema
        .get("properties")
        .and_then(|value| value.as_object())?;
    if properties.is_empty() {
        return None;
    }
    let required = schema_required_fields(schema);
    let mut names = properties.keys().cloned().collect::<Vec<_>>();
    names.sort();
    if names.len() > 4 {
        names.truncate(4);
        names.push("...".to_string());
    }
    let summary = names
        .into_iter()
        .map(|name| {
            if required.contains(name.as_str()) {
                format!("{}*", name)
            } else {
                name
            }
        })
        .collect::<Vec<_>>()
        .join(", ");
    Some(format!("{{{}}}", summary))
}

fn schema_required_fields(schema: &serde_json::Value) -> std::collections::BTreeSet<&str> {
    schema
        .get("required")
        .and_then(|value| value.as_array())
        .into_iter()
        .flatten()
        .filter_map(|value| value.as_str())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_capability_catalog_groups_by_category() {
        let catalog = build_capability_catalog(
            &[
                ActionMeta::new("file_read", "Read a file")
                    .with_category("direct")
                    .with_input_kinds(["workspace.path"])
                    .with_output_kinds(["workspace.text_file"])
                    .with_input_schema(serde_json::json!({
                        "type": "object",
                        "properties": {
                            "path": { "type": "string" }
                        },
                        "required": ["path"]
                    }))
                    .with_output_schema(serde_json::json!({
                        "type": "object",
                        "properties": {
                            "content": { "type": "string" }
                        },
                        "required": ["content"]
                    })),
                ActionMeta::new("document_apply_patch", "Apply document patch")
                    .with_category("document")
                    .with_input_kinds(["document.patch_spec"])
                    .with_output_kinds(["document.apply_result"])
                    .with_input_schema(serde_json::json!({
                        "type": "object",
                        "properties": {
                            "patch_spec": { "type": "object" }
                        },
                        "required": ["patch_spec"]
                    }))
                    .with_output_schema(serde_json::json!({
                        "type": "object",
                        "properties": {
                            "updated_paths": {
                                "type": "array",
                                "items": { "type": "string" }
                            }
                        },
                        "required": ["updated_paths"]
                    })),
                ActionMeta::new("mcp__demo", "Call MCP").with_capability("mcp"),
            ],
            &[("xlsx", "Spreadsheet automation")],
        );

        assert!(catalog.contains("Actions:"));
        assert!(catalog.contains("file_read [direct]: Read a file"));
        assert!(catalog.contains("document_apply_patch [document]: Apply document patch"));
        assert!(catalog.contains("consumes: workspace.path"));
        assert!(catalog.contains("produces: workspace.text_file"));
        assert!(catalog.contains("consumes: document.patch_spec"));
        assert!(catalog.contains("produces: document.apply_result"));
        assert!(catalog.contains("input_fields: path (required)"));
        assert!(catalog.contains("output_fields: content (required)"));
        assert!(catalog.contains("input_fields: patch_spec (required)"));
        assert!(catalog.contains("output_fields: updated_paths (required)"));
        assert!(catalog
            .contains("MCP Tools (call tool_lookup first for input schema, then call the tool directly by its exact name):"));
        assert!(catalog
            .contains("Skills (use skill_activate to load full instructions before executing):"));
        assert!(catalog.contains("- xlsx: Spreadsheet automation"));
    }
}