use roder_api::inference::InferenceEngine;
use roder_api::transcript::{ToolResultRecord, TranscriptItem, VIEW_IMAGE_DISPLAY_KEY};
use serde_json::Value;
use crate::Runtime;
pub(crate) const IMAGE_WITHHELD_NOTICE: &str =
"screenshot not shown: this model cannot receive images in tool results";
const IMAGE_ONLY_TOOLS: &[&str] = &[
"browser_use_screenshot",
"chrome_screenshot",
"jev_tab_screenshot",
"view_image",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolImages {
Forwarded,
Withheld,
}
impl ToolImages {
pub(crate) fn for_engine(engine: &dyn InferenceEngine, model: &str) -> Self {
if engine.tool_result_image_input(model) {
Self::Forwarded
} else {
Self::Withheld
}
}
pub(crate) fn offers(self, tool: &str) -> bool {
self == Self::Forwarded || !IMAGE_ONLY_TOOLS.contains(&tool)
}
}
impl Runtime {
pub(crate) fn tool_images(&self, provider: &str, model: &str) -> ToolImages {
self.engine_for(provider)
.map(|engine| ToolImages::for_engine(engine.as_ref(), model))
.unwrap_or(ToolImages::Forwarded)
}
}
pub(crate) fn carries_image(result: &ToolResultRecord) -> bool {
result
.display_payload
.as_ref()
.is_some_and(|payload| payload.get(VIEW_IMAGE_DISPLAY_KEY).is_some())
}
pub(crate) fn request_transcript(
items: &[TranscriptItem],
images: ToolImages,
) -> Vec<TranscriptItem> {
items
.iter()
.map(|item| match item {
TranscriptItem::ToolResult(result)
if images == ToolImages::Withheld && carries_image(result) =>
{
TranscriptItem::ToolResult(without_image(result))
}
other => other.clone(),
})
.collect()
}
fn without_image(result: &ToolResultRecord) -> ToolResultRecord {
let mut display_payload = result.display_payload.clone();
if let Some(Value::Object(fields)) = &mut display_payload {
fields.remove(VIEW_IMAGE_DISPLAY_KEY);
if fields.is_empty() {
display_payload = None;
}
}
let text = if result.result.is_empty() {
IMAGE_WITHHELD_NOTICE.to_string()
} else {
format!("{}\n{IMAGE_WITHHELD_NOTICE}", result.result)
};
ToolResultRecord {
id: result.id.clone(),
name: result.name.clone(),
result: text,
display_payload,
is_error: result.is_error,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn result(text: &str, payload: Option<Value>) -> TranscriptItem {
TranscriptItem::ToolResult(ToolResultRecord {
id: "call".into(),
name: Some("browser_use_click".into()),
result: text.into(),
display_payload: payload,
is_error: false,
})
}
fn shot() -> Value {
json!({"__view_image": {"image_url": "data:image/png;base64,YWJj", "detail": "original"}})
}
fn text_of(item: &TranscriptItem) -> &str {
match item {
TranscriptItem::ToolResult(result) => &result.result,
other => panic!("not a tool result: {other:?}"),
}
}
#[test]
fn forwarded_engines_get_the_transcript_unchanged() {
let items = vec![result("clicked", Some(shot()))];
assert_eq!(request_transcript(&items, ToolImages::Forwarded), items);
}
#[test]
fn withheld_drops_only_the_image_key_and_adds_one_notice_line() {
let mut payload = shot();
payload["path"] = json!("board.png");
let items = vec![result("clicked. Screenshot attached.", Some(payload))];
let copy = request_transcript(&items, ToolImages::Withheld);
assert_eq!(
text_of(©[0]),
format!("clicked. Screenshot attached.\n{IMAGE_WITHHELD_NOTICE}")
);
let TranscriptItem::ToolResult(copy) = ©[0] else {
unreachable!()
};
assert_eq!(copy.display_payload, Some(json!({"path": "board.png"})));
assert!(!serde_json::to_string(copy).unwrap().contains("data:image"));
assert_eq!(text_of(&items[0]), "clicked. Screenshot attached.");
assert!(carries_image(match &items[0] {
TranscriptItem::ToolResult(result) => result,
_ => unreachable!(),
}));
}
#[test]
fn withheld_leaves_results_without_an_image_alone() {
let items = vec![
result("no picture", None),
result("other payload", Some(json!({"path": "a"}))),
TranscriptItem::AssistantMessage(roder_api::transcript::AssistantMessage {
text: "ok".into(),
phase: None,
}),
];
assert_eq!(request_transcript(&items, ToolImages::Withheld), items);
}
#[test]
fn an_empty_result_becomes_just_the_notice_and_an_empty_payload_is_dropped() {
let copy = request_transcript(&[result("", Some(shot()))], ToolImages::Withheld);
assert_eq!(text_of(©[0]), IMAGE_WITHHELD_NOTICE);
let TranscriptItem::ToolResult(copy) = ©[0] else {
unreachable!()
};
assert_eq!(copy.display_payload, None);
}
#[test]
fn only_image_only_tools_are_hidden_and_only_when_withheld() {
for tool in IMAGE_ONLY_TOOLS {
assert!(ToolImages::Forwarded.offers(tool));
assert!(!ToolImages::Withheld.offers(tool), "{tool}");
}
for tool in ["browser_use_click", "chrome_click", "jev_tab_look", "shell"] {
assert!(ToolImages::Withheld.offers(tool), "{tool}");
}
}
}