You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Browse filesBrowse the repository at this point in the historyBrowse files
authored
feat(gmail): eval-driven fixes to the AI-optimized Gmail actions (PipedreamHQ#21491)
* feat(gmail): eval-driven fixes to the AI-optimized Gmail actions
Iterated against the MCP eval suite (pd-connect-eval-monster/evals/gmail);
this ships the fixes those evals surfaced. Confirming run: 29/31 pass^2 on
Sonnet 5 (multi-step 12/12), and a Haiku 4.5 + Sonnet 5 sweep flags no failure
as a tool problem — every remaining failure passes on at least one model.
- send-email [patch]: eval #16 ("reply so both messages stay in one
conversation") returned HTTP 400 "Recipient address required" on EVERY reply
that passed inReplyToMessageId without an explicit `to`. getOptionsToSendEmail
only populated opts.to on the replyAll branch, so the documented "only the
original sender is addressed" behavior was never implemented. Now defaults to
the replied-to message's Reply-To/From. Also covers a replyAll self-thread
case where filtering the user's own address left zero recipients, and stops
a missing To/Cc header throwing. #16 now passes.
- create-draft [patch]: same defect via the shared app file. #17 ("draft a
reply to Hammond") now passes.
- find-email [minor]: eval #23 ("catch me up on this week's mail and email me
the summary") produced a confidently wrong digest — it described an offline
fence sensor as healthy. The agent had correctly asked for format:"full" plus
payload; the 42k-char response exceeded the MCP client's 25k-token result
ceiling and was spilled to a file, and the in-tool budget then stripped the
requested field, leaving 197-char snippets. The needed sentence sits at
character 670 of a 934-character body, so no snippet could contain it and
nothing signalled that it had been cut. Adds `bodyText` (decoded plain text,
~half the size of raw payload), a `bodyChars` cap with bodyTruncated /
bodyTotalChars, snippetTruncated on metadata results, lowers the response cap
to 30k chars, and inverts the budget rule: a field the caller explicitly named
is never stripped — whole messages are dropped instead and the note says how
many of how many are shown. #23 now passes; largest tool result is 45% of the
ceiling. All new props are optional; omitting them reproduces prior output.
- list-thread-messages [minor]: same result-ceiling risk; response cap lowered
to 30k chars. It already degraded full -> metadata -> tail, so it needed the
cap rather than a fields projection.
- modify-labels [patch]: three description defects the evals surfaced — the
agent skipped the tool in 20% of tasks that required it, trashed an entire
label's worth of mail with no confirmation (PipedreamHQ#31), and believed it could create
filter rules for future mail (#27). Adds "use this whenever the user says
star / archive / file / label / trash / mark read", a trash-is-destructive
confirmation rule, and a "not for filters or future mail" exclusion. Tool
recall across the suite went 0.88 -> 1.00.
App package.json bumped 2.1.0 -> 2.2.0 (largest segment applied: minor).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(gmail): bump every component that bundles the changed shared code
The "Ensure component commits modify component versions" check flagged this and
it is correct: gmail.app.mjs and common/utils.mjs are bundled into every gmail
component at publish time, so each one has to be revved for the registry to pick
up the new code — even where its own behavior is unchanged.
Patch bump for the 12 actions and 5 sources that were not already versioned in
the previous commit. No functional change in any of them; the reply-recipient
fix in gmail.app.mjs only reaches send-email and create-draft (already bumped),
and the utils additions are new functions with no existing call sites touched.
package.json stays at 2.2.0 — the largest applied segment is still the minor
bump from find-email / list-thread-messages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gmail): address review findings on the eval-driven changes
- find-email: requesting `fields: ["payload"]` without also setting
`format: "full"` fetched metadata, whose payload carries only headers — no
`parts`, no `body.attachmentId`. The caller got a payload-shaped object with
none of the attachment data it asked for. `fetchFull` now keys off
`wantsPayload`, so either route to a payload forces the full fetch. Verified:
that call now fetches full and returns the attachment id; default,
`bodyText`, and `format: "full"` paths are unchanged.
- gmail.app.mjs: a present-but-blank `Reply-To` defeated the reply recipient
fallback. `??` only guards null/undefined, so an empty header left
originalSender = "" and the fallback dead — reintroducing the HTTP 400
"Recipient address required" that fallback exists to prevent. The header
helper now treats blank/whitespace-only values as absent. Verified across
blank, whitespace, valid, and missing Reply-To.
- gmail.app.mjs: reply-all Cc'd the authenticated user back on their own reply.
`opts.to` had a self-filter, `opts.cc` did not. Extracted the existing filter
and applied it to both.
- utils.mjs: MAX_RESPONSE_CHARS was declared identically in find-email and
list-thread-messages. It is a property of the MCP client, not of either
action, so both copies had to move in lockstep. Now exported once from
common/utils.mjs and imported by both.
Not changed: reply-all still propagates the original message's Bcc. That
predates this PR, only fires on replies to a sent copy (Gmail strips Bcc from
received mail), and removing it is a behavior change to published semantics
with no eval covering either side — it belongs in its own change, not a
review fix-up. Flagged for the reviewer.
No version bumps: every touched component is already bumped relative to master
in this PR and none of these versions has been released.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Dylan Sather <Dylan Sather>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: michelle0927 <michelle0927@users.noreply.github.com>
Copy file name to clipboardExpand all lines: components/gmail/actions/create-draft/create-draft.mjs
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -11,7 +11,7 @@ export default {
11
11
+" To draft to yourself, pass `\"me\"` in `to` — the action resolves it to the authenticated user's email address. No pre-call to **Get Current User** required."
12
12
+" Attachments use `file-ref` inputs and require matching `attachmentFilenames[]` entries."
13
13
+" [See the documentation](https://developers.google.com/gmail/api/reference/rest/v1/users.drafts/create).",
Copy file name to clipboardExpand all lines: components/gmail/actions/delete-label/delete-label.mjs
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -8,7 +8,7 @@ export default {
8
8
+" Only user-created labels can be deleted — Gmail's built-in system labels (`INBOX`, `SENT`, `SPAM`, `TRASH`, etc.) cannot."
9
9
+" This deletes the label *definition* itself; to merely detach a label from specific messages without removing it, use **Modify Labels** with `removeLabels` instead."
10
10
+" [See the documentation](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.labels/delete).",
// Applied only when a response busts the budget AND the caller named no `fields` of its
6
+
// own: keeps every message but strips it to what callers reason about, so counts stay
7
+
// correct. Never applied over an explicit `fields` choice — see utils.fitToBudget.
8
+
constCOMPACT_FIELDS=[
9
+
"labelIds",
10
+
"subject",
11
+
"sender",
12
+
"recipient",
13
+
"date",
14
+
"snippet",
15
+
];
16
+
constDEFAULT_BODY_CHARS=2000;
17
+
// Bodies shrink toward this floor before whole messages are dropped. Truncation is only
18
+
// acceptable because it announces itself per message (`bodyTruncated`), so the caller can
19
+
// re-fetch the one message it cares about; below this a body is too clipped to reason from.
20
+
constMIN_BODY_CHARS=400;
21
+
// Gmail snippets are a fixed ~200-char prefix. At this length the body is longer than
22
+
// what was returned, and the caller cannot tell that from the snippet alone.
23
+
constSNIPPET_TRUNCATED_AT=190;
6
24
constMETADATA_HEADERS=[
7
25
"From",
8
26
"To",
@@ -21,10 +39,14 @@ export default {
21
39
+" The `q` parameter accepts the full Gmail search operator set — combine operators freely: `from:alan@ingen.com is:unread newer_than:7d has:attachment subject:\"DNA sequences\"`. Common operators: `from:`, `to:`, `subject:`, `has:attachment`, `filename:pdf`, `is:unread`, `is:starred`, `label:INBOX`, `newer_than:7d`, `older_than:1m`, `after:2025/01/01`, `before:2025/12/31`, `category:primary`."
22
40
+" `labelIds` accepts either raw label IDs (`INBOX`, `STARRED`) or user-visible names (`Clients/Acme`) — names are resolved server-side via **List Labels**."
23
41
+" Each returned message carries `id`, `threadId`, `labelIds`, the decoded `subject`/`sender`/`recipient`/`date`, and a `snippet`. With `format: \"full\"` the decoded body text and `payload.parts[].body.attachmentId` + `filename` + `mimeType` are also included — feed those into **Download Attachment**, or feed `threadId` into **Get Thread** for the whole conversation."
24
-
+" `format` defaults to `metadata` (headers + snippet only) to keep the response small; set it to `full` only when you actually need body text or attachment IDs."
25
-
+" Responses are hard-capped at 100k characters — anything beyond is truncated with a `[truncated]` marker so the caller knows to narrow the query."
42
+
+" **Set `fields` on every call** to name just what you need — message records are large, and a wide search returns tens of thousands of characters that crowd out the rest of the task. `id` and `threadId` are always returned."
43
+
+" To BROWSE or COUNT, use `[\"subject\", \"sender\", \"date\"]`. **To READ or SUMMARISE — \"catch me up\", \"what did X say about Y\", \"is there anything I need to reply to\" — use `[\"subject\", \"sender\", \"date\", \"bodyText\"]`.**"
44
+
+" `bodyText` is the decoded plain-text body (HTML converted, MIME scaffolding and attachments stripped), about half the size of the raw `payload`, and requesting it fetches full messages for you."
45
+
+" **Never answer a question about what an email SAYS from `snippet`** — it is a fixed ~200-character prefix, so the sentence you need is usually past its end, and nothing in a snippet indicates that it was cut. Where the snippet is all you asked for and content was likely cut, the message carries `snippetTruncated: true`."
46
+
+" `format` stays `metadata` unless you need the raw MIME tree for **Download Attachment**, in which case pass `format: \"full\"` and request `payload`."
47
+
+" Responses are capped. Over the cap: if you named `fields`, whole messages are dropped rather than your chosen fields being removed, and the note says how many of how many are shown — narrow `q` and retry. If you named none, messages are compacted instead so counts stay accurate. `bodyText` shrinks toward a floor before anything is dropped, flagging each cut message with `bodyTruncated: true`."
26
48
+" [See the documentation](https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list) and [Gmail search operators](https://support.google.com/mail/answer/7190).",
27
-
version: "0.2.1",
49
+
version: "0.3.0",
28
50
annotations: {
29
51
destructiveHint: false,
30
52
openWorldHint: true,
@@ -77,6 +99,30 @@ export default {
77
99
default: "metadata",
78
100
optional: true,
79
101
},
102
+
fields: {
103
+
type: "string[]",
104
+
label: "Fields",
105
+
description:
106
+
"Return only these fields on each message, instead of the full record. **Always set this** unless you genuinely need every field — message records are large, and a wide search can return tens of thousands of characters that crowd out the rest of the task."
107
+
+" `id` and `threadId` are always included so results can be fed into **Get Thread**, **Modify Labels**, or **Download Attachment**."
+"\n- **Reading, summarising, \"catch me up\", \"what did X say\"** → `[\"subject\", \"sender\", \"date\", \"bodyText\"]`. **`bodyText` is a derived field**: the decoded plain-text body, HTML converted, attachments and MIME scaffolding stripped. It is roughly half the size of `payload` and is what you want for any question about what an email SAYS. Requesting it fetches full messages automatically — you do not also need `format: \"full\"`."
111
+
+"\n- **Downloading an attachment** → `[\"payload\"]` with `format: \"full\"`, then read `payload.parts[].body.attachmentId`."
112
+
+"\n\nDo NOT try to answer a content question from `snippet`: it is a fixed ~200-character prefix, so the sentence you need is usually past its end, and a snippet gives no sign that anything was cut. Ask for `bodyText` instead."
113
+
+" Omit `fields` entirely to return the complete message record, exactly as this tool has always done.",
114
+
optional: true,
115
+
},
116
+
bodyChars: {
117
+
type: "integer",
118
+
label: "Body Characters",
119
+
description:
120
+
`Maximum characters of \`bodyText\` to return per message. Default ${DEFAULT_BODY_CHARS}, which covers a normal one-page email in full.`
121
+
+" Only applies when `bodyText` is requested in `fields`. Any message whose body is cut comes back with `bodyTruncated: true` and `bodyTotalChars` (its real length) — re-fetch that single message with a higher limit, or use **Get Thread**, to read the rest."
122
+
+" Raise it for long newsletters or threads; lower it to fit more messages in one response.",
123
+
optional: true,
124
+
default: DEFAULT_BODY_CHARS,
125
+
},
80
126
},
81
127
asyncrun({ $ }){
82
128
letlabelIds=this.labelIds;
@@ -95,23 +141,35 @@ export default {
95
141
}
96
142
97
143
constformat=this.format||"metadata";
144
+
constfields=this.fields;
145
+
// `bodyText` is derived from the decoded body, which only a full fetch carries, so
146
+
// asking for it upgrades the fetch. The caller shouldn't have to know that.
note: `[truncated] response exceeded ${MAX_RESPONSE_CHARS} chars — dropped ${results.length-kept.length} of ${results.length} messages from the tail. Narrow the query, lower maxResults, or keep format:"metadata".`,
? `Every message was reduced to ${COMPACT_FIELDS.join(", ")}; all ${results.length} matches are still listed, so counts remain accurate. `
269
+
: ""}`
270
+
+`${shrunk
271
+
? `\`bodyText\` was capped at ${bodyCap} chars per message (messages cut this way carry \`bodyTruncated: true\`). `
272
+
: ""}`
273
+
+`${dropped
274
+
? `${dropped} of ${results.length} messages were dropped from the tail — THIS LIST IS INCOMPLETE, ${kept.length} of ${results.length} matches are shown. Narrow \`q\`, lower \`maxResults\`, or request fewer fields, then retry. `
Copy file name to clipboardExpand all lines: components/gmail/actions/get-current-user/get-current-user.mjs
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -4,7 +4,7 @@ export default {
4
4
key: "gmail-get-current-user",
5
5
name: "Get Current User",
6
6
description: "Returns the authenticated Gmail user's name, email address, and mailbox stats (total messages and threads). Call this first when the user says 'my emails', 'my inbox', or needs identity context. Use the returned `emailAddress` to identify the user's own messages in **Find Emails** results. [See the documentation](https://developers.google.com/gmail/api/reference/rest/v1/users/getProfile).",
Copy file name to clipboardExpand all lines: components/gmail/actions/get-send-as-alias/get-send-as-alias.mjs
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -5,7 +5,7 @@ export default {
5
5
key: "gmail-get-send-as-alias",
6
6
name: "Get Send As Alias",
7
7
description: "Get a send as alias for the authenticated user. [See the documentation](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.settings.sendAs/get)",
Copy file name to clipboardExpand all lines: components/gmail/actions/list-labels/list-labels.mjs
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -8,7 +8,7 @@ export default {
8
8
+" Call this before **Modify Labels** or **Find Emails** when you need to target a label that the user named rather than an obvious system label — it resolves a name like `Clients/Acme` to its opaque label ID."
9
9
+" User labels are returned first, then system labels."
10
10
+" [See the documentation](https://developers.google.com/gmail/api/reference/rest/v1/users.labels/list).",
0 commit comments