"""Guided tour (highlight + narrate UI elements) in the Hermes desktop GUI: the agent discovers targets (``action="targets"``), then highlights one step at a time (``show``) or hands over a step list the user pages (``start``). Round-trips through the gateway blocking-prompt bridge (``tour.request``/``tour.respond``) so the agent learns whether the selector matched. Registered in ``desktop_ui`` and hidden from the model when tours are off: a tour covers the whole screen, so "off" must mean the model is never told the tool exists rather than offered a call that fails.""" import json from typing import Callable, Optional from tools import desktop_ui from tools.registry import registry, tool_error ACTIONS = ("targets", "show", "start", "next", "prev", "stop") SURFACES = ("app", "preview") SIDES = ("top", "right", "bottom", "left") def tour_tool(action: str = "", surface: Optional[str] = None, selector: Optional[str] = None, title: Optional[str] = None, text: Optional[str] = None, side: Optional[str] = None, steps: Optional[list] = None, step_index: Optional[int] = None, callback: Optional[Callable] = None) -> str: """Dispatch one tour action to the desktop renderer and return its outcome.""" if callback is None: return tool_error("tour is only available in the Hermes desktop app.") verb = (action or "").strip().lower() if verb not in ACTIONS: return tool_error(f"action must be one of: {', '.join(ACTIONS)}.") where = (surface or "app").strip().lower() if where not in SURFACES: return tool_error(f"surface must be one of: {', '.join(SURFACES)}.") if side is not None and side not in SIDES: return tool_error(f"side must be one of: {', '.join(SIDES)}.") # Every highlighted moment needs something to point at or something to say. if verb == "show" and not (selector or title or text): return tool_error("show needs a selector (and/or title/text for the popover).") if verb == "start": if not isinstance(steps, list) or not steps: return tool_error("start needs a non-empty steps array.") for i, step in enumerate(steps): if not isinstance(step, dict): return tool_error(f"steps[{i}] must be an object.") if not (step.get("selector") or step.get("title") or step.get("text")): return tool_error(f"steps[{i}] needs a selector and/or title/text.") fields = {"action": verb, "surface": where, "selector": selector, "title": title, "text": text, "side": side, "steps": steps, "step_index": step_index} try: raw = callback({key: val for key, val in fields.items() if val is not None}) except Exception as exc: return tool_error(f"Tour action failed: {exc}") if not raw: return tool_error("The tour request timed out, or no GUI window answered. " "For surface='preview' open a page in the preview pane first.") # The renderer answers with a JSON object; pass it through, else wrap it. try: return json.dumps(json.loads(raw), ensure_ascii=False) except (TypeError, ValueError): return json.dumps({"text": str(raw)}, ensure_ascii=False) _STEP_SCHEMA = { "type": "object", "properties": { "selector": { "type": "string", "description": "Element to highlight; omit = centered narration.", }, "title": {"type": "string", "description": "Popover title."}, "text": {"type": "string", "description": "Popover body."}, "side": { "type": "string", "enum": list(SIDES), "description": "Popover side; omit to auto-place.", }, }, } TOUR_SCHEMA = { "name": "gui_tour", # Description keeps the targets-first flow + stable-selector preference: # without them the model guesses selectors on re-rendering UI. # See #95681. "description": ( "Guided tour in the desktop GUI: dim the screen, highlight an " "element, attach a titled popover. Surfaces: 'app' (Hermes itself) " "or 'preview' (the page in the preview pane). ALWAYS call " "action='targets' first — prefer targets marked stable:true (their " "selectors survive re-renders); re-scan if one stops matching. Then " "narrate with action='show' (one highlight per call, replaces the " "last — pair each with a chat message) or hand over with " "action='start' + steps (user gets Next/Prev; 'next'/'prev' also " "page it). 'stop' clears. Use for how-does-X-work / where-is-Y " "walkthroughs." ), "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": list(ACTIONS), "description": "targets first; show narrates; start hands over.", }, "surface": { "type": "string", "enum": list(SURFACES), "description": "'app' (default) or 'preview'.", }, "selector": { "type": "string", "description": "show: selector from targets (prefer stable). Omit = centered narration.", }, "title": {"type": "string", "description": "show: popover title."}, "text": {"type": "string", "description": "show: popover body."}, "side": { "type": "string", "enum": list(SIDES), "description": "show: popover side; omit to auto-place.", }, "steps": { "type": "array", "items": _STEP_SCHEMA, "description": "start: ordered steps.", }, "step_index": { "type": "integer", "description": "start: 0-indexed first step.", }, }, "required": ["action"], }, } def check_tours_enabled() -> bool: """The user's Settings → Appearance switch. On unless they turned it off.""" return desktop_ui.user_enabled("in_app_tours", default=True) registry.register( name="gui_tour", toolset="desktop_ui", schema=TOUR_SCHEMA, check_fn=check_tours_enabled, handler=lambda args, **kw: tour_tool( action=args.get("action", ""), callback=kw.get("callback"), **{k: args.get(k) for k in ("surface", "selector", "title", "text", "side", "steps", "step_index")}), emoji="🧭")