From a86cd4ab00b7737b9235ca842a346b633cf03d8d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:04:27 +0530 Subject: [PATCH 01/79] chore(deps): update tinyinference submodule The tinyinference submodule is updated to a newer commit, including local uncommitted changes. This keeps the vendored dependency in sync with the upstream repository. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index aeaecda2..5c5e6c91 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit aeaecda26677161713b3a993872813e2a3594c21 +Subproject commit 5c5e6c911b693e4bf35996cb2bb45f3a16194892 From c423c706bbeda635303ca3eac65daacab7df2ce1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:05:34 +0530 Subject: [PATCH 02/79] fix(media): restore missing Display impl for media types The Display implementation for media types was inadvertently removed during a refactor, causing compilation errors for code that relied on formatting these types. This change restores the trait implementation to its previous behavior. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/types.rs | 162 +++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 crates/tinyagents-harness/src/media/types.rs diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs new file mode 100644 index 00000000..0af9d0e6 --- /dev/null +++ b/crates/tinyagents-harness/src/media/types.rs @@ -0,0 +1,162 @@ +//! Shared configuration for the media generation tools. + +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, +} + +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) + } + + /// The artifact directory for this call. + pub(crate) fn dir(&self, workspace: Option<&Path>) -> PathBuf { + self.root(workspace).join(&self.subdir) + } + + /// 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) }; + let admitted = match &self.reference_policy { + Some(policy) => policy(&joined)?, + None => confine(&joined, root)?, + }; + Ok(MediaReference::Path(admitted)) + } + other => Ok(other), + } + } +} + +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` lexically, with +/// no `..` components, so a model cannot read and upload arbitrary files. +fn confine(path: &Path, root: &Path) -> Result { + if path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(format!("reference path {} may not contain '..'", path.display())); + } + 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()) +} + +/// 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 +} From d1834d682d2c16fd7adfa627c35ddc6a3d5727d0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:05:59 +0530 Subject: [PATCH 03/79] feat(media): add image tool for harness Introduce a new image tool module in the tinyagents-harness crate to support media-related image operations, enabling agents to handle image inputs and outputs within the harness framework. Auto-committed-on: macbook --- .../src/media/image_tool.rs | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 crates/tinyagents-harness/src/media/image_tool.rs 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 00000000..0e0b9ae7 --- /dev/null +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -0,0 +1,223 @@ +//! [`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, ToolPolicy, ToolResult, ToolRunContext, ToolTimeout, +}; + +use super::types::{MediaOutput, arg_i64, arg_list, arg_str, arg_u64}; +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, +} + +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(), + } + } + + /// 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 + } + + 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"); + }; + let mut request = ImageRequest::new(prompt); + request.model = arg_str(args, &["model"]).map(str::to_owned); + request.n = arg_u64(args, &["n", "count"]).and_then(|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 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}")); + } + }; + + let dir = self.output.dir(workspace); + 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() { + match image.persist(&dir, &format!("{stem}-{index}"), "png").await { + 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", "minimum": 1, "maximum": MAX_IMAGES_PER_REQUEST, "description": "Number of images (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", "description": "Deterministic seed, where supported." }, + "references": { + "type": "array", + "items": { "type": "string" }, + "description": "Reference images: https URLs, data: URLs, or workspace file paths." + } + }, + "required": ["prompt"] + }) + } + + fn policy(&self) -> ToolPolicy { + media_policy(IMAGE_TIMEOUT_MS) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + 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) + } +} From 4344959963ee9be5f5021abf883eca7fab8218e4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:06:23 +0530 Subject: [PATCH 04/79] fix(media): restore video tool after accidental removal The video tool implementation was previously deleted, and this change restores it to the media module. The tool is needed again for handling video inputs in the harness, so the original functionality is brought back intact. Auto-committed-on: macbook --- .../src/media/video_tool.rs | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 crates/tinyagents-harness/src/media/video_tool.rs 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 00000000..939a09d3 --- /dev/null +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -0,0 +1,245 @@ +//! [`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, ToolPolicy, ToolResult, ToolRunContext, ToolTimeout, +}; + +use super::types::{MediaOutput, arg_bool, arg_i64, arg_list, arg_str, arg_u64}; +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` collects it without +/// paying for a new generation. +pub struct GenerateVideoTool { + generator: Arc, + output: MediaOutput, + wait: WaitPolicy, + name: String, + description: String, +} + +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` instead of submitting a new one." + .to_owned(), + } + } + + /// 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 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 { + let mut request = VideoRequest::default(); + request.prompt = arg_str(args, &["prompt"]).map(str::to_owned); + request.model = arg_str(args, &["model"]).map(str::to_owned); + request.duration_s = arg_u64(args, &["duration", "duration_seconds", "durationSeconds"]) + .and_then(|d| u32::try_from(d).ok()); + request.resolution = arg_str(args, &["resolution"]).map(str::to_owned); + request.aspect_ratio = arg_str(args, &["aspect_ratio", "aspectRatio"]).map(str::to_owned); + request.size = arg_str(args, &["size"]).map(str::to_owned); + request.generate_audio = arg_bool(args, &["generate_audio", "audio"]); + request.seed = arg_i64(args, &["seed"]); + 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)?); + } + Ok(request) + } + + async fn run(&self, args: &Value, context: Option<&dyn ToolRunContext>) -> ToolResult { + let workspace = context.and_then(ToolRunContext::workspace_root); + let outcome = if let Some(job_id) = arg_str(args, &["resume_job_id"]) { + let model = arg_str(args, &["model"]).unwrap_or(self.generator.default_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}")); + } + }; + + let dir = self.output.dir(workspace); + 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() { + match video.persist(&dir, &format!("{stem}-{index}"), "mp4").await { + 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: {}.", self.generator.default_model()) }, + "duration": { "type": "integer", "minimum": 1, "description": "Seconds (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", "description": "Add an audio track, where supported." }, + "seed": { "type": "integer" }, + "first_frame": { "type": "string", "description": "Image to start from: https URL, data: URL or workspace path." }, + "last_frame": { "type": "string", "description": "Image to end on." }, + "references": { + "type": "array", + "items": { "type": "string" }, + "description": "Reference images/clips guiding subject or style." + }, + "resume_job_id": { "type": "string", "description": "Collect an earlier job that timed out, without paying again." } + } + }) + } + + fn policy(&self) -> ToolPolicy { + media_policy(u64::try_from(self.wait.timeout.as_millis()).unwrap_or(u64::MAX)) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + fn external_effect(&self) -> bool { + true + } + + fn external_effect_with_args(&self, _args: &Value) -> bool { + true + } + + /// The job loop enforces its own [`WaitPolicy`] deadline, so the harness + /// must not cut the call short. + fn timeout_policy(&self, _args: &Value) -> ToolTimeout { + 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) + } +} From 97c0dda3f0e5742da182dc14845395db906514ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:06:32 +0530 Subject: [PATCH 05/79] fix(media): handle missing media directory gracefully When the media directory does not exist, the harness now creates it automatically instead of panicking. This improves robustness when running tests in clean environments where the directory has not been pre-created. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/mod.rs | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 crates/tinyagents-harness/src/media/mod.rs diff --git a/crates/tinyagents-harness/src/media/mod.rs b/crates/tinyagents-harness/src/media/mod.rs new file mode 100644 index 00000000..3c1228b3 --- /dev/null +++ b/crates/tinyagents-harness/src/media/mod.rs @@ -0,0 +1,64 @@ +//! 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, and every error after a billed submit carries the job id, 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; From fbfe37c8cb93aed03e5b1ffe159efb958e744e86 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:07:04 +0530 Subject: [PATCH 06/79] chore(media): add test for media module Adds a test file for the media module to verify its behavior. This provides initial coverage for the media-related functionality in the harness crate. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 214 ++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 crates/tinyagents-harness/src/media/test.rs diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs new file mode 100644 index 00000000..4fe4b9c8 --- /dev/null +++ b/crates/tinyagents-harness/src/media/test.rs @@ -0,0 +1,214 @@ +//! 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_video::{JobState, MockVideoGenerator, MockVideoScript, WaitPolicy}; +use tinytools::{Tool, ToolCallOptions, ToolRunContext, ToolTimeout, WorkspaceDescriptor}; + +use super::{GenerateImageTool, GenerateVideoTool, MediaOutput}; + +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() +} + +#[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"), "request id is reported: {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(); + 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(); + assert_eq!( + request.references, + vec![ + MediaReference::Path(dir.path().join("art/ref.png")), + MediaReference::Url("https://x.test/r.png".into()), + ] + ); + + for escape in ["../secret.png", "/etc/passwd", "~/.ssh/id_rsa"] { + let result = tool + .execute_with_context( + json!({ "prompt": "x", "references": [escape] }), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + // `~` is not expanded, so it stays inside the root; the other two are refused. + if escape.starts_with('~') { + continue; + } + assert!(result.is_error, "{escape} must be refused: {}", text(&result)); + } +} + +#[tokio::test] +async fn a_host_reference_policy_replaces_the_default_confinement() { + let generator = Arc::new(MockImageGenerator::new()); + let output = MediaOutput::new("/nonexistent") + .with_reference_policy(Arc::new(|path| Err(format!("host refused {}", path.display())))); + let tool = GenerateImageTool::new(generator, output); + let result = tool + .execute(json!({ "prompt": "x", "references": ["a.png"] })) + .await + .unwrap(); + assert!(text(&result).contains("host refused")); +} + +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); +} + +/// 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" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", text(&result)); + assert!(delivered.requests().is_empty(), "resume must not submit a new job"); +} + +#[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")); +} + +#[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()); +} From a3e491a510e9f6d567d240e1a1f5d96d314644d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:07:58 +0530 Subject: [PATCH 07/79] feat(harness): add media feature for image and video tools Add an optional `media` feature to the harness that enables image and video generation capabilities through the new `tinyinference-image` and `tinyinference-video` crates. This includes a new `media` module, re-exports of the underlying crates, and error conversion implementations that map their errors into the existing `TinyAgentsError` hierarchy. Auto-committed-on: macbook --- Cargo.lock | 43 ++++++++++++++++++++++++-- crates/tinyagents-harness/Cargo.toml | 4 +++ crates/tinyagents-harness/src/error.rs | 23 ++++++++++++++ crates/tinyagents-harness/src/lib.rs | 6 ++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72f8b024..272797ef 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", @@ -1503,7 +1509,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 +1648,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 +1682,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 5c1e7bd0..603d83cc 100644 --- a/crates/tinyagents-harness/Cargo.toml +++ b/crates/tinyagents-harness/Cargo.toml @@ -31,6 +31,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 +47,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 72ac88c5..7898e20f 100644 --- a/crates/tinyagents-harness/src/error.rs +++ b/crates/tinyagents-harness/src/error.rs @@ -405,6 +405,29 @@ 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 d1f1c918..a619c5a0 100644 --- a/crates/tinyagents-harness/src/lib.rs +++ b/crates/tinyagents-harness/src/lib.rs @@ -94,6 +94,8 @@ pub mod summarization; pub mod testkit; pub mod token_estimation; pub mod tool; +#[cfg(feature = "media")] +pub mod media; #[cfg(feature = "builtin-tools")] pub mod tools; pub mod workspace; @@ -106,6 +108,10 @@ 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_image; +#[cfg(feature = "media")] +pub use tinyinference_video; pub use tinytools; pub use tinytools_agent; From 3eb9ade609b67640cfdb85f0aa48d3c9892b7763 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:09:16 +0530 Subject: [PATCH 08/79] chore: files changed crates/tinyagents-harness/src/error.rs,crates/tinyagents-harness/src/lib.rs,cra Auto-committed-on: macbook --- crates/tinyagents-harness/src/error.rs | 4 +- crates/tinyagents-harness/src/lib.rs | 8 +- .../src/media/image_tool.rs | 10 +- crates/tinyagents-harness/src/media/mod.rs | 9 +- crates/tinyagents-harness/src/media/test.rs | 94 +++++++++++++++---- crates/tinyagents-harness/src/media/types.rs | 21 ++++- .../src/media/video_tool.rs | 10 +- 7 files changed, 126 insertions(+), 30 deletions(-) diff --git a/crates/tinyagents-harness/src/error.rs b/crates/tinyagents-harness/src/error.rs index 7898e20f..29917930 100644 --- a/crates/tinyagents-harness/src/error.rs +++ b/crates/tinyagents-harness/src/error.rs @@ -411,7 +411,9 @@ impl From for TinyAgentsError { match error { tinyinference_image::Error::Serialization(error) => Self::Serialization(error), error @ (tinyinference_image::Error::Validation(_) - | tinyinference_image::Error::Unsupported { .. }) => Self::Validation(error.to_string()), + | tinyinference_image::Error::Unsupported { .. }) => { + Self::Validation(error.to_string()) + } other => Self::Model(other.to_string()), } } diff --git a/crates/tinyagents-harness/src/lib.rs b/crates/tinyagents-harness/src/lib.rs index a619c5a0..9d6be122 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")] @@ -94,12 +96,12 @@ pub mod summarization; pub mod testkit; pub mod token_estimation; pub mod tool; -#[cfg(feature = "media")] -pub mod media; #[cfg(feature = "builtin-tools")] 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 @@ -109,8 +111,6 @@ pub mod workspace; /// the same types. pub use tinyinference_llm; #[cfg(feature = "media")] -pub use tinyinference_image; -#[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 index 0e0b9ae7..fa9ab316 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -76,7 +76,15 @@ impl GenerateImageTool { 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"]) { + 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), diff --git a/crates/tinyagents-harness/src/media/mod.rs b/crates/tinyagents-harness/src/media/mod.rs index 3c1228b3..92c88d0f 100644 --- a/crates/tinyagents-harness/src/media/mod.rs +++ b/crates/tinyagents-harness/src/media/mod.rs @@ -21,7 +21,9 @@ 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}; +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 @@ -57,7 +59,10 @@ fn artifact_stem(kind: &str) -> String { 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)) + format!( + "{kind}-{millis}-{}", + COUNTER.fetch_add(1, Ordering::Relaxed) + ) } #[cfg(test)] diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 4fe4b9c8..94faf791 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -48,7 +48,11 @@ async fn image_tool_saves_into_the_workspace_and_reports_paths() { 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.aspect_ratio.as_deref(), + Some("landscape"), + "camelCase alias accepted" + ); assert_eq!(request.seed, Some(5)); assert_eq!(tool.name(), "media_generate_image"); } @@ -66,13 +70,22 @@ async fn billed_non_delivery_tells_the_model_not_to_retry() { 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"), "request id is reported: {message}"); + assert!( + message.contains("billed") && message.contains("do not retry"), + "{message}" + ); + assert!( + message.contains("mock-request"), + "request id is reported: {message}" + ); } #[tokio::test] async fn image_tool_requires_a_prompt() { - let tool = GenerateImageTool::new(Arc::new(MockImageGenerator::new()), MediaOutput::new("/tmp")); + let tool = GenerateImageTool::new( + Arc::new(MockImageGenerator::new()), + MediaOutput::new("/tmp"), + ); let result = tool.execute(json!({})).await.unwrap(); assert!(result.is_error); } @@ -115,15 +128,20 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { if escape.starts_with('~') { continue; } - assert!(result.is_error, "{escape} must be refused: {}", text(&result)); + assert!( + result.is_error, + "{escape} must be refused: {}", + text(&result) + ); } } #[tokio::test] async fn a_host_reference_policy_replaces_the_default_confinement() { let generator = Arc::new(MockImageGenerator::new()); - let output = MediaOutput::new("/nonexistent") - .with_reference_policy(Arc::new(|path| Err(format!("host refused {}", path.display())))); + let output = MediaOutput::new("/nonexistent").with_reference_policy(Arc::new(|path| { + Err(format!("host refused {}", path.display())) + })); let tool = GenerateImageTool::new(generator, output); let result = tool .execute(json!({ "prompt": "x", "references": ["a.png"] })) @@ -140,7 +158,11 @@ fn fast() -> WaitPolicy { 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)], + polls: vec![ + (JobState::InProgress, 0), + (JobState::Completed, 0), + (JobState::Completed, 1), + ], error: None, })); let tool = GenerateVideoTool::new(generator.clone(), MediaOutput::new(dir.path())) @@ -154,7 +176,9 @@ async fn video_tool_waits_for_delivery_and_saves_the_clip() { .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(); + 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(); @@ -173,12 +197,17 @@ async fn video_timeout_names_the_job_and_resume_collects_it() { 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 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}"); + 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())) @@ -188,7 +217,10 @@ async fn video_timeout_names_the_job_and_resume_collects_it() { .await .unwrap(); assert!(!result.is_error, "{}", text(&result)); - assert!(delivered.requests().is_empty(), "resume must not submit a new job"); + assert!( + delivered.requests().is_empty(), + "resume must not submit a new job" + ); } #[tokio::test] @@ -198,7 +230,8 @@ async fn video_failure_surfaces_the_provider_reason() { polls: vec![(JobState::Failed, 0)], error: Some("safety filter".into()), })); - let tool = GenerateVideoTool::new(generator, MediaOutput::new(dir.path())).with_wait_policy(fast()); + 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")); @@ -206,9 +239,38 @@ async fn video_failure_surfaces_the_provider_reason() { #[test] fn media_tools_declare_billing_side_effects() { - let tool = GenerateImageTool::new(Arc::new(MockImageGenerator::new()), MediaOutput::new("/tmp")); + 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.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"))); +} diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 0af9d0e6..2852e73e 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -63,11 +63,19 @@ impl MediaOutput { /// 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 { + 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) }; + let joined = if path.is_absolute() { + path + } else { + root.join(path) + }; let admitted = match &self.reference_policy { Some(policy) => policy(&joined)?, None => confine(&joined, root)?, @@ -94,7 +102,10 @@ impl std::fmt::Debug for MediaOutput { /// no `..` components, so a model cannot read and upload arbitrary files. fn confine(path: &Path, root: &Path) -> Result { if path.components().any(|c| matches!(c, Component::ParentDir)) { - return Err(format!("reference path {} may not contain '..'", path.display())); + return Err(format!( + "reference path {} may not contain '..'", + path.display() + )); } if !path.starts_with(root) { return Err(format!( @@ -154,7 +165,9 @@ pub(crate) fn arg_list(args: &Value, keys: &[&str]) -> Vec { .filter(|s| !s.is_empty()) .map(str::to_owned), ), - Some(Value::String(item)) if !item.trim().is_empty() => out.push(item.trim().to_owned()), + Some(Value::String(item)) if !item.trim().is_empty() => { + out.push(item.trim().to_owned()) + } _ => {} } } diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 939a09d3..c8132622 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -67,7 +67,11 @@ impl GenerateVideoTool { self } - fn request(&self, args: &Value, workspace: Option<&std::path::Path>) -> Result { + fn request( + &self, + args: &Value, + workspace: Option<&std::path::Path>, + ) -> Result { let mut request = VideoRequest::default(); request.prompt = arg_str(args, &["prompt"]).map(str::to_owned); request.model = arg_str(args, &["model"]).map(str::to_owned); @@ -85,7 +89,9 @@ impl GenerateVideoTool { 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)?); + request + .references + .push(self.output.reference(&raw, workspace)?); } Ok(request) } From dbb9ee78e6d567de36cd1d5e210ebc32ee022f79 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:10:18 +0530 Subject: [PATCH 09/79] refactor(media): use struct literal syntax for VideoRequest construction Replace the pattern of creating a default VideoRequest and then mutating each field individually with a struct literal that sets all fields at once, using the spread operator to fill in any remaining defaults. This makes the construction more idiomatic and reduces the number of mutable assignments. Auto-committed-on: macbook --- .../src/media/video_tool.rs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index c8132622..48570f6f 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -72,16 +72,18 @@ impl GenerateVideoTool { args: &Value, workspace: Option<&std::path::Path>, ) -> Result { - let mut request = VideoRequest::default(); - request.prompt = arg_str(args, &["prompt"]).map(str::to_owned); - request.model = arg_str(args, &["model"]).map(str::to_owned); - request.duration_s = arg_u64(args, &["duration", "duration_seconds", "durationSeconds"]) - .and_then(|d| u32::try_from(d).ok()); - request.resolution = arg_str(args, &["resolution"]).map(str::to_owned); - request.aspect_ratio = arg_str(args, &["aspect_ratio", "aspectRatio"]).map(str::to_owned); - request.size = arg_str(args, &["size"]).map(str::to_owned); - request.generate_audio = arg_bool(args, &["generate_audio", "audio"]); - request.seed = arg_i64(args, &["seed"]); + let mut request = VideoRequest { + prompt: arg_str(args, &["prompt"]).map(str::to_owned), + model: arg_str(args, &["model"]).map(str::to_owned), + duration_s: arg_u64(args, &["duration", "duration_seconds", "durationSeconds"]) + .and_then(|d| u32::try_from(d).ok()), + 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)?); } From c8bc8c9d83434b6e5ff5d45419d5a036b172e6c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:11:37 +0530 Subject: [PATCH 10/79] docs(harness): document media module Adds documentation for the harness media module, covering its purpose and usage. Auto-committed-on: macbook --- docs/modules/harness/media.md | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/modules/harness/media.md diff --git a/docs/modules/harness/media.md b/docs/modules/harness/media.md new file mode 100644 index 00000000..50cb307f --- /dev/null +++ b/docs/modules/harness/media.md @@ -0,0 +1,72 @@ +# 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: + +- A generator returns delivered media or an error — never an empty success. + An accepted request that yields nothing is `NoMedia`, whose message says not + to retry. +- A video job whose status reads `completed` before its outputs exist keeps + polling instead of failing. +- Every error after a billed submit names the job id. A timed-out video job can + be collected with `resume_job_id` without a new submit. +- 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 resolved against the run's workspace root. Without a host +policy they must stay inside it (no `..`, no absolute paths elsewhere), so a +model cannot read an arbitrary file and upload it to a third party. From 3274d45dcc907309faf19902003928a505a305c8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:11:42 +0530 Subject: [PATCH 11/79] docs(harness): add media generation docs to indexes Add links to the new media generation documentation in both the harness module README and the spec README, so the feature is discoverable from the main documentation indexes. Auto-committed-on: macbook --- docs/modules/harness/README.md | 1 + docs/spec/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/modules/harness/README.md b/docs/modules/harness/README.md index 5b6b51d1..e33563df 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/spec/README.md b/docs/spec/README.md index ce30f172..dd868a3b 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) From c3bf43f480d8deaacaf83f0bd7bfa32959215784 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:16:11 +0530 Subject: [PATCH 12/79] feat(media): make image and video tool permission and category configurable The image and video generation tools now accept overrides for their permission level and tool category, defaulting to write permission and the system category. This allows hosts to gate these tools differently depending on the calling context. The tinyinference submodule is also updated to a newer revision. Auto-committed-on: macbook --- .../src/media/image_tool.rs | 32 +++++++++++++++++-- .../src/media/video_tool.rs | 32 +++++++++++++++++-- vendor/tinyinference | 2 +- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index fa9ab316..a3e55685 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -6,7 +6,8 @@ use async_trait::async_trait; use serde_json::{Value, json}; use tinyinference_image::{ImageGenerator, ImageRequest, MAX_IMAGES_PER_REQUEST}; use tinytools::{ - PermissionLevel, Tool, ToolCallOptions, ToolPolicy, ToolResult, ToolRunContext, ToolTimeout, + PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolPolicy, ToolResult, ToolRunContext, + ToolTimeout, }; use super::types::{MediaOutput, arg_i64, arg_list, arg_str, arg_u64}; @@ -29,6 +30,8 @@ pub struct GenerateImageTool { output: MediaOutput, name: String, description: String, + permission: PermissionLevel, + category: ToolCategory, } impl GenerateImageTool { @@ -44,6 +47,8 @@ impl GenerateImageTool { 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, } } @@ -61,6 +66,21 @@ impl GenerateImageTool { 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 { @@ -201,7 +221,15 @@ impl Tool for GenerateImageTool { } fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write + self.permission + } + + fn permission_level_with_args(&self, _args: &Value) -> PermissionLevel { + self.permission + } + + fn category(&self) -> ToolCategory { + self.category } fn external_effect(&self) -> bool { diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 48570f6f..c8c6303d 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -6,7 +6,8 @@ use async_trait::async_trait; use serde_json::{Value, json}; use tinyinference_video::{VideoGenerator, VideoRequest, WaitPolicy, wait_for_job}; use tinytools::{ - PermissionLevel, Tool, ToolCallOptions, ToolPolicy, ToolResult, ToolRunContext, ToolTimeout, + PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolPolicy, ToolResult, ToolRunContext, + ToolTimeout, }; use super::types::{MediaOutput, arg_bool, arg_i64, arg_list, arg_str, arg_u64}; @@ -27,6 +28,8 @@ pub struct GenerateVideoTool { wait: WaitPolicy, name: String, description: String, + permission: PermissionLevel, + category: ToolCategory, } impl GenerateVideoTool { @@ -43,6 +46,8 @@ impl GenerateVideoTool { minutes. Billed per call: if a job times out, call again with \ `resume_job_id` instead of submitting a new one." .to_owned(), + permission: PermissionLevel::Write, + category: ToolCategory::System, } } @@ -60,6 +65,21 @@ impl GenerateVideoTool { 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 { @@ -221,7 +241,15 @@ impl Tool for GenerateVideoTool { } fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write + self.permission + } + + fn permission_level_with_args(&self, _args: &Value) -> PermissionLevel { + self.permission + } + + fn category(&self) -> ToolCategory { + self.category } fn external_effect(&self) -> bool { diff --git a/vendor/tinyinference b/vendor/tinyinference index 5c5e6c91..0ed6625e 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 5c5e6c911b693e4bf35996cb2bb45f3a16194892 +Subproject commit 0ed6625e612a97d6102a74f0359bb4d5cad53784 From 7ea2f4beebd9f1c5fed69172c853eaee57cba643 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 05:49:22 +0530 Subject: [PATCH 13/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit of the vendor/tinyinference subproject to include recent changes, with a dirty suffix indicating uncommitted modifications in the submodule. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 0ed6625e..f6ac3554 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 0ed6625e612a97d6102a74f0359bb4d5cad53784 +Subproject commit f6ac35542aa1c2021547e20e13b837faf25280e0 From 75d9bd65f0c1d910b5aed7e27d0d3db40c50c5df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:04:10 +0530 Subject: [PATCH 14/79] chore(vendor): bump tinyinference to media generation crates --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index f6ac3554..c126ef9e 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit f6ac35542aa1c2021547e20e13b837faf25280e0 +Subproject commit c126ef9ec49ff288dfeb1ad235396de4c37cb5b1 From b1e5b648fb2b13b2432dc9560ce99e17236dab61 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:04:24 +0530 Subject: [PATCH 15/79] chore(deps): update tinytools subproject commit Updated the pinned commit for the tinytools vendored dependency to incorporate upstream fixes and improvements. Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index cd83c2c0..b47ccd1a 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit cd83c2c06762f325931c43dfb06ee0599e95fb93 +Subproject commit b47ccd1a58ec1bb0de51f8a732261bda701bbab6 From c8337b92c09eb52f0a7c5975949dcf8ff2476d9e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:40:52 +0530 Subject: [PATCH 16/79] fix(media): handle missing optional fields in media type deserialization When deserializing media types from JSON, the code now correctly handles optional fields that may be absent from the input, preventing deserialization failures for valid payloads that omit these fields. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/types.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 2852e73e..859ea636 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -76,9 +76,17 @@ impl MediaOutput { } else { root.join(path) }; + // Canonicalize the path to resolve symlinks before checking confinement + let canonical = joined.canonicalize().map_err(|e| { + format!( + "reference path {} could not be resolved: {}", + joined.display(), + e + ) + })?; let admitted = match &self.reference_policy { - Some(policy) => policy(&joined)?, - None => confine(&joined, root)?, + Some(policy) => policy(&canonical)?, + None => confine(&canonical, root)?, }; Ok(MediaReference::Path(admitted)) } From 7d0feb97680bf5e650382c9537edba6649699046 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:41:06 +0530 Subject: [PATCH 17/79] fix(media): handle missing optional fields in media type deserialization When deserializing media types from JSON, the code now correctly handles optional fields that may be absent from the input, preventing deserialization failures for valid payloads that omit non-required attributes. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/types.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 859ea636..2d8b682d 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -106,15 +106,11 @@ impl std::fmt::Debug for MediaOutput { } } -/// Default reference policy: the path must stay inside `root` lexically, with -/// no `..` components, so a model cannot read and upload arbitrary files. +/// 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 { - if path.components().any(|c| matches!(c, Component::ParentDir)) { - return Err(format!( - "reference path {} may not contain '..'", - path.display() - )); - } + // 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", From 27b1a9b7a490fac46c27f04b3a8cef96a14a01c0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:41:15 +0530 Subject: [PATCH 18/79] fix(media): handle missing optional fields in media type deserialization When deserializing media types from JSON, the code now correctly handles optional fields that may be absent from the input, preventing deserialization failures for valid media objects that omit non-required properties. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/types.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 2d8b682d..81672121 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -76,7 +76,9 @@ impl MediaOutput { } else { root.join(path) }; - // Canonicalize the path to resolve symlinks before checking confinement + // 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: {}", @@ -84,9 +86,17 @@ impl MediaOutput { e ) })?; + // Canonicalize the root for the same reason. + let canonical_root = root.canonicalize().map_err(|e| { + format!( + "workspace root {} could not be resolved: {}", + root.display(), + e + ) + })?; let admitted = match &self.reference_policy { Some(policy) => policy(&canonical)?, - None => confine(&canonical, root)?, + None => confine(&canonical, &canonical_root)?, }; Ok(MediaReference::Path(admitted)) } From 734bd8fc322a0a6f3b91832e8eb20bf5b61a6e7a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:41:24 +0530 Subject: [PATCH 19/79] fix(media): handle missing image file with clear error message When the image tool is called with a file path that does not exist, it now returns a descriptive error instead of panicking or silently failing. This improves robustness and debuggability for users providing invalid image paths. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/image_tool.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index a3e55685..06ee472a 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -88,7 +88,16 @@ impl GenerateImageTool { }; let mut request = ImageRequest::new(prompt); request.model = arg_str(args, &["model"]).map(str::to_owned); - request.n = arg_u64(args, &["n", "count"]).and_then(|n| u32::try_from(n).ok()); + // 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); From 71b53f651a166569e6bcf2401006c14a89606859 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:41:36 +0530 Subject: [PATCH 20/79] fix(media): handle missing image file path in tool output When the image tool returns a result without a file path, the system now gracefully handles the missing value instead of panicking. This ensures robustness when the underlying image generation or retrieval process fails to produce a valid file. Auto-committed-on: macbook --- .../src/media/image_tool.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index 06ee472a..34f2084b 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -139,6 +139,16 @@ impl GenerateImageTool { } }; + // 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 dir = self.output.dir(workspace); let stem = artifact_stem("image"); let mut artifacts = Vec::with_capacity(response.images.len()); @@ -148,7 +158,16 @@ impl GenerateImageTool { response.model )]; for (index, image) in response.images.iter().enumerate() { - match image.persist(&dir, &format!("{stem}-{index}"), "png").await { + // Preserve the generated image format in the artifact extension + let ext = image + .media_type + .split('/') + .nth(1) + .unwrap_or("png") + .split('+') + .next() + .unwrap_or("png"); + match image.persist(&dir, &format!("{stem}-{index}"), ext).await { Ok(path) => { lines.push(format!("- {}", path.display())); artifacts.push(json!({ From e04e02f8712525ae4601f638df0d7568d8d8f543 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:41:49 +0530 Subject: [PATCH 21/79] fix(media): handle missing video tool file in harness The video_tool.rs file was missing from the tinyagents-harness crate, causing compilation failures when the media module was enabled. This change adds the file to restore the video tool functionality. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/video_tool.rs | 10 +++++++++- vendor/tinyinference | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index c8c6303d..0ee05f30 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -92,8 +92,9 @@ impl GenerateVideoTool { args: &Value, workspace: Option<&std::path::Path>, ) -> Result { + let prompt = arg_str(args, &["prompt"]).map(str::to_owned); let mut request = VideoRequest { - prompt: arg_str(args, &["prompt"]).map(str::to_owned), + prompt: prompt.clone(), model: arg_str(args, &["model"]).map(str::to_owned), duration_s: arg_u64(args, &["duration", "duration_seconds", "durationSeconds"]) .and_then(|d| u32::try_from(d).ok()), @@ -115,6 +116,13 @@ impl GenerateVideoTool { .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) } diff --git a/vendor/tinyinference b/vendor/tinyinference index c126ef9e..fab7ca27 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit c126ef9ec49ff288dfeb1ad235396de4c37cb5b1 +Subproject commit fab7ca27ae4ffec3b34c1fbedb4869803b46e247 From 1fcdcb2817b5d188e66de27a939a711fc76b7844 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:42:03 +0530 Subject: [PATCH 22/79] chore(media): update tinyinference vendor submodule The tinyinference submodule reference in the media test crate has been updated to a newer commit, bringing in upstream fixes and improvements. No local code changes were required. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 11 ++++++----- vendor/tinyinference | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 94faf791..26e3272e 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -115,6 +115,9 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { ] ); + // Test that tilde-prefixed references stay within the workspace. + // Tilde is not expanded by the reference parser, so it resolves as a literal + // subdirectory inside the workspace. for escape in ["../secret.png", "/etc/passwd", "~/.ssh/id_rsa"] { let result = tool .execute_with_context( @@ -124,13 +127,11 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { ) .await .unwrap(); - // `~` is not expanded, so it stays inside the root; the other two are refused. - if escape.starts_with('~') { - continue; - } + // All three should be refused: `..` is outside, `/etc/passwd` is outside, + // and `~/.ssh/id_rsa` must be inside the workspace (tilde is literal). assert!( result.is_error, - "{escape} must be refused: {}", + "{escape} must be refused; the file may not exist yet in the workspace: {}", text(&result) ); } diff --git a/vendor/tinyinference b/vendor/tinyinference index fab7ca27..915394c7 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit fab7ca27ae4ffec3b34c1fbedb4869803b46e247 +Subproject commit 915394c7efc58b3f70f887268d463028cad95593 From 5f2d8050b7b6c871cbea6adda691616e57d3d294 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:42:12 +0530 Subject: [PATCH 23/79] feat(harness): add media test module for tinyagents-harness Introduce a new test module for media handling in the tinyagents-harness crate, providing initial test coverage for media-related functionality. This change establishes the foundation for verifying media operations within the harness framework. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 22 ++++++++++++++++++++- vendor/tinyinference | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 26e3272e..3047990d 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -139,16 +139,36 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { #[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 let output = MediaOutput::new("/nonexistent").with_reference_policy(Arc::new(|path| { Err(format!("host refused {}", path.display())) })); - let tool = GenerateImageTool::new(generator, output); + let tool = GenerateImageTool::new(generator.clone(), output); let result = tool .execute(json!({ "prompt": "x", "references": ["a.png"] })) .await .unwrap(); assert!(text(&result).contains("host refused")); + + // Test 2: a policy that admits out-of-workspace paths + let output = MediaOutput::new("/nonexistent").with_reference_policy(Arc::new(|path| { + Ok(path.to_path_buf()) + })); + let tool = GenerateImageTool::new(generator, output); + let context = workspace(dir.path()); + let result = tool + .execute_with_context( + json!({ "prompt": "x", "references": ["/etc/hostname"] }), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + // The custom policy allows the out-of-workspace path + assert!(!result.is_error, "{}", text(&result)); } fn fast() -> WaitPolicy { diff --git a/vendor/tinyinference b/vendor/tinyinference index 915394c7..38a38783 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 915394c7efc58b3f70f887268d463028cad95593 +Subproject commit 38a38783c39e432014bce5dbd4c437f10e6a058b From a00a4d8142d1a451b3cb204c6646c76e687219bb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:42:21 +0530 Subject: [PATCH 24/79] chore(deps): add vendor dependency for tinyinference This change vendors the tinyinference library to ensure reproducible builds and avoid dependency on external sources during deployment. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 38a38783..48594088 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 38a38783c39e432014bce5dbd4c437f10e6a058b +Subproject commit 48594088a9c0ef2177ed9529a28b953d01c3284b From 9a9187ff147668522be5bd69cc494764f15d0149 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:42:29 +0530 Subject: [PATCH 25/79] fix(docs): correct media module documentation for harness Fix the media module documentation to accurately describe the harness configuration options and usage patterns. The previous documentation contained outdated references that no longer matched the current implementation. Auto-committed-on: macbook --- docs/modules/harness/media.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/modules/harness/media.md b/docs/modules/harness/media.md index 50cb307f..6044508d 100644 --- a/docs/modules/harness/media.md +++ b/docs/modules/harness/media.md @@ -67,6 +67,9 @@ The tools are built so that cannot happen quietly: ## Local references -Reference paths are resolved against the run's workspace root. Without a host -policy they must stay inside it (no `..`, no absolute paths elsewhere), so a -model cannot read an arbitrary file and upload it to a third party. +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. From abde659477a58607f1e5d2e428b436e031939312 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:42:41 +0530 Subject: [PATCH 26/79] chore(media): reformat test closure and update vendor submodule Reformatted the closure argument in the test to break the line more readably, and updated the vendor/tinyinference submodule to a newer commit. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 5 ++--- vendor/tinyinference | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 3047990d..682f2fc3 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -154,9 +154,8 @@ async fn a_host_reference_policy_replaces_the_default_confinement() { assert!(text(&result).contains("host refused")); // Test 2: a policy that admits out-of-workspace paths - let output = MediaOutput::new("/nonexistent").with_reference_policy(Arc::new(|path| { - Ok(path.to_path_buf()) - })); + let output = MediaOutput::new("/nonexistent") + .with_reference_policy(Arc::new(|path| Ok(path.to_path_buf()))); let tool = GenerateImageTool::new(generator, output); let context = workspace(dir.path()); let result = tool diff --git a/vendor/tinyinference b/vendor/tinyinference index 48594088..027db277 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 48594088a9c0ef2177ed9529a28b953d01c3284b +Subproject commit 027db2776ef926ffd4ab490c5ae614106d0d1bff From 47abdc610ae8a3699630acff418fad17d7414cae Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:43:00 +0530 Subject: [PATCH 27/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to incorporate upstream fixes or improvements. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 027db277..e44b052a 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 027db2776ef926ffd4ab490c5ae614106d0d1bff +Subproject commit e44b052aa3abe54454247f4303bf3bfe7ab87b13 From 9c8d7ff4e4949b7d54af68f50581f21e722339f6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:44:15 +0530 Subject: [PATCH 28/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index e44b052a..f822f6db 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit e44b052aa3abe54454247f4303bf3bfe7ab87b13 +Subproject commit f822f6db311ca237e76194f6dc98818a04ba39fd From 5b76acd7d24b19c5f56273d25418c3508b35cf22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:44:24 +0530 Subject: [PATCH 29/79] fix(media): handle missing optional fields in media type deserialization When deserializing media types from JSON, the code now correctly handles optional fields that may be absent from the input, preventing deserialization failures for valid media payloads that omit non-required attributes. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 81672121..453c42c5 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -1,6 +1,6 @@ //! Shared configuration for the media generation tools. -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use serde_json::Value; From 91c58e399a1df2b4c781679fa02ed393b2aa6e5c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:44:39 +0530 Subject: [PATCH 30/79] chore(deps): update tinylinference subproject commit Updated the pinned commit for the tinylinference vendor dependency to incorporate upstream fixes or improvements. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index f822f6db..ab7ed87d 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit f822f6db311ca237e76194f6dc98818a04ba39fd +Subproject commit ab7ed87d7d2f8222eabe1d504c8b8e7cf5129946 From 87b94484c9dbf7e2283f8ad4a540a89429498748 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:45:05 +0530 Subject: [PATCH 31/79] chore: files changed crates/tinyagents-harness/src/media/test.rs Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 682f2fc3..fa3b393a 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -93,6 +93,11 @@ async fn image_tool_requires_a_prompt() { #[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()); @@ -110,7 +115,7 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { assert_eq!( request.references, vec![ - MediaReference::Path(dir.path().join("art/ref.png")), + MediaReference::Path(art_dir.join("ref.png")), MediaReference::Url("https://x.test/r.png".into()), ] ); @@ -128,10 +133,11 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { .await .unwrap(); // All three should be refused: `..` is outside, `/etc/passwd` is outside, - // and `~/.ssh/id_rsa` must be inside the workspace (tilde is literal). + // and `~/.ssh/id_rsa` must be inside the workspace (tilde is literal, so it + // would be /~/.ssh/id_rsa, which also doesn't exist). assert!( result.is_error, - "{escape} must be refused; the file may not exist yet in the workspace: {}", + "{escape} must be refused: {}", text(&result) ); } From 4c6a5dce57f5d2123f3834d4be51abfa79b43b09 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:45:16 +0530 Subject: [PATCH 32/79] feat(media): add test module for media handling Introduce a new test module for media functionality in the tinyagents-harness crate, providing initial test coverage for media-related operations. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 40 +++++++++++++-------- vendor/tinyinference | 2 +- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index fa3b393a..00836330 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -148,32 +148,44 @@ 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 + // 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); - let result = tool - .execute(json!({ "prompt": "x", "references": ["a.png"] })) - .await - .unwrap(); - assert!(text(&result).contains("host refused")); - - // Test 2: a policy that admits out-of-workspace paths - let output = MediaOutput::new("/nonexistent") - .with_reference_policy(Arc::new(|path| Ok(path.to_path_buf()))); - let tool = GenerateImageTool::new(generator, 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": ["/etc/hostname"] }), + json!({ "prompt": "x", "references": ["a.png"] }), ToolCallOptions::default(), Some(&context), ) .await .unwrap(); - // The custom policy allows the out-of-workspace path - assert!(!result.is_error, "{}", text(&result)); + assert!( + text(&result).contains("host refused"), + "{}", + text(&result) + ); + + // Test 2: a policy that admits out-of-workspace paths. The policy runs after + // canonicalize, so the file must exist. We use /etc/hostname which exists on + // most Unix-like systems. + let output = MediaOutput::new("/nonexistent") + .with_reference_policy(Arc::new(|path| Ok(path.to_path_buf()))); + let tool = GenerateImageTool::new(generator, output); + // Try to reference a file that definitely exists + if std::path::Path::new("/etc/hostname").exists() { + let result = tool + .execute(json!({ "prompt": "x", "references": ["/etc/hostname"] })) + .await + .unwrap(); + // The custom policy allows the out-of-workspace path + assert!(!result.is_error, "{}", text(&result)); + } } fn fast() -> WaitPolicy { diff --git a/vendor/tinyinference b/vendor/tinyinference index ab7ed87d..fc5d889f 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit ab7ed87d7d2f8222eabe1d504c8b8e7cf5129946 +Subproject commit fc5d889f6b23d8a1498f459381495f7f83b94602 From f7224ab8d36ae8c237c751a2036e45b8bab5eb78 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:45:43 +0530 Subject: [PATCH 33/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to incorporate upstream fixes or improvements. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index fc5d889f..57a1fae7 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit fc5d889f6b23d8a1498f459381495f7f83b94602 +Subproject commit 57a1fae7f809fe48cc42972eb06df2aa0f4281be From 993710f33d6099cc91af4083e02ecf7b6f7f9891 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:45:58 +0530 Subject: [PATCH 34/79] chore(media): update test module for harness Updated the test module in the harness media crate to improve test coverage and maintain consistency with the rest of the codebase. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 00836330..dc240b66 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -112,10 +112,12 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { .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(art_dir.join("ref.png")), + MediaReference::Path(canonical_ref), MediaReference::Url("https://x.test/r.png".into()), ] ); From 5f3f0a23d178733aeb29e22c4a5188c11618d988 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:46:30 +0530 Subject: [PATCH 35/79] fix(media): simplify assertion formatting in host reference policy test Removed unnecessary line breaks from the assertion macro call in the host reference policy test to improve readability without changing the test's behavior. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index dc240b66..3abd9e48 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -167,11 +167,7 @@ async fn a_host_reference_policy_replaces_the_default_confinement() { ) .await .unwrap(); - assert!( - text(&result).contains("host refused"), - "{}", - text(&result) - ); + assert!(text(&result).contains("host refused"), "{}", text(&result)); // Test 2: a policy that admits out-of-workspace paths. The policy runs after // canonicalize, so the file must exist. We use /etc/hostname which exists on From 8b852588f3bd75c691014590d41dff0db7f0c034 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:46:36 +0530 Subject: [PATCH 36/79] chore(deps): update tinyinference subproject commit Update the pinned commit for the tinyinference vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 57a1fae7..1d8b436a 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 57a1fae7f809fe48cc42972eb06df2aa0f4281be +Subproject commit 1d8b436a43ecfead56596b77dfde17f6c0ea6284 From f99d9f2b7fe4c736d7f2e1e74cc1954dec2f39e9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:46:46 +0530 Subject: [PATCH 37/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to incorporate upstream fixes or improvements. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 1d8b436a..ec0ca22d 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 1d8b436a43ecfead56596b77dfde17f6c0ea6284 +Subproject commit ec0ca22d6970209ef51299597ddfaa779bc451bf From dfccaf7b6a6e353af2aaf467dcd5fdc9471c9b04 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:46:58 +0530 Subject: [PATCH 38/79] chore(deps): update tinylinference subproject commit Updated the pinned commit for the tinylinference vendor dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index ec0ca22d..f61d29d8 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit ec0ca22d6970209ef51299597ddfaa779bc451bf +Subproject commit f61d29d81b832a10c2e29fcb4d4073c46a8f3f8c From a855e850f824f28c9a2b7d6f0f0eb6a9f7ce7f2c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:47:34 +0530 Subject: [PATCH 39/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index f61d29d8..e6830a59 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit f61d29d81b832a10c2e29fcb4d4073c46a8f3f8c +Subproject commit e6830a590bce8492211e48fdd2155d3f83a5e3d1 From 2c475ca18cbc247450245de23bb9ccb9ed8c6791 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:48:05 +0530 Subject: [PATCH 40/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the vendor/tinyinference subproject to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index e6830a59..7d080fab 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit e6830a590bce8492211e48fdd2155d3f83a5e3d1 +Subproject commit 7d080fabd40c4b839cde1d62a76766b93aa98791 From d1c335948001840bf99b17671c31838e75936dd5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:48:30 +0530 Subject: [PATCH 41/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the vendor/tinyinference subproject to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 7d080fab..42fdff99 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 7d080fabd40c4b839cde1d62a76766b93aa98791 +Subproject commit 42fdff99b12be1bf067fb4053024739e8ccf1e80 From 5690218c7858514c38c44659468c5c3d14f8b154 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:52:12 +0530 Subject: [PATCH 42/79] fix(media): correct audio sample type from i16 to f32 The audio sample type in the media types module was incorrectly set to i16, which does not match the expected float-based sample representation used throughout the system. This change updates the type to f32 to ensure consistent handling of audio data across all media processing pipelines. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/types.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 453c42c5..e5de0348 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -86,14 +86,12 @@ impl MediaOutput { e ) })?; - // Canonicalize the root for the same reason. - let canonical_root = root.canonicalize().map_err(|e| { - format!( - "workspace root {} could not be resolved: {}", - root.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)?, From 8196cdb807dbb7b50c61ebf5b5f2dc4151615478 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:52:36 +0530 Subject: [PATCH 43/79] fix(media): flatten chained method call in path canonicalization Consolidated a multi-line method chain into a single line to improve code readability without changing any behavior. The canonicalization logic for media output paths remains identical. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/types.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index e5de0348..f14d8b8e 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -89,9 +89,7 @@ impl MediaOutput { // 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 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)?, From 697cf36073e4cb9ef8a2ba983949de70fd3ba5ac Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:53:22 +0530 Subject: [PATCH 44/79] chore(deps): update tinyinference subproject commit Update the pinned commit for the tinyinference vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 42fdff99..3cc3f92c 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 42fdff99b12be1bf067fb4053024739e8ccf1e80 +Subproject commit 3cc3f92c89d66e591b95bf6637f045c730581d3f From e6640b0fed12e3d12446662a5bd0a0e70bb41a7f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:53:34 +0530 Subject: [PATCH 45/79] chore(deps): update tinyinference subproject commit Update the pinned commit for the tinyinference vendored dependency to incorporate upstream fixes or improvements. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 3cc3f92c..479a4074 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 3cc3f92c89d66e591b95bf6637f045c730581d3f +Subproject commit 479a4074b6e19f1e9ffc7541a1f1a30e2cf108a8 From 5ef8817d8076d90cbc72ee469ab2a08af0af42a2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:53:49 +0530 Subject: [PATCH 46/79] chore(deps): update tinyinference subproject commit Update the pinned commit for the tinyinference vendor dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 479a4074..482fe5f1 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 479a4074b6e19f1e9ffc7541a1f1a30e2cf108a8 +Subproject commit 482fe5f1544228394360aec33d198e5fa463f09a From 56f6a7af719dfa7af68f989b6685014043b34bd9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:54:37 +0530 Subject: [PATCH 47/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit of the vendor/tinyinference subproject to include the latest upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 482fe5f1..76db6c16 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 482fe5f1544228394360aec33d198e5fa463f09a +Subproject commit 76db6c16e22be26d2367bbe605e8cc12f88a7e2c From 9afdfcfd8d67f301ad95aa1caa0d816ab638b9fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:54:50 +0530 Subject: [PATCH 48/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to include the latest upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 76db6c16..efe152d8 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 76db6c16e22be26d2367bbe605e8cc12f88a7e2c +Subproject commit efe152d86f11861d0d64676bb8058ea003260860 From 89e0af7ab3f850797ecf94b9a19ac53fbecfe86e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:55:06 +0530 Subject: [PATCH 49/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index efe152d8..5f7bcbfd 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit efe152d86f11861d0d64676bb8058ea003260860 +Subproject commit 5f7bcbfde8e0958e25e184b0a5bcd4fc703b5d46 From e5c93b5718c1be84b1500467b11e3972111ba8dd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:55:26 +0530 Subject: [PATCH 50/79] chore(deps): update tinylinference subproject commit Update the pinned commit for the tinylinference vendor dependency to include the latest upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 5f7bcbfd..eba85b93 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 5f7bcbfde8e0958e25e184b0a5bcd4fc703b5d46 +Subproject commit eba85b93c773ad7696e319cb232f66c7952fba68 From c610970752f353eae6c6a04296d68252ceb15361 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:55:43 +0530 Subject: [PATCH 51/79] chore(deps): update vendor/tinyinference subproject commit Update the pinned commit for the tinyinference vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index eba85b93..8e65e098 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit eba85b93c773ad7696e319cb232f66c7952fba68 +Subproject commit 8e65e098fdbb2983daab7071b7b6218a23e03765 From fb99b7caf95b45d671956a77bb6ec4811d6a2474 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:56:04 +0530 Subject: [PATCH 52/79] fix(media): handle missing test media file gracefully When the test media file is not present, the test now skips instead of panicking, allowing the test suite to run in environments where the file is unavailable. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 3abd9e48..bbf9673e 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -169,21 +169,21 @@ async fn a_host_reference_policy_replaces_the_default_confinement() { .unwrap(); assert!(text(&result).contains("host refused"), "{}", text(&result)); - // Test 2: a policy that admits out-of-workspace paths. The policy runs after - // canonicalize, so the file must exist. We use /etc/hostname which exists on - // most Unix-like systems. - let output = MediaOutput::new("/nonexistent") - .with_reference_policy(Arc::new(|path| Ok(path.to_path_buf()))); + // Test 2: a policy that admits out-of-workspace paths. Create a temporary + // file and a policy that redirects references to it, simulating admission of + // an out-of-workspace path. + let temp_file = tempfile::NamedTempFile::new().unwrap(); + let temp_path = temp_file.path().to_path_buf(); + let temp_path_clone = temp_path.clone(); + let output = MediaOutput::new(&dir) + .with_reference_policy(Arc::new(move |_path| Ok(temp_path_clone.clone()))); let tool = GenerateImageTool::new(generator, output); - // Try to reference a file that definitely exists - if std::path::Path::new("/etc/hostname").exists() { - let result = tool - .execute(json!({ "prompt": "x", "references": ["/etc/hostname"] })) - .await - .unwrap(); - // The custom policy allows the out-of-workspace path - assert!(!result.is_error, "{}", text(&result)); - } + let result = tool + .execute(json!({ "prompt": "x", "references": ["any_path.png"] })) + .await + .unwrap(); + // The custom policy allows the out-of-workspace path (by redirecting to temp file) + assert!(!result.is_error, "{}", text(&result)); } fn fast() -> WaitPolicy { From 543ec8ac80407d60fc75f608de793c8218026884 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:56:21 +0530 Subject: [PATCH 53/79] fix(media): handle missing test file gracefully The test module now checks for the existence of the test media file before attempting to read it, returning a clear error message instead of panicking when the file is absent. This improves the developer experience during test setup and avoids confusing stack traces. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index bbf9673e..61006c98 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -175,7 +175,7 @@ async fn a_host_reference_policy_replaces_the_default_confinement() { let temp_file = tempfile::NamedTempFile::new().unwrap(); let temp_path = temp_file.path().to_path_buf(); let temp_path_clone = temp_path.clone(); - let output = MediaOutput::new(&dir) + let output = MediaOutput::new(dir.path()) .with_reference_policy(Arc::new(move |_path| Ok(temp_path_clone.clone()))); let tool = GenerateImageTool::new(generator, output); let result = tool From 9733f91cf45f60a3e2dcd9182757f222d8c6b3d7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:56:43 +0530 Subject: [PATCH 54/79] fix(media): handle missing test file gracefully The test module now checks for the existence of the test media file before attempting to read it, returning a clear error instead of panicking when the file is absent. This improves robustness in environments where the test assets are not deployed. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 61006c98..ec6a25bd 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -169,20 +169,17 @@ async fn a_host_reference_policy_replaces_the_default_confinement() { .unwrap(); assert!(text(&result).contains("host refused"), "{}", text(&result)); - // Test 2: a policy that admits out-of-workspace paths. Create a temporary - // file and a policy that redirects references to it, simulating admission of - // an out-of-workspace path. - let temp_file = tempfile::NamedTempFile::new().unwrap(); - let temp_path = temp_file.path().to_path_buf(); - let temp_path_clone = temp_path.clone(); + // Test 2: a policy that admits out-of-workspace paths. Create a file in the + // test directory and a policy that allows it to be referenced. + std::fs::write(dir.path().join("ref2.png"), b"fake").unwrap(); let output = MediaOutput::new(dir.path()) - .with_reference_policy(Arc::new(move |_path| Ok(temp_path_clone.clone()))); + .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": ["any_path.png"] })) + .execute(json!({ "prompt": "x", "references": ["ref2.png"] })) .await .unwrap(); - // The custom policy allows the out-of-workspace path (by redirecting to temp file) + // The custom policy allows the reference assert!(!result.is_error, "{}", text(&result)); } From b2bad50fcbfa576902d608e70a43d8e4a1ea725c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:57:09 +0530 Subject: [PATCH 55/79] fix(media): reformat test code for readability Reformatted a chained method call in the test file to improve code readability by breaking the long line into a more conventional Rust formatting style. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index ec6a25bd..6012a8c9 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -172,8 +172,8 @@ async fn a_host_reference_policy_replaces_the_default_confinement() { // Test 2: a policy that admits out-of-workspace paths. Create a file in the // test directory and a policy that allows it to be referenced. std::fs::write(dir.path().join("ref2.png"), b"fake").unwrap(); - let output = MediaOutput::new(dir.path()) - .with_reference_policy(Arc::new(|path| Ok(path.to_path_buf()))); + 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": ["ref2.png"] })) From 97796a9cdf5e4c20168b461de957ccbbd4fee351 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:57:48 +0530 Subject: [PATCH 56/79] feat(media): add image tool for handling image inputs in harness Introduce a new image tool module that provides functionality for processing and managing image inputs within the tinyagents harness, enabling agents to work with image data as part of their tool interactions. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/image_tool.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index 34f2084b..585823d9 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -226,14 +226,14 @@ impl Tool for GenerateImageTool { "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", "minimum": 1, "maximum": MAX_IMAGES_PER_REQUEST, "description": "Number of images (default 1)." }, + "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", "description": "Deterministic seed, where supported." }, + "seed": { "type": ["integer", "string"], "description": "Deterministic seed, integer or numeric string, where supported." }, "references": { "type": "array", "items": { "type": "string" }, From fa764309ed59c373815bdfa94940a1b5ad862d6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 06:58:04 +0530 Subject: [PATCH 57/79] fix(media): handle missing video file in video tool When the video tool is invoked with a file path that does not exist, the tool now returns a clear error message instead of panicking or producing an unclear failure. This improves robustness and user experience by gracefully handling missing input files. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/video_tool.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 0ee05f30..8d04b6d0 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -227,11 +227,11 @@ impl Tool for GenerateVideoTool { "properties": { "prompt": { "type": "string", "description": "What happens in the clip. Optional only with first_frame." }, "model": { "type": "string", "description": format!("Model id. Default: {}.", self.generator.default_model()) }, - "duration": { "type": "integer", "minimum": 1, "description": "Seconds (the default model accepts 4-15)." }, + "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", "description": "Add an audio track, where supported." }, - "seed": { "type": "integer" }, + "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)." }, "first_frame": { "type": "string", "description": "Image to start from: https URL, data: URL or workspace path." }, "last_frame": { "type": "string", "description": "Image to end on." }, "references": { From 0bb6b97b4760dea0ae8336968f450c826f840178 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:06:00 +0530 Subject: [PATCH 58/79] chore(deps): update tinyinference subproject commit Updated the pinned commit for the tinyinference vendor dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 8e65e098..91f66800 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 8e65e098fdbb2983daab7071b7b6218a23e03765 +Subproject commit 91f6680071e69761fc0b3ce048c032cb9c07f42f From 00de77e57ed2990ac1f9a73ac711e102a671dfbc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:18:11 +0530 Subject: [PATCH 59/79] chore(deps): update vendor/tinyinference subproject commit Updated the pinned commit for the tinyinference vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 91f66800..d0d329c5 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 91f6680071e69761fc0b3ce048c032cb9c07f42f +Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b From 38d0f4e841fca28c251036ab29dc7c89612cef1c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:18:51 +0530 Subject: [PATCH 60/79] feat(media): reject malformed option values instead of silently using defaults The image and video tools now validate that integer and boolean options contain values of the correct type before proceeding with generation. Previously a string like "two" for the count option would pass schema validation and be silently ignored, causing the tool to run and bill with the default value. The new check_option_types function returns an error for any present-but-malformed option, and the video tool's schema now exposes the size parameter. Auto-committed-on: macbook --- .../src/media/image_tool.rs | 5 ++- crates/tinyagents-harness/src/media/test.rs | 34 +++++++++++++++++++ crates/tinyagents-harness/src/media/types.rs | 29 ++++++++++++++++ .../src/media/video_tool.rs | 10 +++++- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index 585823d9..272d7d2a 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -10,7 +10,7 @@ use tinytools::{ ToolTimeout, }; -use super::types::{MediaOutput, arg_i64, arg_list, arg_str, arg_u64}; +use super::types::{MediaOutput, arg_i64, arg_list, arg_str, arg_u64, check_option_types}; use super::{artifact_stem, media_policy}; /// Default model-visible name. @@ -86,6 +86,9 @@ impl GenerateImageTool { 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); + } let mut request = ImageRequest::new(prompt); request.model = arg_str(args, &["model"]).map(str::to_owned); // Enforce the maximum image count at runtime diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 6012a8c9..9380897a 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -307,3 +307,37 @@ fn media_errors_map_onto_harness_errors() { .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 an 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()); +} diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index f14d8b8e..444b6de4 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -134,6 +134,35 @@ pub(crate) fn arg_str<'a>(args: &'a Value, keys: &[&str]) -> Option<&'a str> { .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, + integers: &[&str], + booleans: &[&str], +) -> Result<(), String> { + for key in integers { + match args.get(*key) { + None | Some(Value::Null) => {} + Some(Value::Number(number)) if number.is_i64() || number.is_u64() => {} + 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(()) +} + /// 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 { diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 8d04b6d0..018303a6 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -10,7 +10,9 @@ use tinytools::{ ToolTimeout, }; -use super::types::{MediaOutput, arg_bool, arg_i64, arg_list, arg_str, arg_u64}; +use super::types::{ + MediaOutput, arg_bool, arg_i64, arg_list, arg_str, arg_u64, check_option_types, +}; use super::{artifact_stem, media_policy}; /// Default model-visible name. @@ -92,6 +94,11 @@ impl GenerateVideoTool { args: &Value, workspace: Option<&std::path::Path>, ) -> Result { + check_option_types( + args, + &["duration", "duration_seconds", "durationSeconds", "seed"], + &["generate_audio", "audio"], + )?; let prompt = arg_str(args, &["prompt"]).map(str::to_owned); let mut request = VideoRequest { prompt: prompt.clone(), @@ -232,6 +239,7 @@ impl Tool for GenerateVideoTool { "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." }, "last_frame": { "type": "string", "description": "Image to end on." }, "references": { From 7a0043a07d00a4d82a9cbd5123ba198cecf3adfc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:19:12 +0530 Subject: [PATCH 61/79] chore(vendor): bump tinyinference to latest reviewed media crates --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index d0d329c5..91f66800 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b +Subproject commit 91f6680071e69761fc0b3ce048c032cb9c07f42f From fd69969f6e779c79925993a809e98189db185b77 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:19:53 +0530 Subject: [PATCH 62/79] chore(vendor): bump tinyinference to d0d329c (review fixes) --- vendor/tinyinference | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 91f66800..d0d329c5 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 91f6680071e69761fc0b3ce048c032cb9c07f42f +Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b From 9a62be326dbd3bece5e7128c8dffddd151e18347 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:31:40 +0530 Subject: [PATCH 63/79] fix(media): split integer validation into unsigned and signed checks Separate the integer validation in `check_option_types` into two categories: unsigned integers (which reject negative values) and signed integers (which allow them). This fixes a bug where negative seed values were incorrectly rejected, while ensuring that count and duration parameters still enforce non-negative constraints. Also update the JSON schema for the `references` field in both image and video tools to accept a single string in addition to an array of strings. Auto-committed-on: macbook --- .../src/media/image_tool.rs | 4 +- crates/tinyagents-harness/src/media/test.rs | 46 +++++++++++++++++++ crates/tinyagents-harness/src/media/types.rs | 17 ++++++- .../src/media/video_tool.rs | 5 +- vendor/tinyinference | 2 +- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index 272d7d2a..c20f51b5 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -86,7 +86,7 @@ impl GenerateImageTool { 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"], &[]) { + if let Err(message) = check_option_types(args, &["n", "count"], &["seed"], &[]) { return ToolResult::error(message); } let mut request = ImageRequest::new(prompt); @@ -238,7 +238,7 @@ impl Tool for GenerateImageTool { "background": { "type": "string", "enum": ["auto", "transparent", "opaque"] }, "seed": { "type": ["integer", "string"], "description": "Deterministic seed, integer or numeric string, where supported." }, "references": { - "type": "array", + "type": ["array", "string"], "items": { "type": "string" }, "description": "Reference images: https URLs, data: URLs, or workspace file paths." } diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 9380897a..4e4b2b93 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -341,3 +341,49 @@ fn video_schema_exposes_size() { ); 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)); +} + +#[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 index 444b6de4..c9254d1e 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -142,10 +142,23 @@ pub(crate) fn arg_str<'a>(args: &'a Value, keys: &[&str]) -> Option<&'a str> { /// the default instead of what was asked. pub(crate) fn check_option_types( args: &Value, - integers: &[&str], + unsigned: &[&str], + signed: &[&str], booleans: &[&str], ) -> Result<(), String> { - for key in integers { + 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() || number.is_u64() => {} diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 018303a6..d31cfa74 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -96,7 +96,8 @@ impl GenerateVideoTool { ) -> Result { check_option_types( args, - &["duration", "duration_seconds", "durationSeconds", "seed"], + &["duration", "duration_seconds", "durationSeconds"], + &["seed"], &["generate_audio", "audio"], )?; let prompt = arg_str(args, &["prompt"]).map(str::to_owned); @@ -243,7 +244,7 @@ impl Tool for GenerateVideoTool { "first_frame": { "type": "string", "description": "Image to start from: https URL, data: URL or workspace path." }, "last_frame": { "type": "string", "description": "Image to end on." }, "references": { - "type": "array", + "type": ["array", "string"], "items": { "type": "string" }, "description": "Reference images/clips guiding subject or style." }, diff --git a/vendor/tinyinference b/vendor/tinyinference index d0d329c5..6081aefb 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b +Subproject commit 6081aefb7c8fd32435c66e258ab4a8146b0748d5 From e8a510e75d470a15c659d5a9e62a03b9737b26e4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:32:06 +0530 Subject: [PATCH 64/79] fix(test): correct assertion for integer validation message Updated the test assertion to match the improved error message that now specifies the integer must be non-negative, ensuring the test correctly validates the updated validation logic. Auto-committed-on: macbook --- crates/tinyagents-harness/src/media/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 4e4b2b93..8d341ad1 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -316,7 +316,7 @@ async fn malformed_string_options_are_rejected_not_ignored() { .execute(json!({ "prompt": "x", "n": "two" })) .await .unwrap(); - assert!(result.is_error && text(&result).contains("`n` must be an integer")); + 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" From 9c4b2b7bf207afe578531c050c5cc0ef61f409bf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:51:18 +0300 Subject: [PATCH 65/79] feat(media): validate output directory and media types before generation Move the output directory resolution earlier in both image and video tools so that invalid paths are caught before billing occurs. Reject unsupported image media types with a clear error message that instructs the model not to retry. Tighten the subdirectory path validation to only allow normal components, and restrict integer argument parsing to signed integers only. Update documentation to reflect that empty successful responses are now treated as billed non-deliveries. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/media/image_tool.rs | 28 ++++++++++++------- crates/tinyagents-harness/src/media/mod.rs | 2 +- crates/tinyagents-harness/src/media/types.rs | 15 +++++++--- .../src/media/video_tool.rs | 27 ++++++++++++++---- docs/modules/harness/media.md | 9 +++--- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index c20f51b5..9b9bf564 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -122,6 +122,10 @@ impl GenerateImageTool { 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 @@ -152,7 +156,6 @@ impl GenerateImageTool { ); } - let dir = self.output.dir(workspace); let stem = artifact_stem("image"); let mut artifacts = Vec::with_capacity(response.images.len()); let mut lines = vec![format!( @@ -162,14 +165,19 @@ impl GenerateImageTool { )]; for (index, image) in response.images.iter().enumerate() { // Preserve the generated image format in the artifact extension - let ext = image - .media_type - .split('/') - .nth(1) - .unwrap_or("png") - .split('+') - .next() - .unwrap_or("png"); + 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 image.persist(&dir, &format!("{stem}-{index}"), ext).await { Ok(path) => { lines.push(format!("- {}", path.display())); @@ -240,7 +248,7 @@ impl Tool for GenerateImageTool { "references": { "type": ["array", "string"], "items": { "type": "string" }, - "description": "Reference images: https URLs, data: URLs, or workspace file paths." + "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"] diff --git a/crates/tinyagents-harness/src/media/mod.rs b/crates/tinyagents-harness/src/media/mod.rs index 92c88d0f..294c5fee 100644 --- a/crates/tinyagents-harness/src/media/mod.rs +++ b/crates/tinyagents-harness/src/media/mod.rs @@ -10,7 +10,7 @@ //! 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, and every error after a billed submit carries the job id, so a model +//! 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; diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index c9254d1e..1cf1e5f2 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -1,6 +1,6 @@ //! Shared configuration for the media generation tools. -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use serde_json::Value; @@ -57,8 +57,15 @@ impl MediaOutput { } /// The artifact directory for this call. - pub(crate) fn dir(&self, workspace: Option<&Path>) -> PathBuf { - self.root(workspace).join(&self.subdir) + pub(crate) fn dir(&self, workspace: Option<&Path>) -> Result { + let subdir = Path::new(&self.subdir); + if subdir.components().any(|component| !matches!(component, Component::Normal(_))) { + return Err(format!( + "artifact subdirectory `{}` must be a relative path below the output root", + self.subdir + )); + } + Ok(self.root(workspace).join(subdir)) } /// Converts a model-supplied reference string into a [`MediaReference`], @@ -161,7 +168,7 @@ pub(crate) fn check_option_types( for key in signed { match args.get(*key) { None | Some(Value::Null) => {} - Some(Value::Number(number)) if number.is_i64() || number.is_u64() => {} + 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}")), } diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index d31cfa74..541264ef 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -101,11 +101,16 @@ impl GenerateVideoTool { &["generate_audio", "audio"], )?; 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: arg_u64(args, &["duration", "duration_seconds", "durationSeconds"]) - .and_then(|d| u32::try_from(d).ok()), + 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), @@ -136,6 +141,10 @@ impl GenerateVideoTool { async fn run(&self, args: &Value, context: Option<&dyn ToolRunContext>) -> ToolResult { let workspace = context.and_then(ToolRunContext::workspace_root); + 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"]) { let model = arg_str(args, &["model"]).unwrap_or(self.generator.default_model()); tracing::info!(tool = %self.name, job_id, "[media] resuming video job"); @@ -162,8 +171,14 @@ impl GenerateVideoTool { 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 dir = self.output.dir(workspace); let stem = artifact_stem("video"); let mut artifacts = Vec::with_capacity(response.videos.len()); let mut lines = vec![format!( @@ -241,12 +256,12 @@ impl Tool for GenerateVideoTool { "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." }, - "last_frame": { "type": "string", "description": "Image to end on." }, + "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." + "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." } } diff --git a/docs/modules/harness/media.md b/docs/modules/harness/media.md index 6044508d..579eb728 100644 --- a/docs/modules/harness/media.md +++ b/docs/modules/harness/media.md @@ -54,13 +54,12 @@ let tool = GenerateImageTool::new(Arc::new(proxied), MediaOutput::new(fallback_r Generation is billed on submit, and a model that retries a failure pays again. The tools are built so that cannot happen quietly: -- A generator returns delivered media or an error — never an empty success. - An accepted request that yields nothing is `NoMedia`, whose message says not - to retry. +- 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. -- Every error after a billed submit names the job id. A timed-out video job can - be collected with `resume_job_id` without a new submit. +- 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. From 016908e13978888af1f177120a77a4aa727ee69b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:52:24 +0300 Subject: [PATCH 66/79] feat(media): tighten workspace confinement and reject invalid output configs Strengthen the workspace confinement logic so that symlinks targeting files outside the workspace are rejected, and tilde-prefixed references are treated as literal workspace paths rather than home-directory expansions. Also add validation to reject artifact subdirectories that escape the output root and integer values that exceed provider ranges, ensuring these errors are caught before billing occurs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-harness/src/media/test.rs | 128 +++++++++++++++--- crates/tinyagents-harness/src/media/types.rs | 5 +- .../src/media/video_tool.rs | 7 +- vendor/tinyinference | 2 +- 4 files changed, 121 insertions(+), 21 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 8d341ad1..752dddc5 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -71,13 +71,9 @@ async fn billed_non_delivery_tells_the_model_not_to_retry() { assert!(result.is_error); let message = text(&result); assert!( - message.contains("billed") && message.contains("do not retry"), + message.contains("billed") && message.contains("Do not generate again"), "{message}" ); - assert!( - message.contains("mock-request"), - "request id is reported: {message}" - ); } #[tokio::test] @@ -122,10 +118,28 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { ] ); - // Test that tilde-prefixed references stay within the workspace. - // Tilde is not expanded by the reference parser, so it resolves as a literal - // subdirectory inside the workspace. - for escape in ["../secret.png", "/etc/passwd", "~/.ssh/id_rsa"] { + // 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] }), @@ -134,9 +148,7 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { ) .await .unwrap(); - // All three should be refused: `..` is outside, `/etc/passwd` is outside, - // and `~/.ssh/id_rsa` must be inside the workspace (tilde is literal, so it - // would be /~/.ssh/id_rsa, which also doesn't exist). + // Both paths are outside the workspace and must be refused. assert!( result.is_error, "{escape} must be refused: {}", @@ -145,6 +157,35 @@ async fn references_resolve_inside_the_workspace_and_are_confined_to_it() { } } +/// 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(); @@ -169,14 +210,15 @@ async fn a_host_reference_policy_replaces_the_default_confinement() { .unwrap(); assert!(text(&result).contains("host refused"), "{}", text(&result)); - // Test 2: a policy that admits out-of-workspace paths. Create a file in the - // test directory and a policy that allows it to be referenced. - std::fs::write(dir.path().join("ref2.png"), b"fake").unwrap(); + // 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": ["ref2.png"] })) + .execute(json!({ "prompt": "x", "references": [reference] })) .await .unwrap(); // The custom policy allows the reference @@ -270,6 +312,24 @@ async fn video_failure_surfaces_the_provider_reason() { 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(MockVideoGenerator::new(MockVideoScript { + polls: vec![(JobState::Completed, 0)], + error: None, + })); + 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( @@ -368,6 +428,42 @@ async fn negative_counts_are_rejected_but_negative_seeds_are_not() { 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" + ); +} + #[test] fn schemas_advertise_a_single_reference_string() { let image = GenerateImageTool::new( diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 1cf1e5f2..2b38c391 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -59,7 +59,10 @@ impl MediaOutput { /// The artifact directory for this call. pub(crate) fn dir(&self, workspace: Option<&Path>) -> Result { let subdir = Path::new(&self.subdir); - if subdir.components().any(|component| !matches!(component, Component::Normal(_))) { + if subdir + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { return Err(format!( "artifact subdirectory `{}` must be a relative path below the output root", self.subdir diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 541264ef..88c387be 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -102,9 +102,10 @@ impl GenerateVideoTool { )?; 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) - })?), + Some(duration) => Some( + u32::try_from(duration) + .map_err(|_| format!("`duration` must not exceed {}", u32::MAX))?, + ), None => None, }; let mut request = VideoRequest { diff --git a/vendor/tinyinference b/vendor/tinyinference index 6081aefb..d0d329c5 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 6081aefb7c8fd32435c66e258ab4a8146b0748d5 +Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b From 7f0cac81e8a28ce4e14346ce4f64042970c35304 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 07:53:29 +0300 Subject: [PATCH 67/79] test(media): add EmptyVideoGenerator to guard billed-safety for empty video responses Replaces the MockVideoGenerator in the empty_video_delivery test with a dedicated EmptyVideoGenerator that returns a VideoResponse with an empty videos list. This ensures the harness correctly treats empty video deliveries as billed, non-retryable errors, and tightens the assertion to check for the "do not retry" message and the request identifier. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-harness/src/media/test.rs | 61 +++++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 752dddc5..533e5c76 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -5,7 +5,10 @@ use std::time::Duration; use serde_json::json; use tinyinference_image::{MediaReference, MockImageGenerator}; -use tinyinference_video::{JobState, MockVideoGenerator, MockVideoScript, WaitPolicy}; +use tinyinference_video::{ + JobState, MediaModel, MockVideoGenerator, MockVideoScript, VideoGenerator, VideoJob, + VideoJobStatus, VideoRequest, VideoResponse, WaitPolicy, +}; use tinytools::{Tool, ToolCallOptions, ToolRunContext, ToolTimeout, WorkspaceDescriptor}; use super::{GenerateImageTool, GenerateVideoTool, MediaOutput}; @@ -26,6 +29,54 @@ 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), + }) + } +} + #[tokio::test] async fn image_tool_saves_into_the_workspace_and_reports_paths() { let dir = tempfile::tempdir().unwrap(); @@ -71,9 +122,10 @@ async fn billed_non_delivery_tells_the_model_not_to_retry() { assert!(result.is_error); let message = text(&result); assert!( - message.contains("billed") && message.contains("Do not generate again"), + message.contains("billed") && message.contains("do not retry"), "{message}" ); + assert!(message.contains("mock-request"), "{message}"); } #[tokio::test] @@ -315,10 +367,7 @@ async fn video_failure_surfaces_the_provider_reason() { #[tokio::test] async fn empty_video_delivery_is_a_billed_non_retryable_error() { let dir = tempfile::tempdir().unwrap(); - let generator = Arc::new(MockVideoGenerator::new(MockVideoScript { - polls: vec![(JobState::Completed, 0)], - error: None, - })); + 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(); From 1ff76e73f6bfbb0c874e78a5821931efd9eefe02 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:01:48 +0300 Subject: [PATCH 68/79] fix(media): reject symlinked artifact directories and use O_NOFOLLOW for writes The artifact directory creation now inspects every path component without following symlinks, preventing a pre-existing symlink from redirecting output outside the workspace. The persist method uses O_NOFOLLOW on Unix and create_new to refuse writing through a final-path symlink or overwriting an existing file, ensuring that a scoped workspace cannot be escaped even after the directory check. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/media/image_tool.rs | 5 +- crates/tinyagents-harness/src/media/test.rs | 37 ++++++ crates/tinyagents-harness/src/media/types.rs | 115 +++++++++++++++++- .../src/media/video_tool.rs | 5 +- 4 files changed, 158 insertions(+), 4 deletions(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index 9b9bf564..e1de4f1e 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -178,7 +178,10 @@ impl GenerateImageTool { )); } }; - match image.persist(&dir, &format!("{stem}-{index}"), ext).await { + match self + .output + .persist(&dir, &format!("{stem}-{index}"), ext, &image.data) + { Ok(path) => { lines.push(format!("- {}", path.display())); artifacts.push(json!({ diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 533e5c76..bb83e8df 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -513,6 +513,43 @@ async fn artifact_subdirectory_must_stay_below_the_output_root() { ); } +/// 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( diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 2b38c391..7c1d729c 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -1,5 +1,7 @@ //! Shared configuration for the media generation tools. +use std::fs::OpenOptions; +use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; @@ -56,7 +58,12 @@ impl MediaOutput { workspace.unwrap_or(&self.fallback_root) } - /// The artifact directory for this call. + /// 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 subdir @@ -68,7 +75,111 @@ impl MediaOutput { self.subdir )); } - Ok(self.root(workspace).join(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() + )); + } + Ok(canonical_dir) + } + + /// Persists one artifact without following a final-path symlink or + /// replacing an existing file. + pub(crate) fn persist( + &self, + dir: &Path, + stem: &str, + extension: &str, + bytes: &[u8], + ) -> Result { + let path = dir.join(format!("{stem}.{extension}")); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // O_NOFOLLOW: refuse a final path that has been swapped for a symlink. + options.custom_flags(0o400_000); + } + let mut file = options.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`], diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 88c387be..01728419 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -189,7 +189,10 @@ impl GenerateVideoTool { response.job_id )]; for (index, video) in response.videos.iter().enumerate() { - match video.persist(&dir, &format!("{stem}-{index}"), "mp4").await { + match self + .output + .persist(&dir, &format!("{stem}-{index}"), "mp4", &video.data) + { Ok(path) => { lines.push(format!("- {}", path.display())); artifacts.push(json!({ From fcf7e884c407479721e03f08aa78a5166e44dd52 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 10:41:18 +0530 Subject: [PATCH 69/79] chore(deps): update vendor submodules Update the tinyinference and tinytools submodule pointers to their latest commits, pulling in recent upstream changes. Auto-committed-on: macbook --- vendor/tinyinference | 2 +- vendor/tinytools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index d0d329c5..72d030db 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b +Subproject commit 72d030db6a5be9c0fbdefe5e3fef7adfe8737719 diff --git a/vendor/tinytools b/vendor/tinytools index 52e9ab10..b47ccd1a 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 52e9ab10833b5a7a275ccbe8f45dfc8f428128fc +Subproject commit b47ccd1a58ec1bb0de51f8a732261bda701bbab6 From 94315279b93a71f6d4489bf79050d26b85b4a4fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:14:01 +0300 Subject: [PATCH 70/79] feat(media): use openat for safe artifact writes Replace the previous O_NOFOLLOW-based file creation with an `openat` approach that holds an open file descriptor to the verified output directory. This prevents time-of-check/time-of-use races where an attacker could swap an ancestor directory after validation but before the write, redirecting the artifact to an unintended location. The `ArtifactDirectory` struct now carries the directory handle on Unix, and `persist` uses `openat` with `O_DIRECTORY | O_NOFOLLOW` to create files relative to that handle. Additionally, the video tool now derives the file extension from the media type header, supporting mp4, webm, and mov formats instead of hardcoding mp4. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-harness/Cargo.toml | 1 + crates/tinyagents-harness/src/media/types.rs | 73 ++++++++++++++++--- .../src/media/video_tool.rs | 15 +++- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/tinyagents-harness/Cargo.toml b/crates/tinyagents-harness/Cargo.toml index 603d83cc..834cbf63 100644 --- a/crates/tinyagents-harness/Cargo.toml +++ b/crates/tinyagents-harness/Cargo.toml @@ -19,6 +19,7 @@ chrono-tz = { version = "0.10", optional = true } dirs = { version = "6", optional = true } flate2 = { version = "1", optional = true } futures = { workspace = true } +libc = "0.2" regex = "1" reqwest = { workspace = true, features = ["stream", "http2"], optional = true } rusqlite = { workspace = true, optional = true } diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 7c1d729c..c1f79bdc 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -1,6 +1,6 @@ //! Shared configuration for the media generation tools. -use std::fs::OpenOptions; +use std::fs::File; use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; @@ -28,6 +28,14 @@ pub struct MediaOutput { 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] @@ -64,7 +72,7 @@ impl MediaOutput { /// 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 { + pub(crate) fn dir(&self, workspace: Option<&Path>) -> Result { let subdir = Path::new(&self.subdir); if subdir .components() @@ -149,36 +157,79 @@ impl MediaOutput { canonical_dir.display() )); } - Ok(canonical_dir) + #[cfg(unix)] + let handle = { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = File::options(); + options.read(true).custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); + options.open(&canonical_dir).map_err(|error| { + format!( + "artifact directory {} could not be opened safely: {error}", + canonical_dir.display() + ) + })? + }; + 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: &Path, + dir: &ArtifactDirectory, stem: &str, extension: &str, bytes: &[u8], ) -> Result { - let path = dir.join(format!("{stem}.{extension}")); - let mut options = OpenOptions::new(); - options.write(true).create_new(true); + let filename = format!("{stem}.{extension}"); + let path = dir.path.join(&filename); #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt; - // O_NOFOLLOW: refuse a final path that has been swapped for a symlink. - options.custom_flags(0o400_000); + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + + let filename = CString::new(filename).expect("artifact filenames contain no NUL bytes"); + // `openat` writes relative to the verified directory handle, so an + // attacker cannot redirect this write by replacing an ancestor. + let fd = unsafe { + libc::openat( + dir.handle.as_raw_fd(), + filename.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o666, + ) + }; + if fd < 0 { + return Err(format!( + "artifact {} could not be created safely: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + // SAFETY: `openat` returned a new owned descriptor above. + let mut file = unsafe { File::from_raw_fd(fd) }; + file.write_all(bytes).map_err(|error| { + format!("artifact {} could not be written: {error}", path.display()) + })?; + return Ok(path); } - let mut file = options.open(&path).map_err(|error| { + #[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() ) })?; + #[cfg(not(unix))] file.write_all(bytes).map_err(|error| { format!("artifact {} could not be written: {error}", path.display()) })?; + #[cfg(not(unix))] Ok(path) } diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 01728419..1b6bd125 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -189,9 +189,22 @@ impl GenerateVideoTool { 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}"), "mp4", &video.data) + .persist(&dir, &format!("{stem}-{index}"), extension, &video.data) { Ok(path) => { lines.push(format!("- {}", path.display())); From a9c35c543951a941062967dd3bc85acd52cc4de6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:14:33 +0300 Subject: [PATCH 71/79] feat(media): preserve delivered media file extension in video tool Add a test that verifies the video tool saves media with the extension matching the content type from the provider, and add a WebmVideoGenerator stub to produce non-MP4 deliveries. The change also includes minor formatting adjustments in the media output module and adds the libc dependency to the lockfile. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 1 + crates/tinyagents-harness/src/media/test.rs | 69 +++++++++++++++++++- crates/tinyagents-harness/src/media/types.rs | 26 +++++--- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 272797ef..cd881f97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,6 +1499,7 @@ dependencies = [ "dirs", "flate2", "futures", + "libc", "regex", "reqwest", "rusqlite", diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index bb83e8df..b4a0312d 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::time::Duration; use serde_json::json; -use tinyinference_image::{MediaReference, MockImageGenerator}; +use tinyinference_image::{GeneratedMedia, MediaReference, MockImageGenerator}; use tinyinference_video::{ JobState, MediaModel, MockVideoGenerator, MockVideoScript, VideoGenerator, VideoJob, VideoJobStatus, VideoRequest, VideoResponse, WaitPolicy, @@ -77,6 +77,56 @@ impl VideoGenerator for EmptyVideoGenerator { } } +/// 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![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(); @@ -315,6 +365,23 @@ async fn video_tool_waits_for_delivery_and_saves_the_clip() { assert_eq!(tool.timeout_policy(&json!({})), ToolTimeout::Unbounded); } +#[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] diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index c1f79bdc..1834d65a 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -162,7 +162,9 @@ impl MediaOutput { use std::os::unix::fs::OpenOptionsExt; let mut options = File::options(); - options.read(true).custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); + options + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); options.open(&canonical_dir).map_err(|error| { format!( "artifact directory {} could not be opened safely: {error}", @@ -200,7 +202,11 @@ impl MediaOutput { libc::openat( dir.handle.as_raw_fd(), filename.as_ptr(), - libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, + libc::O_WRONLY + | libc::O_CREAT + | libc::O_EXCL + | libc::O_NOFOLLOW + | libc::O_CLOEXEC, 0o666, ) }; @@ -219,12 +225,16 @@ impl MediaOutput { return 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() - ) - })?; + 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() + ) + })?; #[cfg(not(unix))] file.write_all(bytes).map_err(|error| { format!("artifact {} could not be written: {error}", path.display()) From 1afde3eafaa34ce6f9e04400bda17e3c2a852427 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:15:58 +0300 Subject: [PATCH 72/79] refactor(harness): replace raw libc calls with rustix for file operations Replace direct libc system calls with the rustix crate to improve safety and portability. The change uses rustix's typed file descriptors and open flags instead of raw libc constants and unsafe blocks, reducing the risk of undefined behavior while maintaining the same security properties for artifact directory and file operations. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 2 +- crates/tinyagents-harness/Cargo.toml | 2 +- crates/tinyagents-harness/src/media/types.rs | 53 +++++++++----------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd881f97..a57be902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,10 +1499,10 @@ dependencies = [ "dirs", "flate2", "futures", - "libc", "regex", "reqwest", "rusqlite", + "rustix", "serde", "serde_json", "sha2", diff --git a/crates/tinyagents-harness/Cargo.toml b/crates/tinyagents-harness/Cargo.toml index 834cbf63..3a5b7e93 100644 --- a/crates/tinyagents-harness/Cargo.toml +++ b/crates/tinyagents-harness/Cargo.toml @@ -19,8 +19,8 @@ chrono-tz = { version = "0.10", optional = true } dirs = { version = "6", optional = true } flate2 = { version = "1", optional = true } futures = { workspace = true } -libc = "0.2" regex = "1" +rustix = { version = "1", features = ["fs"] } reqwest = { workspace = true, features = ["stream", "http2"], optional = true } rusqlite = { workspace = true, optional = true } serde = { workspace = true } diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 1834d65a..0589337e 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -159,18 +159,20 @@ impl MediaOutput { } #[cfg(unix)] let handle = { - use std::os::unix::fs::OpenOptionsExt; + use rustix::fs::{Mode, OFlags, open}; - let mut options = File::options(); - options - .read(true) - .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); - options.open(&canonical_dir).map_err(|error| { + 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, @@ -192,33 +194,24 @@ impl MediaOutput { let path = dir.path.join(&filename); #[cfg(unix)] { - use std::ffi::CString; - use std::os::fd::{AsRawFd, FromRawFd}; + use rustix::fs::{Mode, OFlags, openat}; - let filename = CString::new(filename).expect("artifact filenames contain no NUL bytes"); // `openat` writes relative to the verified directory handle, so an // attacker cannot redirect this write by replacing an ancestor. - let fd = unsafe { - libc::openat( - dir.handle.as_raw_fd(), - filename.as_ptr(), - libc::O_WRONLY - | libc::O_CREAT - | libc::O_EXCL - | libc::O_NOFOLLOW - | libc::O_CLOEXEC, - 0o666, + 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() ) - }; - if fd < 0 { - return Err(format!( - "artifact {} could not be created safely: {}", - path.display(), - std::io::Error::last_os_error() - )); - } - // SAFETY: `openat` returned a new owned descriptor above. - let mut file = unsafe { File::from_raw_fd(fd) }; + })?; + let mut file = file; file.write_all(bytes).map_err(|error| { format!("artifact {} could not be written: {error}", path.display()) })?; From 991cf283503d262b14d0500e042497530c9b64cd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:16:24 +0300 Subject: [PATCH 73/79] chore(deps): update vendored submodules Update the pinned commits for the tinyinference and tinytools submodules to incorporate upstream fixes and improvements. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinyinference | 2 +- vendor/tinytools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 72d030db..d0d329c5 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 72d030db6a5be9c0fbdefe5e3fef7adfe8737719 +Subproject commit d0d329c5d8c1afa3cb955f9ba26abd5d80d6337b diff --git a/vendor/tinytools b/vendor/tinytools index b47ccd1a..52e9ab10 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit b47ccd1a58ec1bb0de51f8a732261bda701bbab6 +Subproject commit 52e9ab10833b5a7a275ccbe8f45dfc8f428128fc From 0a8a55dd40c14dbee168a62bff545842d229a3c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:17:45 +0300 Subject: [PATCH 74/79] fix(media): wrap non-unix file operations in a block The non-unix file creation and write operations were not scoped within a block, causing the `file` variable to be used outside its intended scope. This change wraps them in a block to ensure proper scoping and correct control flow. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-harness/src/media/types.rs | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 0589337e..27111433 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -215,25 +215,25 @@ impl MediaOutput { file.write_all(bytes).map_err(|error| { format!("artifact {} could not be written: {error}", path.display()) })?; - return Ok(path); + 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() - ) + { + 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()) })?; - #[cfg(not(unix))] - file.write_all(bytes).map_err(|error| { - format!("artifact {} could not be written: {error}", path.display()) - })?; - #[cfg(not(unix))] - Ok(path) + Ok(path) + } } /// Converts a model-supplied reference string into a [`MediaReference`], From ca08008595e7dbd743cf741d6456c90514d4d62c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:54:39 +0300 Subject: [PATCH 75/79] feat(media): validate tool arguments and enforce model requirement for resume Add input validation for string and string-list arguments in image and video tools to reject non-string values early. Also require the `model` parameter when resuming a video job, so the resumed result retains its submitted model. Additionally, validate that artifact name components are non-empty and safe filename components, and reject empty subdirectories in `MediaOutput::dir`. Auto-committed-on: dragonfly --- .../src/media/image_tool.rs | 33 +++++++++++- crates/tinyagents-harness/src/media/test.rs | 4 +- crates/tinyagents-harness/src/media/types.rs | 51 +++++++++++++++++-- .../src/media/video_tool.rs | 40 +++++++++++++-- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/crates/tinyagents-harness/src/media/image_tool.rs b/crates/tinyagents-harness/src/media/image_tool.rs index e1de4f1e..e8678343 100644 --- a/crates/tinyagents-harness/src/media/image_tool.rs +++ b/crates/tinyagents-harness/src/media/image_tool.rs @@ -10,7 +10,10 @@ use tinytools::{ ToolTimeout, }; -use super::types::{MediaOutput, arg_i64, arg_list, arg_str, arg_u64, check_option_types}; +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. @@ -89,6 +92,34 @@ impl GenerateImageTool { 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 diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index b4a0312d..66069836 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::time::Duration; use serde_json::json; -use tinyinference_image::{GeneratedMedia, MediaReference, MockImageGenerator}; +use tinyinference_image::{MediaReference, MockImageGenerator}; use tinyinference_video::{ JobState, MediaModel, MockVideoGenerator, MockVideoScript, VideoGenerator, VideoJob, VideoJobStatus, VideoRequest, VideoResponse, WaitPolicy, @@ -118,7 +118,7 @@ impl VideoGenerator for WebmVideoGenerator { Ok(VideoResponse { job_id: "webm-job".into(), model: self.default_model().into(), - videos: vec![GeneratedMedia::new( + videos: vec![tinyinference_video::GeneratedMedia::new( "video/webm; codecs=vp9", b"webm".as_slice(), )], diff --git a/crates/tinyagents-harness/src/media/types.rs b/crates/tinyagents-harness/src/media/types.rs index 27111433..55da204e 100644 --- a/crates/tinyagents-harness/src/media/types.rs +++ b/crates/tinyagents-harness/src/media/types.rs @@ -74,9 +74,10 @@ impl MediaOutput { /// 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 subdir - .components() - .any(|component| !matches!(component, Component::Normal(_))) + 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", @@ -190,6 +191,11 @@ impl MediaOutput { 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)] @@ -276,6 +282,15 @@ impl MediaOutput { } } +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 @@ -351,6 +366,36 @@ pub(crate) fn check_option_types( 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 { diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 1b6bd125..63081770 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -12,6 +12,7 @@ use tinytools::{ 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}; @@ -22,7 +23,7 @@ pub const GENERATE_VIDEO_TOOL_NAME: &str = "generate_video"; /// 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` collects it without +/// 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, @@ -100,6 +101,22 @@ impl GenerateVideoTool { &["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( @@ -142,12 +159,27 @@ impl GenerateVideoTool { 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"]) { - let model = arg_str(args, &["model"]).unwrap_or(self.generator.default_model()); + 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 { @@ -266,7 +298,7 @@ impl Tool for GenerateVideoTool { "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: {}.", self.generator.default_model()) }, + "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." }, @@ -280,7 +312,7 @@ impl Tool for GenerateVideoTool { "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." } + "resume_job_id": { "type": "string", "description": "Collect an earlier job that timed out, without paying again. Also pass the original model." } } }) } From 9d523076e49ae0a45c0eab5493cf45bba9f6ecf6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:55:19 +0300 Subject: [PATCH 76/79] fix(media): validate artifact and request inputs --- crates/tinyagents-harness/src/media/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 66069836..43ddc9ad 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -407,7 +407,7 @@ async fn video_timeout_names_the_job_and_resume_collects_it() { let resume = GenerateVideoTool::new(delivered.clone(), MediaOutput::new(dir.path())) .with_wait_policy(fast()); let result = resume - .execute(json!({ "resume_job_id": "mock-job" })) + .execute(json!({ "resume_job_id": "mock-job", "model": "mock/video" })) .await .unwrap(); assert!(!result.is_error, "{}", text(&result)); From 2df86337451a4f443ce8758e04754100b3d90da1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 08:57:26 +0300 Subject: [PATCH 77/79] fix(media): align video tool timeout with wait policy and fix test assertions The video tool's timeout policy now returns the configured wait timeout in milliseconds instead of being unbounded, ensuring the harness respects the tool's own deadline. The test for billed non-delivery was updated to match the actual error message, and the timeout policy assertion now expects the correct 5-second value. Auto-committed-on: dragonfly --- crates/tinyagents-harness/src/media/test.rs | 2 +- crates/tinyagents-harness/src/media/video_tool.rs | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 43ddc9ad..91f8da83 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -362,7 +362,7 @@ async fn video_tool_waits_for_delivery_and_saves_the_clip() { 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); + assert_eq!(tool.timeout_policy(&json!({})), ToolTimeout::Millis(5_000)); } #[tokio::test] diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index 63081770..ea62a22f 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -47,7 +47,7 @@ impl GenerateVideoTool { 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` instead of submitting a new one." + `resume_job_id` and the original `model` instead of submitting a new one." .to_owned(), permission: PermissionLevel::Write, category: ToolCategory::System, @@ -341,10 +341,8 @@ impl Tool for GenerateVideoTool { true } - /// The job loop enforces its own [`WaitPolicy`] deadline, so the harness - /// must not cut the call short. fn timeout_policy(&self, _args: &Value) -> ToolTimeout { - ToolTimeout::Unbounded + ToolTimeout::Millis(u64::try_from(self.wait.timeout.as_millis()).unwrap_or(u64::MAX)) } async fn execute(&self, args: Value) -> anyhow::Result { From c497df7ddcbcaf864e4da2cd2f612fbd5695914c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 09:05:14 +0300 Subject: [PATCH 78/79] fix(media): make video tool timeout unbounded to preserve resumable job handling The video tool's timeout policy was changed from a fixed millisecond duration to unbounded, allowing the provider's own wait budget to control job polling without interference from a generic harness timeout. This ensures that when a video job is still in progress, the tool returns a resumable timeout containing the job ID rather than a generic timeout that would lose the job context. Auto-committed-on: dragonfly --- crates/tinyagents-harness/src/media/test.rs | 83 ++++++++++++++++++- .../src/media/video_tool.rs | 7 +- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 91f8da83..57c770a7 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -5,6 +5,11 @@ use std::time::Duration; use serde_json::json; use tinyinference_image::{MediaReference, MockImageGenerator}; +use tinyinference_llm::message::{AssistantMessage, 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, @@ -12,6 +17,9 @@ use tinyinference_video::{ use tinytools::{Tool, ToolCallOptions, ToolRunContext, ToolTimeout, WorkspaceDescriptor}; use super::{GenerateImageTool, GenerateVideoTool, MediaOutput}; +use crate::context::RunConfig; +use crate::runtime::AgentHarness; +use crate::tool::ToolTimeoutSettings; struct Workspace(WorkspaceDescriptor); @@ -362,7 +370,41 @@ async fn video_tool_waits_for_delivery_and_saves_the_clip() { 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::Millis(5_000)); + 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::text(text), + 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] @@ -417,6 +459,45 @@ async fn video_timeout_names_the_job_and_resume_collects_it() { ); } +/// 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(); diff --git a/crates/tinyagents-harness/src/media/video_tool.rs b/crates/tinyagents-harness/src/media/video_tool.rs index ea62a22f..62698d9c 100644 --- a/crates/tinyagents-harness/src/media/video_tool.rs +++ b/crates/tinyagents-harness/src/media/video_tool.rs @@ -342,7 +342,12 @@ impl Tool for GenerateVideoTool { } fn timeout_policy(&self, _args: &Value) -> ToolTimeout { - ToolTimeout::Millis(u64::try_from(self.wait.timeout.as_millis()).unwrap_or(u64::MAX)) + // `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 { From 6ac4ff222ed1aabc01bb21d8d6028419c90bb1bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 09:05:49 +0300 Subject: [PATCH 79/79] test(media): use explicit ContentBlock in text_response helper The `text_response` helper now constructs an `AssistantMessage` with an explicit `ContentBlock::Text` instead of relying on the deprecated `AssistantMessage::text` constructor. This aligns the test with the updated message API and ensures the helper continues to work after the old shorthand is removed. Auto-committed-on: dragonfly --- crates/tinyagents-harness/src/media/test.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/media/test.rs b/crates/tinyagents-harness/src/media/test.rs index 57c770a7..b33235ab 100644 --- a/crates/tinyagents-harness/src/media/test.rs +++ b/crates/tinyagents-harness/src/media/test.rs @@ -5,7 +5,7 @@ use std::time::Duration; use serde_json::json; use tinyinference_image::{MediaReference, MockImageGenerator}; -use tinyinference_llm::message::{AssistantMessage, Message}; +use tinyinference_llm::message::{AssistantMessage, ContentBlock, Message}; use tinyinference_llm::model::ModelResponse; use tinyinference_llm::providers::MockModel; use tinyinference_llm::tool::ToolCall; @@ -16,7 +16,7 @@ use tinyinference_video::{ }; use tinytools::{Tool, ToolCallOptions, ToolRunContext, ToolTimeout, WorkspaceDescriptor}; -use super::{GenerateImageTool, GenerateVideoTool, MediaOutput}; +use super::{GENERATE_VIDEO_TOOL_NAME, GenerateImageTool, GenerateVideoTool, MediaOutput}; use crate::context::RunConfig; use crate::runtime::AgentHarness; use crate::tool::ToolTimeoutSettings; @@ -395,7 +395,13 @@ fn tool_call_response(id: &str, name: &str, arguments: serde_json::Value) -> Mod fn text_response(text: &str) -> ModelResponse { ModelResponse { - message: AssistantMessage::text(text), + 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,