Skip to content

gmail_download_attachment refuses the id gmail_read_message just handed the model — the validation re-fetch is what fails, and the download endpoint never needed the round-trip #755

Description

@proagentstore

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 itselfmsg.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:

  • readHandlerconst msg = await getMessage(resolved.token, id); (connectors/gmail.ts:173, maxChars defaults to 40000 at lib/gmail.ts:414)
  • downloadHandlerconst msg = await getMessage(resolved.token, messageId, 1); (connectors/gmail.ts:204)

maxChars slices the body onlytext: 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-673lib/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

  • With a mocked message carrying two attachments, gmail_download_attachment succeeds when given the filename of one of them, and stores it under the filename taken from the handler's own getMessage response (assert the name written to the store, not the name passed in).
  • Given a filename that matches two attachments, it refuses with a message naming both refs — it does not pick one.
  • A raw attachmentId that 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 places attachments before text; a unit test feeds a 39,000-character body through capToolResult and asserts a complete attachments array survives the cut.
  • The refusal text at connectors/gmail.ts:207 contains no raw attachmentId.
  • The verdict from the trace experiment above is recorded as a comment on this issue.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3: laterDesign, strategy, or deferred pending a decision or demandbackendBackend / Worker / API workbugSomething isn't workingconnectorsConnector + tool framework

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions