diff --git a/Cargo.lock b/Cargo.lock index 72f8b0246..a57be9027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.0" @@ -562,7 +568,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1091,7 +1097,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1487,7 +1493,7 @@ version = "2.1.2" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "chrono", "chrono-tz", "dirs", @@ -1496,6 +1502,7 @@ dependencies = [ "regex", "reqwest", "rusqlite", + "rustix", "serde", "serde_json", "sha2", @@ -1503,7 +1510,9 @@ dependencies = [ "thiserror", "tinyagents-definition", "tinyinference-embeddings", + "tinyinference-image", "tinyinference-llm", + "tinyinference-video", "tinytools 0.4.1", "tinytools-agent 0.4.1", "tokio", @@ -1640,6 +1649,22 @@ dependencies = [ "url", ] +[[package]] +name = "tinyinference-image" +version = "0.3.0" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tinyinference-core", + "tokio", + "tracing", +] + [[package]] name = "tinyinference-llm" version = "0.3.0" @@ -1658,6 +1683,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinyinference-video" +version = "0.3.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror", + "tinyinference-image", + "tokio", + "tracing", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/crates/tinyagents-harness/Cargo.toml b/crates/tinyagents-harness/Cargo.toml index 5c1e7bd0d..3a5b7e936 100644 --- a/crates/tinyagents-harness/Cargo.toml +++ b/crates/tinyagents-harness/Cargo.toml @@ -20,6 +20,7 @@ dirs = { version = "6", optional = true } flate2 = { version = "1", optional = true } futures = { workspace = true } regex = "1" +rustix = { version = "1", features = ["fs"] } reqwest = { workspace = true, features = ["stream", "http2"], optional = true } rusqlite = { workspace = true, optional = true } serde = { workspace = true } @@ -31,6 +32,8 @@ tinyagents-definition = { path = "../tinyagents-definition", version = "2.1.2" } tinytools-agent = { path = "../../vendor/tinytools/crates/tinytools-agent", version = "0.4.1", default-features = false } tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" } tinyinference-embeddings = { path = "../../vendor/tinyinference/crates/tinyinference-embeddings", version = "0.3.0" } +tinyinference-image = { path = "../../vendor/tinyinference/crates/tinyinference-image", version = "0.3.0", optional = true } +tinyinference-video = { path = "../../vendor/tinyinference/crates/tinyinference-video", version = "0.3.0", optional = true } tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.4.1" } tokio = { workspace = true, features = ["sync", "time", "macros", "rt", "rt-multi-thread", "fs", "io-util", "process"] } tempfile = { workspace = true } @@ -45,6 +48,8 @@ sqlite = ["dep:rusqlite"] tools = ["builtin-tools"] builtin-tools = ["dep:chrono-tz"] multimodal = ["dep:flate2", "dep:reqwest"] +# Image and video generation tools over tinyinference-image / -video. +media = ["dep:tinyinference-image", "dep:tinyinference-video"] # Gates the Claude Code CLI and Claude Agent SDK provider adapters, which are # the only consumers of `uuid`, `tempfile` beyond the artifact tests, `dirs`, # and `wait-timeout` in this crate. diff --git a/crates/tinyagents-harness/src/error.rs b/crates/tinyagents-harness/src/error.rs index 73369c150..1dcc6868d 100644 --- a/crates/tinyagents-harness/src/error.rs +++ b/crates/tinyagents-harness/src/error.rs @@ -354,6 +354,31 @@ impl From for TinyAgentsError { } } +#[cfg(feature = "media")] +impl From for TinyAgentsError { + fn from(error: tinyinference_image::Error) -> Self { + match error { + tinyinference_image::Error::Serialization(error) => Self::Serialization(error), + error @ (tinyinference_image::Error::Validation(_) + | tinyinference_image::Error::Unsupported { .. }) => { + Self::Validation(error.to_string()) + } + other => Self::Model(other.to_string()), + } + } +} + +#[cfg(feature = "media")] +impl From for TinyAgentsError { + fn from(error: tinyinference_video::Error) -> Self { + match error { + tinyinference_video::Error::Media(error) => error.into(), + error @ tinyinference_video::Error::Timeout { .. } => Self::Timeout(error.to_string()), + other => Self::Model(other.to_string()), + } + } +} + impl TinyAgentsError { /// Builds the right error for a structured provider failure, promoting a /// recognised context overflow to [`TinyAgentsError::ContextOverflow`]. diff --git a/crates/tinyagents-harness/src/lib.rs b/crates/tinyagents-harness/src/lib.rs index b7adebe87..1474a22c6 100644 --- a/crates/tinyagents-harness/src/lib.rs +++ b/crates/tinyagents-harness/src/lib.rs @@ -73,6 +73,8 @@ pub mod handoff; pub mod host; pub mod ids; pub mod limits; +#[cfg(feature = "media")] +pub mod media; pub mod middleware; pub mod model_registry; #[cfg(feature = "multimodal")] @@ -98,6 +100,8 @@ pub mod tool; pub mod tools; pub mod workspace; +#[cfg(feature = "media")] +pub use tinyinference_image; /// Re-exported vendor crates. Downstream consumers should reach these /// dependencies' types through these re-exports (e.g. /// `tinyagents_harness::tinyinference_llm::ChatMessage`) rather than adding @@ -106,6 +110,8 @@ pub mod workspace; /// independent dependency would produce a duplicate, incompatible copy of /// the same types. pub use tinyinference_llm; +#[cfg(feature = "media")] +pub use tinyinference_video; pub use tinytools; pub use tinytools_agent; diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs new file mode 100644 index 000000000..e86783438 --- /dev/null +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -0,0 +1,332 @@ +//! [`GenerateImageTool`]: image generation as a harness tool. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinyinference_image::{ImageGenerator, ImageRequest, MAX_IMAGES_PER_REQUEST}; +use tinytools::{ + PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolPolicy, ToolResult, ToolRunContext, + ToolTimeout, +}; + +use super::types::{ + MediaOutput, arg_i64, arg_list, arg_str, arg_u64, check_option_types, check_string_lists, + check_string_options, +}; +use super::{artifact_stem, media_policy}; + +/// Default model-visible name. +pub const GENERATE_IMAGE_TOOL_NAME: &str = "generate_image"; + +/// Upper bound on one image call; slow models take ~90 s at high quality. +const IMAGE_TIMEOUT_MS: u64 = 300_000; + +/// Generates images with an [`ImageGenerator`] and saves them to the run's +/// workspace. +/// +/// The result lists the saved file paths. A billed call that produced nothing +/// is reported as an error that tells the model not to retry, because a retry +/// is a new, separately billed generation. +pub struct GenerateImageTool { + generator: Arc, + output: MediaOutput, + name: String, + description: String, + permission: PermissionLevel, + category: ToolCategory, +} + +impl GenerateImageTool { + /// Creates the tool with the default name and description. + #[must_use] + pub fn new(generator: Arc, output: MediaOutput) -> Self { + Self { + generator, + output, + name: GENERATE_IMAGE_TOOL_NAME.to_owned(), + description: "Generate or edit images from a text prompt. Pass reference images \ + (URLs or workspace file paths) to edit, restyle, or keep a subject \ + consistent. Saves each image into the workspace and returns its path. \ + Billed per call: do not call again after an error that says it was billed." + .to_owned(), + permission: PermissionLevel::Write, + category: ToolCategory::System, + } + } + + /// Overrides the model-visible name (for hosts with an existing name). + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Overrides the model-visible description. + #[must_use] + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Overrides the permission level the host gates the call on + /// (default [`PermissionLevel::Write`]). + #[must_use] + pub fn with_permission_level(mut self, permission: PermissionLevel) -> Self { + self.permission = permission; + self + } + + /// Overrides the tool's category (default [`ToolCategory::System`]). + #[must_use] + pub fn with_category(mut self, category: ToolCategory) -> Self { + self.category = category; + self + } + + async fn run(&self, args: &Value, context: Option<&dyn ToolRunContext>) -> ToolResult { + let workspace = context.and_then(ToolRunContext::workspace_root); + let Some(prompt) = arg_str(args, &["prompt"]) else { + return ToolResult::error("`prompt` is required"); + }; + if let Err(message) = check_option_types(args, &["n", "count"], &["seed"], &[]) { + return ToolResult::error(message); + } + if let Err(message) = check_string_options( + args, + &[ + "prompt", + "model", + "size", + "resolution", + "aspect_ratio", + "aspectRatio", + "quality", + "output_format", + "format", + "background", + ], + ) { + return ToolResult::error(message); + } + if let Err(message) = check_string_lists( + args, + &[ + "references", + "reference_images", + "input_images", + "inputImages", + ], + ) { + return ToolResult::error(message); + } + let mut request = ImageRequest::new(prompt); + request.model = arg_str(args, &["model"]).map(str::to_owned); + // Enforce the maximum image count at runtime + if let Some(n) = arg_u64(args, &["n", "count"]) { + if n > u64::from(MAX_IMAGES_PER_REQUEST) { + return ToolResult::error(format!( + "image count {} exceeds maximum of {}", + n, MAX_IMAGES_PER_REQUEST + )); + } + request.n = u32::try_from(n).ok(); + } + request.size = arg_str(args, &["size"]).map(str::to_owned); + request.resolution = arg_str(args, &["resolution"]).map(str::to_owned); + request.aspect_ratio = arg_str(args, &["aspect_ratio", "aspectRatio"]).map(str::to_owned); + request.quality = arg_str(args, &["quality"]).map(str::to_owned); + request.output_format = arg_str(args, &["output_format", "format"]).map(str::to_owned); + request.background = arg_str(args, &["background"]).map(str::to_owned); + request.seed = arg_i64(args, &["seed"]); + for raw in arg_list( + args, + &[ + "references", + "reference_images", + "input_images", + "inputImages", + ], + ) { + match self.output.reference(&raw, workspace) { + Ok(reference) => request.references.push(reference), + Err(message) => return ToolResult::error(message), + } + } + let dir = match self.output.dir(workspace) { + Ok(dir) => dir, + Err(message) => return ToolResult::error(message), + }; + + let model_label = request + .model + .clone() + .unwrap_or_else(|| self.generator.default_model().to_owned()); + tracing::info!( + tool = %self.name, + provider = self.generator.name(), + model = %model_label, + references = request.references.len(), + "[media] generate_image" + ); + let response = match self.generator.generate(request).await { + Ok(response) => response, + Err(error) => { + tracing::warn!(tool = %self.name, %error, "[media] image generation failed"); + return ToolResult::error(format!("Image generation failed: {error}")); + } + }; + + // Reject empty responses as billed failures, since the call was billed + // but produced nothing to save. + if response.images.is_empty() { + return ToolResult::error( + "Image generation succeeded and was billed, but returned no images. \ + Do not generate again; report this error to the user." + .to_string(), + ); + } + + let stem = artifact_stem("image"); + let mut artifacts = Vec::with_capacity(response.images.len()); + let mut lines = vec![format!( + "Generated {} image(s) with {}:", + response.images.len(), + response.model + )]; + for (index, image) in response.images.iter().enumerate() { + // Preserve the generated image format in the artifact extension + let ext = match image.media_type.split(';').next().map(str::trim) { + Some("image/png") => "png", + Some("image/jpeg") | Some("image/jpg") => "jpeg", + Some("image/webp") => "webp", + Some("image/gif") => "gif", + other => { + return ToolResult::error(format!( + "Image generation succeeded and was billed, but image {index} had unsupported media type {}. \ + Do not generate again; report this to the user.", + other.unwrap_or("") + )); + } + }; + match self + .output + .persist(&dir, &format!("{stem}-{index}"), ext, &image.data) + { + Ok(path) => { + lines.push(format!("- {}", path.display())); + artifacts.push(json!({ + "type": "image", + "path": path.display().to_string(), + "media_type": image.media_type, + "bytes": image.data.len(), + })); + } + Err(error) => { + return ToolResult::error(format!( + "Image generation succeeded and was billed, but saving image {index} failed: \ + {error}. Do not generate again; report this to the user." + )); + } + } + } + if let Some(cost) = response.cost_usd { + lines.push(format!("Cost: ${cost:.4}")); + } + ToolResult::success_with_markdown( + json!({ + "model": response.model, + "cost_usd": response.cost_usd, + "artifacts": artifacts, + }), + lines.join("\n"), + ) + } +} + +impl std::fmt::Debug for GenerateImageTool { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GenerateImageTool") + .field("name", &self.name) + .field("provider", &self.generator.name()) + .field("output", &self.output) + .finish() + } +} + +#[async_trait] +impl Tool for GenerateImageTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "What to draw, or the edit to apply to the reference images." }, + "model": { "type": "string", "description": format!("Model id. Default: {}.", self.generator.default_model()) }, + "n": { "type": ["integer", "string"], "description": "Number of images, integer or numeric string (default 1)." }, + "aspect_ratio": { "type": "string", "description": "e.g. 1:1, 16:9, 9:16, 4:3, landscape, portrait, square, auto." }, + "resolution": { "type": "string", "description": "Resolution tier: 1K, 2K or 4K." }, + "size": { "type": "string", "description": "Exact pixels such as 1536x1024 (overrides resolution/aspect_ratio)." }, + "quality": { "type": "string", "enum": ["auto", "low", "medium", "high"] }, + "output_format": { "type": "string", "enum": ["png", "jpeg", "webp"] }, + "background": { "type": "string", "enum": ["auto", "transparent", "opaque"] }, + "seed": { "type": ["integer", "string"], "description": "Deterministic seed, integer or numeric string, where supported." }, + "references": { + "type": ["array", "string"], + "items": { "type": "string" }, + "description": "Reference images: https URLs, data: URLs, or workspace file paths. Local paths are canonicalized and must remain inside the workspace, including after symlink resolution." + } + }, + "required": ["prompt"] + }) + } + + fn policy(&self) -> ToolPolicy { + media_policy(IMAGE_TIMEOUT_MS) + } + + fn permission_level(&self) -> PermissionLevel { + self.permission + } + + fn permission_level_with_args(&self, _args: &Value) -> PermissionLevel { + self.permission + } + + fn category(&self) -> ToolCategory { + self.category + } + + fn external_effect(&self) -> bool { + true + } + + fn external_effect_with_args(&self, _args: &Value) -> bool { + true + } + + fn timeout_policy(&self, _args: &Value) -> ToolTimeout { + ToolTimeout::Millis(IMAGE_TIMEOUT_MS) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + Ok(self.run(&args, None).await) + } + + async fn execute_with_context( + &self, + args: Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + Ok(self.run(&args, context).await) + } +} diff --git a/crates/tinyagents-harness/src/media/mod.rs b/crates/tinyagents-harness/src/media/mod.rs new file mode 100644 index 000000000..294c5feed --- /dev/null +++ b/crates/tinyagents-harness/src/media/mod.rs @@ -0,0 +1,69 @@ +//! Media generation tools (feature `media`). +//! +//! [`GenerateImageTool`] and [`GenerateVideoTool`] expose TinyInference's +//! provider-neutral [`tinyinference_image::ImageGenerator`] and +//! [`tinyinference_video::VideoGenerator`] as ordinary [`tinytools::Tool`]s. +//! The harness owns the tool contract — argument parsing (including the loose +//! spellings models emit), artifact persistence into the run's workspace, and +//! result wording. The host owns everything else: which generator (and so +//! which credential and endpoint), the tool's visible name, the output root, +//! and whether a local reference file may leave the machine. +//! +//! Both tools report a billed non-delivery as an error that says not to call +//! again. Video failures name the job id when it can be resumed, so a model +//! does not loop on a paid failure. + +mod image_tool; +mod types; +mod video_tool; + +pub use image_tool::{GENERATE_IMAGE_TOOL_NAME, GenerateImageTool}; +pub use types::{DEFAULT_MEDIA_SUBDIR, MediaOutput, ReferencePathPolicy}; +pub use video_tool::{GENERATE_VIDEO_TOOL_NAME, GenerateVideoTool}; + +use tinytools::{ + ToolAccess, ToolPolicy, ToolRuntime, ToolSideEffects, ToolTimeout, WorkspaceAccess, +}; + +/// Policy shared by both tools: network, a third-party service, a charge, and +/// file writes into the workspace. Never replayed after a crash, because a +/// replay is a second billed generation. +fn media_policy(timeout_ms: u64) -> ToolPolicy { + ToolPolicy::classified() + .with_side_effects(ToolSideEffects { + read_only: false, + writes_files: true, + network: true, + installs_dependencies: false, + destructive: false, + external_service: true, + payment: true, + }) + .with_runtime(ToolRuntime { + timeout_ms: Some(timeout_ms), + timeout: ToolTimeout::Millis(timeout_ms), + idempotent: false, + cancelable: true, + ..ToolRuntime::default() + }) + .with_access(ToolAccess { + workspace: WorkspaceAccess::Scoped, + ..ToolAccess::default() + }) +} + +/// A unique, filesystem-safe artifact stem: `--`. +fn artifact_stem(kind: &str) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis()); + format!( + "{kind}-{millis}-{}", + COUNTER.fetch_add(1, Ordering::Relaxed) + ) +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs new file mode 100644 index 000000000..b33235ab3 --- /dev/null +++ b/crates/tinyagents-harness/src/media/test.rs @@ -0,0 +1,725 @@ +//! Tests for the media generation tools. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tinyinference_image::{MediaReference, MockImageGenerator}; +use tinyinference_llm::message::{AssistantMessage, ContentBlock, Message}; +use tinyinference_llm::model::ModelResponse; +use tinyinference_llm::providers::MockModel; +use tinyinference_llm::tool::ToolCall; +use tinyinference_llm::usage::Usage; +use tinyinference_video::{ + JobState, MediaModel, MockVideoGenerator, MockVideoScript, VideoGenerator, VideoJob, + VideoJobStatus, VideoRequest, VideoResponse, WaitPolicy, +}; +use tinytools::{Tool, ToolCallOptions, ToolRunContext, ToolTimeout, WorkspaceDescriptor}; + +use super::{GENERATE_VIDEO_TOOL_NAME, GenerateImageTool, GenerateVideoTool, MediaOutput}; +use crate::context::RunConfig; +use crate::runtime::AgentHarness; +use crate::tool::ToolTimeoutSettings; + +struct Workspace(WorkspaceDescriptor); + +impl ToolRunContext for Workspace { + fn workspace(&self) -> Option<&WorkspaceDescriptor> { + Some(&self.0) + } +} + +fn workspace(root: &std::path::Path) -> Workspace { + Workspace(WorkspaceDescriptor::new(root.to_path_buf())) +} + +fn text(result: &tinytools::ToolResult) -> String { + serde_json::to_string(result).unwrap() +} + +/// Simulates a provider implementation that violates TinyInference's normal +/// non-empty response guarantee, so the harness keeps its billed-safety guard. +struct EmptyVideoGenerator; + +#[async_trait::async_trait] +impl VideoGenerator for EmptyVideoGenerator { + fn name(&self) -> &str { + "empty" + } + + fn default_model(&self) -> &str { + "empty/video" + } + + async fn submit(&self, _: VideoRequest) -> tinyinference_video::Result { + unreachable!("generate is overridden") + } + + async fn poll(&self, _: &str) -> tinyinference_video::Result { + unreachable!("generate is overridden") + } + + async fn content( + &self, + _: &str, + _: usize, + ) -> tinyinference_video::Result { + unreachable!("generate is overridden") + } + + async fn list_models(&self) -> tinyinference_video::Result> { + unreachable!("generate is overridden") + } + + async fn generate( + &self, + _: VideoRequest, + _: &WaitPolicy, + ) -> tinyinference_video::Result { + Ok(VideoResponse { + job_id: "empty-job".into(), + model: self.default_model().into(), + videos: Vec::new(), + cost_usd: Some(0.0), + }) + } +} + +/// Returns a non-MP4 delivery to ensure artifact suffixes match provider data. +struct WebmVideoGenerator; + +#[async_trait::async_trait] +impl VideoGenerator for WebmVideoGenerator { + fn name(&self) -> &str { + "webm" + } + + fn default_model(&self) -> &str { + "webm/video" + } + + async fn submit(&self, _: VideoRequest) -> tinyinference_video::Result { + unreachable!("generate is overridden") + } + + async fn poll(&self, _: &str) -> tinyinference_video::Result { + unreachable!("generate is overridden") + } + + async fn content( + &self, + _: &str, + _: usize, + ) -> tinyinference_video::Result { + unreachable!("generate is overridden") + } + + async fn list_models(&self) -> tinyinference_video::Result> { + unreachable!("generate is overridden") + } + + async fn generate( + &self, + _: VideoRequest, + _: &WaitPolicy, + ) -> tinyinference_video::Result { + Ok(VideoResponse { + job_id: "webm-job".into(), + model: self.default_model().into(), + videos: vec![tinyinference_video::GeneratedMedia::new( + "video/webm; codecs=vp9", + b"webm".as_slice(), + )], + cost_usd: Some(0.0), + }) + } +} + +#[tokio::test] +async fn image_tool_saves_into_the_workspace_and_reports_paths() { + let dir = tempfile::tempdir().unwrap(); + let generator = Arc::new(MockImageGenerator::new()); + let tool = GenerateImageTool::new(generator.clone(), MediaOutput::new("/nonexistent")) + .with_name("media_generate_image"); + let result = tool + .execute_with_context( + json!({ "prompt": "a comic", "n": "2", "aspectRatio": "landscape", "seed": 5 }), + ToolCallOptions::default(), + Some(&workspace(dir.path())), + ) + .await + .unwrap(); + assert!(!result.is_error, "{}", text(&result)); + let saved: Vec<_> = std::fs::read_dir(dir.path().join("generated-media")) + .unwrap() + .collect(); + assert_eq!(saved.len(), 2); + + let request = generator.requests().pop().unwrap(); + assert_eq!(request.n, Some(2), "numeric strings are accepted"); + assert_eq!( + request.aspect_ratio.as_deref(), + Some("landscape"), + "camelCase alias accepted" + ); + assert_eq!(request.seed, Some(5)); + assert_eq!(tool.name(), "media_generate_image"); +} + +/// Regression (R2): a billed call that returned nothing must surface as an +/// error telling the model not to call again — the incident retried three +/// times and paid three times. +#[tokio::test] +async fn billed_non_delivery_tells_the_model_not_to_retry() { + let dir = tempfile::tempdir().unwrap(); + let tool = GenerateImageTool::new( + Arc::new(MockImageGenerator::returning_no_media()), + MediaOutput::new(dir.path()), + ); + let result = tool.execute(json!({ "prompt": "x" })).await.unwrap(); + assert!(result.is_error); + let message = text(&result); + assert!( + message.contains("billed") && message.contains("do not retry"), + "{message}" + ); + assert!(message.contains("mock-request"), "{message}"); +} + +#[tokio::test] +async fn image_tool_requires_a_prompt() { + let tool = GenerateImageTool::new( + Arc::new(MockImageGenerator::new()), + MediaOutput::new("/tmp"), + ); + let result = tool.execute(json!({})).await.unwrap(); + assert!(result.is_error); +} + +#[tokio::test] +async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { + let dir = tempfile::tempdir().unwrap(); + // Create the referenced file so canonicalize succeeds + let art_dir = dir.path().join("art"); + std::fs::create_dir(&art_dir).unwrap(); + std::fs::write(art_dir.join("ref.png"), b"fake image").unwrap(); + + let generator = Arc::new(MockImageGenerator::new()); + let tool = GenerateImageTool::new(generator.clone(), MediaOutput::new("/nonexistent")); + let context = workspace(dir.path()); + + let result = tool + .execute_with_context( + json!({ "prompt": "x", "references": ["art/ref.png", "https://x.test/r.png"] }), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!result.is_error, "{}", text(&result)); + let request = generator.requests().pop().unwrap(); + // Canonicalize the expected path to match what the reference function returns + let canonical_ref = art_dir.join("ref.png").canonicalize().unwrap(); + assert_eq!( + request.references, + vec![ + MediaReference::Path(canonical_ref), + MediaReference::Url("https://x.test/r.png".into()), + ] + ); + + // Tilde is literal, not home-directory expansion. An existing literal + // workspace path is admitted and canonicalized like every other local path. + let literal_tilde = dir.path().join("~/.ssh"); + std::fs::create_dir_all(&literal_tilde).unwrap(); + std::fs::write(literal_tilde.join("id_rsa"), b"not a key").unwrap(); + let result = tool + .execute_with_context( + json!({ "prompt": "x", "references": ["~/.ssh/id_rsa"] }), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!result.is_error, "{}", text(&result)); + assert_eq!( + generator.requests().pop().unwrap().references, + vec![MediaReference::Path( + literal_tilde.join("id_rsa").canonicalize().unwrap() + )] + ); + + for escape in ["../secret.png", "/etc/passwd"] { + let result = tool + .execute_with_context( + json!({ "prompt": "x", "references": [escape] }), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + // Both paths are outside the workspace and must be refused. + assert!( + result.is_error, + "{escape} must be refused: {}", + text(&result) + ); + } +} + +/// Symlinks are only available on Unix-like platforms without elevated +/// privileges. The canonicalization check must reject a workspace symlink that +/// targets a file outside it. +#[cfg(unix)] +#[tokio::test] +async fn symlinked_references_cannot_escape_the_workspace() { + let workspace_dir = tempfile::tempdir().unwrap(); + let outside_dir = tempfile::tempdir().unwrap(); + let outside = outside_dir.path().join("secret.png"); + std::fs::write(&outside, b"secret").unwrap(); + std::os::unix::fs::symlink(&outside, workspace_dir.path().join("escape.png")).unwrap(); + + let generator = Arc::new(MockImageGenerator::new()); + let tool = GenerateImageTool::new(generator.clone(), MediaOutput::new("/nonexistent")); + let result = tool + .execute_with_context( + json!({ "prompt": "x", "references": ["escape.png"] }), + ToolCallOptions::default(), + Some(&workspace(workspace_dir.path())), + ) + .await + .unwrap(); + assert!(result.is_error, "{}", text(&result)); + assert!( + generator.requests().is_empty(), + "outside file must not be sent" + ); +} + +#[tokio::test] +async fn a_host_reference_policy_replaces_the_default_confinement() { + let dir = tempfile::tempdir().unwrap(); + let generator = Arc::new(MockImageGenerator::new()); + + // Test 1: a policy that rejects all references. Since the custom policy is + // called after canonicalize, it rejects the canonical path. + let output = MediaOutput::new("/nonexistent").with_reference_policy(Arc::new(|path| { + Err(format!("host refused {}", path.display())) + })); + let tool = GenerateImageTool::new(generator.clone(), output); + // Create a real file so canonicalize succeeds, then the policy rejects it + std::fs::write(dir.path().join("a.png"), b"fake").unwrap(); + let context = workspace(dir.path()); + let result = tool + .execute_with_context( + json!({ "prompt": "x", "references": ["a.png"] }), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(text(&result).contains("host refused"), "{}", text(&result)); + + // Test 2: a policy can explicitly admit a path outside the output root. + let outside = tempfile::tempdir().unwrap(); + let reference = outside.path().join("ref2.png"); + std::fs::write(&reference, b"fake").unwrap(); + let output = + MediaOutput::new(dir.path()).with_reference_policy(Arc::new(|path| Ok(path.to_path_buf()))); + let tool = GenerateImageTool::new(generator, output); + let result = tool + .execute(json!({ "prompt": "x", "references": [reference] })) + .await + .unwrap(); + // The custom policy allows the reference + assert!(!result.is_error, "{}", text(&result)); +} + +fn fast() -> WaitPolicy { + WaitPolicy::new(Duration::from_millis(1), Duration::from_secs(5)) +} + +#[tokio::test] +async fn video_tool_waits_for_delivery_and_saves_the_clip() { + let dir = tempfile::tempdir().unwrap(); + let generator = Arc::new(MockVideoGenerator::new(MockVideoScript { + polls: vec![ + (JobState::InProgress, 0), + (JobState::Completed, 0), + (JobState::Completed, 1), + ], + error: None, + })); + let tool = GenerateVideoTool::new(generator.clone(), MediaOutput::new(dir.path())) + .with_wait_policy(fast()); + let result = tool + .execute(json!({ + "prompt": "a lighthouse", "durationSeconds": 5, "aspect_ratio": "16:9", + "first_frame": "https://x.test/f.png", "generate_audio": "true" + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", text(&result)); + assert!(text(&result).contains("mock-job")); + let saved: Vec<_> = std::fs::read_dir(dir.path().join("generated-media")) + .unwrap() + .collect(); + assert_eq!(saved.len(), 1); + + let request = generator.requests().pop().unwrap(); + assert_eq!(request.duration_s, Some(5), "legacy durationSeconds alias"); + assert_eq!(request.generate_audio, Some(true)); + assert!(request.first_frame.is_some()); + assert_eq!(tool.timeout_policy(&json!({})), ToolTimeout::Unbounded); +} + +fn tool_call_response(id: &str, name: &str, arguments: serde_json::Value) -> ModelResponse { + ModelResponse { + message: AssistantMessage { + id: Some(format!("msg-{id}")), + content: Vec::new(), + tool_calls: vec![ToolCall::new(id, name, arguments)], + usage: Some(Usage::new(1, 1)), + origin: None, + }, + usage: Some(Usage::new(1, 1)), + finish_reason: Some("tool_calls".to_owned()), + raw: None, + resolved_model: None, + continue_turn: None, + served_from_cache: false, + correlation: None, + resolved_route: None, + } +} + +fn text_response(text: &str) -> ModelResponse { + ModelResponse { + message: AssistantMessage { + id: None, + content: vec![ContentBlock::Text(text.to_owned())], + tool_calls: Vec::new(), + usage: Some(Usage::new(1, 1)), + origin: None, + }, + usage: Some(Usage::new(1, 1)), + finish_reason: Some("stop".to_owned()), + raw: None, + resolved_model: None, + continue_turn: None, + served_from_cache: false, + correlation: None, + resolved_route: None, + } +} + +#[tokio::test] +async fn video_tool_preserves_delivered_media_extension() { + let dir = tempfile::tempdir().unwrap(); + let tool = GenerateVideoTool::new(Arc::new(WebmVideoGenerator), MediaOutput::new(dir.path())); + let result = tool.execute(json!({ "prompt": "x" })).await.unwrap(); + + assert!(!result.is_error, "{}", text(&result)); + let saved: Vec<_> = std::fs::read_dir(dir.path().join("generated-media")) + .unwrap() + .collect(); + assert_eq!(saved.len(), 1); + assert_eq!( + saved[0].as_ref().unwrap().path().extension().unwrap(), + "webm" + ); +} + +/// Regression (R2): a timed-out job names its id and points at resume instead +/// of a new (billed) submit; resuming collects it without a second submit. +#[tokio::test] +async fn video_timeout_names_the_job_and_resume_collects_it() { + let dir = tempfile::tempdir().unwrap(); + let generator = Arc::new(MockVideoGenerator::new(MockVideoScript { + polls: vec![(JobState::InProgress, 0)], + error: None, + })); + let tool = + GenerateVideoTool::new(generator.clone(), MediaOutput::new(dir.path())).with_wait_policy( + WaitPolicy::new(Duration::from_millis(1), Duration::from_millis(10)), + ); + let result = tool.execute(json!({ "prompt": "x" })).await.unwrap(); + assert!(result.is_error); + let message = text(&result); + assert!( + message.contains("mock-job") && message.contains("do not resubmit"), + "{message}" + ); + + let delivered = Arc::new(MockVideoGenerator::new(MockVideoScript::delivers())); + let resume = GenerateVideoTool::new(delivered.clone(), MediaOutput::new(dir.path())) + .with_wait_policy(fast()); + let result = resume + .execute(json!({ "resume_job_id": "mock-job", "model": "mock/video" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", text(&result)); + assert!( + delivered.requests().is_empty(), + "resume must not submit a new job" + ); +} + +/// The harness must not replace the provider's resumable timeout with its +/// generic tool-timeout result, even when host settings clamp explicit tools +/// to a shorter deadline. +#[tokio::test] +async fn video_timeout_stays_resumable_through_agent_harness() { + let dir = tempfile::tempdir().unwrap(); + let generator = Arc::new(MockVideoGenerator::new(MockVideoScript { + polls: vec![(JobState::InProgress, 0)], + error: None, + })); + let video = GenerateVideoTool::new(generator, MediaOutput::new(dir.path())).with_wait_policy( + WaitPolicy::new(Duration::from_millis(1), Duration::from_millis(10)), + ); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.with_tool_timeout_settings(ToolTimeoutSettings::new(1, 1, 1, 0)); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", GENERATE_VIDEO_TOOL_NAME, json!({ "prompt": "x" })), + text_response("recovered"), + ])), + ); + harness.register_tool(Arc::new(video)); + + let run = harness + .invoke( + &(), + (), + RunConfig::new("resumable-video-timeout"), + vec![Message::user("go")], + ) + .await + .expect("the recoverable video timeout should not abort the run"); + + assert_eq!(run.text(), Some("recovered".to_owned())); + assert!(run.messages[2].text().contains("mock-job")); + assert!(run.messages[2].text().contains("do not resubmit")); +} + +#[tokio::test] +async fn video_failure_surfaces_the_provider_reason() { + let dir = tempfile::tempdir().unwrap(); + let generator = Arc::new(MockVideoGenerator::new(MockVideoScript { + polls: vec![(JobState::Failed, 0)], + error: Some("safety filter".into()), + })); + let tool = + GenerateVideoTool::new(generator, MediaOutput::new(dir.path())).with_wait_policy(fast()); + let result = tool.execute(json!({ "prompt": "x" })).await.unwrap(); + assert!(result.is_error); + assert!(text(&result).contains("safety filter")); +} + +#[tokio::test] +async fn empty_video_delivery_is_a_billed_non_retryable_error() { + let dir = tempfile::tempdir().unwrap(); + let generator = Arc::new(EmptyVideoGenerator); + let tool = + GenerateVideoTool::new(generator, MediaOutput::new(dir.path())).with_wait_policy(fast()); + let result = tool.execute(json!({ "prompt": "x" })).await.unwrap(); + let message = text(&result); + assert!(result.is_error, "{message}"); + assert!( + message.contains("billed") && message.contains("Do not generate again"), + "{message}" + ); +} + +#[test] +fn media_tools_declare_billing_side_effects() { + let tool = GenerateImageTool::new( + Arc::new(MockImageGenerator::new()), + MediaOutput::new("/tmp"), + ); + let policy = tool.policy(); + assert!( + policy.side_effects.payment + && policy.side_effects.network + && policy.side_effects.writes_files + ); + assert!(!policy.runtime.idempotent, "a replay would bill again"); + assert!(tool.external_effect()); +} + +#[test] +fn media_errors_map_onto_harness_errors() { + use crate::TinyAgentsError; + let unsupported: TinyAgentsError = tinyinference_image::Error::Unsupported { + model: "m".into(), + field: "aspect_ratio".into(), + value: "16:9".into(), + allowed: vec!["1:1".into()], + } + .into(); + assert!(matches!(unsupported, TinyAgentsError::Validation(_))); + let no_media: TinyAgentsError = tinyinference_image::Error::NoMedia { request_id: None }.into(); + assert!(matches!(no_media, TinyAgentsError::Model(ref m) if m.contains("do not retry"))); + let timeout: TinyAgentsError = tinyinference_video::Error::Timeout { + job_id: "j".into(), + waited_secs: 1, + last_state: "pending".into(), + } + .into(); + assert!(matches!(timeout, TinyAgentsError::Timeout(ref m) if m.contains("j"))); +} + +#[tokio::test] +async fn malformed_string_options_are_rejected_not_ignored() { + let generator = Arc::new(MockImageGenerator::new()); + let tool = GenerateImageTool::new(generator.clone(), MediaOutput::new("/tmp")); + let result = tool + .execute(json!({ "prompt": "x", "n": "two" })) + .await + .unwrap(); + assert!(result.is_error && text(&result).contains("`n` must be a non-negative integer")); + assert!( + generator.requests().is_empty(), + "nothing billed on a malformed option" + ); + + let video = Arc::new(MockVideoGenerator::new(MockVideoScript::delivers())); + let tool = + GenerateVideoTool::new(video.clone(), MediaOutput::new("/tmp")).with_wait_policy(fast()); + let result = tool + .execute(json!({ "prompt": "x", "generate_audio": "maybe" })) + .await + .unwrap(); + assert!(result.is_error && text(&result).contains("`generate_audio` must be true or false")); + assert!(video.requests().is_empty()); +} + +#[test] +fn video_schema_exposes_size() { + let tool = GenerateVideoTool::new( + Arc::new(MockVideoGenerator::new(MockVideoScript::delivers())), + MediaOutput::new("/tmp"), + ); + assert!(tool.parameters_schema()["properties"].get("size").is_some()); +} + +#[tokio::test] +async fn negative_counts_are_rejected_but_negative_seeds_are_not() { + let generator = Arc::new(MockImageGenerator::new()); + let tool = GenerateImageTool::new(generator.clone(), MediaOutput::new("/tmp")); + let result = tool + .execute(json!({ "prompt": "x", "n": -1 })) + .await + .unwrap(); + assert!(result.is_error && text(&result).contains("non-negative")); + let result = tool + .execute(json!({ "prompt": "x", "count": "-1" })) + .await + .unwrap(); + assert!(result.is_error); + assert!(generator.requests().is_empty()); + + let dir = tempfile::tempdir().unwrap(); + let tool = GenerateImageTool::new(generator.clone(), MediaOutput::new(dir.path())); + let result = tool + .execute(json!({ "prompt": "x", "seed": -7 })) + .await + .unwrap(); + assert!(!result.is_error, "{}", text(&result)); + assert_eq!(generator.requests().pop().unwrap().seed, Some(-7)); +} + +#[tokio::test] +async fn values_outside_provider_integer_ranges_are_rejected_before_billing() { + let image_generator = Arc::new(MockImageGenerator::new()); + let image = GenerateImageTool::new(image_generator.clone(), MediaOutput::new("/tmp")); + let result = image + .execute(json!({ "prompt": "x", "seed": 9_223_372_036_854_775_808_u64 })) + .await + .unwrap(); + assert!(result.is_error && text(&result).contains("`seed` must be an integer")); + assert!(image_generator.requests().is_empty()); + + let video_generator = Arc::new(MockVideoGenerator::new(MockVideoScript::delivers())); + let video = GenerateVideoTool::new(video_generator.clone(), MediaOutput::new("/tmp")); + let result = video + .execute(json!({ "prompt": "x", "duration": 4_294_967_296_u64 })) + .await + .unwrap(); + assert!(result.is_error && text(&result).contains("`duration` must not exceed")); + assert!(video_generator.requests().is_empty()); +} + +#[tokio::test] +async fn artifact_subdirectory_must_stay_below_the_output_root() { + let generator = Arc::new(MockImageGenerator::new()); + let tool = GenerateImageTool::new( + generator.clone(), + MediaOutput::new("/tmp").with_subdir("../outside"), + ); + let result = tool.execute(json!({ "prompt": "x" })).await.unwrap(); + assert!(result.is_error && text(&result).contains("artifact subdirectory")); + assert!( + generator.requests().is_empty(), + "invalid output config must not bill" + ); +} + +/// A pre-existing output symlink must be rejected before the provider is +/// called, otherwise a scoped workspace can write artifacts outside itself. +#[cfg(unix)] +#[tokio::test] +async fn symlinked_artifact_directory_cannot_escape_the_workspace() { + let workspace_dir = tempfile::tempdir().unwrap(); + let outside_dir = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink( + outside_dir.path(), + workspace_dir.path().join("generated-media"), + ) + .unwrap(); + + let generator = Arc::new(MockImageGenerator::new()); + let tool = GenerateImageTool::new(generator.clone(), MediaOutput::new("/nonexistent")); + let result = tool + .execute_with_context( + json!({ "prompt": "x" }), + ToolCallOptions::default(), + Some(&workspace(workspace_dir.path())), + ) + .await + .unwrap(); + + assert!(result.is_error, "{}", text(&result)); + assert!( + generator.requests().is_empty(), + "must reject before billing" + ); + assert!( + std::fs::read_dir(outside_dir.path()) + .unwrap() + .next() + .is_none() + ); +} + +#[test] +fn schemas_advertise_a_single_reference_string() { + let image = GenerateImageTool::new( + Arc::new(MockImageGenerator::new()), + MediaOutput::new("/tmp"), + ); + assert_eq!( + image.parameters_schema()["properties"]["references"]["type"], + json!(["array", "string"]) + ); + let video = GenerateVideoTool::new( + Arc::new(MockVideoGenerator::new(MockVideoScript::delivers())), + MediaOutput::new("/tmp"), + ); + assert_eq!( + video.parameters_schema()["properties"]["references"]["type"], + json!(["array", "string"]) + ); +} diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs new file mode 100644 index 000000000..55da204e5 --- /dev/null +++ b/crates/tinyagents-harness/src/media/types.rs @@ -0,0 +1,447 @@ +//! Shared configuration for the media generation tools. + +use std::fs::File; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use serde_json::Value; +use tinyinference_image::MediaReference; + +/// Sub-directory of the output root that generated media is written into. +pub const DEFAULT_MEDIA_SUBDIR: &str = "generated-media"; + +/// Decides whether a local reference path may be read and sent to a provider, +/// returning the path to read. Relative paths arrive already joined to the +/// workspace root. +pub type ReferencePathPolicy = Arc Result + Send + Sync>; + +/// Where a tool writes artifacts and which local references it may read. +#[derive(Clone)] +pub struct MediaOutput { + /// Root used when the run context carries no workspace. + pub fallback_root: PathBuf, + /// Directory under the root that artifacts are written into. + pub subdir: String, + /// Local-reference admission policy; `None` confines references to the + /// workspace (or fallback) root. + pub reference_policy: Option, +} + +/// A verified artifact directory whose open handle prevents a later rename +/// from redirecting writes through a swapped ancestor. +pub(crate) struct ArtifactDirectory { + path: PathBuf, + #[cfg(unix)] + handle: File, +} + +impl MediaOutput { + /// Writes under `fallback_root/generated-media` when no workspace is known. + #[must_use] + pub fn new(fallback_root: impl Into) -> Self { + Self { + fallback_root: fallback_root.into(), + subdir: DEFAULT_MEDIA_SUBDIR.to_owned(), + reference_policy: None, + } + } + + /// Changes the artifact sub-directory. + #[must_use] + pub fn with_subdir(mut self, subdir: impl Into) -> Self { + self.subdir = subdir.into(); + self + } + + /// Installs a host policy for local reference paths. + #[must_use] + pub fn with_reference_policy(mut self, policy: ReferencePathPolicy) -> Self { + self.reference_policy = Some(policy); + self + } + + /// The root for this call: the run's workspace, else the fallback. + pub(crate) fn root<'a>(&'a self, workspace: Option<&'a Path>) -> &'a Path { + workspace.unwrap_or(&self.fallback_root) + } + + /// Creates and returns the artifact directory for this call. + /// + /// Every pre-existing component is inspected without following symlinks, + /// then the resolved directory is checked against the resolved root. This + /// makes a scoped workspace reject an output directory redirected outside + /// the workspace before a generation request can be billed. + pub(crate) fn dir(&self, workspace: Option<&Path>) -> Result { + let subdir = Path::new(&self.subdir); + if self.subdir.trim().is_empty() + || subdir + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!( + "artifact subdirectory `{}` must be a relative path below the output root", + self.subdir + )); + } + let root = self.root(workspace); + std::fs::create_dir_all(root).map_err(|error| { + format!( + "output root {} could not be created: {error}", + root.display() + ) + })?; + let canonical_root = root.canonicalize().map_err(|error| { + format!( + "output root {} could not be resolved: {error}", + root.display() + ) + })?; + + let mut dir = canonical_root.clone(); + for component in subdir.components() { + let Component::Normal(component) = component else { + unreachable!("subdirectory components were validated above"); + }; + dir.push(component); + match std::fs::symlink_metadata(&dir) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "artifact directory {} must not contain symlinks", + dir.display() + )); + } + Ok(metadata) if !metadata.is_dir() => { + return Err(format!( + "artifact path {} is not a directory", + dir.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir(&dir).map_err(|error| { + format!( + "artifact directory {} could not be created: {error}", + dir.display() + ) + })?; + let metadata = std::fs::symlink_metadata(&dir).map_err(|error| { + format!( + "artifact directory {} could not be inspected: {error}", + dir.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "artifact directory {} changed while being created", + dir.display() + )); + } + } + Err(error) => { + return Err(format!( + "artifact directory {} could not be inspected: {error}", + dir.display() + )); + } + } + } + let canonical_dir = dir.canonicalize().map_err(|error| { + format!( + "artifact directory {} could not be resolved: {error}", + dir.display() + ) + })?; + if !canonical_dir.starts_with(&canonical_root) { + return Err(format!( + "artifact directory {} is outside the output root", + canonical_dir.display() + )); + } + #[cfg(unix)] + let handle = { + use rustix::fs::{Mode, OFlags, open}; + + let handle = open( + &canonical_dir, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| { + format!( + "artifact directory {} could not be opened safely: {error}", + canonical_dir.display() + ) + })?; + File::from(handle) + }; + Ok(ArtifactDirectory { + path: canonical_dir, + #[cfg(unix)] + handle, + }) + } + + /// Persists one artifact without following a final-path symlink or + /// replacing an existing file. + pub(crate) fn persist( + &self, + dir: &ArtifactDirectory, + stem: &str, + extension: &str, + bytes: &[u8], + ) -> Result { + if !is_filename_component(stem) || !is_filename_component(extension) { + return Err( + "artifact name components must be non-empty filename components".to_owned(), + ); + } + let filename = format!("{stem}.{extension}"); + let path = dir.path.join(&filename); + #[cfg(unix)] + { + use rustix::fs::{Mode, OFlags, openat}; + + // `openat` writes relative to the verified directory handle, so an + // attacker cannot redirect this write by replacing an ancestor. + let file = openat( + &dir.handle, + &filename, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::WGRP | Mode::ROTH | Mode::WOTH, + ) + .map(File::from) + .map_err(|error| { + format!( + "artifact {} could not be created safely: {error}", + path.display() + ) + })?; + let mut file = file; + file.write_all(bytes).map_err(|error| { + format!("artifact {} could not be written: {error}", path.display()) + })?; + Ok(path) + } + #[cfg(not(unix))] + { + let mut file = File::options() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| { + format!( + "artifact {} could not be created safely: {error}", + path.display() + ) + })?; + file.write_all(bytes).map_err(|error| { + format!("artifact {} could not be written: {error}", path.display()) + })?; + Ok(path) + } + } + + /// Converts a model-supplied reference string into a [`MediaReference`], + /// resolving and admitting local paths. + pub(crate) fn reference( + &self, + raw: &str, + workspace: Option<&Path>, + ) -> Result { + match MediaReference::parse(raw) { + MediaReference::Path(path) => { + let root = self.root(workspace); + let joined = if path.is_absolute() { + path + } else { + root.join(path) + }; + // Canonicalize the path to resolve symlinks before checking confinement. + // This prevents symlink attacks where a symlink inside the workspace + // points to a location outside it. + let canonical = joined.canonicalize().map_err(|e| { + format!( + "reference path {} could not be resolved: {}", + joined.display(), + e + ) + })?; + // Canonicalize the root if it exists, for accurate symlink-safe + // comparison. If the root is a fallback path that doesn't exist, + // fall back to a lexical check after canonicalizing the path. + let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let admitted = match &self.reference_policy { + Some(policy) => policy(&canonical)?, + None => confine(&canonical, &canonical_root)?, + }; + Ok(MediaReference::Path(admitted)) + } + other => Ok(other), + } + } +} + +fn is_filename_component(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && Path::new(value) + .components() + .all(|part| matches!(part, Component::Normal(_))) +} + +impl std::fmt::Debug for MediaOutput { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MediaOutput") + .field("fallback_root", &self.fallback_root) + .field("subdir", &self.subdir) + .field("reference_policy", &self.reference_policy.is_some()) + .finish() + } +} + +/// Default reference policy: the path must stay inside `root` when canonicalized, +/// so a model cannot read and upload arbitrary files via symlinks. +fn confine(path: &Path, root: &Path) -> Result { + // Both paths are already canonicalized by the caller, so we check the + // resolved path against the resolved root. + if !path.starts_with(root) { + return Err(format!( + "reference path {} is outside the workspace; use a URL or a file inside the workspace", + path.display() + )); + } + Ok(path.to_path_buf()) +} + +/// Reads the first present string among `keys`. +pub(crate) fn arg_str<'a>(args: &'a Value, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .find_map(|key| args.get(*key).and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +/// Rejects a present-but-malformed option instead of silently dropping it. +/// +/// The schemas accept numeric and boolean options as strings too (models emit +/// both), so a value such as `"n": "two"` passes schema validation; without +/// this check it would be ignored and the call would run — and bill — with +/// the default instead of what was asked. +pub(crate) fn check_option_types( + args: &Value, + unsigned: &[&str], + signed: &[&str], + booleans: &[&str], +) -> Result<(), String> { + for key in unsigned { + match args.get(*key) { + None | Some(Value::Null) => {} + Some(Value::Number(number)) if number.is_u64() => {} + Some(Value::String(text)) if text.trim().parse::().is_ok() => {} + Some(other) => { + return Err(format!( + "`{key}` must be a non-negative integer, got {other}" + )); + } + } + } + for key in signed { + match args.get(*key) { + None | Some(Value::Null) => {} + Some(Value::Number(number)) if number.is_i64() => {} + Some(Value::String(text)) if text.trim().parse::().is_ok() => {} + Some(other) => return Err(format!("`{key}` must be an integer, got {other}")), + } + } + for key in booleans { + match args.get(*key) { + None | Some(Value::Null) | Some(Value::Bool(_)) => {} + Some(Value::String(text)) if text.trim().parse::().is_ok() => {} + Some(other) => return Err(format!("`{key}` must be true or false, got {other}")), + } + } + Ok(()) +} + +/// Rejects present string options that would otherwise be treated as absent. +pub(crate) fn check_string_options(args: &Value, keys: &[&str]) -> Result<(), String> { + for key in keys { + match args.get(*key) { + None | Some(Value::Null) | Some(Value::String(_)) => {} + Some(other) => return Err(format!("`{key}` must be a string, got {other}")), + } + } + Ok(()) +} + +/// Rejects a present list unless every entry is a non-empty string. +pub(crate) fn check_string_lists(args: &Value, keys: &[&str]) -> Result<(), String> { + for key in keys { + match args.get(*key) { + None | Some(Value::Null) | Some(Value::String(_)) => {} + Some(Value::Array(items)) + if items + .iter() + .all(|item| item.as_str().is_some_and(|s| !s.trim().is_empty())) => {} + Some(other) => { + return Err(format!( + "`{key}` must be a string or an array of non-empty strings, got {other}" + )); + } + } + } + Ok(()) +} + +/// Reads the first present unsigned integer among `keys` (numbers or numeric +/// strings, since models emit both). +pub(crate) fn arg_u64(args: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| match args.get(*key)? { + Value::Number(number) => number.as_u64(), + Value::String(text) => text.trim().parse().ok(), + _ => None, + }) +} + +/// Reads the first present integer among `keys`. +pub(crate) fn arg_i64(args: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| match args.get(*key)? { + Value::Number(number) => number.as_i64(), + Value::String(text) => text.trim().parse().ok(), + _ => None, + }) +} + +/// Reads the first present boolean among `keys`. +pub(crate) fn arg_bool(args: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| match args.get(*key)? { + Value::Bool(flag) => Some(*flag), + Value::String(text) => text.trim().parse().ok(), + _ => None, + }) +} + +/// Reads string lists among `keys`, accepting a single string too. +pub(crate) fn arg_list(args: &Value, keys: &[&str]) -> Vec { + let mut out = Vec::new(); + for key in keys { + match args.get(*key) { + Some(Value::Array(items)) => out.extend( + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned), + ), + Some(Value::String(item)) if !item.trim().is_empty() => { + out.push(item.trim().to_owned()) + } + _ => {} + } + } + out +} diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs new file mode 100644 index 000000000..62698d9cc --- /dev/null +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -0,0 +1,365 @@ +//! [`GenerateVideoTool`]: asynchronous video generation as a harness tool. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinyinference_video::{VideoGenerator, VideoRequest, WaitPolicy, wait_for_job}; +use tinytools::{ + PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolPolicy, ToolResult, ToolRunContext, + ToolTimeout, +}; + +use super::types::{ + MediaOutput, arg_bool, arg_i64, arg_list, arg_str, arg_u64, check_option_types, + check_string_lists, check_string_options, +}; +use super::{artifact_stem, media_policy}; + +/// Default model-visible name. +pub const GENERATE_VIDEO_TOOL_NAME: &str = "generate_video"; + +/// Generates videos with a [`VideoGenerator`], waits for the job, and saves +/// the clips to the run's workspace. +/// +/// A job that is still running when the wait budget runs out is reported with +/// its id; calling the tool again with `resume_job_id` and its original model collects it without +/// paying for a new generation. +pub struct GenerateVideoTool { + generator: Arc, + output: MediaOutput, + wait: WaitPolicy, + name: String, + description: String, + permission: PermissionLevel, + category: ToolCategory, +} + +impl GenerateVideoTool { + /// Creates the tool with the default name, description and wait policy. + #[must_use] + pub fn new(generator: Arc, output: MediaOutput) -> Self { + Self { + generator, + output, + wait: WaitPolicy::default(), + name: GENERATE_VIDEO_TOOL_NAME.to_owned(), + description: "Generate a short video clip from a text prompt, optionally starting \ + from (or ending on) an image, and save it into the workspace. Takes \ + minutes. Billed per call: if a job times out, call again with \ + `resume_job_id` and the original `model` instead of submitting a new one." + .to_owned(), + permission: PermissionLevel::Write, + category: ToolCategory::System, + } + } + + /// Overrides the model-visible name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Overrides the model-visible description. + #[must_use] + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Overrides the permission level the host gates the call on + /// (default [`PermissionLevel::Write`]). + #[must_use] + pub fn with_permission_level(mut self, permission: PermissionLevel) -> Self { + self.permission = permission; + self + } + + /// Overrides the tool's category (default [`ToolCategory::System`]). + #[must_use] + pub fn with_category(mut self, category: ToolCategory) -> Self { + self.category = category; + self + } + + /// Overrides how long and how often the tool waits for a job. + #[must_use] + pub fn with_wait_policy(mut self, wait: WaitPolicy) -> Self { + self.wait = wait; + self + } + + fn request( + &self, + args: &Value, + workspace: Option<&std::path::Path>, + ) -> Result { + check_option_types( + args, + &["duration", "duration_seconds", "durationSeconds"], + &["seed"], + &["generate_audio", "audio"], + )?; + check_string_options( + args, + &[ + "prompt", + "model", + "resolution", + "aspect_ratio", + "aspectRatio", + "size", + "first_frame", + "inputImage", + "input_image", + "last_frame", + ], + )?; + check_string_lists(args, &["references", "reference_images"])?; + let prompt = arg_str(args, &["prompt"]).map(str::to_owned); + let duration_s = match arg_u64(args, &["duration", "duration_seconds", "durationSeconds"]) { + Some(duration) => Some( + u32::try_from(duration) + .map_err(|_| format!("`duration` must not exceed {}", u32::MAX))?, + ), + None => None, + }; + let mut request = VideoRequest { + prompt: prompt.clone(), + model: arg_str(args, &["model"]).map(str::to_owned), + duration_s, + resolution: arg_str(args, &["resolution"]).map(str::to_owned), + aspect_ratio: arg_str(args, &["aspect_ratio", "aspectRatio"]).map(str::to_owned), + size: arg_str(args, &["size"]).map(str::to_owned), + generate_audio: arg_bool(args, &["generate_audio", "audio"]), + seed: arg_i64(args, &["seed"]), + ..VideoRequest::default() + }; + if let Some(raw) = arg_str(args, &["first_frame", "inputImage", "input_image"]) { + request.first_frame = Some(self.output.reference(raw, workspace)?); + } + if let Some(raw) = arg_str(args, &["last_frame"]) { + request.last_frame = Some(self.output.reference(raw, workspace)?); + } + for raw in arg_list(args, &["references", "reference_images"]) { + request + .references + .push(self.output.reference(&raw, workspace)?); + } + // Reject requests without a prompt or first frame: prompt is optional only with + // first_frame, and first_frame is optional only with prompt. + if prompt.is_none() && request.first_frame.is_none() { + return Err( + "either `prompt` or `first_frame` is required for video generation".to_string(), + ); + } + Ok(request) + } + + async fn run(&self, args: &Value, context: Option<&dyn ToolRunContext>) -> ToolResult { + let workspace = context.and_then(ToolRunContext::workspace_root); + if let Err(message) = check_string_options(args, &["resume_job_id", "model"]) { + return ToolResult::error(message); + } + let dir = match self.output.dir(workspace) { + Ok(dir) => dir, + Err(message) => return ToolResult::error(message), + }; + let outcome = if let Some(job_id) = arg_str(args, &["resume_job_id"]) { + if !job_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return ToolResult::error( + "`resume_job_id` must contain only ASCII letters, digits, `_`, or `-`", + ); + } + let Some(model) = arg_str(args, &["model"]) else { + return ToolResult::error( + "`model` is required with `resume_job_id` so the resumed result retains its submitted model", + ); + }; + tracing::info!(tool = %self.name, job_id, "[media] resuming video job"); + wait_for_job(self.generator.as_ref(), job_id, model, &self.wait).await + } else { + let request = match self.request(args, workspace) { + Ok(request) => request, + Err(message) => return ToolResult::error(message), + }; + tracing::info!( + tool = %self.name, + provider = self.generator.name(), + model = request.model.as_deref().unwrap_or(self.generator.default_model()), + duration_s = request.duration_s, + first_frame = request.first_frame.is_some(), + "[media] generate_video" + ); + self.generator.generate(request, &self.wait).await + }; + let response = match outcome { + Ok(response) => response, + Err(error) => { + tracing::warn!(tool = %self.name, %error, job_id = ?error.job_id(), "[media] video generation failed"); + return ToolResult::error(format!("Video generation failed: {error}")); + } + }; + if response.videos.is_empty() { + return ToolResult::error(format!( + "Video job {} succeeded and was billed, but returned no videos. \ + Do not generate again; report this error to the user.", + response.job_id + )); + } + + let stem = artifact_stem("video"); + let mut artifacts = Vec::with_capacity(response.videos.len()); + let mut lines = vec![format!( + "Generated {} video(s) with {} (job {}):", + response.videos.len(), + response.model, + response.job_id + )]; + for (index, video) in response.videos.iter().enumerate() { + let extension = match video.media_type.split(';').next().map(str::trim) { + Some("video/mp4") => "mp4", + Some("video/webm") => "webm", + Some("video/quicktime") => "mov", + other => { + return ToolResult::error(format!( + "Video job {} delivered and was billed, but clip {index} has unsupported media type {}. \ + Do not generate again; report this error to the user.", + response.job_id, + other.unwrap_or("(missing)") + )); + } + }; + match self + .output + .persist(&dir, &format!("{stem}-{index}"), extension, &video.data) + { + Ok(path) => { + lines.push(format!("- {}", path.display())); + artifacts.push(json!({ + "type": "video", + "path": path.display().to_string(), + "media_type": video.media_type, + "bytes": video.data.len(), + })); + } + Err(error) => { + return ToolResult::error(format!( + "Video job {} delivered and was billed, but saving clip {index} failed: \ + {error}. Retry with resume_job_id={} to download it again.", + response.job_id, response.job_id + )); + } + } + } + if let Some(cost) = response.cost_usd { + lines.push(format!("Cost: ${cost:.4}")); + } + ToolResult::success_with_markdown( + json!({ + "job_id": response.job_id, + "model": response.model, + "cost_usd": response.cost_usd, + "artifacts": artifacts, + }), + lines.join("\n"), + ) + } +} + +impl std::fmt::Debug for GenerateVideoTool { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GenerateVideoTool") + .field("name", &self.name) + .field("provider", &self.generator.name()) + .field("output", &self.output) + .field("wait", &self.wait) + .finish() + } +} + +#[async_trait] +impl Tool for GenerateVideoTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "What happens in the clip. Optional only with first_frame." }, + "model": { "type": "string", "description": format!("Model id. Default for a new job: {}. Required when resuming a job.", self.generator.default_model()) }, + "duration": { "type": ["integer", "string"], "description": "Seconds (integer or numeric string; the default model accepts 4-15)." }, + "resolution": { "type": "string", "description": "480p, 720p or 1080p (model-dependent)." }, + "aspect_ratio": { "type": "string", "description": "e.g. 16:9, 9:16, 1:1, landscape, portrait." }, + "generate_audio": { "type": ["boolean", "string"], "description": "Add an audio track (boolean or string \"true\"/\"false\"), where supported." }, + "seed": { "type": ["integer", "string"], "description": "Deterministic seed (integer or numeric string)." }, + "size": { "type": "string", "description": "Exact pixels such as 1280x720 (interchangeable with resolution + aspect_ratio)." }, + "first_frame": { "type": "string", "description": "Image to start from: https URL, data: URL or workspace path. Local paths are canonicalized and must remain inside the workspace, including after symlink resolution." }, + "last_frame": { "type": "string", "description": "Image to end on; local paths follow the first_frame workspace policy." }, + "references": { + "type": ["array", "string"], + "items": { "type": "string" }, + "description": "Reference images/clips guiding subject or style. Local paths are canonicalized and must remain inside the workspace, including after symlink resolution." + }, + "resume_job_id": { "type": "string", "description": "Collect an earlier job that timed out, without paying again. Also pass the original model." } + } + }) + } + + fn policy(&self) -> ToolPolicy { + media_policy(u64::try_from(self.wait.timeout.as_millis()).unwrap_or(u64::MAX)) + } + + fn permission_level(&self) -> PermissionLevel { + self.permission + } + + fn permission_level_with_args(&self, _args: &Value) -> PermissionLevel { + self.permission + } + + fn category(&self) -> ToolCategory { + self.category + } + + fn external_effect(&self) -> bool { + true + } + + fn external_effect_with_args(&self, _args: &Value) -> bool { + true + } + + fn timeout_policy(&self, _args: &Value) -> ToolTimeout { + // `wait_for_job` owns the provider wait budget and returns a resumable + // timeout containing the billed job id. A harness deadline around the + // whole call could expire during request setup or submission first and + // replace that result with a generic timeout, so leave this outer + // policy unbounded. The run's wall-clock budget remains a hard limit. + ToolTimeout::Unbounded + } + + async fn execute(&self, args: Value) -> anyhow::Result { + Ok(self.run(&args, None).await) + } + + async fn execute_with_context( + &self, + args: Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + Ok(self.run(&args, context).await) + } +} diff --git a/docs/modules/harness/README.md b/docs/modules/harness/README.md index 5b6b51d12..e33563dff 100644 --- a/docs/modules/harness/README.md +++ b/docs/modules/harness/README.md @@ -235,6 +235,7 @@ Feature details: - [Model and provider feature](model.md) - [Local models and embeddings (Ollama, LM Studio)](local-models.md) - [Embeddings and retrieval feature](embeddings.md) +- [Media generation tools](media.md) - [State graph runtime feature](state-graph.md) - [Prompt feature](prompt.md) - [Tool feature](tool.md) diff --git a/docs/modules/harness/media.md b/docs/modules/harness/media.md new file mode 100644 index 000000000..579eb7286 --- /dev/null +++ b/docs/modules/harness/media.md @@ -0,0 +1,74 @@ +# Media generation tools + +Feature: `media` on `tinyagents-harness`. Module: `tinyagents_harness::media`. + +The harness exposes image and video generation as ordinary `tinytools::Tool`s +over TinyInference's provider-neutral generators: + +| Tool | Generator trait | Default model-visible name | +| --- | --- | --- | +| `GenerateImageTool` | `tinyinference_image::ImageGenerator` | `generate_image` | +| `GenerateVideoTool` | `tinyinference_video::VideoGenerator` | `generate_video` | + +`tinyinference_image` and `tinyinference_video` are re-exported from the harness +when the feature is on, so hosts reach generator types through +`tinyagents_harness::tinyinference_image::…` rather than adding their own +dependency. + +## Who owns what + +- **TinyInference** owns the wire: OpenRouter's `POST /images`, `POST /videos`, + `GET /videos/{id}`, `GET /videos/{id}/content`; reference inlining (URL, + `data:` URL, bytes, local path → content parts); output-shape normalization + (`"16x9"`, `"landscape"`, `"full hd"`); capability pre-flight checks; the + billing-aware retry policy; and the submit → poll → download job loop. +- **The harness** owns the tool contract: argument parsing (including the loose + spellings and legacy camelCase aliases models emit), artifact persistence into + the run's workspace, result wording, and tool policy metadata. +- **The host** owns the generator (and so the credential and endpoint), the + tool's visible name, the fallback output root, and whether a local reference + file may leave the machine (`MediaOutput::with_reference_policy`). + +## Using OpenRouter directly or through a backend + +```rust +use std::sync::Arc; +use tinyagents_harness::media::{GenerateImageTool, MediaOutput}; +use tinyagents_harness::tinyinference_image::{MediaAuth, MediaTransport, OpenRouterImageGenerator}; + +// Direct: the caller's OpenRouter key. +let direct = OpenRouterImageGenerator::new(MediaAuth::ApiKey(key)); + +// Proxied: a backend that forwards OpenRouter's media routes verbatim. +let proxied = OpenRouterImageGenerator::with_transport( + MediaTransport::new(MediaAuth::Bearer(Arc::new(|| session_token()))) + .with_base_url("https://api.example.com/agent-integrations/openrouter"), +); + +let tool = GenerateImageTool::new(Arc::new(proxied), MediaOutput::new(fallback_root)) + .with_name("media_generate_image"); +``` + +## Billing safety + +Generation is billed on submit, and a model that retries a failure pays again. +The tools are built so that cannot happen quietly: + +- The tools reject an empty successful image or video response as a billed + non-delivery and tell the model not to retry. +- A video job whose status reads `completed` before its outputs exist keeps + polling instead of failing. +- A timed-out video job names its id and can be collected with `resume_job_id` + without a new submit. Image-generation errors cannot be resumed. +- Tool policy declares `payment`, `network`, `external_service` and + `writes_files`, and `idempotent: false`, so hosts never replay a call after a + crash. + +## Local references + +Reference paths are canonicalized and resolved against the run's workspace root. +Without a host policy they must stay inside it (no `..`, no absolute paths +elsewhere, and no symlinks pointing outside it), so a model cannot read an +arbitrary file and upload it to a third party. A custom +`MediaOutput::with_reference_policy` can override this to permit out-of-workspace +references when the host policy allows. diff --git a/docs/spec/README.md b/docs/spec/README.md index ce30f172d..dd868a3b7 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -39,6 +39,7 @@ observability, or test contracts. - [Context](../modules/harness/context.md) - [Model and providers](../modules/harness/model.md) - [Embeddings and retrieval](../modules/harness/embeddings.md) + - [Media generation](../modules/harness/media.md) - [Prompt](../modules/harness/prompt.md) - [Tool](../modules/harness/tool.md) - [Tool exposure and discovery](../modules/harness/tool-discovery.md) diff --git a/vendor/tinyinference b/vendor/tinyinference index aeaecda26..d0d329c5d 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit aeaecda26677161713b3a993872813e2a3594c21 +Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b diff --git a/vendor/tinytools b/vendor/tinytools index cd6dcabb2..52e9ab108 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit cd6dcabb26538909f68c5fbd5a182202b01da93f +Subproject commit 52e9ab10833b5a7a275ccbe8f45dfc8f428128fc