What the user saw
A Gmail-enabled agent read a message, saw its attachments, and could not download either of them.
- Instance
e17d538d-6940-4773-80f6-3715c1b2d2fa, message 1a00cff038273010
- Subject:
Fwd: Urgent Please Read - Hawthorn Tennis Tennis Club Summer Competition Form and Junior Club Champs Forms Attached- Thanks Kelly
gmail_download_attachment returned That message has no attachment with id <id>. It has: <the same two filenames with different opaque ids>
- Retrying with an id copied out of that failure message failed the same way
- Passing the filename (
Junior Comp Hawthorn Entry Form SUMMER 2026:2027.doc) as attachment_id was also refused
Expected: the attachment lands in the instance file store and a file_id comes back. That handoff is the foundation of every email-to-files workflow (club forms, school forms, invoices, job packs), so a shipped tool failing it closed is worth a real fix rather than a retry loop.
Priority — P1: blocks external users: gmail_download_attachment shipped in #711 and cannot complete its stated function in production. Any external user who connects a mailbox hits this on their first attachment.
The mechanism — VERIFIED
gmail_download_attachment does not fail at Gmail. It fails at its own validation step, before it ever calls Gmail's attachment endpoint.
workers/api/src/lib/connectors/gmail.ts:202-208:
// Re-read the message for the attachment's declared name/type/size. The alternative is
// trusting three more model-supplied strings, and the filename becomes an R2 key.
const msg = await getMessage(resolved.token, messageId, 1);
const att = msg.attachments.find((a) => a.attachmentId === attachmentId);
if (!att) {
const names = msg.attachments.map((a) => `${a.filename} (${a.attachmentId || "inline, no id"})`).join(", ");
return fail(`That message has no attachment with id ${attachmentId}.${names ? ` It has: ${names}` : " It has no attachments."}`);
}
That .find(…) on exact string equality is the only thing that failed. Below it, downloadAttachment (workers/api/src/lib/gmail.ts:431-439) addresses the attachment endpoint directly:
export async function downloadAttachment(
accessToken: string,
messageId: string,
attachmentId: string,
): Promise<{ base64url: string; size: number }> {
const res = await gmailFetch(
accessToken,
`/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
);
So the id the handler had just fetched itself — msg.attachments[n].attachmentId — would have downloaded fine. The round-trip through the model is the only reason a comparison is needed at all, and the comparison is what breaks.
The re-fetch's stated reason is legitimate and any fix must preserve it. att.filename becomes an R2 object name; it must come from Gmail's own response, never from a model-supplied string. That property is not in question here.
What this is NOT — eliminated, VERIFIED
1. It is not nested MIME / a Fwd: part-walking difference. Both handlers call the same getMessage on the same ?format=full endpoint, and the only difference is the third argument:
readHandler — const msg = await getMessage(resolved.token, id); (connectors/gmail.ts:173, maxChars defaults to 40000 at lib/gmail.ts:414)
downloadHandler — const msg = await getMessage(resolved.token, messageId, 1); (connectors/gmail.ts:204)
maxChars slices the body only — text: collectText(msg.payload).slice(0, maxChars) (lib/gmail.ts:348) — while attachments: collectAttachments(msg.payload) (lib/gmail.ts:349) is the identical recursive walk in both cases (lib/gmail.ts:289-303). The two calls see the same attachment manifest.
2. It is NOT the declared maxLength: 400 on the parameter. The declaration exists —
connectors/gmail.ts:611: attachment_id: { type: "string", required: true, description: "Attachment id, from gmail_read_message.", maxLength: 400 },
— but it is inert on both ends, which is itself worth recording:
- It never reaches the model.
toJsonSchema (lib/connectors/manifest.ts:139-147) copies only type and description into the schema; maxLength is dropped, so the model is not told a 400-character limit exists.
- It is never enforced on the server.
clampArgs (manifest.ts:150-158) is applied at exactly one site, manifest.ts:207, inside the request-template branch of compileConnector. gmail_download_attachment takes the handler branch (manifest.ts:181), which returns handler: fn with no clamp — GMAIL_CONNECTOR = compileConnector(GMAIL_MANIFEST, { gmail_download_attachment: downloadHandler, … }), connectors/gmail.ts:620-628.
So a 700-character id passes through untouched and un-clamped. The original hypothesis that "the schema truncates long ids" is wrong. (An inert declared cap on a handler-bound manifest tool is a separate, general contract gap — every maxLength on every handler-bound tool in connectors/*.ts is decoration. Not filed here; it is not what broke this.)
Open question — three candidates, one experiment separates them
All three predict "retrying with the newly listed id fails the same way", so the symptom does not discriminate. This has not been reproduced end-to-end from this session (the repro instance is on an account this session cannot reach).
(a) Gmail rotates the id between two messages.get calls. The original hypothesis. Not verified — nothing in this repo can confirm or deny it.
(b) The model garbled or shortened a long opaque token while copying it between two calls. Gmail attachment ids are long base64url blobs; a Fwd: carries the forwarded parts, which lengthens them further. Exact lengths for this message are not measured.
(c) capToolResult cut the attachment manifest out of the model's copy of the read result. This is new, and it is verified in code rather than hypothesised:
gmail_read_message returns okUntrusted(msg, …), i.e. JSON.stringify(payload, null, 2) (connectors/gmail.ts:79-82), and declares untrustedOutput: true (connectors/gmail.ts:542), so renderToolContent wraps it in the fence preamble (lib/tool-registry.ts:670-673 → lib/untrusted-fence.ts:53-65).
toGmailMessage serialises text before attachments (lib/gmail.ts:337-350), and text may be up to 40,000 characters (lib/gmail.ts:414).
- In chat, that result is then cut to 24,000 characters, head-first:
const capped = capToolResult(content); (agent-think.ts:995), TOOL_RESULT_MAX_CHARS = 24_000 (lib/tool-result-cap.ts:37).
So for any message whose body exceeds roughly 23.5k characters — a long forwarded thread is exactly that shape — the attachments array is partly or wholly removed from what the model reads, and a manifest cut mid-string yields a truncated id. capToolResult is applied at that one site and nowhere else (grep -rn "capToolResult" workers/api/src | grep -v '\.test\.' → one call site, agent-think.ts:995; the rest are imports and comments), so this candidate applies to the chat path and not to MCP or POST /v1/instances/:id/tools/:name. The repro is the chat path.
The experiment that separates them, from the trace, without a live mailbox: read the instance's agent_trace / instance_messages for that turn and compare three strings — the id in the gmail_read_message result, the id in the gmail_download_attachment call arguments, and the ids echoed in the failure text.
| observation |
verdict |
| result and call args match; failure lists different ids |
(a) the ids rotated |
| call args are a prefix of, or differ from, an id present in full in the result |
(b) the model mangled it |
the read result ends in [truncated: this tool returned N characters…], or contains no complete attachments array |
(c) the manifest never reached the model |
Record the answer on this issue. It changes nothing about the fix below, which closes all three — but it decides whether (c) needs its own follow-up for every other long-bodied tool result.
What to do — cheapest first
The through-line: stop round-tripping a long opaque token through the model. Give it a short handle the server resolves against its own fresh fetch. The filename still comes from Gmail's response in every option below, so the R2-key property the re-fetch exists to protect is preserved.
1. Accept the filename as an alternative to the id (~10 lines, ships alone, unblocks the reported case).
The user already tried this and was refused. Resolve attachment_id against att.filename as well as att.attachmentId, then download using the attachmentId from the handler's own fetch. If two attachments share a filename, refuse and name the ambiguity rather than guessing.
2. Give each attachment a short ordinal ref, and make that the documented input.
gmail_read_message emits ref: "att1" | "att2" | … per attachment alongside filename/mimeType/size; gmail_download_attachment takes attachment and resolves the ref positionally against its own fetch. A one-token ordinal cannot be truncated or garbled, and MIME part order within a single message is fixed. Keep accepting the raw id and the filename for compatibility.
3. Move attachments above text in toGmailMessage (lib/gmail.ts:337-350) — one line, closes candidate (c) outright.
capToolResult keeps the HEAD, and this codebase already treats that as the rule: "A HEADER, for the reason repo_read_file's disclosure is one: capToolResult keeps the HEAD, so a count at the tail is the FIRST thing a second cut removes" (lib/connectors/repo-local.ts:672-675; same reasoning at lib/connectors/types.ts:121-124). The attachment manifest is the small, structural, must-survive part of that result and it is currently last. Optionally also lower gmail_read_message's maxChars from 40,000 to something under the 24,000 chat cap, so the tool's own bound and the cap stop disagreeing.
4. Stop putting full attachment ids in the refusal. connectors/gmail.ts:207 currently re-injects every long opaque token into the model's context — the exact string that is hard to copy. List ref + filename instead.
Alternatives considered and rejected
- Delete the re-fetch and trust the model's
filename/mimeType/size. Rejected: the comment at connectors/gmail.ts:202-203 is right — the filename becomes an R2 key. Every fix above keeps the server's own fetch as the source of the name.
- Prefix / fuzzy-match the supplied id against the real ones. Rejected: it makes a truncated id silently succeed and a wrong id succeed against the wrong attachment. A short handle is deterministic; a fuzzy match is a hack that hides candidate (b) instead of removing it.
- Raise
TOOL_RESULT_MAX_CHARS. Rejected: lib/tool-result-cap.ts:31-36 sets the ceiling above every deliberate per-tool cap on purpose. The tool that should bound itself is gmail_read_message, whose 40,000 is above the global cap.
- Return the attachment bytes from the tool. Rejected, and already argued at
connectors/gmail.ts:181-186: base64 of a PDF in a tool result evicts the rest of the context. The file store is the right destination.
Acceptance criteria
Regression risk
- The R2-key property. The one thing that must not change is that
att.filename comes from Gmail. A refactor that starts passing the model's string into fileUpload re-opens what the re-fetch exists to prevent. The first acceptance criterion is written to catch exactly that: it asserts the stored name, so a fix that echoes the input name fails.
- Key reordering in
toGmailMessage. GmailMessage is consumed by replyHandler/sendHandler (connectors/gmail.ts:372, threading headers) and by the pipeline binder via unfenceUntrusted + $ref. Field order does not affect either — but gmail.test.ts and any $ref path should be re-run rather than assumed.
- A shorter
maxChars on gmail_read_message trades body detail for a surviving manifest. It is a real loss on long messages; the honest version announces it, the way capToolResult does.
Related: #711 built these tools · #713 built gmail_reply with attachment_file_ids · #710 is the closed epic this is the last unfinished step of · #756 is the workflow this blocks.
What the user saw
A Gmail-enabled agent read a message, saw its attachments, and could not download either of them.
e17d538d-6940-4773-80f6-3715c1b2d2fa, message1a00cff038273010Fwd: Urgent Please Read - Hawthorn Tennis Tennis Club Summer Competition Form and Junior Club Champs Forms Attached- Thanks Kellygmail_download_attachmentreturnedThat message has no attachment with id <id>. It has: <the same two filenames with different opaque ids>Junior Comp Hawthorn Entry Form SUMMER 2026:2027.doc) asattachment_idwas also refusedExpected: the attachment lands in the instance file store and a
file_idcomes back. That handoff is the foundation of every email-to-files workflow (club forms, school forms, invoices, job packs), so a shipped tool failing it closed is worth a real fix rather than a retry loop.Priority —
P1: blocks external users:gmail_download_attachmentshipped in #711 and cannot complete its stated function in production. Any external user who connects a mailbox hits this on their first attachment.The mechanism — VERIFIED
gmail_download_attachmentdoes not fail at Gmail. It fails at its own validation step, before it ever calls Gmail's attachment endpoint.workers/api/src/lib/connectors/gmail.ts:202-208:That
.find(…)on exact string equality is the only thing that failed. Below it,downloadAttachment(workers/api/src/lib/gmail.ts:431-439) addresses the attachment endpoint directly:So the id the handler had just fetched itself —
msg.attachments[n].attachmentId— would have downloaded fine. The round-trip through the model is the only reason a comparison is needed at all, and the comparison is what breaks.The re-fetch's stated reason is legitimate and any fix must preserve it.
att.filenamebecomes an R2 object name; it must come from Gmail's own response, never from a model-supplied string. That property is not in question here.What this is NOT — eliminated, VERIFIED
1. It is not nested MIME / a
Fwd:part-walking difference. Both handlers call the samegetMessageon the same?format=fullendpoint, and the only difference is the third argument:readHandler—const msg = await getMessage(resolved.token, id);(connectors/gmail.ts:173,maxCharsdefaults to 40000 atlib/gmail.ts:414)downloadHandler—const msg = await getMessage(resolved.token, messageId, 1);(connectors/gmail.ts:204)maxCharsslices the body only —text: collectText(msg.payload).slice(0, maxChars)(lib/gmail.ts:348) — whileattachments: collectAttachments(msg.payload)(lib/gmail.ts:349) is the identical recursive walk in both cases (lib/gmail.ts:289-303). The two calls see the same attachment manifest.2. It is NOT the declared
maxLength: 400on the parameter. The declaration exists —connectors/gmail.ts:611:attachment_id: { type: "string", required: true, description: "Attachment id, from gmail_read_message.", maxLength: 400 },— but it is inert on both ends, which is itself worth recording:
toJsonSchema(lib/connectors/manifest.ts:139-147) copies onlytypeanddescriptioninto the schema;maxLengthis dropped, so the model is not told a 400-character limit exists.clampArgs(manifest.ts:150-158) is applied at exactly one site,manifest.ts:207, inside the request-template branch ofcompileConnector.gmail_download_attachmenttakes the handler branch (manifest.ts:181), which returnshandler: fnwith no clamp —GMAIL_CONNECTOR = compileConnector(GMAIL_MANIFEST, { gmail_download_attachment: downloadHandler, … }),connectors/gmail.ts:620-628.So a 700-character id passes through untouched and un-clamped. The original hypothesis that "the schema truncates long ids" is wrong. (An inert declared cap on a handler-bound manifest tool is a separate, general contract gap — every
maxLengthon every handler-bound tool inconnectors/*.tsis decoration. Not filed here; it is not what broke this.)Open question — three candidates, one experiment separates them
All three predict "retrying with the newly listed id fails the same way", so the symptom does not discriminate. This has not been reproduced end-to-end from this session (the repro instance is on an account this session cannot reach).
(a) Gmail rotates the id between two
messages.getcalls. The original hypothesis. Not verified — nothing in this repo can confirm or deny it.(b) The model garbled or shortened a long opaque token while copying it between two calls. Gmail attachment ids are long base64url blobs; a
Fwd:carries the forwarded parts, which lengthens them further. Exact lengths for this message are not measured.(c)
capToolResultcut the attachment manifest out of the model's copy of the read result. This is new, and it is verified in code rather than hypothesised:gmail_read_messagereturnsokUntrusted(msg, …), i.e.JSON.stringify(payload, null, 2)(connectors/gmail.ts:79-82), and declaresuntrustedOutput: true(connectors/gmail.ts:542), sorenderToolContentwraps it in the fence preamble (lib/tool-registry.ts:670-673→lib/untrusted-fence.ts:53-65).toGmailMessageserialisestextbeforeattachments(lib/gmail.ts:337-350), andtextmay be up to 40,000 characters (lib/gmail.ts:414).const capped = capToolResult(content);(agent-think.ts:995),TOOL_RESULT_MAX_CHARS = 24_000(lib/tool-result-cap.ts:37).So for any message whose body exceeds roughly 23.5k characters — a long forwarded thread is exactly that shape — the
attachmentsarray is partly or wholly removed from what the model reads, and a manifest cut mid-string yields a truncated id.capToolResultis applied at that one site and nowhere else (grep -rn "capToolResult" workers/api/src | grep -v '\.test\.'→ one call site,agent-think.ts:995; the rest are imports and comments), so this candidate applies to the chat path and not to MCP orPOST /v1/instances/:id/tools/:name. The repro is the chat path.The experiment that separates them, from the trace, without a live mailbox: read the instance's
agent_trace/instance_messagesfor that turn and compare three strings — the id in thegmail_read_messageresult, the id in thegmail_download_attachmentcall arguments, and the ids echoed in the failure text.[truncated: this tool returned N characters…], or contains no completeattachmentsarrayRecord the answer on this issue. It changes nothing about the fix below, which closes all three — but it decides whether (c) needs its own follow-up for every other long-bodied tool result.
What to do — cheapest first
The through-line: stop round-tripping a long opaque token through the model. Give it a short handle the server resolves against its own fresh fetch. The filename still comes from Gmail's response in every option below, so the R2-key property the re-fetch exists to protect is preserved.
1. Accept the filename as an alternative to the id (~10 lines, ships alone, unblocks the reported case).
The user already tried this and was refused. Resolve
attachment_idagainstatt.filenameas well asatt.attachmentId, then download using theattachmentIdfrom the handler's own fetch. If two attachments share a filename, refuse and name the ambiguity rather than guessing.2. Give each attachment a short ordinal ref, and make that the documented input.
gmail_read_messageemitsref: "att1" | "att2" | …per attachment alongsidefilename/mimeType/size;gmail_download_attachmenttakesattachmentand resolves the ref positionally against its own fetch. A one-token ordinal cannot be truncated or garbled, and MIME part order within a single message is fixed. Keep accepting the raw id and the filename for compatibility.3. Move
attachmentsabovetextintoGmailMessage(lib/gmail.ts:337-350) — one line, closes candidate (c) outright.capToolResultkeeps the HEAD, and this codebase already treats that as the rule: "A HEADER, for the reason repo_read_file's disclosure is one:capToolResultkeeps the HEAD, so a count at the tail is the FIRST thing a second cut removes" (lib/connectors/repo-local.ts:672-675; same reasoning atlib/connectors/types.ts:121-124). The attachment manifest is the small, structural, must-survive part of that result and it is currently last. Optionally also lowergmail_read_message'smaxCharsfrom 40,000 to something under the 24,000 chat cap, so the tool's own bound and the cap stop disagreeing.4. Stop putting full attachment ids in the refusal.
connectors/gmail.ts:207currently re-injects every long opaque token into the model's context — the exact string that is hard to copy. Listref+ filename instead.Alternatives considered and rejected
filename/mimeType/size. Rejected: the comment atconnectors/gmail.ts:202-203is right — the filename becomes an R2 key. Every fix above keeps the server's own fetch as the source of the name.TOOL_RESULT_MAX_CHARS. Rejected:lib/tool-result-cap.ts:31-36sets the ceiling above every deliberate per-tool cap on purpose. The tool that should bound itself isgmail_read_message, whose 40,000 is above the global cap.connectors/gmail.ts:181-186: base64 of a PDF in a tool result evicts the rest of the context. The file store is the right destination.Acceptance criteria
gmail_download_attachmentsucceeds when given the filename of one of them, and stores it under the filename taken from the handler's owngetMessageresponse (assert the name written to the store, not the name passed in).attachmentIdthat does match still succeeds (no regression on the Gmail read tools: search, read a message, and download its attachments (no new scope needed) #711 path).gmail_read_message's serialised result placesattachmentsbeforetext; a unit test feeds a 39,000-character body throughcapToolResultand asserts a completeattachmentsarray survives the cut.connectors/gmail.ts:207contains no rawattachmentId.Regression risk
att.filenamecomes from Gmail. A refactor that starts passing the model's string intofileUploadre-opens what the re-fetch exists to prevent. The first acceptance criterion is written to catch exactly that: it asserts the stored name, so a fix that echoes the input name fails.toGmailMessage.GmailMessageis consumed byreplyHandler/sendHandler(connectors/gmail.ts:372, threading headers) and by the pipeline binder viaunfenceUntrusted+$ref. Field order does not affect either — butgmail.test.tsand any$refpath should be re-run rather than assumed.maxCharsongmail_read_messagetrades body detail for a surviving manifest. It is a real loss on long messages; the honest version announces it, the waycapToolResultdoes.Related: #711 built these tools · #713 built
gmail_replywithattachment_file_ids· #710 is the closed epic this is the last unfinished step of · #756 is the workflow this blocks.