fix(agent): read document attachments on demand (#5122)

This commit is contained in:
chengyongru
2026-07-28 13:33:06 +08:00
committed by GitHub
parent 096a86a7f4
commit 12f828ea3d
15 changed files with 323 additions and 450 deletions
+15 -9
View File
@@ -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
View File
@@ -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
+21 -5
View File
@@ -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