feat(read_file): let capable models view images, video and PDFs
Nightly Build / build (push) Successful in 6m38s

When the resolved model declares an input modality (vision → images,
video, document → PDFs), read_file now hands a binary media file back to
the model as native input instead of failing on non-UTF-8 bytes.

- ToolResult gains a Media { text, media } variant carrying MediaRef
  { host_path, mime }; the tool message keeps only the text note, the
  bytes travel out of band in a new additive chat_llm_tools.media column
  (mirrors preview_old/new).
- read_file sniffs the resolved host file; a recognized medium becomes a
  Media result (with a neutral note), everything else keeps the textual
  path. Capability gating lives in the message builder, so read_file
  never needs the model caps and degrades cleanly on a text-only model.
- MessageBuilder inlines current-turn tool media as a synthetic user
  message right after the tool-result group (media_turn_start boundary,
  so older turns are never re-billed), reusing media.rs primitives via a
  new inline_paths helper that contains against the caller's workspace
  roots. OpenAI forwards the parts verbatim; the Anthropic client now
  also translates the PDF `file` part into a native `document` block
  (image_url → image was already handled).
- read_file's description is annotated per serving model in
  call_llm_round, listing the formats it can open, so the model knows
  reading one shows it the content.

Tests: media sniff (PDF), PDF file-part build, inline_paths containment
+ capability gating, capability hint, read_file media-vs-text, Anthropic
file→document, and the owner-schema-stands-alone check with the new
column.
This commit is contained in:
2026-07-22 11:01:44 +01:00
parent 624f6b0a95
commit cfaa7bace3
12 changed files with 524 additions and 42 deletions
@@ -66,13 +66,20 @@ impl ChatSessionHandler {
request_id: Some(request_id.clone()),
};
// Tell the model, in read_file's description, which media formats it can
// open directly — keyed on the model actually serving this attempt, so a
// fallback to a text-only model drops the claim. `None` (no media
// capability) leaves the shared defs untouched, avoiding a clone.
let annotated = media_annotated_tools(tool_defs, &cur_llm.capabilities);
let defs: &[Value] = annotated.as_deref().unwrap_or(tool_defs);
// Clone the Arc so the in-flight future does not borrow `cur_llm` across
// the fallback reassignment below. On cancel we drop the future
// (aborting the request) and return immediately.
let client = cur_llm.client.clone();
let call_result = tokio::select! {
_ = token.cancelled() => return RoundLlm::Cancelled,
r = client.chat_with_tools_raw(messages.as_slice(), tool_defs, &options) => r,
r = client.chat_with_tools_raw(messages.as_slice(), defs, &options) => r,
};
let e = match call_result {
@@ -163,6 +170,25 @@ fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).to_string()
}
/// Appends a per-model media hint to `read_file`'s description when the resolved
/// model can view images/video/PDFs, so the model knows reading one of those shows
/// it the content natively. Returns `None` (leaving the shared, model-independent
/// defs untouched — no clone) when the model has no media modality. Done here, per
/// attempt, so a fallback to a different model re-derives the hint from its caps.
fn media_annotated_tools(tool_defs: &[Value], capabilities: &[String]) -> Option<Vec<Value>> {
let hint = super::media::media_capability_hint(capabilities)?;
let mut out = tool_defs.to_vec();
for def in &mut out {
if def["function"]["name"].as_str() == Some("read_file") {
if let Some(d) = def["function"]["description"].as_str() {
def["function"]["description"] = Value::String(format!("{d}{hint}"));
}
break;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::is_retriable_llm_error;
+243 -13
View File
@@ -18,13 +18,15 @@
//!
//! Anything failing a check silently stays on the textual path.
use std::path::Path;
use std::path::{Path, PathBuf};
use base64::Engine as _;
use serde_json::{json, Value};
use tracing::debug;
use core_api::message_meta::Attachment;
use core_api::tool::MediaRef;
use core_api::user_fs::UserFs;
/// Max media parts inlined per turn.
const MAX_MEDIA_PER_TURN: usize = 4;
@@ -32,16 +34,20 @@ const MAX_MEDIA_PER_TURN: usize = 4;
const MAX_IMAGE_BYTES: u64 = 10 * 1024 * 1024;
/// Max bytes for one inlined video.
const MAX_VIDEO_BYTES: u64 = 32 * 1024 * 1024;
/// Max bytes for one inlined PDF (Anthropic's per-request document ceiling).
const MAX_PDF_BYTES: u64 = 32 * 1024 * 1024;
/// Max combined media bytes inlined per turn.
const MAX_TOTAL_MEDIA_BYTES: u64 = 48 * 1024 * 1024;
/// A model-input modality: the capability that unlocks it, the content-part
/// type it maps to, its byte cap and the sniffed MIME types accepted.
/// type it maps to, its byte cap, the sniffed MIME types accepted, and a
/// human-readable format list for the `read_file` description.
struct Modality {
capability: &'static str,
part_type: &'static str,
max_bytes: u64,
mimes: &'static [&'static str],
formats: &'static str,
}
const MODALITIES: &[Modality] = &[
@@ -50,6 +56,7 @@ const MODALITIES: &[Modality] = &[
part_type: "image_url",
max_bytes: MAX_IMAGE_BYTES,
mimes: &["image/png", "image/jpeg", "image/gif", "image/webp"],
formats: "images (PNG, JPEG, GIF, WebP)",
},
Modality {
capability: "video",
@@ -64,9 +71,34 @@ const MODALITIES: &[Modality] = &[
"video/x-flv",
"video/3gpp",
],
formats: "video (MP4, WebM, MOV, …)",
},
// PDF documents. The `file` part is the OpenAI file-input shape
// (`{"type":"file","file":{"filename","file_data"}}`), forwarded verbatim by
// OpenAI-compatible clients and translated to a native `document` block by the
// Anthropic client. Gated on the `document` capability, so a model row without
// it (any OpenAI-compat endpoint that can't take a `file` part) never receives
// one — set the capability only on rows whose endpoint accepts PDFs.
Modality {
capability: "document",
part_type: "file",
max_bytes: MAX_PDF_BYTES,
mimes: &["application/pdf"],
formats: "PDF documents",
},
];
/// Builds the OpenAI-wire content part for one inlined medium. Images/video use the
/// `{"type":"image_url"|"video_url","…":{"url":data-URL}}` shape; PDFs use the
/// `file` shape carrying a filename + `file_data` data-URL.
fn build_media_part(part_type: &str, mime: &str, b64: &str, filename: &str) -> Value {
let url = format!("data:{mime};base64,{b64}");
match part_type {
"file" => json!({ "type": "file", "file": { "filename": filename, "file_data": url } }),
t => json!({ "type": t, t: { "url": url } }),
}
}
/// The result of partitioning a message's attachments.
pub struct MediaPartition {
/// OpenAI-style content parts, ready to append after the text part.
@@ -117,8 +149,9 @@ pub async fn partition_under(
MediaPartition { parts, rest }
}
/// Promotes one attachment to a content part, or `None` when any check fails
/// (logged at debug level; the caller keeps it on the textual path).
/// Promotes one uploaded attachment to a content part, or `None` when any check
/// fails (logged at debug level; the caller keeps it on the textual path).
/// Containment is against the uploads `root`; the rest is [`promote`].
async fn try_inline(
a: &Attachment,
capabilities: &[String],
@@ -131,32 +164,146 @@ async fn try_inline(
debug!(path = %a.path, "media not inlined: outside the uploads root");
return None;
}
promote(&abs, &a.name, capabilities, used_total).await
}
let mut file = tokio::fs::File::open(&abs).await.ok()?;
/// Read + sniff + capability/budget check + build the content part for one file at
/// an **already-contained** absolute path. Shared by the uploaded-attachment path
/// ([`try_inline`]) and the tool-produced-media path ([`inline_paths`]); neither
/// containment nor per-turn count budget is enforced here — the callers do that.
/// `None` (logged at debug) when the file is not a recognized medium, the model
/// lacks the modality, or a byte budget is exhausted.
async fn promote(
abs: &Path,
filename: &str,
capabilities: &[String],
used_total: u64,
) -> Option<(Value, u64)> {
let mut file = tokio::fs::File::open(abs).await.ok()?;
let mut head = [0u8; 16];
let n = tokio::io::AsyncReadExt::read(&mut file, &mut head).await.ok()?;
let mime = sniff_mime(&head[..n])?;
let modality = MODALITIES.iter().find(|m| m.mimes.contains(&mime))?;
if !capabilities.iter().any(|c| c == modality.capability) {
debug!(path = %a.path, mime, "media not inlined: model lacks the capability");
debug!(path = %abs.display(), mime, "media not inlined: model lacks the capability");
return None;
}
let size = file.metadata().await.ok()?.len();
if size > modality.max_bytes {
debug!(path = %a.path, size, "media not inlined: file too large");
debug!(path = %abs.display(), size, "media not inlined: file too large");
return None;
}
if used_total + size > MAX_TOTAL_MEDIA_BYTES {
debug!(path = %a.path, "media not inlined: per-turn byte budget exhausted");
debug!(path = %abs.display(), "media not inlined: per-turn byte budget exhausted");
return None;
}
let bytes = tokio::fs::read(&abs).await.ok()?;
let bytes = tokio::fs::read(abs).await.ok()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
let url = format!("data:{mime};base64,{b64}");
let t = modality.part_type;
Some((json!({ "type": t, t: { "url": url } }), size))
Some((build_media_part(modality.part_type, mime, &b64, filename), size))
}
/// Inline media a tool produced (e.g. `read_file` on an image) as content parts,
/// for the current turn only. Mirrors [`partition_under`] but contains against the
/// caller's **workspace roots** (home + shared + projects + docs) rather than the
/// uploads dir — the tool already resolved + contained the path, so this is a
/// fail-closed re-check against a symlink swap since the read (§6). Same per-file,
/// per-count and per-turn byte budgets; the capability gate lives here, so a
/// tool always records the media and the model only sees it when able.
pub async fn inline_paths(
refs: &[MediaRef],
capabilities: &[String],
fs: &UserFs,
) -> Vec<Value> {
let capable = MODALITIES
.iter()
.any(|m| capabilities.iter().any(|c| c == m.capability));
if !capable || refs.is_empty() {
return Vec::new();
}
let roots = workspace_roots(fs);
if roots.is_empty() {
return Vec::new();
}
let mut parts: Vec<Value> = Vec::new();
let mut total: u64 = 0;
for r in refs {
if parts.len() >= MAX_MEDIA_PER_TURN {
break;
}
let canon = crate::tools::fs::canonicalize_for_policy(&r.host_path, Path::new("/"));
if !roots.iter().any(|root| crate::tools::fs::path_under(&canon, root)) {
debug!(path = %r.host_path, "tool media not inlined: outside the workspace");
continue;
}
let filename = canon
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
if let Some((part, bytes)) = promote(&canon, &filename, capabilities, total).await {
total += bytes;
parts.push(part);
}
}
parts
}
/// The caller's workspace roots, canonicalized for prefix-checking: private home,
/// each shared folder, each project, and the read-only docs mount.
fn workspace_roots(fs: &UserFs) -> Vec<PathBuf> {
let canon = |p: &Path| crate::tools::fs::canonicalize_for_policy(&p.to_string_lossy(), Path::new("/"));
let mut roots = vec![canon(&fs.home_host)];
for m in &fs.shared {
roots.push(canon(&m.host));
}
for m in &fs.projects {
roots.push(canon(&m.host));
}
if let Some(d) = &fs.docs_host {
roots.push(canon(d));
}
roots
}
/// Sentence appended to `read_file`'s description when the resolved model can view
/// media, naming the formats it takes as native input. `None` when the model has
/// no media modality (description stays unchanged). See `call_llm_round`.
pub fn media_capability_hint(capabilities: &[String]) -> Option<String> {
let forms: Vec<&'static str> = MODALITIES
.iter()
.filter(|m| capabilities.iter().any(|c| c == m.capability))
.map(|m| m.formats)
.collect();
if forms.is_empty() {
return None;
}
Some(format!(
" This model can view {} directly: when you read_file one of these, its content is given to you as native model input (not text).",
join_human(&forms),
))
}
/// `["a"] → "a"`, `["a","b"] → "a and b"`, `["a","b","c"] → "a, b, and c"`.
fn join_human(items: &[&str]) -> String {
match items {
[] => String::new(),
[a] => a.to_string(),
[a, b] => format!("{a} and {b}"),
[rest @ .., last] => format!("{}, and {last}", rest.join(", ")),
}
}
/// Opens a file and sniffs its first bytes, returning a recognized media MIME
/// (`image/*`, `video/*`, `application/pdf`) or `None` for an ordinary/unreadable
/// file. Used by `read_file` to decide whether to hand a file back as native media
/// rather than trying to read it as UTF-8 text.
pub async fn probe_media(path: &Path) -> Option<&'static str> {
let mut file = tokio::fs::File::open(path).await.ok()?;
let mut head = [0u8; 16];
let n = tokio::io::AsyncReadExt::read(&mut file, &mut head).await.ok()?;
sniff_mime(&head[..n])
}
/// Sniffs the magic bytes of a medium we know how to inline, returning its
@@ -199,6 +346,9 @@ pub fn sniff_mime(head: &[u8]) -> Option<&'static str> {
if head.starts_with(&[0x00, 0x00, 0x01, 0xBA]) || head.starts_with(&[0x00, 0x00, 0x01, 0xB3]) {
return Some("video/mpeg");
}
if head.starts_with(b"%PDF-") {
return Some("application/pdf");
}
None
}
@@ -238,7 +388,7 @@ mod tests {
assert_eq!(sniff_mime(b"RIFF\x00\x00\x00\x00AVI "), Some("video/x-msvideo"));
assert_eq!(sniff_mime(b"FLV\x01\x05"), Some("video/x-flv"));
assert_eq!(sniff_mime(&[0x00, 0x00, 0x01, 0xBA]), Some("video/mpeg"));
assert_eq!(sniff_mime(b"%PDF-1.7"), None);
assert_eq!(sniff_mime(b"%PDF-1.7"), Some("application/pdf"));
assert_eq!(sniff_mime(b""), None);
}
@@ -305,4 +455,84 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
fn pdf_bytes() -> Vec<u8> {
let mut v = b"%PDF-1.7\n".to_vec();
v.extend_from_slice(&[0x00; 64]);
v
}
#[tokio::test]
async fn partition_inlines_pdf_as_file_part_for_document_model() {
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
let dir = tmp.join("data/uploads/u/1");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("a.pdf"), pdf_bytes()).await.unwrap();
// A document-capable model inlines the PDF as the OpenAI `file` part shape.
let p = partition_under(&[att("data/uploads/u/1/a.pdf")], &caps(&["document"]), &tmp).await;
assert!(p.rest.is_empty());
assert_eq!(p.parts.len(), 1);
assert_eq!(p.parts[0]["type"], "file");
assert_eq!(p.parts[0]["file"]["filename"], "a.pdf");
let fd = p.parts[0]["file"]["file_data"].as_str().unwrap();
assert!(fd.starts_with("data:application/pdf;base64,"), "{fd}");
// vision alone does not unlock PDFs.
let p = partition_under(&[att("data/uploads/u/1/a.pdf")], &caps(&["vision"]), &tmp).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
/// A throwaway [`UserFs`] whose private home is `root/homes/u1`.
fn fs_home(home: &std::path::Path) -> UserFs {
UserFs::new(
"u1",
home.to_path_buf(),
"skald-u1",
PathBuf::from("/root"),
vec![],
vec![],
None,
)
}
#[tokio::test]
async fn inline_paths_contains_and_gates_on_capability() {
let tmp = std::env::temp_dir().join(format!("skald-toolmedia-{}", uuid::Uuid::new_v4()));
let home = tmp.join("homes/u1");
tokio::fs::create_dir_all(&home).await.unwrap();
tokio::fs::write(home.join("pic.png"), png_bytes()).await.unwrap();
tokio::fs::write(tmp.join("outside.png"), png_bytes()).await.unwrap();
let fs = fs_home(&home);
let inside = MediaRef { host_path: home.join("pic.png").to_string_lossy().into_owned(), mime: "image/png".into() };
let outside = MediaRef { host_path: tmp.join("outside.png").to_string_lossy().into_owned(), mime: "image/png".into() };
// capable + inside the home → one image part.
let parts = inline_paths(std::slice::from_ref(&inside), &caps(&["vision"]), &fs).await;
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["type"], "image_url");
assert!(parts[0]["image_url"]["url"].as_str().unwrap().starts_with("data:image/png;base64,"));
// no capability → nothing inlined.
assert!(inline_paths(std::slice::from_ref(&inside), &caps(&[]), &fs).await.is_empty());
// a real image outside the workspace is rejected fail-closed.
assert!(inline_paths(std::slice::from_ref(&outside), &caps(&["vision"]), &fs).await.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[test]
fn media_capability_hint_lists_enabled_formats_only() {
assert!(media_capability_hint(&caps(&[])).is_none());
let h = media_capability_hint(&caps(&["vision"])).unwrap();
assert!(h.contains("images (PNG, JPEG, GIF, WebP)"), "{h}");
assert!(!h.contains("PDF"), "{h}");
let h = media_capability_hint(&caps(&["vision", "document"])).unwrap();
assert!(h.contains("images (PNG, JPEG, GIF, WebP)") && h.contains("PDF documents"), "{h}");
}
}
@@ -4,6 +4,9 @@ use std::sync::Arc;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use core_api::tool::MediaRef;
use core_api::user_fs::UserFs;
use crate::compactor::{ContextCompactor, SUMMARY_PREFIX};
use crate::config::DatetimeConfig;
use crate::db::{chat_history, chat_llm_tools, chat_summaries};
@@ -52,6 +55,11 @@ pub struct MessageBuilder {
/// paths. `None` for non-project sessions, in which case an `inject_memory`
/// entry that references `__PROJECT_ROOT__` is skipped (with a warning).
pub project_root: Option<String>,
/// The caller's filesystem view — its workspace roots contain (fail-closed)
/// the media a tool produced (`read_file` on an image/PDF) before it is inlined
/// for the model. `None` in the inert/ownerless bundle and unit tests that
/// don't exercise tool media (media inlining is then skipped).
pub fs: Option<Arc<UserFs>>,
}
impl MessageBuilder {
@@ -352,6 +360,33 @@ impl MessageBuilder {
"content": result_content,
}));
}
// Media a tool produced this turn (e.g. read_file on an
// image/PDF): inline it as a synthetic `user` message right
// after the tool-result group, so a capable model sees the
// bytes. Reuses the user-attachment translation path in each
// client (OpenAI verbatim; Anthropic image/document blocks).
// Current turn only (`idx >= media_turn_start`) — older-turn
// media stays the textual note, never re-billed. `inline_paths`
// gates on the model's capability + budgets + containment.
if idx >= media_turn_start
&& let Some(fs) = self.fs.as_deref()
{
let mut refs: Vec<MediaRef> = Vec::new();
for tc in &tool_calls {
if let Some(mj) = &tc.media
&& let Ok(mut v) = serde_json::from_str::<Vec<MediaRef>>(mj)
{
refs.append(&mut v);
}
}
if !refs.is_empty() {
let parts = super::media::inline_paths(&refs, capabilities, fs).await;
if !parts.is_empty() {
out.push(json!({ "role": "user", "content": parts }));
}
}
}
}
}
}
@@ -38,6 +38,9 @@ impl ChatSessionHandler {
max_tool_result_chars: self.max_tool_result_chars,
compactor: self.compactor.clone(),
project_root,
// Snapshot the fs cell for this build — its workspace roots contain the
// tool-produced media inlined into the current turn (§6 remount-safe).
fs: Some(self.fs.load()),
};
// `pool` is passed in from the caller (always `&self.db`) but we take
// ownership via Arc::clone above so the signature stays backward-compatible.
@@ -50,6 +50,14 @@ impl ChatSessionHandler {
let kind = result.kind();
debug!(session_id = self.session_id, tool = %tool_name, tool_call_id, result_len = wire.len(), "tool done");
chat_llm_tools::complete(pool, tool_call_id, &wire, kind).await?;
// Media the tool produced (e.g. read_file on an image/PDF) rides
// out of band in the `media` column; the message builder inlines it
// as a synthetic user message for a capable model on the current turn.
let media = result.media();
if !media.is_empty() {
let media_json = serde_json::to_string(media).unwrap_or_else(|_| "[]".to_string());
chat_llm_tools::set_media(pool, tool_call_id, &media_json).await?;
}
// Persist a file-write's diff snapshot so it re-renders after a reload,
// and carry it on the event so an auto-allowed write shows the diff live.
let (preview_old, preview_new) = match preview {