Skip to content

feat(gws-gmail-voice): manifest skill exposing triage_email inline tool - #702

Merged
sonichi merged 3 commits into
mainfrom
feat-gws-gmail-voice-manifest
May 15, 2026
Merged

sonichi merged 3 commits into
mainfrom
feat-gws-gmail-voice-manifest

Conversation

@sonichi

@sonichi sonichi commented May 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • Voice/phone agents (Gemini) get a sub-second path to read unread Gmail via triage_email inline tool.
  • Replaces today's failure mode: phone-call Gmail check made Gemini hallucinate a URL through openUrlTool (which can't read content back to the caller anyway). workTool delegation is too slow (~5-30s).
  • OSS-safe: if gws CLI isn't on PATH, exports empty tools[] and voice agent doesn't see the tool. Self-detected at module load.

Design

  • Inline tool (execution: 'inline') — Gemini calls it directly. ~2s warm round-trip via execFileSync('gws', ['gmail', '+triage', '--format', 'json', '--max', N]). Fits Gemini's 5s tool budget.
  • Loaded by existing manifest infra — loadSkillManifestTools() in src/inline-tools.ts:905 already scans skills/ + \$SUTANDO_PRIVATE_DIR/skills/. No new loader.
  • OSS-safety — gwsAvailable() runs which gws once at module load. If missing, tools = [] (no registration); one-line stderr log. Users with the public gws-gmail skill installed + OAuth configured pick it up automatically.

Why not a "skill the voice agent knows about"

Latency. Today's failure was Gemini choosing openUrlTool over work because work is 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

  • Voice agent restart picks up new tool (verify in logs/voice-agent.log at startup)
  • Caller: "what unread emails do I have?" → Gemini calls triage_email, summarizes (not openUrl)
  • OSS dry-run: temporarily mv gws out of PATH, restart voice-agent, confirm triage_email absent + load-time log line present
  • Re-run today's failed phone-call scenario end-to-end

🤖 Generated with Claude Code

sonichi and others added 3 commits May 14, 2026 18:36
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>
@sonichi
sonichi merged commit 22059ef into main May 15, 2026
1 check passed
@sonichi
sonichi deleted the feat-gws-gmail-voice-manifest branch May 15, 2026 02:04
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant