Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/command_system/input_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,38 @@ def expand_at_mentions(
return text, attachments


def read_file_attachment(path: str, *, max_text_bytes: int | None = None) -> dict[str, Any]:
"""Classify one file for the prompt the way an ``@path`` mention would.

``{"kind": "file", "content": …}`` for text the model can read inline;
``{"kind": "binary", "hint": …}`` for PDFs, archives, images and anything
the sniff or the decode says is not text — with the same Read-tool hint
the @-mention pipeline gives, so an uploaded PDF and an @-mentioned one
reach the model with one vocabulary; ``{"kind": "large"}`` for a text
file past ``max_text_bytes``, decided from its size so a file that will
only be pointed at is never read and decoded whole. ``ext`` rides along
on all of them. Takes a concrete path rather than mention text: an
uploaded file may sit in a directory with spaces in its name, which the
mention grammar cannot express.
"""
ext = os.path.splitext(path)[1].lstrip(".").lower()
if ext in _AT_MENTION_IMAGE_EXTENSIONS:
return {"kind": "binary", "ext": ext, "hint": "Use the Read tool to view the image."}
if ext in _AT_MENTION_BINARY_EXTENSIONS or _looks_like_binary(path):
return {"kind": "binary", "ext": ext, "hint": _binary_hint_for_ext(ext)}
if max_text_bytes is not None:
try:
size = os.path.getsize(path)
except OSError:
size = 0
if size > max_text_bytes:
return {"kind": "large", "ext": ext}
data = _read_text_with_encoding(path)
if data is None:
return {"kind": "binary", "ext": ext, "hint": _binary_hint_for_ext(ext)}
return {"kind": "file", "ext": ext, "content": data}


def format_at_mention_attachments(attachments: list[dict[str, Any]]) -> str:
"""Render attachments produced by :func:`expand_at_mentions` and
:func:`expand_agent_mentions` as a single string ready to be prepended to
Expand Down
350 changes: 341 additions & 9 deletions src/server/agent_server.py

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions src/server/desktop_gateway_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,7 @@ def __init__(self, websocket: WebSocket, state: DesktopServeState) -> None:
"fs.read_related": self.fs_read_related,
"fs.search_files": self.fs_search_files,
"image.attach": self.image_attach,
"file.attach": self.file_attach,
"plan.get": self.plan_get,
"provider.list": self.provider_list,
"provider.save_key": self.provider_save_key,
Expand Down Expand Up @@ -2106,6 +2107,68 @@ async def image_attach(self, params: dict[str, Any]) -> dict[str, Any]:
"name": result.get("name") or name,
}

async def file_attach(self, params: dict[str, Any]) -> dict[str, Any]:
"""Attach a file of any type to the next prompt.

The twin of :meth:`image_attach`: the bytes come over the socket, land
in a temp file, and the agent's ``attach_file`` control copies them —
under their own name — into the session's readable artifact directory
before the upload copy is removed. ``placeholder: True`` makes the
client's ``[File #N]`` chip authoritative, as it is for images. Size is
checked here too, before a decode nobody needs: a file past the cap is
refused with the cap named.
"""
import base64
import os
import tempfile

from src.server.agent_server import _AgentSession, _format_bytes, _safe_attachment_leaf

raw = params.get("data")
if not isinstance(raw, str):
return {"error": "no file data"}
if raw.startswith("data:"):
_, _, raw = raw.partition(",")
cap = _AgentSession.MAX_ATTACHED_FILE_BYTES
# Base64 is 4/3 of the payload: refuse before decoding what cannot fit.
if len(raw) > cap * 4 // 3 + 4:
return {"error": f"files up to {_format_bytes(cap)} can be attached"}
try:
blob = base64.b64decode(raw, validate=True)
except Exception: # noqa: BLE001
return {"error": "file data was not valid base64"}
if len(blob) > cap:
return {"error": f"files up to {_format_bytes(cap)} can be attached"}

session = self._session(params)
name = _safe_attachment_leaf(str(params.get("name") or "file"))
suffix = os.path.splitext(name)[1]
handle, path = tempfile.mkstemp(prefix="clawcodex-upload-", suffix=suffix)
try:
with os.fdopen(handle, "wb") as fh:
fh.write(blob)
result = await session.control_query(
"attach_file",
{"path": path, "name": name, "placeholder": True, "persist_source": True},
)
finally:
try:
os.unlink(path)
except OSError:
logger.debug("file.attach: could not remove %s", path)

if not isinstance(result, dict):
return {"error": "no response from the session"}
if result.get("attached") is not True:
return {"error": str(result.get("error") or "the session refused the file")}
return {
"attached": True,
"id": result.get("id"),
"name": result.get("name") or name,
"path": result.get("path"),
"size": result.get("size", len(blob)),
}

async def fs_search_files(self, params: dict[str, Any]) -> dict[str, Any]:
"""Workspace files matching ``query``, for the composer's @ mentions.

Expand Down
Loading
Loading