fix(agent): read document attachments on demand (#5122)
This commit is contained in:
@@ -209,7 +209,7 @@ class ContextBuilder:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
root = workspace or self.workspace
|
||||
user_content = self._build_user_content(current_message, media)
|
||||
user_content = self.build_user_content(current_message, image_paths=media)
|
||||
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
||||
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
||||
messages = [
|
||||
@@ -241,27 +241,33 @@ class ContextBuilder:
|
||||
messages.append(current)
|
||||
return messages
|
||||
|
||||
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
||||
"""Build user message content with optional base64-encoded images."""
|
||||
if not media:
|
||||
def build_user_content(
|
||||
self,
|
||||
text: str,
|
||||
image_paths: list[str] | None,
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Build user message content from prefiltered image paths."""
|
||||
if not image_paths:
|
||||
return text
|
||||
|
||||
images = []
|
||||
for path in media:
|
||||
image_blocks = []
|
||||
for path in image_paths:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
continue
|
||||
raw = p.read_bytes()
|
||||
# Re-detect from the bytes used for the request: the file may have
|
||||
# changed since attachment routing, and the data URL needs its MIME.
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||
if not mime or not mime.startswith("image/"):
|
||||
continue
|
||||
b64 = base64.b64encode(raw).decode()
|
||||
images.append({
|
||||
image_blocks.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime};base64,{b64}"},
|
||||
"_meta": {"path": str(p)},
|
||||
})
|
||||
|
||||
if not images:
|
||||
if not image_blocks:
|
||||
return text
|
||||
return images + [{"type": "text", "text": text}]
|
||||
return image_blocks + [{"type": "text", "text": text}]
|
||||
|
||||
+18
-19
@@ -82,7 +82,7 @@ from nanobot.session.model_selection import (
|
||||
)
|
||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
||||
from nanobot.utils.cancellation import task_is_cancelling
|
||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||
from nanobot.utils.document import reference_non_image_attachments
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
@@ -854,11 +854,17 @@ class AgentLoop:
|
||||
|
||||
async def _to_user_message(pending_msg: InboundMessage) -> dict[str, Any]:
|
||||
content = pending_msg.content
|
||||
media = pending_msg.media if pending_msg.media else None
|
||||
if media:
|
||||
content, media = self._prepare_message_media(content, media)
|
||||
media = media or None
|
||||
user_content = self.context._build_user_content(content, media)
|
||||
image_paths = pending_msg.media if pending_msg.media else None
|
||||
if image_paths:
|
||||
content, image_paths = reference_non_image_attachments(
|
||||
content,
|
||||
image_paths,
|
||||
)
|
||||
image_paths = image_paths or None
|
||||
user_content = self.context.build_user_content(
|
||||
content,
|
||||
image_paths=image_paths,
|
||||
)
|
||||
row: dict[str, Any] = {"role": "user", "content": user_content}
|
||||
metadata = pending_msg.metadata if isinstance(pending_msg.metadata, dict) else {}
|
||||
if pending_msg.channel != "system":
|
||||
@@ -1478,12 +1484,15 @@ class AgentLoop:
|
||||
)
|
||||
|
||||
async def _restore_turn(self, ctx: TurnContext) -> None:
|
||||
"""Restore checkpoint / pending user turn; extract documents."""
|
||||
"""Restore checkpoint / pending user turn; reference non-image attachments."""
|
||||
msg = ctx.msg
|
||||
|
||||
if ctx.kind is TurnKind.USER and msg.media:
|
||||
new_content, image_only = self._prepare_message_media(msg.content, msg.media)
|
||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_only)
|
||||
new_content, image_paths = reference_non_image_attachments(
|
||||
msg.content,
|
||||
msg.media,
|
||||
)
|
||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
|
||||
msg = ctx.msg
|
||||
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
@@ -1510,16 +1519,6 @@ class AgentLoop:
|
||||
if self._restore_pending_user_turn(ctx.session):
|
||||
self.sessions.save(ctx.session)
|
||||
|
||||
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
|
||||
if self._should_extract_document_text():
|
||||
return extract_documents(content, media)
|
||||
return reference_non_image_attachments(content, media)
|
||||
|
||||
def _should_extract_document_text(self) -> bool:
|
||||
if self.channels_config is None:
|
||||
return True
|
||||
return self.channels_config.extract_document_text
|
||||
|
||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
||||
ctx.pending_summary = pending
|
||||
|
||||
@@ -261,6 +261,8 @@ class ReadFileTool(_FsTool):
|
||||
"Text output format: LINE_NUM|CONTENT. "
|
||||
"Images return visual content for analysis. "
|
||||
"Supports PDF, DOCX, XLSX, PPTX documents. "
|
||||
"Uploaded non-image attachments are referenced by path; read them "
|
||||
"with this tool only when their contents are needed. "
|
||||
"Use find_files/list_dir first when the path is uncertain. "
|
||||
"Read the relevant range before editing so replacements or patches "
|
||||
"are based on current content. "
|
||||
@@ -366,11 +368,25 @@ class ReadFileTool(_FsTool):
|
||||
try:
|
||||
text_content = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Binary file - return error message
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||
if mime and mime.startswith("image/"):
|
||||
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
||||
return ToolResult.error(f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported.")
|
||||
# Match the former eager extractor for known text formats while
|
||||
# keeping arbitrary binary files on the guarded error path.
|
||||
from nanobot.utils.document import _is_text_extension
|
||||
|
||||
if _is_text_extension(fp.suffix.lower()):
|
||||
text_content = raw.decode("latin-1")
|
||||
else:
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||
if mime and mime.startswith("image/"):
|
||||
return build_image_content_blocks(
|
||||
raw,
|
||||
mime,
|
||||
str(fp),
|
||||
f"(Image file: {path})",
|
||||
)
|
||||
return ToolResult.error(
|
||||
f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). "
|
||||
"Only supported text files and images can be read."
|
||||
)
|
||||
|
||||
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
|
||||
# concern (git checkouts with autocrlf, editors saving CRLF) but
|
||||
|
||||
Reference in New Issue
Block a user