feat: enhance Gmail draft update functionality

- Improved the update_gmail_draft tool to allow users to review and edit draft content before applying changes.
- Added logic to generate draft body content based on user requests and conversation context.
- Implemented fetching of existing draft body to facilitate user edits in the approval card.
- Updated UI components to support displaying and editing existing draft content, enhancing user experience.
This commit is contained in:
Anish Sarkar 2026-03-21 00:30:18 +05:30
parent ab6eeaf02e
commit cb6b687933
6 changed files with 132 additions and 18 deletions

View file

@ -32,6 +32,12 @@ def create_update_gmail_draft_tool(
Use when the user asks to modify, edit, or add content to an existing
email draft. This replaces the draft content with the new version.
The user will be able to review and edit the content before it is applied.
If the user simply wants to "edit" a draft without specifying exact changes,
generate the body yourself using your best understanding of the conversation
context. The user will review and can freely edit the content in the approval
card before confirming.
IMPORTANT: This tool is ONLY for Gmail drafts, NOT for Notion pages,
calendar events, or any other content type.
@ -39,7 +45,8 @@ def create_update_gmail_draft_tool(
Args:
draft_subject_or_id: The exact subject line of the draft to update
(as it appears in Gmail drafts).
body: The full updated body content for the draft.
body: The full updated body content for the draft. Generate this
yourself based on the user's request and conversation context.
to: Optional new recipient email address (keeps original if omitted).
subject: Optional new subject line (keeps original if omitted).
cc: Optional CC recipient(s), comma-separated.
@ -62,6 +69,7 @@ def create_update_gmail_draft_tool(
Examples:
- "Update the Kurseong Plan draft with the new itinerary details"
- "Edit my draft about the project proposal and change the recipient"
- "Let me edit the meeting notes draft" (call with current body content so user can edit in the approval card)
"""
logger.info(
f"update_gmail_draft called: draft_subject_or_id='{draft_subject_or_id}'"

View file

@ -2,10 +2,11 @@ import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from sqlalchemy import and_, func, or_
from sqlalchemy import String, and_, cast, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
@ -264,7 +265,7 @@ class GmailToolMetadataService:
if auth_expired:
await self._persist_auth_expired(connector.id)
result = {
result: dict = {
"account": acc_dict,
"email": message.to_dict(),
}
@ -273,8 +274,111 @@ class GmailToolMetadataService:
if meta.get("draft_id"):
result["draft_id"] = meta["draft_id"]
if not auth_expired:
existing_body = await self._fetch_draft_body(
connector, message.message_id, meta.get("draft_id")
)
if existing_body is not None:
result["existing_body"] = existing_body
return result
async def _fetch_draft_body(
self,
connector: SearchSourceConnector,
message_id: str,
draft_id: str | None,
) -> str | None:
"""Fetch the plain-text body of a Gmail draft via the API.
Tries ``drafts.get`` first (if *draft_id* is available), then falls
back to scanning ``drafts.list`` to resolve the draft by *message_id*.
Returns ``None`` on any failure so callers can degrade gracefully.
"""
try:
creds = await self._build_credentials(connector)
service = build("gmail", "v1", credentials=creds)
if not draft_id:
draft_id = await self._find_draft_id(service, message_id)
if not draft_id:
return None
draft = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.users()
.drafts()
.get(userId="me", id=draft_id, format="full")
.execute(),
)
payload = draft.get("message", {}).get("payload", {})
return self._extract_body_from_payload(payload)
except Exception:
logger.warning(
"Failed to fetch draft body for message_id=%s",
message_id,
exc_info=True,
)
return None
async def _find_draft_id(self, service: Any, message_id: str) -> str | None:
"""Resolve a draft ID from its message ID by scanning drafts.list."""
try:
page_token = None
while True:
kwargs: dict[str, Any] = {"userId": "me", "maxResults": 100}
if page_token:
kwargs["pageToken"] = page_token
response = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.users().drafts().list(**kwargs).execute(),
)
for draft in response.get("drafts", []):
if draft.get("message", {}).get("id") == message_id:
return draft["id"]
page_token = response.get("nextPageToken")
if not page_token:
break
return None
except Exception:
logger.warning(
"Failed to look up draft by message_id=%s", message_id, exc_info=True
)
return None
@staticmethod
def _extract_body_from_payload(payload: dict) -> str | None:
"""Extract the plain-text (or html→text) body from a Gmail payload."""
import base64
def _get_parts(p: dict) -> list[dict]:
if "parts" in p:
parts: list[dict] = []
for sub in p["parts"]:
parts.extend(_get_parts(sub))
return parts
return [p]
parts = _get_parts(payload)
text_content = ""
for part in parts:
mime_type = part.get("mimeType", "")
data = part.get("body", {}).get("data", "")
if mime_type == "text/plain" and data:
text_content += base64.urlsafe_b64decode(data + "===").decode(
"utf-8", errors="ignore"
)
elif mime_type == "text/html" and data and not text_content:
from markdownify import markdownify as md
raw_html = base64.urlsafe_b64decode(data + "===").decode(
"utf-8", errors="ignore"
)
text_content = md(raw_html).strip()
return text_content.strip() if text_content.strip() else None
async def get_trash_context(
self, search_space_id: int, user_id: str, email_ref: str
) -> dict:
@ -325,7 +429,7 @@ class GmailToolMetadataService:
SearchSourceConnector.user_id == user_id,
or_(
func.lower(
Document.document_metadata["subject"].astext
cast(Document.document_metadata["subject"], String)
)
== func.lower(email_ref),
func.lower(Document.title) == func.lower(email_ref),

View file

@ -5,7 +5,7 @@ from datetime import datetime
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from sqlalchemy import and_, func, or_
from sqlalchemy import String, and_, cast, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
@ -389,7 +389,7 @@ class GoogleCalendarToolMetadataService:
SearchSourceConnector.user_id == user_id,
or_(
func.lower(
Document.document_metadata["event_summary"].astext
cast(Document.document_metadata["event_summary"], String)
)
== func.lower(event_ref),
func.lower(Document.title) == func.lower(event_ref),

View file

@ -175,6 +175,7 @@ const TOOLS_WITH_UI = new Set([
"update_calendar_event",
"delete_calendar_event",
"create_gmail_draft",
"update_gmail_draft",
"send_gmail_email",
"trash_gmail_email",
"execute",

View file

@ -5,7 +5,6 @@ import {
CornerDownLeftIcon,
MailIcon,
Pen,
SendIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
@ -193,7 +192,6 @@ function ApprovalCard({
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div className="flex items-center gap-2">
<SendIcon className="size-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"

View file

@ -49,6 +49,7 @@ interface InterruptResult {
account?: GmailAccount;
email?: GmailMessage;
draft_id?: string;
existing_body?: string;
error?: string;
};
}
@ -176,6 +177,7 @@ function ApprovalCard({
const account = interruptData.context?.account;
const email = interruptData.context?.email;
const draftId = interruptData.context?.draft_id;
const existingBody = interruptData.context?.existing_body;
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? [
@ -193,6 +195,7 @@ function ApprovalCard({
const currentTo = pendingEdits?.to ?? args.to ?? "";
const currentCc = pendingEdits?.cc ?? args.cc ?? "";
const currentBcc = pendingEdits?.bcc ?? args.bcc ?? "";
const editableBody = currentBody || existingBody || "";
const handleApprove = useCallback(() => {
if (decided || isPanelOpen) return;
@ -208,7 +211,7 @@ function ApprovalCard({
draft_id: draftId,
to: currentTo,
subject: currentSubject,
body: currentBody,
body: editableBody,
cc: currentCc,
bcc: currentBcc,
connector_id: email?.connector_id ?? account?.id,
@ -226,7 +229,7 @@ function ApprovalCard({
draftId,
pendingEdits,
currentSubject,
currentBody,
editableBody,
currentTo,
currentCc,
currentBcc,
@ -308,10 +311,10 @@ function ApprovalCard({
value: currentBcc,
},
];
openHitlEditPanel({
title: currentSubject,
content: currentBody,
toolName: "Gmail Draft",
openHitlEditPanel({
title: currentSubject,
content: editableBody,
toolName: "Gmail Draft",
extraFields,
onSave: (
newTitle,
@ -410,8 +413,8 @@ function ApprovalCard({
{currentSubject}
</p>
)}
{currentBody != null && (
<div
{editableBody ? (
<div
className="mt-2 max-h-[7rem] overflow-hidden text-sm"
style={{
maskImage:
@ -421,14 +424,14 @@ function ApprovalCard({
}}
>
<PlateEditor
markdown={String(currentBody)}
markdown={String(editableBody)}
readOnly
preset="readonly"
editorVariant="none"
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
/>
</div>
)}
) : null}
</div>
{/* Action buttons */}