feat(read_file): let capable models view images, video and PDFs
Nightly Build / build (push) Successful in 6m38s
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user