mahbot 0.4.2

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
Documentation
use crate::Tool;
use async_trait::async_trait;
use serde_json::json;
use std::path::Path;

/// Tool for generating videos via OpenRouter's async videos API.
///
/// Submits a video generation job, polls for completion, downloads the
/// resulting video file, and returns its path so the agent can send it via
/// `[VIDEO:path]` in its reply.
pub struct VideoGenTool;

#[async_trait]
impl Tool for VideoGenTool {
    fn name(&self) -> &'static str {
        "video_gen"
    }

    fn media_marker(&self) -> Option<&'static str> {
        Some("[VIDEO:")
    }

    fn preserve_full_output(&self) -> bool {
        // Result is the marker plus an LLM transcription capped at
        // 2048 tokens/3-min timeout that can exceed the 5 KB budget —
        // the Artist needs the full description to reason about its output.
        true
    }

    fn parameters_schema(&self) -> serde_json::Value {
        super::tool_params_schema(
            &json!({
                "prompt": {
                    "type": "string",
                    "description": "Text description of the video to generate"
                },
                "images": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Path to a reference/start image for image-to-video generation (single image only)",
                    "maxItems": 1
                },
                "duration": {
                    "type": "integer",
                    "description": "Duration in seconds (model-dependent; valid values come from the active model's capability block)"
                },
                "resolution": {
                    "type": "string",
                    "description": "Desired resolution for the generated video (model-dependent; valid values come from the active model's capability block)"
                },
                "aspect_ratio": {
                    "type": "string",
                    "description": "Aspect ratio for the generated video (model-dependent; valid values come from the active model's capability block)"
                },
                "size": {
                    "type": "string",
                    "pattern": "\\d+x\\d+",
                    "description": "Exact size in WxH format (e.g. 1280x720; model-dependent)"
                },
                "generate_audio": {
                    "type": "boolean",
                    "description": "Whether to generate an audio track (model-dependent)"
                },
                "seed": {
                    "type": "integer",
                    "description": "Seed for reproducible generation"
                }
            }),
            &["prompt"],
        )
    }

    async fn execute(
        &self,
        ws: &crate::Workspace,
        args: serde_json::Value,
    ) -> anyhow::Result<String> {
        let prompt = super::get_str(&args, "prompt")?;

        let model = crate::config::CONFIG.video_model();

        let duration = super::get_opt_i64(&args, "duration");
        let resolution = super::get_opt_str(&args, "resolution");
        let aspect_ratio = super::get_opt_str(&args, "aspect_ratio");
        let size = super::get_opt_str(&args, "size");
        let generate_audio = super::get_opt_bool(&args, "generate_audio");
        let seed = super::get_opt_i64(&args, "seed");

        // Build the API base URL (strip /chat/completions if present).
        // Video generation always targets OpenRouter (mahbot-1884) — a custom
        // chat endpoint never serves video models.
        let endpoint = crate::config::DEFAULT_PROVIDER_ENDPOINT.to_string();
        let api_base = crate::providers::ensure_base_url(&endpoint);

        // ── Build the video generation request body ─────────────────────
        let mut body = json!({
            "model": model,
            "prompt": prompt,
        });

        if let Some(d) = duration {
            body["duration"] = json!(d);
        }

        if let Some(r) = resolution {
            body["resolution"] = json!(r);
        }

        if let Some(a) = aspect_ratio {
            body["aspect_ratio"] = json!(a);
        }

        if let Some(s) = size {
            body["size"] = json!(s);
        }

        if let Some(g) = generate_audio {
            body["generate_audio"] = json!(g);
        }

        if let Some(s) = seed {
            body["seed"] = json!(s);
        }

        // Optional: add reference image via input_references. Reference-load
        // failures are hard errors — never silently degrade to text-to-video.
        let images: Vec<String> = super::get_str_array(&args, "images");

        if images.len() > 1 {
            anyhow::bail!(
                "Video generation supports only a single reference image (received {}). \
                 Retry with exactly one image.",
                images.len(),
            );
        }

        let mut references: Vec<crate::util::ReferenceImage> = Vec::new();
        if let Some(img_path) = images.first() {
            let reference = crate::util::load_reference_image(
                Path::new(img_path),
                super::MAX_REFERENCE_IMAGE_BYTES,
            )
            .await?;
            references.push(reference);
            body[super::INPUT_REFERENCES_KEY] = super::reference_json(&references);
        }

        // Aggregate body budget: a single reference at the per-image cap
        // already approaches the ~2 MB body limit once base64 + JSON overhead
        // is added, so verify the serialized body before submitting.
        super::fit_request_body_budget(&mut body, &mut references, super::MAX_REQUEST_BODY_BYTES)?;

        let video_bytes =
            super::fetch_async_video(&api_base, &body, super::VideoJobLabels::GENERATION).await?;

        // Save to workspace/generated/ and format the media marker. The marker
        // stays first; the transcription is appended for the Artist to reason
        // about its own output (fail-open: marker-only on transcription failure).
        let output_path = super::save_generated_file(ws, &video_bytes, "video", "mp4").await?;
        let marker = self.format_media_result(&output_path);
        Ok(super::format_video_result(marker, &output_path).await)
    }
}