use anyhow::Context;
pub(crate) mod active_models;
pub(crate) mod analyze;
pub mod browser;
pub mod browser_daemon;
pub(crate) mod catalog_cache;
pub(crate) mod edit;
pub mod image_catalog;
pub(crate) mod image_gen;
pub(crate) mod implement;
pub(crate) mod path;
pub(crate) mod read;
pub(crate) mod research;
pub(crate) mod search;
pub(crate) mod search_archived_tickets;
pub(crate) mod shell;
pub(crate) mod ticket;
pub(crate) mod video_catalog;
pub(crate) mod video_edit;
pub(crate) mod video_gen;
pub(crate) mod web_search;
pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
const MAX_REFERENCE_IMAGE_BYTES: u64 = 1_500_000;
const MAX_REQUEST_BODY_BYTES: usize = 2_000_000;
pub(crate) const MAX_REFERENCE_IMAGES_PER_REQUEST: usize = 16;
const PATH_ALIAS_KEYS: &[&str] = &["file", "filename"];
fn check_file_size(meta: &std::fs::Metadata) -> anyhow::Result<()> {
if meta.len() > MAX_FILE_SIZE_BYTES {
anyhow::bail!(
"File too large: {} bytes (limit: {} bytes)",
meta.len(),
MAX_FILE_SIZE_BYTES
);
}
Ok(())
}
fn reference_json(references: &[crate::util::ReferenceImage]) -> serde_json::Value {
serde_json::json!(
references
.iter()
.map(|r| serde_json::json!({
"type": "image_url",
"image_url": { "url": r.data_uri() }
}))
.collect::<Vec<_>>()
)
}
const INPUT_REFERENCES_KEY: &str = "input_references";
pub(crate) fn fit_request_body_budget(
body: &mut serde_json::Value,
references: &mut [crate::util::ReferenceImage],
max_body_bytes: usize,
) -> anyhow::Result<()> {
if !references.is_empty() {
body[INPUT_REFERENCES_KEY] = reference_json(references);
}
loop {
if serde_json::to_vec(body)?.len() <= max_body_bytes {
for r in references {
r.release_source_bytes();
}
return Ok(());
}
let Some(idx) = references
.iter()
.enumerate()
.filter(|(_, r)| r.has_compression_left())
.max_by_key(|(_, r)| r.data_uri().len())
.map(|(i, _)| i)
else {
break;
};
references[idx].compress_more()?;
body[INPUT_REFERENCES_KEY] = reference_json(references);
}
let body_len = serde_json::to_vec(body)?.len();
if references.is_empty() {
anyhow::bail!(
"Generation request body is too large ({body_len} bytes; limit {max_body_bytes} bytes). \
Shorten the prompt.",
);
}
let advice = if references.len() > 1 {
"Reduce the reference image size, the number of references, or the prompt length."
} else {
"Reduce the reference image size or shorten the prompt."
};
anyhow::bail!(
"Generation request body is too large ({body_len} bytes after compression; \
limit {max_body_bytes} bytes). {advice}",
);
}
pub(crate) use analyze::{AnalyzeTool, DispatchMode};
pub(crate) use browser::BrowserTool;
pub(crate) use edit::EditTool;
pub(crate) use image_gen::ImageGenTool;
pub(crate) use implement::ImplementTool;
pub(crate) use read::ReadTool;
pub(crate) use research::ResearchTool;
pub(crate) use search::SearchTool;
pub(crate) use search_archived_tickets::SearchArchivedTicketsTool;
pub(crate) use shell::{ShellMode, ShellTool};
pub(crate) use ticket::{
AddCommentTool, CreateTicketTool, GetTicketTool, ListTicketsTool, UpdateTicketTool,
};
pub(crate) use video_edit::VideoEditTool;
pub(crate) use video_gen::VideoGenTool;
pub(crate) use web_search::{WebSearchBackend, WebSearchTool};
use crate::{Tool, Workspace};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::util::json::{
get_bool, get_opt_bool, get_opt_i64, get_opt_str, get_opt_u64, get_str, get_str_array,
get_usize,
};
#[must_use]
fn tool_params_schema(properties: &serde_json::Value, required: &[&str]) -> serde_json::Value {
let mut schema = serde_json::json!({
"type": "object",
"properties": properties,
});
if !required.is_empty() {
schema["required"] = serde_json::json!(required);
}
schema
}
use crate::util::scrub_credentials;
#[must_use]
pub(crate) fn scrub_tool_output(
tool: &dyn Tool,
call_arguments: &serde_json::Value,
output: &str,
) -> String {
if tool.should_scrub_output(call_arguments) {
scrub_credentials(output)
} else {
output.to_string()
}
}
pub(crate) const TOOL_FAILURE_MARKER: &str = "Tool call failed.";
#[must_use]
pub(crate) fn format_tool_failure_feedback(
tool_name: &str,
tool_args: &serde_json::Value,
reason: &str,
) -> String {
let args_preview = scrub_credentials(&crate::util::truncate(&tool_args.to_string(), 1000));
format!(
"{TOOL_FAILURE_MARKER}\n\
tool: {tool_name}\n\
arguments: {args_preview}\n\
reason:\n{reason}"
)
}
#[derive(Debug)]
pub(crate) struct ToolExecutionOutcome {
pub output: String,
pub success: bool,
pub image_payload: Option<ImagePayload>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ImagePayloadSource {
Read,
Generated,
Browser,
}
impl ImagePayloadSource {
#[must_use]
pub(crate) fn opener(self) -> &'static str {
match self {
Self::Read => "Read image file",
Self::Generated => "Generated image file",
Self::Browser => "Browser screenshot",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ImagePayload {
pub path: String,
pub data_uri: String,
pub width: u32,
pub height: u32,
pub format: String,
pub recovery_note: Option<String>,
pub source: ImagePayloadSource,
}
impl ImagePayload {
#[must_use]
pub(crate) fn attached_annotation(&self) -> String {
let base = format!(
"{} {} ({}x{}, {}). Image content attached to the conversation as a native image.",
self.source.opener(),
self.path,
self.width,
self.height,
self.format
);
self.with_recovery_note(base)
}
#[must_use]
pub(crate) fn already_attached_annotation(&self) -> String {
let base = format!(
"{} {} ({}x{}, {}). Image content is already attached to the conversation as a native image.",
self.source.opener(),
self.path,
self.width,
self.height,
self.format
);
self.with_recovery_note(base)
}
#[must_use]
fn with_recovery_note(&self, base: String) -> String {
match &self.recovery_note {
Some(note) => format!("{note}\n{base}"),
None => base,
}
}
}
#[must_use]
pub(crate) fn normalize_tool_call(
name: &str,
mut args: serde_json::Value,
) -> (String, serde_json::Value) {
if name == "glob"
&& let Some(obj) = args.as_object_mut()
&& !obj.contains_key("mode")
{
obj.insert("mode".to_string(), serde_json::json!("files"));
}
let normalized_name = normalize_tool_name(name).to_string();
normalize_tool_arguments(&normalized_name, &mut args);
(normalized_name, args)
}
pub(crate) fn normalize_tool_name(name: &str) -> &str {
match name {
"bash" | "run_terminal_cmd" => "shell",
"grep" | "rg" | "grep_search" | "glob" => "search",
"read_file" => "read",
"str_replace" => "edit",
_ => name,
}
}
fn normalize_tool_arguments(name: &str, args: &mut serde_json::Value) {
let Some(obj) = args.as_object_mut() else {
return;
};
for &alias in PATH_ALIAS_KEYS {
remap_arg_key(obj, alias, "path");
}
match name {
"edit" => {
remap_arg_key(obj, "old_str", "old_string");
remap_arg_key(obj, "new_str", "new_string");
}
"shell" => {
remap_arg_key(obj, "cmd", "command");
remap_arg_key(obj, "script", "command");
}
"get_ticket" | "update_ticket" | "add_comment" => {
remap_arg_key(obj, "id", "ticket_id");
remap_arg_key(obj, "ticket", "ticket_id");
}
_ => {}
}
}
fn remap_arg_key(obj: &mut serde_json::Map<String, serde_json::Value>, from: &str, to: &str) {
if !obj.contains_key(to)
&& let Some(v) = obj.remove(from)
{
obj.insert(to.to_string(), v);
}
}
#[must_use]
pub(crate) fn find_tool<'a>(tools: &'a [Box<dyn Tool>], name: &str) -> Option<&'a dyn Tool> {
let normalized = normalize_tool_name(name);
tools
.iter()
.find(|t| t.name() == normalized)
.map(Box::as_ref)
}
async fn save_generated_file(
ws: &Workspace,
bytes: &[u8],
prefix: &str,
ext: &str,
) -> anyhow::Result<PathBuf> {
let generated_dir = ws.as_path().join("generated");
tokio::fs::create_dir_all(&generated_dir)
.await
.with_context(|| {
format!(
"Failed to create generated directory at {}",
generated_dir.display()
)
})?;
let timestamp = crate::util::unix_millis();
let output_path = generated_dir.join(format!("{prefix}_{timestamp}.{ext}"));
tokio::fs::write(&output_path, bytes)
.await
.with_context(|| {
format!(
"Failed to write generated file to {}",
output_path.display()
)
})?;
Ok(output_path)
}
pub(crate) async fn format_video_result(marker: String, output_path: &std::path::Path) -> String {
match crate::providers::transcribe_video_file(output_path, None).await {
Some(text) => format!("{marker}\n\nVideo content: {text}"),
None => marker,
}
}
const VIDEO_JOB_TIMEOUT: Duration = Duration::from_hours(1);
const VIDEO_POLL_INTERVAL: Duration = Duration::from_secs(30);
struct VideoJobLabels {
label: &'static str,
gerund: &'static str,
download: &'static str,
}
impl VideoJobLabels {
const GENERATION: Self = Self {
label: "Video generation",
gerund: "generation",
download: "Video download",
};
const EDIT: Self = Self {
label: "Video edit",
gerund: "editing",
download: "Video edit download",
};
}
#[expect(clippy::too_many_lines)]
async fn fetch_async_video(
api_base: &str,
body: &serde_json::Value,
labels: VideoJobLabels,
) -> anyhow::Result<Vec<u8>> {
let submit_url = format!("{api_base}/videos");
let submit_body: serde_json::Value = match crate::util::http::post_json_to_provider(
&submit_url,
body,
&format!("{} submission", labels.label),
)
.await
{
Ok(v) => v,
Err(e) => {
if e.downcast_ref::<crate::util::error::HttpError>()
.map(|e| e.status)
== Some(402)
{
anyhow::bail!(
"Insufficient OpenRouter credits for video {} (HTTP 402). \
Please add credits to your OpenRouter account and try again.",
labels.gerund,
);
}
return Err(e);
}
};
let job_id = match submit_body.get("id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
anyhow::bail!("No job ID in submission response: {submit_body}");
}
};
let polling_url = match submit_body.get("polling_url").and_then(|v| v.as_str()) {
Some(url) => url.to_string(),
None => format!("{api_base}/videos/{job_id}"),
};
tracing::info!(%job_id, "{} job submitted", labels.label);
let deadline = Instant::now() + VIDEO_JOB_TIMEOUT;
let mut result_url: Option<String> = None;
let mut attempt: u32 = 0;
while Instant::now() < deadline {
attempt += 1;
let remaining = deadline.saturating_duration_since(Instant::now());
tokio::time::sleep(VIDEO_POLL_INTERVAL.min(remaining)).await;
if Instant::now() >= deadline {
break;
}
let remaining = deadline.saturating_duration_since(Instant::now());
let poll_body = match tokio::time::timeout(
remaining,
crate::util::http::get_json_from_provider(
&polling_url,
&format!("{} poll", labels.label),
),
)
.await
{
Ok(Ok(v)) => v,
Ok(Err(e)) => {
tracing::debug!(%job_id, attempt, error = %e, "Poll failed");
continue;
}
Err(_) => break,
};
let status = poll_body
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
tracing::debug!(%job_id, %status, attempt, "{} poll", labels.label);
if status == "completed" {
result_url = poll_body
.get("unsigned_urls")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
Some(format!("{api_base}/videos/{job_id}/content?index=0"))
});
break;
}
if status == "failed" || status == "cancelled" || status == "expired" {
let err_msg = poll_body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
anyhow::bail!("{} failed: {err_msg}", labels.label);
}
}
let Some(download_url) = result_url else {
anyhow::bail!(
"{} did not complete within the 1-hour timeout period",
labels.label
);
};
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
anyhow::bail!(
"{} did not complete within the 1-hour timeout period",
labels.label
);
}
let video_bytes =
crate::util::http::get_bytes_from_provider(&download_url, labels.download, remaining)
.await?;
if video_bytes.len() <= 100_000 || &video_bytes[4..8] != b"ftyp" {
anyhow::bail!(
"{} returned an invalid file ({} bytes, no ftyp header)",
labels.download,
video_bytes.len(),
);
}
Ok(video_bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Tool;
use crate::ToolSpec;
use crate::workspace::test_ws_named;
use tempfile::TempDir;
#[test]
fn tool_spec_serde_roundtrip() {
let spec = ToolSpec {
name: "test".into(),
description: "A test tool".into(),
parameters: serde_json::json!({"type": "object"}),
};
let parsed: ToolSpec =
serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
assert_eq!(parsed.name, "test");
}
#[test]
fn image_payload_generated_verb_annotation() {
let payload = ImagePayload {
path: "/gen/img.png".into(),
data_uri: "data:image/jpeg;base64,aaa".into(),
width: 4,
height: 4,
format: "PNG".into(),
recovery_note: None,
source: ImagePayloadSource::Generated,
};
let fresh = payload.attached_annotation();
assert!(
fresh.starts_with("Generated image file /gen/img.png"),
"fresh: {fresh}"
);
assert!(
!fresh.starts_with("Read image file"),
"must not be Read: {fresh}"
);
let dup = payload.already_attached_annotation();
assert!(
dup.starts_with("Generated image file /gen/img.png"),
"dup: {dup}"
);
assert!(dup.contains("already attached"), "dup: {dup}");
}
#[test]
fn find_tool_aliases() {
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(SearchTool),
Box::new(ShellTool::new(ShellMode::Full)),
Box::new(ReadTool),
Box::new(EditTool),
];
let cases: &[(&str, Option<&str>)] = &[
("search", Some("search")),
("shell", Some("shell")),
("read", Some("read")),
("edit", Some("edit")),
("bash", Some("shell")),
("run_terminal_cmd", Some("shell")),
("grep", Some("search")),
("rg", Some("search")),
("grep_search", Some("search")),
("glob", Some("search")),
("read_file", Some("read")),
("str_replace", Some("edit")),
("unknown", None),
];
for &(input, expected) in cases {
let found = find_tool(&tools, input);
assert_eq!(found.map(Tool::name), expected, "find_tool({input:?})");
}
}
#[test]
fn contains_glob_detects_wildcards() {
assert!(crate::tools::path::contains_glob("src/*.rs", true));
assert!(crate::tools::path::contains_glob("lib?.rs", true));
assert!(!crate::tools::path::contains_glob("src/main.rs", true));
}
#[test]
fn normalize_tool_call_repairs_names_and_args() {
let (name, args) = normalize_tool_call("bash", serde_json::json!({"cmd": "echo hi"}));
assert_eq!(name, "shell");
assert_eq!(args["command"], "echo hi");
let (name, args) = normalize_tool_call("glob", serde_json::json!({"query": "main.rs"}));
assert_eq!(name, "search");
assert_eq!(args["mode"], "files");
let (name, args) = normalize_tool_call("get_ticket", serde_json::json!({"id": "mahbot-1"}));
assert_eq!(name, "get_ticket");
assert_eq!(args["ticket_id"], "mahbot-1");
let (name, args) = normalize_tool_call("read", serde_json::json!({"file": "src/main.rs"}));
assert_eq!(name, "read");
assert_eq!(args["path"], "src/main.rs");
let (name, args) =
normalize_tool_call("read_file", serde_json::json!({"filename": "lib.rs"}));
assert_eq!(name, "read");
assert_eq!(args["path"], "lib.rs");
let (name, args) = normalize_tool_call(
"edit",
serde_json::json!({"file": "main.rs", "old_str": "foo", "new_str": "bar"}),
);
assert_eq!(name, "edit");
assert_eq!(args["path"], "main.rs");
assert_eq!(args["old_string"], "foo");
assert_eq!(args["new_string"], "bar");
let (name, args) = normalize_tool_call(
"read",
serde_json::json!({"path": "canonical.rs", "file": "alias.rs"}),
);
assert_eq!(name, "read");
assert_eq!(args["path"], "canonical.rs");
assert!(args.as_object().unwrap().contains_key("file"));
}
#[test]
fn all_media_tools_implement_media_marker() {
let tools: [(&str, Box<dyn Tool>); 3] = [
("ImageGenTool", Box::new(ImageGenTool)),
("VideoGenTool", Box::new(VideoGenTool)),
("VideoEditTool", Box::new(VideoEditTool)),
];
for (name, tool) in &tools {
let marker = tool.media_marker();
assert!(
marker.is_some(),
"{name} should return Some from media_marker()"
);
let marker = marker.unwrap();
assert!(
marker.starts_with('['),
"{name} marker {marker:?} should start with '['"
);
assert!(
marker.ends_with(':'),
"{name} marker {marker:?} should end with ':'"
);
let kind = &marker[1..marker.len() - 1]; assert!(
!kind.is_empty() && kind.chars().all(char::is_uppercase),
"{name} marker kind {kind:?} should be non-empty uppercase letters"
);
let full_marker = format!("{marker}/some/path]");
assert!(
crate::util::MEDIA_MARKER_RE.is_match(&full_marker),
"{name} marker + path should match MEDIA_MARKER_RE, got: {full_marker:?}"
);
}
}
#[test]
fn normalize_tool_call_remaps_all_path_aliases() {
for &alias in PATH_ALIAS_KEYS {
for (tool_name, extra) in &[
("read", serde_json::json!({})),
("shell", serde_json::json!({"cmd": "ls"})),
] {
let mut input = serde_json::json!({});
input[alias] = serde_json::json!("src/main.rs");
if let Some(obj) = extra.as_object() {
for (k, v) in obj {
input[k] = v.clone();
}
}
let (name, args) = normalize_tool_call(tool_name, input);
assert_eq!(
name, *tool_name,
"tool name should not change for {tool_name} with alias {alias}"
);
assert_eq!(
args["path"], "src/main.rs",
"alias {alias} should be remapped to 'path' for tool {tool_name}"
);
assert!(
!args.as_object().unwrap().contains_key(alias),
"alias key {alias} should be removed after normalization for {tool_name}"
);
if *tool_name == "shell" {
assert_eq!(args["command"], "ls");
}
}
}
}
#[tokio::test]
async fn save_generated_file_creates_file() {
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws_named(&tmp.path().to_string_lossy(), "test");
let data = b"hello world";
let path = save_generated_file(&ws, data, "img", "png")
.await
.expect("save_generated_file should succeed");
assert!(path.exists(), "file should exist: {}", path.display());
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "hello world");
let file_name = path.file_name().unwrap().to_str().unwrap();
assert!(
file_name.starts_with("img_"),
"filename should start with 'img_': {file_name}",
);
assert!(
std::path::Path::new(file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("png")),
"filename should end with '.png': {file_name}",
);
let _ = tokio::fs::remove_dir_all(tmp.path()).await;
}
#[tokio::test]
async fn save_generated_file_creates_directory_if_missing() {
let tmp = TempDir::new().expect("tempdir");
let ws = test_ws_named(&tmp.path().join("nested").to_string_lossy(), "test");
let data = b"test content";
let path = save_generated_file(&ws, data, "vid", "mp4")
.await
.expect("save_generated_file should create dirs");
assert!(path.exists(), "file should exist: {}", path.display());
assert!(
path.starts_with(tmp.path().join("nested")),
"file should be inside workspace"
);
let _ = tokio::fs::remove_dir_all(tmp.path()).await;
}
#[tokio::test]
async fn reference_body_budget_compresses_further() {
const BUDGET: usize = 150_000;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("ref.png");
std::fs::write(&path, crate::util::test::noisy_png(512, 512)).unwrap();
let mut refs = vec![
crate::util::load_reference_image(&path, MAX_REFERENCE_IMAGE_BYTES)
.await
.unwrap(),
];
let original = refs[0].data_uri().to_string();
let mut body = serde_json::json!({
"model": "test",
"prompt": "test",
"input_references": reference_json(&refs),
});
assert!(serde_json::to_vec(&body).unwrap().len() > BUDGET);
fit_request_body_budget(&mut body, &mut refs, BUDGET).unwrap();
assert!(serde_json::to_vec(&body).unwrap().len() <= BUDGET);
assert_ne!(
refs[0].data_uri(),
original,
"reference should be compressed"
);
}
#[tokio::test]
async fn body_budget_falls_through_to_smaller_reference() {
let tmp = TempDir::new().unwrap();
let big = tmp.path().join("big.png");
let small = tmp.path().join("small.png");
std::fs::write(&big, crate::util::test::noisy_png(640, 640)).unwrap();
std::fs::write(&small, crate::util::test::noisy_png(128, 128)).unwrap();
let mut refs = vec![
crate::util::load_reference_image(&big, MAX_REFERENCE_IMAGE_BYTES)
.await
.unwrap(),
crate::util::load_reference_image(&small, MAX_REFERENCE_IMAGE_BYTES)
.await
.unwrap(),
];
let small_original = refs[1].data_uri().to_string();
let mut body = serde_json::json!({
"model": "test",
"prompt": "test",
"input_references": reference_json(&refs),
});
while refs[0].has_compression_left() {
refs[0].compress_more().unwrap();
}
body[INPUT_REFERENCES_KEY] = reference_json(&refs);
let raw_body = serde_json::to_vec(&body).unwrap().len();
fit_request_body_budget(&mut body, &mut refs, raw_body - 1).unwrap();
assert!(serde_json::to_vec(&body).unwrap().len() < raw_body);
assert_ne!(
refs[1].data_uri(),
small_original,
"smaller reference should have been compressed"
);
}
}