vibe-action 0.1.4

Command router — execute shell commands and LLM prompts via simple YAML actions.
//! LLM cluster executor.
//! Sends prompts to vibe-cluster and returns model responses.

use std::time::Duration;

use anyhow::Result;
use vibe_cluster::BatchOptions;
use vibe_cluster::Prompt;

use crate::configs::app::AppConfig;
use crate::engine::parser;
use crate::models::action::ActionRun;
use crate::output::output::OutputKind;
use crate::print_template;
use crate::utils;

/// Distributed cluster execution response payload.
pub struct ClusterResult {
    /// The generated text prompt or command payload to be executed.
    pub prompt: String,
    /// Normalized text response generated by the cluster node model.
    pub result: String,
}

pub struct Cluster;

impl Cluster {
    /// Execute an LLM prompt batch concurrently across the distributed cluster.
    pub async fn exec(
        system: &str,
        retries: u32,
        run: &ActionRun,
        prompts: &[String],
        images: Option<Vec<String>>,
    ) -> Result<Vec<ClusterResult>> {
        let config = AppConfig::instance()?;
        let cluster = config.create_cluster_filtered(run)?;

        let options = BatchOptions {
            retries: Some(retries as usize),
            ..BatchOptions::default()
        };

        let cluster_prompts: Vec<Prompt> = prompts
            .iter()
            .map(|p| Prompt {
                key: None,
                user: parser::unescape_text(p),
                system: Some(system.to_string()),
                images: images.clone(),
            })
            .collect();

        let cluster_results = cluster
            .batch_call_with_options(&cluster_prompts, &options, move |result, current, total| {
                // Get size image
                let image_info = result
                    .prompt
                    .images
                    .as_ref()
                    .map(|imgs| {
                        let size: usize = imgs.iter().map(|i| i.len()).sum();
                        format!(", {} image", utils::format::format_bytes(size))
                    })
                    .unwrap_or_default();

                // Get size token
                let bpe = tiktoken_rs::o200k_base().unwrap();
                // Approximate token count for non-OpenAI models
                let user_tokens = bpe.encode_with_special_tokens(&result.prompt.user).len();

                let system_tokens = result
                    .prompt
                    .system
                    .as_ref()
                    .map(|s| bpe.encode_with_special_tokens(s).len())
                    .unwrap_or(0);

                // Get label
                let role_label = AppConfig::instance()
                    .ok()
                    .and_then(|c| c.cluster.iter().find(|n| n.model == result.model))
                    .and_then(|n| n.role.as_ref())
                    .map(|r| r.to_string())
                    .unwrap_or_else(|| "llm".to_string());

                // Get duration
                let duration_ms =
                    utils::format::format_duration(Duration::from_millis(result.duration_ms));

                // Print result info
                print_template!(
                    OutputKind::Progress,
                    "[{role}] node batch: {current}/{total} | {model} | {duration} (~{tokens} tokens{image})",
                    "role" => role_label,
                    "current" => format!("{:>width$}", current, width = total.to_string().len()),
                    "total" => total.to_string(),
                    "model" => result.model,
                    "duration" => duration_ms,
                    "tokens" => (user_tokens + system_tokens).to_string(),
                    "image" => image_info,
                );
            })
            .await
            .map_err(|e| anyhow::anyhow!("Cluster batch call failed: {}", e))?;

        let mut final_results = Vec::with_capacity(cluster_results.len());

        for result in cluster_results {
            if let Some(err) = result.error {
                anyhow::bail!("Cluster node execution failed: {}", err);
            }
            if let Some(text) = result.text {
                // @todo
                // println!("----");
                // println!("{}", &text);
                // println!("----");
                final_results.push(ClusterResult {
                    prompt: result.prompt.user,
                    result: vibe_cluster::normalize_text(&text),
                });
            }
        }

        Ok(final_results)
    }
}