car-server-core 0.34.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Media-generation tools for the general assistant — the capabilities a
//! text-only agent (Claude Code, Codex) structurally cannot offer:
//! `generate_image` and `generate_speech`, backed by CAR's local image and
//! voice models via the inference engine.
//!
//! Like [`NetTools`](super::net_tools::NetTools), these run **host-side** (the
//! models need the host GPU/MLX, and the default sandbox is `--network none`),
//! but they are wired in as a [`car_engine::ToolExecutor`] delegate so the
//! runtime's validator, policy engine, and event log still wrap every call.
//!
//! **Artifact contract** (neo's design): a producer writes a file under the
//! working root and returns its *path*, never inline bytes — base64 media in a
//! tool result would be shredded by the loop's 16 KB observation cap and poison
//! the context. The output path is clamped under the root exactly like
//! `write_file` (plus a symlink-escape recheck, since these writers run
//! host-side sharing the mount), and the sandbox mounts that root, so a
//! host-written artifact is visible to the agent's substrate-side `read_file`.

use std::path::PathBuf;
use std::sync::Arc;

use async_trait::async_trait;
use car_engine::ToolExecutor;
use car_inference::tasks::synthesize::SynthesizeRequest;
use car_inference::{GenerateImageRequest, InferenceEngine, ModelCapability};
use serde_json::{json, Value};

use crate::coder::policy::stays_under;

/// Host-side media generation, backed by the inference engine's local models.
pub struct MediaTools {
    engine: Arc<InferenceEngine>,
    root: PathBuf,
}

impl MediaTools {
    pub fn new(engine: Arc<InferenceEngine>, root: PathBuf) -> Self {
        Self { engine, root }
    }

    /// True when a local model with `cap` is actually available — so a tool is
    /// never advertised on a host that can't run it (a def that errors at call
    /// time is worse than an absent one; the model would keep retrying).
    fn has_capability(&self, cap: ModelCapability) -> bool {
        self.engine
            .list_models_unified()
            .iter()
            .any(|m| m.available && m.capabilities.contains(&cap))
    }

    /// Advertised tool defs — one per available capability, empty when none.
    pub fn tool_defs(&self) -> Vec<Value> {
        let mut defs = Vec::new();
        if self.has_capability(ModelCapability::ImageGeneration) {
            defs.push(image_tool_def());
        }
        if self.has_capability(ModelCapability::TextToSpeech) {
            defs.push(speech_tool_def());
        }
        defs
    }

    /// Resolve a caller-supplied `output_path` (or a default) to a validated
    /// absolute path under the working root. Cheap lexical clamp first, then —
    /// because this writer runs HOST-side and shares its mount with an agent
    /// that has shell — resolve symlinks and re-assert the REAL parent is under
    /// the REAL root (a planted `ln -s ~/.car evil` + `output_path: "evil/x"`
    /// must not let the host write outside the sandbox). Returns
    /// `(relative_for_reporting, absolute_final_path)`.
    fn resolve_output(
        &self,
        params: &Value,
        default_rel: &str,
    ) -> Result<(String, PathBuf), String> {
        let rel = params
            .get("output_path")
            .and_then(|v| v.as_str())
            .filter(|s| !s.trim().is_empty())
            .map(|s| s.to_string())
            .unwrap_or_else(|| default_rel.to_string());

        if !stays_under(&self.root, &rel) {
            return Err(format!("output_path '{rel}' escapes the working directory"));
        }
        let abs = self.root.join(&rel);
        let parent = abs
            .parent()
            .ok_or_else(|| "output_path has no parent directory".to_string())?;
        std::fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;

        let root_real = self
            .root
            .canonicalize()
            .map_err(|e| format!("resolve working directory: {e}"))?;
        let parent_real = parent
            .canonicalize()
            .map_err(|e| format!("resolve output directory: {e}"))?;
        if !parent_real.starts_with(&root_real) {
            return Err(format!(
                "output_path '{rel}' resolves outside the working directory"
            ));
        }
        let file_name = abs
            .file_name()
            .ok_or_else(|| "output_path has no file name".to_string())?;
        Ok((rel, parent_real.join(file_name)))
    }

    async fn run_generate_image(&self, params: &Value) -> Result<Value, String> {
        let prompt = params
            .get("prompt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("generate_image requires a non-empty `prompt`")?;

        let (rel, final_path) =
            self.resolve_output(params, &format!("assets/{}.png", slug(prompt)))?;

        // Clamp dimensions to a hard ceiling — a model asked for 16384×16384
        // would allocate a huge host buffer and hold the device lock for a long
        // time (host-OOM / long-hold DoS). Prose in the description isn't enough.
        let clamp_dim = |v: Option<u64>| v.map(|n| (n as u32).clamp(64, 1536));

        let req = GenerateImageRequest {
            prompt: prompt.to_string(),
            width: clamp_dim(params.get("width").and_then(Value::as_u64)),
            height: clamp_dim(params.get("height").and_then(Value::as_u64)),
            seed: params.get("seed").and_then(Value::as_u64),
            output_path: Some(final_path.to_string_lossy().to_string()),
            ..Default::default()
        };

        let result = self
            .engine
            .generate_image(req)
            .await
            .map_err(|e| format!("image generation failed: {e}"))?;

        Ok(json!({
            "image_path": rel,
            "media_type": result.media_type,
            "model_used": result.model_used,
            "note": format!("Wrote a generated image to {rel}. Reference it from HTML/CSS (e.g. <img src=\"{rel}\">) or read it like any file."),
        }))
    }

    async fn run_generate_speech(&self, params: &Value) -> Result<Value, String> {
        let text = params
            .get("text")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("generate_speech requires non-empty `text`")?;

        let (rel, final_path) =
            self.resolve_output(params, &format!("assets/{}.wav", slug(text)))?;

        let req = SynthesizeRequest {
            text: text.to_string(),
            voice: params
                .get("voice")
                .and_then(|v| v.as_str())
                .filter(|s| !s.trim().is_empty())
                .map(String::from),
            output_path: Some(final_path.to_string_lossy().to_string()),
            ..Default::default()
        };

        let result = self
            .engine
            .synthesize(req)
            .await
            .map_err(|e| format!("speech synthesis failed: {e}"))?;

        Ok(json!({
            "audio_path": rel,
            "media_type": result.media_type,
            "model_used": result.model_used,
            "voice_used": result.voice_used,
            "note": format!("Wrote generated speech audio to {rel}. Embed it (e.g. <audio controls src=\"{rel}\">) or read it like any file."),
        }))
    }
}

#[async_trait]
impl ToolExecutor for MediaTools {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        match tool {
            "generate_image" => self.run_generate_image(params).await,
            "generate_speech" => self.run_generate_speech(params).await,
            // The prefix must be exactly "unknown tool" so the ChainedDelegate
            // falls through to the next executor.
            other => Err(format!("unknown tool: '{other}'")),
        }
    }
}

fn image_tool_def() -> Value {
    json!({
        "name": "generate_image",
        "description": "Generate an image from a text prompt using CAR's local image model, \
            write it as a PNG under the working directory, and return its path. Use this to \
            create real visual assets — illustrations, art, backgrounds, icons, textures, \
            logos — that you can then reference from HTML/CSS or place in a project. The \
            result is a file path; read or embed it like any other file. Generation takes \
            roughly a minute, so prefer a few well-chosen images over many.",
        "parameters": {
            "type": "object",
            "properties": {
                "prompt": {"type": "string", "description": "What the image should depict. Be specific and descriptive (subject, style, lighting, composition)."},
                "output_path": {"type": "string", "description": "Where to write the PNG, relative to the working directory (e.g. \"assets/jet.png\"). Parent directories are created. Defaults to \"assets/<slug>.png\"."},
                "width": {"type": "integer", "description": "Pixels wide (default 768; keep <= 1024)."},
                "height": {"type": "integer", "description": "Pixels tall (default 512; keep <= 1024)."},
                "seed": {"type": "integer", "description": "Optional seed for a reproducible result."}
            },
            "required": ["prompt"]
        },
        // Self-declared metadata the loop derives behavior from (neo's design):
        // a write is real progress (resets the no-progress guard); sandbox tier.
        "mutating": true,
        "tier": "sandbox_edit"
    })
}

fn speech_tool_def() -> Value {
    json!({
        "name": "generate_speech",
        "description": "Generate spoken audio from text using CAR's local voice model, write it \
            as a WAV under the working directory, and return its path. Use this for narration, \
            voiceover, a spoken UI, or game/video audio — real synthesized speech, not a text \
            placeholder. The result is a file path; embed it (e.g. <audio controls src=\"...\">) \
            or read it like any other file.",
        "parameters": {
            "type": "object",
            "properties": {
                "text": {"type": "string", "description": "The words to speak."},
                "output_path": {"type": "string", "description": "Where to write the WAV, relative to the working directory (e.g. \"assets/welcome.wav\"). Parent directories are created. Defaults to \"assets/<slug>.wav\"."},
                "voice": {"type": "string", "description": "Optional voice id (e.g. \"af_heart\"). Omit for the default voice."}
            },
            "required": ["text"]
        },
        "mutating": true,
        "tier": "sandbox_edit"
    })
}

/// A filesystem-safe, bounded slug from a prompt for the default output name.
fn slug(s: &str) -> String {
    let mut out = String::new();
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
        } else if !out.ends_with('-') {
            out.push('-');
        }
        if out.len() >= 40 {
            break;
        }
    }
    let trimmed = out.trim_matches('-').to_string();
    if trimmed.is_empty() {
        "media".to_string()
    } else {
        trimmed
    }
}

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

    #[test]
    fn slug_is_filesystem_safe_and_bounded() {
        assert_eq!(
            slug("A sleek Private Jet!!! at sunset"),
            "a-sleek-private-jet-at-sunset"
        );
        assert_eq!(slug("***"), "media");
        assert!(slug(&"x".repeat(100)).len() <= 40);
    }

    #[tokio::test]
    async fn unknown_tool_falls_through() {
        let engine = Arc::new(InferenceEngine::new(Default::default()));
        let media = MediaTools::new(engine, std::env::temp_dir());
        let err = media.execute("nope", &json!({})).await.unwrap_err();
        assert!(err.starts_with("unknown tool"), "{err}");
    }

    #[tokio::test]
    async fn generate_image_rejects_empty_prompt_and_escaping_path() {
        let engine = Arc::new(InferenceEngine::new(Default::default()));
        let media = MediaTools::new(engine, std::env::temp_dir());
        assert!(media
            .execute("generate_image", &json!({"prompt": "  "}))
            .await
            .unwrap_err()
            .contains("non-empty"));
        assert!(media
            .execute(
                "generate_image",
                &json!({"prompt": "x", "output_path": "../escape.png"})
            )
            .await
            .unwrap_err()
            .contains("escapes"));
    }

    #[tokio::test]
    async fn generate_speech_rejects_empty_text_and_escaping_path() {
        let engine = Arc::new(InferenceEngine::new(Default::default()));
        let media = MediaTools::new(engine, std::env::temp_dir());
        assert!(media
            .execute("generate_speech", &json!({"text": "  "}))
            .await
            .unwrap_err()
            .contains("non-empty"));
        assert!(media
            .execute(
                "generate_speech",
                &json!({"text": "hi", "output_path": "../escape.wav"})
            )
            .await
            .unwrap_err()
            .contains("escapes"));
    }
}