Skip to content

Commit f9db771

Browse files
committed
Align agent schema with mail and calendar fixes
1 parent 5f13f8e commit f9db771

7 files changed

Lines changed: 452 additions & 12 deletions

File tree

backend/custom_bridge/__init__.py

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,68 @@ def handle_gmail_list(user_id: str, payload: Dict[str, Any], access_token: str |
482482
}
483483

484484

485+
def handle_gmail_search(user_id: str, payload: Dict[str, Any], access_token: str | None = None) -> Dict[str, Any]:
486+
result = handle_gmail_list(user_id, payload, access_token=access_token)
487+
result["action"] = "gmail_search"
488+
result["query"] = str(payload.get("q") or "").strip()
489+
return result
490+
491+
492+
def _calendar_time_object(payload: Dict[str, Any], field_name: str) -> Dict[str, Any]:
493+
value = payload.get(field_name)
494+
if isinstance(value, dict):
495+
out: Dict[str, Any] = {}
496+
for key in ("dateTime", "date", "timeZone"):
497+
item = value.get(key)
498+
if item is not None and str(item).strip():
499+
out[key] = str(item).strip()
500+
return out
501+
502+
def _tz() -> str:
503+
return str(
504+
payload.get(f"{field_name}_timeZone")
505+
or payload.get(f"{field_name}TimeZone")
506+
or payload.get(f"{field_name}_timezone")
507+
or payload.get("timeZone")
508+
or payload.get("time_zone")
509+
or ""
510+
).strip()
511+
512+
if isinstance(value, str) and value.strip():
513+
out = {"dateTime": value.strip()}
514+
tz = _tz()
515+
if tz:
516+
out["timeZone"] = tz
517+
return out
518+
519+
for alias in (
520+
f"{field_name}_dateTime",
521+
f"{field_name}DateTime",
522+
f"{field_name}_datetime",
523+
):
524+
candidate = payload.get(alias)
525+
if isinstance(candidate, str) and candidate.strip():
526+
out = {"dateTime": candidate.strip()}
527+
tz = _tz()
528+
if tz:
529+
out["timeZone"] = tz
530+
return out
531+
532+
for alias in (
533+
f"{field_name}_date",
534+
f"{field_name}Date",
535+
):
536+
candidate = payload.get(alias)
537+
if isinstance(candidate, str) and candidate.strip():
538+
out = {"date": candidate.strip()}
539+
tz = _tz()
540+
if tz:
541+
out["timeZone"] = tz
542+
return out
543+
544+
return {}
545+
546+
485547
def handle_gmail_get(user_id: str, payload: Dict[str, Any], access_token: str | None = None) -> Dict[str, Any]:
486548
message_id = payload.get("message_id")
487549
if not message_id:
@@ -567,9 +629,46 @@ def handle_calendar_list_events(user_id: str, payload: Dict[str, Any], access_to
567629
params["timeMin"] = payload["time_min"]
568630
if payload.get("time_max"):
569631
params["timeMax"] = payload["time_max"]
570-
resp = gmail.calendar_request("get", "calendars/primary/events", params=params)
571-
items = resp.json().get("items", [])
572-
return {"action": "calendar_list_events", "status": "ok", "account_slot": account_slot, "events": items, "count": len(items)}
632+
include_all_calendars = bool(payload.get("include_all_calendars", False))
633+
calendar_ids = payload.get("calendar_ids")
634+
if isinstance(calendar_ids, str):
635+
calendar_ids = [item.strip() for item in calendar_ids.split(",")]
636+
if not isinstance(calendar_ids, list):
637+
calendar_ids = []
638+
calendar_ids = [str(item).strip() for item in calendar_ids if str(item).strip()]
639+
640+
if include_all_calendars and not calendar_ids:
641+
list_resp = gmail.calendar_request("get", "users/me/calendarList", params={"minAccessRole": "reader", "showHidden": True})
642+
calendars = list_resp.json().get("items", [])
643+
calendar_ids = [
644+
str(item.get("id") or "").strip()
645+
for item in calendars
646+
if isinstance(item, dict) and str(item.get("id") or "").strip()
647+
]
648+
649+
if not calendar_ids:
650+
calendar_ids = ["primary"]
651+
652+
events: list[dict] = []
653+
for calendar_id in calendar_ids:
654+
resp = gmail.calendar_request("get", f"calendars/{calendar_id}/events", params=params)
655+
items = resp.json().get("items", [])
656+
for item in items:
657+
if isinstance(item, dict):
658+
enriched = dict(item)
659+
enriched.setdefault("calendarId", calendar_id)
660+
events.append(enriched)
661+
662+
events.sort(key=lambda item: str((item or {}).get("start", {}).get("dateTime") or (item or {}).get("start", {}).get("date") or ""))
663+
return {
664+
"action": "calendar_list_events",
665+
"status": "ok",
666+
"account_slot": account_slot,
667+
"calendar_ids": calendar_ids,
668+
"events": events,
669+
"count": len(events),
670+
"include_all_calendars": include_all_calendars,
671+
}
573672

574673

575674
def handle_calendar_get_event(user_id: str, payload: Dict[str, Any], access_token: str | None = None) -> Dict[str, Any]:
@@ -585,7 +684,13 @@ def handle_calendar_get_event(user_id: str, payload: Dict[str, Any], access_toke
585684
def handle_calendar_create_event(user_id: str, payload: Dict[str, Any], access_token: str | None = None) -> Dict[str, Any]:
586685
account_slot = str(payload.get("account_slot") or "primary").strip() or "primary"
587686
gmail = GmailClient(user_id, access_token=access_token, account_slot=account_slot)
588-
event_body = {k: payload[k] for k in ("summary", "description", "start", "end", "attendees", "location", "recurrence") if k in payload}
687+
event_body = {k: payload[k] for k in ("summary", "description", "attendees", "location", "recurrence") if k in payload}
688+
start = _calendar_time_object(payload, "start")
689+
end = _calendar_time_object(payload, "end")
690+
if start:
691+
event_body["start"] = start
692+
if end:
693+
event_body["end"] = end
589694
resp = gmail.calendar_request("post", "calendars/primary/events", json=event_body)
590695
created = resp.json()
591696
return {"action": "calendar_create_event", "status": "ok", "account_slot": account_slot, "event_id": created.get("id"), "event": created}
@@ -597,7 +702,13 @@ def handle_calendar_update_event(user_id: str, payload: Dict[str, Any], access_t
597702
raise ValueError("event_id is required for calendar_update_event")
598703
account_slot = str(payload.get("account_slot") or "primary").strip() or "primary"
599704
gmail = GmailClient(user_id, access_token=access_token, account_slot=account_slot)
600-
patch_body = {k: payload[k] for k in ("summary", "description", "start", "end", "attendees", "location", "recurrence") if k in payload}
705+
patch_body = {k: payload[k] for k in ("summary", "description", "attendees", "location", "recurrence") if k in payload}
706+
start = _calendar_time_object(payload, "start")
707+
end = _calendar_time_object(payload, "end")
708+
if start:
709+
patch_body["start"] = start
710+
if end:
711+
patch_body["end"] = end
601712
resp = gmail.calendar_request("patch", f"calendars/primary/events/{event_id}", json=patch_body)
602713
return {"action": "calendar_update_event", "status": "ok", "account_slot": account_slot, "event": resp.json()}
603714

@@ -620,6 +731,7 @@ def handle_calendar_delete_event(user_id: str, payload: Dict[str, Any], access_t
620731
"gmail_send": handle_gmail_send,
621732
"gmail_reply": handle_gmail_reply,
622733
"gmail_list": handle_gmail_list,
734+
"gmail_search": handle_gmail_search,
623735
"gmail_get": handle_gmail_get,
624736
"gmail_trash": handle_gmail_trash,
625737
"gmail_delete": handle_gmail_delete,

backend/custom_gpt_tools/actions_openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
"properties": {
122122
"action": {
123123
"type": "string",
124-
"description": "One of oauth_authorize|oauth_exchange|oauth_status|ensure_authorized|gmail_send|gmail_list|gmail_get|gmail_trash|gmail_delete|gmail_attachment"
124+
"description": "One of oauth_authorize|oauth_exchange|oauth_status|ensure_authorized|gmail_send|gmail_list|gmail_search|gmail_get|gmail_trash|gmail_delete|gmail_attachment|gmail_accounts_list|calendar_list_events|calendar_get_event|calendar_create_event|calendar_update_event|calendar_delete_event"
125125
},
126126
"user_id": { "type": "string" },
127127
"payload": { "type": "object", "additionalProperties": true }

backend/tool_call_handler/__init__.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ def get_json(self):
9797
RESPONSES_INSTRUCTIONS = (
9898
"You are OmniFlow PA. Use tools for any claim about user data.\n"
9999
"- If asked about tasks, read/update TM.json via tools. Do not claim writes without a write tool call.\n"
100-
"- If asked about Gmail, prefer gmail_recent_metadata to fetch recent message metadata; do not ask for 50 if user requested 20.\n"
100+
"- If asked about Gmail, use mail.search for query-based filtering and gmail_recent_metadata for lightweight recent metadata; do not ask for 50 if user requested 20.\n"
101+
"- For calendar.events.create/update, send start and end as nested objects with dateTime and timeZone.\n"
102+
"- For calendar.events.list, prefer include_all_calendars=true when the user asks for their calendar broadly, and use calendar_ids to narrow to specific calendars.\n"
101103
"- For gmail_action gmail_send, always provide JSON with payload.to, payload.subject, payload.body.\n"
102104
"- For gmail_action gmail_get/gmail_trash/gmail_delete, always provide payload.message_id.\n"
103105
"- If a Gmail tool returns NOT_AUTHORIZED, instruct user to Connect.\n"
@@ -5184,6 +5186,49 @@ def _resolve_account_slot(default: str = "primary") -> str:
51845186
result["enriched_count"] = len([m for m in enriched if (m.get("subject") or m.get("from") or m.get("snippet"))])
51855187
return _capability_response("success", capability, result=result), 200
51865188

5189+
if capability == "mail.search":
5190+
max_results = int(arguments.get("max_results", arguments.get("limit", 20)) or 20)
5191+
account_slot = _resolve_account_slot()
5192+
query = str(arguments.get("query") or arguments.get("q") or "").strip()
5193+
if not query:
5194+
return _capability_response("error", capability, error={"code": "INVALID_REQUEST", "message": "arguments.query is required"}), 400
5195+
payload = {
5196+
"max_results": max(1, min(50, max_results)),
5197+
"q": query,
5198+
"label_ids": _mail_normalize_list(arguments.get("label_ids") or arguments.get("labelIds")),
5199+
"exclude_label_ids": _mail_normalize_list(arguments.get("exclude_label_ids") or arguments.get("excludeLabelIds")),
5200+
"include_spam_trash": bool(arguments.get("include_spam_trash", arguments.get("includeSpamTrash", False))),
5201+
"page_token": arguments.get("page_token") or arguments.get("pageToken"),
5202+
"account_slot": account_slot,
5203+
}
5204+
result = _bridge_action("gmail_search", str(effective_user_id), payload)
5205+
messages = list(result.get("messages") or []) if isinstance(result, dict) else []
5206+
metadata_limit = int(arguments.get("metadata_limit", 20) or 20)
5207+
metadata_limit = max(1, min(20, metadata_limit))
5208+
enriched: list[dict] = []
5209+
for idx, raw_item in enumerate(messages):
5210+
if not isinstance(raw_item, dict):
5211+
continue
5212+
mid = str(raw_item.get("id") or "").strip()
5213+
if not mid:
5214+
continue
5215+
message_obj: Dict[str, Any] = {}
5216+
if idx < metadata_limit:
5217+
try:
5218+
got = _bridge_action(
5219+
"gmail_get",
5220+
str(effective_user_id),
5221+
{"message_id": mid, "format": "metadata", "account_slot": account_slot},
5222+
)
5223+
message_obj = dict(got.get("message") or {}) if isinstance(got, dict) else {}
5224+
except Exception:
5225+
message_obj = {}
5226+
enriched.append(_mail_enriched_row(raw_item, message_obj))
5227+
result["messages"] = enriched
5228+
result["enriched_count"] = len([m for m in enriched if (m.get("subject") or m.get("from") or m.get("snippet"))])
5229+
result["query"] = query
5230+
return _capability_response("success", capability, result=result), 200
5231+
51875232
if capability == "mail.read":
51885233
message_id = str(arguments.get("message_id") or "").strip()
51895234
if not message_id:
@@ -5310,6 +5355,8 @@ def _to_rfc3339(val: Any) -> Any:
53105355
"time_min": _to_rfc3339(arguments.get("time_min")),
53115356
"time_max": _to_rfc3339(arguments.get("time_max")),
53125357
"max_results": arguments.get("max_results"),
5358+
"calendar_ids": arguments.get("calendar_ids") or arguments.get("calendarIds"),
5359+
"include_all_calendars": bool(arguments.get("include_all_calendars", arguments.get("includeAllCalendars", True))),
53135360
}
53145361
result = _bridge_action("calendar_list_events", str(effective_user_id), payload)
53155362
return _capability_response("success", capability, result=result), 200

docs/shared/custom_bridge_openapi.json

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"ensure_authorized",
3030
"gmail_send",
3131
"gmail_list",
32+
"gmail_search",
3233
"gmail_get",
3334
"gmail_trash",
3435
"gmail_delete",
@@ -58,7 +59,8 @@
5859
"required": [
5960
"action",
6061
"user_id"
61-
]
62+
],
63+
"additionalProperties": false
6264
},
6365
"GmailGetPayload": {
6466
"type": "object",
@@ -92,7 +94,7 @@
9294
"/api/custom_bridge": {
9395
"post": {
9496
"operationId": "custom_bridge",
95-
"summary": "Unified GPT tool for Gmail actions.",
97+
"summary": "Unified GPT tool for Gmail and Calendar actions.",
9698
"requestBody": {
9799
"required": true,
98100
"content": {
@@ -163,6 +165,20 @@
163165
}
164166
}
165167
},
168+
"gmail_search": {
169+
"summary": "gmail_search example",
170+
"value": {
171+
"action": "gmail_search",
172+
"user_id": "dokuczacz@gmail.com",
173+
"payload": {
174+
"q": "in:inbox category:primary -in:spam -in:trash newer_than:7d",
175+
"label_ids": ["INBOX"],
176+
"exclude_label_ids": ["PROMOTIONS"],
177+
"include_spam_trash": false,
178+
"max_results": 10
179+
}
180+
}
181+
},
166182
"gmail_get": {
167183
"summary": "gmail_get example",
168184
"value": {
@@ -203,6 +219,58 @@
203219
"attachment_id": "ATTACHMENT_ID"
204220
}
205221
}
222+
},
223+
"calendar_list_events": {
224+
"summary": "calendar_list_events example",
225+
"value": {
226+
"action": "calendar_list_events",
227+
"user_id": "dokuczacz@gmail.com",
228+
"payload": {
229+
"account_slot": "primary",
230+
"include_all_calendars": true,
231+
"max_results": 10,
232+
"time_min": "2026-05-01T00:00:00Z",
233+
"time_max": "2026-05-31T23:59:59Z"
234+
}
235+
}
236+
},
237+
"calendar_create_event": {
238+
"summary": "calendar_create_event example",
239+
"value": {
240+
"action": "calendar_create_event",
241+
"user_id": "dokuczacz@gmail.com",
242+
"payload": {
243+
"summary": "TEST OmniFlow Calendar",
244+
"description": "Test event",
245+
"start": {
246+
"dateTime": "2026-05-01T10:00:00",
247+
"timeZone": "Europe/Zurich"
248+
},
249+
"end": {
250+
"dateTime": "2026-05-01T10:30:00",
251+
"timeZone": "Europe/Zurich"
252+
}
253+
}
254+
}
255+
},
256+
"calendar_update_event": {
257+
"summary": "calendar_update_event example",
258+
"value": {
259+
"action": "calendar_update_event",
260+
"user_id": "dokuczacz@gmail.com",
261+
"payload": {
262+
"event_id": "EVENT_ID",
263+
"summary": "Updated title",
264+
"start": {
265+
"dateTime": "2026-05-01T11:00:00",
266+
"timeZone": "Europe/Zurich"
267+
},
268+
"end": {
269+
"dateTime": "2026-05-01T11:30:00",
270+
"timeZone": "Europe/Zurich"
271+
}
272+
}
273+
}
206274
}
207275
}
208276
}

docs/shared/tool_call_handler_openapi.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"mail.status",
3333
"mail.authorize",
3434
"mail.inbox.list",
35+
"mail.search",
3536
"mail.read",
3637
"mail.summarize",
3738
"mail.reply",
@@ -68,7 +69,7 @@
6869
"arguments": {
6970
"type": "object",
7071
"additionalProperties": true,
71-
"description": "Capability-specific payload. For memory.interaction.save provide either (user_message + assistant_response) or (role + content). Optional per-call user override can be passed as arguments.user_id. For mail/calendar capabilities you can pass arguments.account_slot = primary|secondary. For mail.inbox.list you can pass q/query, label_ids, exclude_label_ids, include_spam_trash, page_token, and max_results/limit. For mail.authorize pass arguments.force=true to force a new OAuth consent even if a token already exists (use when token is expired or revoked)."
72+
"description": "Capability-specific payload. For memory.interaction.save provide either (user_message + assistant_response) or (role + content). Optional per-call user override can be passed as arguments.user_id. For mail/calendar capabilities you can pass arguments.account_slot = primary|secondary. For calendar.events.create and calendar.events.update, send start/end as objects like {dateTime, timeZone} (or use start_dateTime/start_timeZone and end_dateTime/end_timeZone aliases). For calendar.events.list you can pass include_all_calendars=true to aggregate all accessible calendars, or calendar_ids to narrow scope. For mail.inbox.list you can pass q/query, label_ids, exclude_label_ids, include_spam_trash, page_token, and max_results/limit. For mail.search pass query (or q) plus optional label_ids, exclude_label_ids, include_spam_trash, page_token, and max_results/limit. For mail.authorize pass arguments.force=true to force a new OAuth consent even if a token already exists (use when token is expired or revoked)."
7273
}
7374
},
7475
"required": [
@@ -295,6 +296,7 @@
295296
"arguments": {
296297
"account_slot": "primary",
297298
"max_results": 10,
299+
"include_all_calendars": true,
298300
"time_min": "2026-03-11T00:00:00Z",
299301
"time_max": "2026-03-18T00:00:00Z"
300302
}
@@ -314,10 +316,12 @@
314316
"summary": "Project sync",
315317
"description": "Weekly team sync",
316318
"start": {
317-
"dateTime": "2026-03-12T09:00:00+01:00"
319+
"dateTime": "2026-03-12T09:00:00",
320+
"timeZone": "Europe/Zurich"
318321
},
319322
"end": {
320-
"dateTime": "2026-03-12T09:30:00+01:00"
323+
"dateTime": "2026-03-12T09:30:00",
324+
"timeZone": "Europe/Zurich"
321325
},
322326
"location": "Teams"
323327
}

0 commit comments

Comments
 (0)