Attachment Staging — Giving the Agent a File
When your plugin gets hold of bytes the agent should be able to keep — a
mail attachment, an issue attachment, an upload, a downloaded PDF — putting
them in the tool result is not an option: the model cannot round-trip binary,
and a 4 MB document has no business in the context window.
Stage it instead. One call writes the bytes into the conversation's staging
area and returns the path the agent can reach them at, read-only:
from codumentor.agent.attachment_stager import stage_attachment
staged = stage_attachment(
ctx, # the hook ctx, or self._current_ctx in a tool
filename="invoice.pdf", # sanitized for you; may be user-supplied
data=raw_bytes,
mime_type="application/pdf",
producer="gmail", # your plugin's name — see "Keying" below
)
staged is a StagedAttachment:
| Member | What it gives you |
|---|---|
agent_path | The path to tell the model, e.g. /attachments/from-gmail/invoice.pdf (None when the session could not build a sandbox) |
hint() | Ready-made prompt lines: where the file is, that it is read-only, and a cp example. Append to any text you return |
to_result_dict() | {filename, size_bytes, mime_type, path, note} — the payload shape for a download tool's JSON result |
host_path, rel_path, size, mime_type, name | The underlying StagedFile details |
The agent copies the file into a repo with the ordinary shell tool; there is
no dedicated "save" tool to call and none to add. Staged files sit outside the
repo namespace, so read_file / read_content will not open them — after the
copy, the repo path is readable through the normal tools.
Keying: name yourself, never a directory
Files live at <storage>/attachments/<conversation_id>/<group>/<name>, and
<group> is the one thing a producer must not invent. There are exactly two
kinds:
- Producer-keyed (
producer="gmail"→from-gmail/) — for bytes with an identity of their own. Staging is idempotent on identical content, so fetching the same attachment again in a later turn converges on the one file instead of leaving a copy per turn. - Turn-keyed (omit
producer) — only for bytes that arrive with a turn, the way a pasted image does. Core uses this for chat attachments.
Pass your plugin's own name and nothing else. If you find yourself encoding a
message id or a timestamp into producer, that is the sign to stop: identical
bytes already deduplicate, and different bytes already get a -2 suffix.
Limits and failure
- Per-file ceiling.
agent.attachment_staging.max_file_bytes(25 MB by default) is enforced for you —stage_attachmentraisesAttachmentTooLarge. Do not add a limit of your own. When your API reports a size before the download (Redmine'sfilesize, aContent-Length), checkstaging_limit_bytes(ctx)first and skip the transfer. - Per-conversation caps (count, bytes, age) are enforced automatically after every stage; the group you just wrote to is spared, so the path you are about to hand the model stays valid.
Nonemeans "this session stages nothing" — noagent.storage_dir, or a ctx from outside a turn. Behave as you did before you staged anything;STAGING_UNAVAILABLE_MESSAGEis the shared wording if the file was the point of the call.- Filesystem errors raise. If your tool exists to deliver this file, let the failure reach the tool result. If the bytes are a bonus on top of something else (extracted text, say), catch and carry on.
Where the path shows up
The staging directory is bind-mounted read-only into whatever execution view
the session uses, and the mount is requested for every turn — so a file you
stage in the middle of a turn is reachable by the next shell call without
any further plumbing. Staging also refreshes the conversation's inventory,
which core renders as an [Attached Files] note, so the model can still find
the file several turns later without you re-announcing it.
Available in tools (self._current_ctx), in hook handlers (the ctx
argument), and inside subagents (the handle is forwarded). onAtContentResolve
gets a deliberately narrow ctx, but the staging keys are in it — that is how
client_fs stages single-file uploads.
Worked examples in-tree
| Producer | Where |
|---|---|
| Gmail attachment download | plugins/google_workspace/gmail/attachments.py |
| Redmine attachment download (with size preflight) | RedmineGetAttachmentTool in plugins/redmine/tools.py |
| Browser single-file upload (bonus bytes on top of text) | ClientFsPlugin._stage_upload |
Design notes and the reasoning behind the split live in
doc/core-dev/design/attachment-staging.md.