Skip to content

Commit 19af310

Browse files
dcramercodex
andcommitted
Add Gmail archive/label operations to gog capability
Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent d61f39e commit 19af310

5 files changed

Lines changed: 405 additions & 2 deletions

File tree

src/ash/integrations/skills/capabilities/google/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ ash-sb capability invoke -c gog.email -o list_messages --account work --input-js
112112
ash-sb capability invoke -c gog.email -o search_messages --account work --input-json '{"query":"is:unread newer_than:1d","limit":20}'
113113
ash-sb capability invoke -c gog.email -o get_message --account work --input-json '{"id":"<message_id>"}'
114114
ash-sb capability invoke -c gog.email -o get_thread --account work --input-json '{"thread_id":"<thread_id>","limit":20}'
115+
ash-sb capability invoke -c gog.email -o archive_messages --account work --input-json '{"ids":["<message_id>"],"archive":true}'
116+
ash-sb capability invoke -c gog.email -o update_labels --account work --input-json '{"ids":["<message_id>"],"add_label_ids":["IMPORTANT"],"remove_label_ids":[]}'
115117
ash-sb capability invoke -c gog.calendar -o list_events --account work --input-json '{"calendar":"primary","window":"1d"}'
116118
ash-sb capability invoke -c gog.calendar -o create_event --account work --input-json '{"title":"Team sync","start":"2026-03-04T18:00:00Z"}'
117119
```
@@ -148,11 +150,12 @@ Use Google calendar + Google email only for this view.
148150

149151
### Standard mutations
150152

151-
Before `send_message` or `create_event`, confirm key details if user intent is ambiguous.
153+
Before `send_message`, `create_event`, `archive_messages`, or `update_labels`, always confirm key details.
152154
Required confirmation fields:
153155

154156
- Email send: recipient, subject, body intent
155157
- Event create: title, start time/date, end time or duration, timezone context if unclear
158+
- Archive/update labels: target account alias, message IDs (or clearly identified messages), and the exact label/archive action
156159

157160
## Output Rules
158161

src/ash/skills/bundled/gog/scripts/gogcli_bridge.py

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"gog.email": (
9090
"https://www.googleapis.com/auth/gmail.readonly"
9191
" https://www.googleapis.com/auth/gmail.send"
92+
" https://www.googleapis.com/auth/gmail.modify"
9293
),
9394
"gog.calendar": "https://www.googleapis.com/auth/calendar",
9495
}
@@ -449,7 +450,10 @@ def _google_api_request(
449450
request.add_header("Content-Type", "application/json")
450451
try:
451452
with urlopen(request, timeout=30) as resp: # noqa: S310
452-
return json.loads(resp.read().decode("utf-8"))
453+
raw_body = resp.read().decode("utf-8").strip()
454+
if not raw_body:
455+
return {}
456+
return json.loads(raw_body)
453457
except HTTPError as e:
454458
if e.code == 401:
455459
raise BridgeError(
@@ -728,6 +732,18 @@ def _handle_definitions() -> dict[str, Any]:
728732
"requires_auth": True,
729733
"mutating": True,
730734
},
735+
{
736+
"name": "archive_messages",
737+
"description": "Archive or unarchive one or more messages",
738+
"requires_auth": True,
739+
"mutating": True,
740+
},
741+
{
742+
"name": "update_labels",
743+
"description": "Add/remove labels on one or more messages",
744+
"requires_auth": True,
745+
"mutating": True,
746+
},
731747
],
732748
},
733749
{
@@ -1427,6 +1443,20 @@ def _invoke_operation(
14271443
account_ref=account_ref,
14281444
)
14291445

1446+
if capability_id == "gog.email" and operation == "archive_messages":
1447+
return _invoke_archive_messages(
1448+
input_data=input_data,
1449+
access_token=access_token,
1450+
account_ref=account_ref,
1451+
)
1452+
1453+
if capability_id == "gog.email" and operation == "update_labels":
1454+
return _invoke_update_labels(
1455+
input_data=input_data,
1456+
access_token=access_token,
1457+
account_ref=account_ref,
1458+
)
1459+
14301460
if capability_id == "gog.calendar" and operation == "list_events":
14311461
return _invoke_list_events(
14321462
input_data=input_data,
@@ -1888,6 +1918,157 @@ def _invoke_send_message(
18881918
}
18891919

18901920

1921+
def _required_string_list(
1922+
value: Any,
1923+
*,
1924+
field_name: str,
1925+
max_items: int,
1926+
allow_empty: bool = False,
1927+
) -> list[str]:
1928+
if value is None:
1929+
values: list[Any] = []
1930+
elif isinstance(value, list):
1931+
values = value
1932+
else:
1933+
raise BridgeError("capability_invalid_input", f"{field_name} must be a list")
1934+
1935+
result: list[str] = []
1936+
seen: set[str] = set()
1937+
for item in values:
1938+
text = _optional_text(item)
1939+
if not text:
1940+
raise BridgeError(
1941+
"capability_invalid_input",
1942+
f"{field_name} entries must be non-empty strings",
1943+
)
1944+
if text in seen:
1945+
continue
1946+
seen.add(text)
1947+
result.append(text)
1948+
1949+
if not allow_empty and not result:
1950+
raise BridgeError("capability_invalid_input", f"{field_name} is required")
1951+
if len(result) > max_items:
1952+
raise BridgeError(
1953+
"capability_invalid_input",
1954+
f"{field_name} must contain at most {max_items} items",
1955+
)
1956+
return result
1957+
1958+
1959+
def _gmail_modify_messages(
1960+
*,
1961+
access_token: str,
1962+
ids: list[str],
1963+
add_label_ids: list[str],
1964+
remove_label_ids: list[str],
1965+
) -> None:
1966+
gmail_base = _google_gmail_api_base_url()
1967+
payload: dict[str, Any] = {}
1968+
if add_label_ids:
1969+
payload["addLabelIds"] = add_label_ids
1970+
if remove_label_ids:
1971+
payload["removeLabelIds"] = remove_label_ids
1972+
1973+
if len(ids) == 1:
1974+
_google_api_request(
1975+
"POST",
1976+
f"{gmail_base}/gmail/v1/users/me/messages/{ids[0]}/modify",
1977+
access_token=access_token,
1978+
json_body=payload,
1979+
)
1980+
return
1981+
1982+
_google_api_request(
1983+
"POST",
1984+
f"{gmail_base}/gmail/v1/users/me/messages/batchModify",
1985+
access_token=access_token,
1986+
json_body={**payload, "ids": ids},
1987+
)
1988+
1989+
1990+
def _invoke_archive_messages(
1991+
*,
1992+
input_data: dict[str, Any],
1993+
access_token: str,
1994+
account_ref: str,
1995+
) -> dict[str, Any]:
1996+
ids = _required_string_list(
1997+
input_data.get("ids"),
1998+
field_name="ids",
1999+
max_items=100,
2000+
)
2001+
raw_archive = input_data.get("archive", True)
2002+
if not isinstance(raw_archive, bool):
2003+
raise BridgeError("capability_invalid_input", "archive must be a boolean")
2004+
archive = raw_archive
2005+
2006+
add_label_ids = [] if archive else ["INBOX"]
2007+
remove_label_ids = ["INBOX"] if archive else []
2008+
_gmail_modify_messages(
2009+
access_token=access_token,
2010+
ids=ids,
2011+
add_label_ids=add_label_ids,
2012+
remove_label_ids=remove_label_ids,
2013+
)
2014+
return {
2015+
"output": {
2016+
"status": "updated",
2017+
"archive": archive,
2018+
"updated_count": len(ids),
2019+
"ids": ids,
2020+
"account_ref": account_ref,
2021+
}
2022+
}
2023+
2024+
2025+
def _invoke_update_labels(
2026+
*,
2027+
input_data: dict[str, Any],
2028+
access_token: str,
2029+
account_ref: str,
2030+
) -> dict[str, Any]:
2031+
ids = _required_string_list(
2032+
input_data.get("ids"),
2033+
field_name="ids",
2034+
max_items=100,
2035+
)
2036+
add_label_ids = _required_string_list(
2037+
input_data.get("add_label_ids"),
2038+
field_name="add_label_ids",
2039+
max_items=100,
2040+
allow_empty=True,
2041+
)
2042+
remove_label_ids = _required_string_list(
2043+
input_data.get("remove_label_ids"),
2044+
field_name="remove_label_ids",
2045+
max_items=100,
2046+
allow_empty=True,
2047+
)
2048+
if not add_label_ids and not remove_label_ids:
2049+
raise BridgeError(
2050+
"capability_invalid_input",
2051+
"at least one of add_label_ids or remove_label_ids is required",
2052+
)
2053+
2054+
_gmail_modify_messages(
2055+
access_token=access_token,
2056+
ids=ids,
2057+
add_label_ids=add_label_ids,
2058+
remove_label_ids=remove_label_ids,
2059+
)
2060+
return {
2061+
"output": {
2062+
"status": "updated",
2063+
"updated_count": len(ids),
2064+
"ids": ids,
2065+
"add_label_ids": add_label_ids,
2066+
"remove_label_ids": remove_label_ids,
2067+
"account_ref": account_ref,
2068+
}
2069+
}
2070+
2071+
18912072
def _invoke_list_events(
18922073
*,
18932074
input_data: dict[str, Any],

tests/test_bundled_gog_skill.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,10 @@ def test_google_skill_includes_summary_and_day_at_a_glance_playbooks() -> None:
5050
assert "summarize emails" in text
5151
assert "day at a glance" in text
5252
assert "get_message" in text
53+
54+
55+
def test_google_skill_includes_archive_and_label_mutation_guidance() -> None:
56+
text = _load_google_skill_text().lower()
57+
assert "archive_messages" in text
58+
assert "update_labels" in text
59+
assert "always confirm key details" in text

tests/test_gog_capability_e2e.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,23 @@ def do_POST(self) -> None: # noqa: N802
206206
)
207207
return
208208

209+
if self.path.startswith(
210+
"/gmail/v1/users/me/messages/"
211+
) and self.path.endswith("/modify"):
212+
request_body = json.loads(body)
213+
self._json_response(
214+
200,
215+
{
216+
"id": self.path.split("/")[-2],
217+
"labelIds": request_body.get("addLabelIds", []),
218+
},
219+
)
220+
return
221+
222+
if self.path == "/gmail/v1/users/me/messages/batchModify":
223+
self._json_response(204, {})
224+
return
225+
209226
# /calendar/v3/calendars/{calendarId}/events
210227
if "/calendar/v3/calendars/" in self.path and self.path.endswith("/events"):
211228
request_body = json.loads(body)
@@ -481,6 +498,47 @@ async def test_gog_capability_rpc_stack_round_trip_and_policy_enforcement(
481498
assert message_output["thread_id"] == "thread_msg_1"
482499
assert message_output["body_text"] == "Plain body for msg_1"
483500

501+
# ---- Invoke email archive_messages ----
502+
archive_email = await _rpc(
503+
server,
504+
request_id=62,
505+
method="capability.invoke",
506+
params={
507+
"context_token": user1_private,
508+
"capability": "gog.email",
509+
"operation": "archive_messages",
510+
"input": {"ids": ["msg_1"], "archive": True},
511+
},
512+
)
513+
assert archive_email.error is None
514+
assert isinstance(archive_email.result, dict)
515+
archive_output = archive_email.result["output"]
516+
assert archive_output["status"] == "updated"
517+
assert archive_output["archive"] is True
518+
assert archive_output["updated_count"] == 1
519+
520+
# ---- Invoke email update_labels ----
521+
update_labels = await _rpc(
522+
server,
523+
request_id=63,
524+
method="capability.invoke",
525+
params={
526+
"context_token": user1_private,
527+
"capability": "gog.email",
528+
"operation": "update_labels",
529+
"input": {
530+
"ids": ["msg_1"],
531+
"add_label_ids": ["IMPORTANT"],
532+
"remove_label_ids": ["INBOX"],
533+
},
534+
},
535+
)
536+
assert update_labels.error is None
537+
assert isinstance(update_labels.result, dict)
538+
labels_output = update_labels.result["output"]
539+
assert labels_output["status"] == "updated"
540+
assert labels_output["updated_count"] == 1
541+
484542
# ---- Auth begin + complete for calendar ----
485543
begin_calendar = await _rpc(
486544
server,

0 commit comments

Comments
 (0)