Files
Bailey DixonandClaude Opus 4.7 197b54ddc3 feat(bridge): JIT permission-denied surfacing (v0.4.1)
Make permission-denied bridge tool failures legible to both the LLM and
the human, instead of bubbling up as opaque error strings that the agent
has to pattern-match its way through.

  - plugin/tools/resolve_result.py — new typed-union dataclass hierarchy
    with Found(value) / NotFound(detail) / PermissionDenied(permission,
    reason) variants and a from_bridge_response classifier. Reads both
    the v0.4.1 canonical wire keys (`code` / `permission`) and the
    legacy aliases (`error_code` / `required_permission`) so the rollout
    is forwards/backwards compatible across mixed-version installs.

  - plugin/tools/android_tool.py — Tier C agent-tool wrappers
    (android_search_contacts, android_send_sms, android_call,
    android_location) now run their bridge response through
    _maybe_jit_permission_response. On `code: permission_denied` the
    wrapper upgrades the response to a structured envelope with
    deterministic LLM-readable copy that names the exact Settings
    deep-link path: "User has not granted Contacts permission
    (android.permission.READ_CONTACTS). They can enable it in Settings
    > Apps > Hermes Relay > Permissions. Tool: android_search_contacts."

  - BridgeCommandHandler.respondFromResult now emits the canonical `code`
    + `permission` aliases ALONGSIDE the existing `error_code` +
    `required_permission` fields so both phone APK generations produce
    parseable envelopes. LocalDispatchResult also accepts either spelling.

  - VoiceModeOverlay — new PermissionDeniedChip composable surfaces
    above the mic button when a voice intent fails with
    permission_denied. Tap deep-links to ACTION_APPLICATION_DETAILS_SETTINGS
    for BuildConfig.APPLICATION_ID (so each flavor lands on its own
    package's permission page). VoiceUiState gains permissionDeniedCallout;
    VoiceViewModel.buildPermissionDeniedCallout reads the structured
    `permission` field off result.resultJson and builds copy like "I need
    Contacts to Send SMS here. Tap to open Settings." Callout cleared on
    chip tap and on the next mic-tap (fresh turn). Voice TTS already says
    "Permission needed. {hint}" from the prior session — chip is additive.

  - 17 new Python unit tests in plugin/tests/test_resolve_result.py covering
    the classifier, both wire-key spellings, success passthrough, non-
    permission error passthrough, and JIT upgrades for all four Tier C
    wrappers. Existing 39 Tier-C tests still pass with no regressions.

Docs: ROADMAP.md "Tiered permission checklist with JIT permission errors"
moved to a "shipped on feature/tiered-permissions" pointer; DEVLOG.md
entry for 2026-04-16; CHANGELOG.md [Unreleased] section gains v0.4.1
Bridge fast-follows entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 20:52:18 -04:00

175 lines
6.4 KiB
Python

"""
ResolveResult — typed return shape for plugin-side resolvers (v0.4.1).
A small dataclass-based ``sealed class`` analogue used by helper resolvers
(contact lookup, location, SMS pre-flight, call pre-flight) to make the
"why didn't this work?" axis machine-readable rather than an opaque
``None`` or string. Mirrors the Kotlin ``ContactResolution`` /
``AppResolution`` sealed types added in 2026-04-15 voice-fixes session
and the bridge response envelope's ``error_code`` / ``required_permission``
fields produced by ``BridgeCommandHandler.classifyBridgeError``.
Python doesn't have proper sealed classes, but a tagged-union dataclass
hierarchy plus a ``from_bridge_response`` constructor gives us:
>>> r = ResolveResult.from_bridge_response({"contacts": []})
>>> isinstance(r, NotFound)
True
>>> r = ResolveResult.from_bridge_response({"error": "no perm",
... "code": "permission_denied",
... "permission": "android.permission.READ_CONTACTS"})
>>> isinstance(r, PermissionDenied)
True
>>> r.permission
'android.permission.READ_CONTACTS'
The class is consumed by the agent-tool wrapper layer in
``plugin/tools/android_tool.py``: every Tier C (C1-C4) tool wrapper now
runs the bridge response through ``ResolveResult.from_bridge_response``
and, on a ``PermissionDenied``, returns a structured error JSON to the
LLM with a clear actionable hint instead of relaying the raw bridge
error string.
Wire shape on the bridge response that this parser consumes:
{
"error": "Grant SMS permission in Settings...",
"code": "permission_denied", # canonical (v0.4.1 alias)
"permission": "android.permission.SEND_SMS", # canonical
# legacy aliases still recognized for older phone builds:
"error_code": "permission_denied",
"required_permission": "android.permission.SEND_SMS",
}
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Generic, Mapping, Optional, TypeVar, Union
T = TypeVar("T")
# Canonical key the v0.4.1 bridge response uses for the structured error
# code. The pre-v0.4.1 phone builds emitted ``error_code`` instead — the
# parser accepts either to keep the agent server backwards-compatible
# while the phone APK rolls out across users.
_CODE_KEYS = ("code", "error_code")
_PERMISSION_KEYS = ("permission", "required_permission")
@dataclass(frozen=True)
class Found(Generic[T]):
"""Resolver succeeded with a payload."""
value: T
@dataclass(frozen=True)
class NotFound:
"""Resolver completed but found no match (e.g. no contact named 'Sam')."""
detail: Optional[str] = None
@dataclass(frozen=True)
class PermissionDenied:
"""
Resolver couldn't run because the user hasn't granted a required
runtime permission.
The ``permission`` field carries the Android permission name
(e.g. ``android.permission.READ_CONTACTS``) so downstream layers
can surface the exact Settings deep-link.
"""
permission: str
reason: str = ""
# Tagged-union alias for type hints — `ResolveResult[Contact]` reads as
# `Found[Contact] | NotFound | PermissionDenied`. Python's type system
# enforces the variants via mypy / pyright; runtime checks use isinstance.
ResolveResult = Union[Found[T], NotFound, PermissionDenied]
def _first_present(mapping: Mapping[str, Any], keys: tuple[str, ...]) -> Optional[str]:
"""Return the first non-empty string value among ``keys`` in ``mapping``, else None."""
for k in keys:
v = mapping.get(k)
if isinstance(v, str) and v:
return v
return None
def from_bridge_response(
response: Mapping[str, Any],
*,
found_value: Optional[T] = None,
) -> "ResolveResult[T]":
"""
Classify a bridge tool's HTTP-shaped response as a ResolveResult.
Decision tree:
1. If ``response`` carries ``code`` (or legacy ``error_code``) of
``"permission_denied"``, return ``PermissionDenied(permission, reason)``
where ``permission`` is read from ``permission`` /
``required_permission`` and ``reason`` from ``error``.
2. If ``found_value`` is provided (truthy or explicit non-None),
return ``Found(found_value)``. The caller has already extracted
the meaningful payload (e.g. the contact list or the GPS fix)
and just wants a typed wrapper.
3. Otherwise return ``NotFound(detail)`` where ``detail`` is the
response's ``error`` field if any.
The function deliberately doesn't model every possible failure mode
(timeout, dispatch_exception, sideload_only, …) because those are
all "transient or configuration" — the LLM should retry or report,
not surface a permission prompt to the user. PermissionDenied is the
one error category that maps onto a deterministic UI affordance
(deep-link to Settings → Apps → Hermes Relay → Permissions), so it
earns the dedicated dataclass.
"""
code = _first_present(response, _CODE_KEYS)
if code == "permission_denied":
permission = _first_present(response, _PERMISSION_KEYS) or ""
reason = response.get("error") if isinstance(response.get("error"), str) else ""
return PermissionDenied(permission=permission, reason=reason or "")
if found_value is not None:
return Found(found_value)
detail = response.get("error") if isinstance(response.get("error"), str) else None
return NotFound(detail=detail)
# Re-export for ``from .resolve_result import *`` consumers, keeping the
# public surface explicit.
__all__ = [
"Found",
"NotFound",
"PermissionDenied",
"ResolveResult",
"from_bridge_response",
]
# ── Module-level convenience: namespaced static-method access ─────────────
#
# The Kotlin sealed-class equivalent is invoked as
# ``ResolveResult.PermissionDenied(...)`` rather than the bare class. To
# keep the call sites symmetric across the Kotlin/Python boundary the
# wrapper module also exposes the variants as attributes on a tiny
# namespace object, so callers can write either:
#
# from plugin.tools.resolve_result import PermissionDenied
# return PermissionDenied("android.permission.READ_CONTACTS")
#
# OR
#
# from plugin.tools import resolve_result as RR
# return RR.PermissionDenied("android.permission.READ_CONTACTS")
#
# The latter mirrors the Kotlin idiom one-to-one without forcing a star
# import on every call site.