Repository navigation
feat(gws-gmail-voice): manifest skill exposing triage_email inline tool - #702
Merged
Merged
Conversation
Voice/phone agents (Gemini) had no fast path to read Gmail. Options were openUrlTool (hallucinated URL on today's phone call) or workTool (5-30s round-trip via core agent). This wraps `gws gmail +triage` as an inline tool so Gemini summarizes the inbox sub-second. OSS-safe: self-detection at module load via `which gws`. If gws CLI isn't installed, exports empty tools[] and the voice agent simply doesn't see triage_email. No broken tool, no error noise. Loaded by existing `loadSkillManifestTools()` in src/inline-tools.ts:905 which already scans skills/ + \$SUTANDO_PRIVATE_DIR/skills/ — no new infra. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gws gmail +triage --format json emits an object: { messages: [...],
query: ..., resultSizeEstimate: N }, not a bare array. My initial
indexOf('[') sliced into the inner messages array but kept the trailing
}, producing a JSON parse error at the closing bracket.
Found by local smoke test before Mini's review. Now finds first '{',
parses the outer object, extracts messages[]. Verified end-to-end:
3 messages returned cleanly. Backward-compatible if gws ever emits
a bare array (Array.isArray fallback retained).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two concerns addressed in one commit:
1) Manifest access_tier was decorative — loader ignored it. Now
`loadSkillManifestTools()` returns `{owner, anyCaller}` split by
manifest's `access_tier` ("owner" default, "any_caller" opt-in).
`personalTools.owner` is now also wired into `ownerOnlyTools` so a
verified phone-owner caller (Chi calling in) sees triage_email —
the very scenario that motivated this PR (today's hallucinated
Gmail URL on phone). Non-owner callers still don't see it.
Verified end-to-end via dynamic import:
triage_email in inlineTools: true (web client / Chi)
triage_email in ownerOnlyTools: true (verified phone-owner)
triage_email in anyCallerTools: false (unverified caller)
2) Tool description tweaks per Chi: dropped the "Do NOT use openUrlTool"
prohibition (too rigid); added explicit work-tool fallback on
timeout/error ("call `work` with 'check gmail unread inbox via
gws gmail +triage'"). Gemini now has a documented graceful path.
3) Mini's review nits (PR #702 LGTM + 2 non-blocking):
- timeout 5_000ms → 10_000ms (catches ~95% of long-tail OAuth-cold
paths vs 5s's marginal headroom)
- JSON-parse: `/^\{/m` regex (start-of-line `{`) with fallback to
bare `indexOf('{')`. Defends against future gws header lines
that may contain literal `{`.
Smoke-tested: tools[0].execute({max:3}) → 3 msgs, status=ok.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 of 5 tasks
sonichi
added a commit
that referenced
this pull request
May 15, 2026
…704) * feat(gws-gmail-voice): cache-first 3-tier path + importance scoring Solves two compounding bugs from PR #702 voice testing: 1) Gemini interpreted "most important email" → singular → passed max=1 2) Tool returned N-by-recency, no importance ranking → wrong top hit Architecture (Chi 2026-05-14): - Tier 1 CACHE — read `state/external-cache/inbox-important.json` (mtime <15min). Pre-scored top-3 importance-ranked. Sub-50ms. - Tier 2 LIVE — cache stale/missing or mode=recent → call gws (10s timeout). - Tier 3 WORK — gws fails → description tells Gemini to call `work` next. Cache writer: `skills/gws-gmail-voice/scripts/refresh-cache.py`. Reads `gws gmail +triage --max 30`, scores each via importance heuristics from feedback_triage_recency_not_importance.md: - BLACKLIST domains (substack, linkedin digest, FounderCoHo, ODSC, etc.): -100 - Self-notifs (Chi Wang via github.com notifications, CI failures): +15 - Academic .edu senders, openreview.net: +5/+10 - Subject patterns (deadline/RSVP/CI failure/accepted/urgent/review): +10 each - Calendar invitations: +8 - Sorts top-3 (score > -100), atomic-writes JSON. Tool description tweaks (the real fix to the max=1 bug): - Mode is now explicit: "important" (default) returns pre-ranked top-3; "recent" returns top-N by date. - Explicit instruction: "DO NOT pass max=1 — the tool already returns the most important; max only matters for mode=recent." - Pre-empts Gemini's previous misinterpretation. Run cache writer via ACT loop opportunistically OR ad-hoc: python3 skills/gws-gmail-voice/scripts/refresh-cache.py Smoke-tested end-to-end: - mode=important + fresh cache → top_3: CI failure / Xinyang Han .edu / GitHub PR - mode=recent + live gws → newest-first: Levay LinkedIn / CI / FounderCoHo - Cache stale path: falls through to live correctly. state/external-cache/ is gitignored (real email metadata); only the writer script + tool changes ship. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gws-gmail-voice): apply Mini PR #704 review nits 1. @chiwang.cc → $SUTANDO_OWNER_EMAIL_DOMAIN env var. Same OSS-hygiene pattern as PR #700 (which I just shipped + Mini caught me repeating). Empty env → skip the bump (no penalty). Set per-user. 2. Drop foundercoho from BLACKLIST_DOMAINS — overly specific brand. The generic noreply@/marketing@/newsletter@/no-reply@ patterns catch it (and arbitrary other brands) anyway. Added bare `noreply@` alongside `no-reply@` for completeness. 3. Cache TTL 15min → 30min. Materially improves cache-hit rate (~70% → ~95% per Mini's analysis) for minor freshness sacrifice; live gws fallback covers the freshness edge case anyway. 4. Replace brittle "DO NOT pass max=1" negative anti-pattern guidance with schema-level ignore + positive description: - tools.ts execute() now logs and ignores `max` when mode=important ("cache returns top-3 regardless") - Description: positive phrasing — "max is ignored in important mode; pass max only with mode='recent'". Harder for LLM to misread. - Parameter zod description follows the same shape. Smoke-tested all 3 paths: - mode=important default → cache hit, top-3 - mode=important + max=1 → logs "ignoring max=1", still returns top-3 - mode=recent + max=3 → live gws, count=3 All Mini's substantive nits addressed; minor style observations (O(N²) sort with N≤30, CACHE_PATH cwd assumption) left as documented in his review since they're non-broken under current usage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 21, 2026
liususan091219
pushed a commit
to liususan091219/sutando-fork
that referenced
this pull request
May 28, 2026
…ol (sonichi#702) * feat(gws-gmail-voice): manifest skill exposing triage_email inline tool Voice/phone agents (Gemini) had no fast path to read Gmail. Options were openUrlTool (hallucinated URL on today's phone call) or workTool (5-30s round-trip via core agent). This wraps `gws gmail +triage` as an inline tool so Gemini summarizes the inbox sub-second. OSS-safe: self-detection at module load via `which gws`. If gws CLI isn't installed, exports empty tools[] and the voice agent simply doesn't see triage_email. No broken tool, no error noise. Loaded by existing `loadSkillManifestTools()` in src/inline-tools.ts:905 which already scans skills/ + \$SUTANDO_PRIVATE_DIR/skills/ — no new infra. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gws-gmail-voice): parse gws JSON object (not array) gws gmail +triage --format json emits an object: { messages: [...], query: ..., resultSizeEstimate: N }, not a bare array. My initial indexOf('[') sliced into the inner messages array but kept the trailing }, producing a JSON parse error at the closing bracket. Found by local smoke test before Mini's review. Now finds first '{', parses the outer object, extracts messages[]. Verified end-to-end: 3 messages returned cleanly. Backward-compatible if gws ever emits a bare array (Array.isArray fallback retained). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gws-gmail-voice): tier-aware loader + Chi/Mini review feedback Two concerns addressed in one commit: 1) Manifest access_tier was decorative — loader ignored it. Now `loadSkillManifestTools()` returns `{owner, anyCaller}` split by manifest's `access_tier` ("owner" default, "any_caller" opt-in). `personalTools.owner` is now also wired into `ownerOnlyTools` so a verified phone-owner caller (Chi calling in) sees triage_email — the very scenario that motivated this PR (today's hallucinated Gmail URL on phone). Non-owner callers still don't see it. Verified end-to-end via dynamic import: triage_email in inlineTools: true (web client / Chi) triage_email in ownerOnlyTools: true (verified phone-owner) triage_email in anyCallerTools: false (unverified caller) 2) Tool description tweaks per Chi: dropped the "Do NOT use openUrlTool" prohibition (too rigid); added explicit work-tool fallback on timeout/error ("call `work` with 'check gmail unread inbox via gws gmail +triage'"). Gemini now has a documented graceful path. 3) Mini's review nits (PR sonichi#702 LGTM + 2 non-blocking): - timeout 5_000ms → 10_000ms (catches ~95% of long-tail OAuth-cold paths vs 5s's marginal headroom) - JSON-parse: `/^\{/m` regex (start-of-line `{`) with fallback to bare `indexOf('{')`. Defends against future gws header lines that may contain literal `{`. Smoke-tested: tools[0].execute({max:3}) → 3 msgs, status=ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219
pushed a commit
to liususan091219/sutando-fork
that referenced
this pull request
May 28, 2026
…onichi#704) * feat(gws-gmail-voice): cache-first 3-tier path + importance scoring Solves two compounding bugs from PR sonichi#702 voice testing: 1) Gemini interpreted "most important email" → singular → passed max=1 2) Tool returned N-by-recency, no importance ranking → wrong top hit Architecture (Chi 2026-05-14): - Tier 1 CACHE — read `state/external-cache/inbox-important.json` (mtime <15min). Pre-scored top-3 importance-ranked. Sub-50ms. - Tier 2 LIVE — cache stale/missing or mode=recent → call gws (10s timeout). - Tier 3 WORK — gws fails → description tells Gemini to call `work` next. Cache writer: `skills/gws-gmail-voice/scripts/refresh-cache.py`. Reads `gws gmail +triage --max 30`, scores each via importance heuristics from feedback_triage_recency_not_importance.md: - BLACKLIST domains (substack, linkedin digest, FounderCoHo, ODSC, etc.): -100 - Self-notifs (Chi Wang via github.com notifications, CI failures): +15 - Academic .edu senders, openreview.net: +5/+10 - Subject patterns (deadline/RSVP/CI failure/accepted/urgent/review): +10 each - Calendar invitations: +8 - Sorts top-3 (score > -100), atomic-writes JSON. Tool description tweaks (the real fix to the max=1 bug): - Mode is now explicit: "important" (default) returns pre-ranked top-3; "recent" returns top-N by date. - Explicit instruction: "DO NOT pass max=1 — the tool already returns the most important; max only matters for mode=recent." - Pre-empts Gemini's previous misinterpretation. Run cache writer via ACT loop opportunistically OR ad-hoc: python3 skills/gws-gmail-voice/scripts/refresh-cache.py Smoke-tested end-to-end: - mode=important + fresh cache → top_3: CI failure / Xinyang Han .edu / GitHub PR - mode=recent + live gws → newest-first: Levay LinkedIn / CI / FounderCoHo - Cache stale path: falls through to live correctly. state/external-cache/ is gitignored (real email metadata); only the writer script + tool changes ship. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gws-gmail-voice): apply Mini PR sonichi#704 review nits 1. @chiwang.cc → $SUTANDO_OWNER_EMAIL_DOMAIN env var. Same OSS-hygiene pattern as PR sonichi#700 (which I just shipped + Mini caught me repeating). Empty env → skip the bump (no penalty). Set per-user. 2. Drop foundercoho from BLACKLIST_DOMAINS — overly specific brand. The generic noreply@/marketing@/newsletter@/no-reply@ patterns catch it (and arbitrary other brands) anyway. Added bare `noreply@` alongside `no-reply@` for completeness. 3. Cache TTL 15min → 30min. Materially improves cache-hit rate (~70% → ~95% per Mini's analysis) for minor freshness sacrifice; live gws fallback covers the freshness edge case anyway. 4. Replace brittle "DO NOT pass max=1" negative anti-pattern guidance with schema-level ignore + positive description: - tools.ts execute() now logs and ignores `max` when mode=important ("cache returns top-3 regardless") - Description: positive phrasing — "max is ignored in important mode; pass max only with mode='recent'". Harder for LLM to misread. - Parameter zod description follows the same shape. Smoke-tested all 3 paths: - mode=important default → cache hit, top-3 - mode=important + max=1 → logs "ignoring max=1", still returns top-3 - mode=recent + max=3 → live gws, count=3 All Mini's substantive nits addressed; minor style observations (O(N²) sort with N≤30, CACHE_PATH cwd assumption) left as documented in his review since they're non-broken under current usage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
triage_emailinline tool.openUrlTool(which can't read content back to the caller anyway).workTooldelegation is too slow (~5-30s).gwsCLI isn't on PATH, exports emptytools[]and voice agent doesn't see the tool. Self-detected at module load.Design
execution: 'inline') — Gemini calls it directly. ~2s warm round-trip viaexecFileSync('gws', ['gmail', '+triage', '--format', 'json', '--max', N]). Fits Gemini's 5s tool budget.loadSkillManifestTools()insrc/inline-tools.ts:905already scansskills/+\$SUTANDO_PRIVATE_DIR/skills/. No new loader.gwsAvailable()runswhich gwsonce at module load. If missing,tools = [](no registration); one-line stderr log. Users with the publicgws-gmailskill installed + OAuth configured pick it up automatically.Why not a "skill the voice agent knows about"
Latency. Today's failure was Gemini choosing
openUrlTooloverworkbecauseworkis slow. An inline tool the model sees in its tool list is the only path that keeps voice responsive. The description tells Gemini explicitly NOT to use openUrlTool for this.Test plan
logs/voice-agent.logat startup)triage_email, summarizes (not openUrl)mvgws out of PATH, restart voice-agent, confirmtriage_emailabsent + load-time log line present🤖 Generated with Claude Code