diff --git a/acp_adapter/auth.py b/acp_adapter/auth.py index b04a7b7b40..e065370222 100644 --- a/acp_adapter/auth.py +++ b/acp_adapter/auth.py @@ -11,69 +11,50 @@ TERMINAL_SETUP_AUTH_METHOD_ID = "hermes-setup" def detect_provider() -> Optional[str]: """Resolve the active Hermes runtime provider, or None if unavailable. - Treats a ``Callable`` ``api_key`` (Azure Foundry Entra ID bearer - token provider — see :mod:`agent.azure_identity_adapter`) as a valid - credential. Without this, ACP sessions for Entra-configured Foundry - deployments silently default to ``"openrouter"`` and the ACP auth - handshake rejects the legitimate provider. + A callable ``api_key`` (Azure Foundry Entra ID bearer-token provider, see + :mod:`agent.azure_identity_adapter`) counts as a valid credential; otherwise + Entra-configured Foundry deployments would default to ``"openrouter"`` and + the ACP auth handshake would reject the legitimate provider. """ try: from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider() - api_key = runtime.get("api_key") - provider = runtime.get("provider") + api_key, provider = runtime.get("api_key"), runtime.get("provider") if not isinstance(provider, str) or not provider.strip(): return None - is_string_key = isinstance(api_key, str) and api_key.strip() - is_callable_provider = callable(api_key) and not isinstance(api_key, str) - if is_string_key or is_callable_provider: + if (isinstance(api_key, str) and api_key.strip()) or callable(api_key): return provider.strip().lower() except Exception: return None return None -def has_provider() -> bool: - """Return True if Hermes can resolve any runtime provider credentials.""" - return detect_provider() is not None - - def build_auth_methods() -> list[Any]: """Return registry-compatible ACP auth methods for Hermes. - The official ACP registry validates that agents advertise at least one - usable auth method during the initial handshake. A fresh Zed install may - not have Hermes provider credentials configured yet, so Hermes always - advertises a terminal setup method. When credentials are already present, - it also advertises the resolved provider as the default agent-managed - runtime credential method. + The ACP registry requires at least one usable auth method in the initial + handshake. A fresh Zed install may have no Hermes credentials yet, so the + terminal setup method is always advertised; when credentials resolve, the + provider is also advertised as the default agent-managed runtime method. """ from acp.schema import AuthMethodAgent, TerminalAuthMethod methods: list[Any] = [] provider = detect_provider() if provider: - methods.append( - AuthMethodAgent( - id=provider, - name=f"{provider} runtime credentials", - description=( - "Authenticate Hermes using the currently configured " - f"{provider} runtime credentials." - ), - ) - ) - - methods.append( - TerminalAuthMethod( - id=TERMINAL_SETUP_AUTH_METHOD_ID, - name="Configure Hermes provider", - description=( - "Open Hermes' interactive model/provider setup in a terminal. " - "Use this when Hermes has not been configured on this machine yet." - ), - type="terminal", - args=["--setup"], - ) - ) + methods.append(AuthMethodAgent( + id=provider, + name=f"{provider} runtime credentials", + description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.", + )) + methods.append(TerminalAuthMethod( + id=TERMINAL_SETUP_AUTH_METHOD_ID, + name="Configure Hermes provider", + description=( + "Open Hermes' interactive model/provider setup in a terminal. " + "Use this when Hermes has not been configured on this machine yet." + ), + type="terminal", + args=["--setup"], + )) return methods diff --git a/acp_adapter/edit_approval.py b/acp_adapter/edit_approval.py index b73325ec09..a126140c9e 100644 --- a/acp_adapter/edit_approval.py +++ b/acp_adapter/edit_approval.py @@ -1,8 +1,8 @@ """Pre-execution ACP edit approval helpers. -This module is intentionally isolated from the generic tool registry. ACP binds -an edit approval requester in a ContextVar for the duration of one ACP agent run; -CLI, gateway, and other sessions leave it unset and therefore bypass this guard. +Intentionally isolated from the generic tool registry: ACP binds an edit +approval requester in a ContextVar for the duration of one ACP agent run; CLI, +gateway, and other sessions leave it unset and therefore bypass this guard. """ from __future__ import annotations @@ -35,41 +35,28 @@ class EditProposal: EditApprovalRequester = Callable[[EditProposal], bool] -_EDIT_APPROVAL_REQUESTER: ContextVar[EditApprovalRequester | None] = ContextVar( - "ACP_EDIT_APPROVAL_REQUESTER", - default=None, -) +_EDIT_APPROVAL_REQUESTER: ContextVar[EditApprovalRequester | None] = ContextVar("ACP_EDIT_APPROVAL_REQUESTER", default=None) _PERMISSION_REQUEST_IDS = count(1) - SENSITIVE_AUTO_APPROVE_NAMES = {".env", ".env.local", ".env.production", "id_rsa", "id_ed25519"} AUTO_APPROVE_ASK = "ask" AUTO_APPROVE_WORKSPACE = "workspace_session" AUTO_APPROVE_SESSION = "session" +_V4A_FILE_RE = re.compile(r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', re.MULTILINE) +_V4A_MOVE_RE = re.compile(r'^\*\*\*\s+Move\s+File:\s*(.+?)\s*->\s*(.+)$', re.MULTILINE) + def set_edit_approval_requester(requester: EditApprovalRequester | None) -> Token: """Bind an ACP edit approval requester for the current context.""" - return _EDIT_APPROVAL_REQUESTER.set(requester) def reset_edit_approval_requester(token: Token) -> None: """Restore a previous edit approval requester binding.""" - _EDIT_APPROVAL_REQUESTER.reset(token) -def clear_edit_approval_requester() -> None: - """Clear the current requester; primarily used by tests.""" - - _EDIT_APPROVAL_REQUESTER.set(None) - - -def get_edit_approval_requester() -> EditApprovalRequester | None: - return _EDIT_APPROVAL_REQUESTER.get() - - def _read_text_if_exists(path: str) -> str | None: p = Path(path).expanduser() if not p.exists(): @@ -79,31 +66,26 @@ def _read_text_if_exists(path: str) -> str | None: return p.read_text(encoding="utf-8", errors="replace") -def _proposal_for_write_file(arguments: dict[str, Any]) -> EditProposal: +def _required_path(arguments: dict[str, Any]) -> str: path = str(arguments.get("path") or "") if not path: raise ValueError("path required") + return path + + +def _proposal_for_write_file(arguments: dict[str, Any]) -> EditProposal: + path = _required_path(arguments) content = arguments.get("content") if content is None: raise ValueError("content required") - return EditProposal( - tool_name="write_file", - path=path, - old_text=_read_text_if_exists(path), - new_text=str(content), - arguments=dict(arguments), - ) + return EditProposal("write_file", path, _read_text_if_exists(path), str(content), dict(arguments)) def _proposal_for_patch_replace(arguments: dict[str, Any]) -> EditProposal: - path = str(arguments.get("path") or "") - if not path: - raise ValueError("path required") - old_string = arguments.get("old_string") - new_string = arguments.get("new_string") + path = _required_path(arguments) + old_string, new_string = arguments.get("old_string"), arguments.get("new_string") if old_string is None or new_string is None: raise ValueError("old_string and new_string required") - old_text = _read_text_if_exists(path) if old_text is None: raise ValueError(f"Failed to read file: {path}") @@ -111,99 +93,69 @@ def _proposal_for_patch_replace(arguments: dict[str, Any]) -> EditProposal: from tools.fuzzy_match import fuzzy_find_and_replace new_text, match_count, _strategy, error = fuzzy_find_and_replace( - old_text, - str(old_string), - str(new_string), - bool(arguments.get("replace_all", False)), + old_text, str(old_string), str(new_string), bool(arguments.get("replace_all", False)), ) if error or match_count == 0: raise ValueError(error or f"Could not find match for old_string in {path}") - - return EditProposal( - tool_name="patch", - path=path, - old_text=old_text, - new_text=new_text, - arguments=dict(arguments), - ) + return EditProposal("patch", path, old_text, new_text, dict(arguments)) def _extract_v4a_patch_paths(patch_body: str) -> list[str]: - paths: list[str] = [] - for match in re.finditer( - r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', - patch_body, - re.MULTILINE, - ): - path = match.group(1).strip() - if path: - paths.append(path) - for match in re.finditer( - r'^\*\*\*\s+Move\s+File:\s*(.+?)\s*->\s*(.+)$', - patch_body, - re.MULTILINE, - ): - src = match.group(1).strip() - dst = match.group(2).strip() - if src: - paths.append(src) - if dst: - paths.append(dst) - return paths + paths = [m.group(1).strip() for m in _V4A_FILE_RE.finditer(patch_body)] + for match in _V4A_MOVE_RE.finditer(patch_body): + paths.extend(match.group(i).strip() for i in (1, 2)) + return [p for p in paths if p] def _proposal_for_patch_v4a(arguments: dict[str, Any]) -> EditProposal: patch_body = arguments.get("patch") if not isinstance(patch_body, str) or not patch_body: raise ValueError("patch content required") - paths = _extract_v4a_patch_paths(patch_body) if not paths: raise ValueError("no file paths found in V4A patch") - - proposal_path = paths[0] if len(paths) == 1 else ", ".join(paths) - old_text = _read_text_if_exists(paths[0]) if len(paths) == 1 else None + single = len(paths) == 1 + # ACP only supports a single diff payload: surface the exact V4A patch as + # new_text so patch-mode calls are permissioned and denied patches cannot mutate. return EditProposal( - tool_name="patch", - path=proposal_path, - old_text=old_text, - # ACP only supports a single diff payload here. Surface the exact V4A - # patch content before execution so patch-mode calls are permissioned - # and denied patches cannot mutate. - new_text=patch_body, - arguments=dict(arguments), + "patch", paths[0] if single else ", ".join(paths), + _read_text_if_exists(paths[0]) if single else None, patch_body, dict(arguments), ) +# (tool_name, patch mode or None) -> proposal builder. +_PROPOSAL_BUILDERS = { + ("write_file", None): _proposal_for_write_file, + ("patch", "replace"): _proposal_for_patch_replace, + ("patch", "patch"): _proposal_for_patch_v4a, +} + + def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None: """Return an edit proposal for supported file mutation calls.""" - - if tool_name == "write_file": - return _proposal_for_write_file(arguments) - if tool_name == "patch": - mode = arguments.get("mode", "replace") - if mode == "replace": - return _proposal_for_patch_replace(arguments) - if mode == "patch": - return _proposal_for_patch_v4a(arguments) - return None + mode = arguments.get("mode", "replace") if tool_name == "patch" else None + builder = _PROPOSAL_BUILDERS.get((tool_name, mode)) + return builder(arguments) if builder else None def _is_sensitive_auto_approve_path(path: str) -> bool: - parts = Path(path).expanduser().parts - lowered = {part.lower() for part in parts} - if ".git" in lowered or ".ssh" in lowered: + lowered = {part.lower() for part in Path(path).expanduser().parts} + return bool(lowered & {".git", ".ssh"}) or Path(path).name.lower() in SENSITIVE_AUTO_APPROVE_NAMES + + +def _is_under(path: Path, root: Path) -> bool: + try: + path.relative_to(root) return True - return Path(path).name.lower() in SENSITIVE_AUTO_APPROVE_NAMES + except ValueError: + return False def should_auto_approve_edit(proposal: EditProposal, policy: str, cwd: str | None = None) -> bool: """Return whether an ACP edit proposal may bypass the prompt for this session. - This is intentionally session-scoped and conservative: sensitive paths still - ask even under autonomous policies. + Session-scoped and conservative: sensitive paths still ask under autonomous policies. """ - policy = str(policy or AUTO_APPROVE_ASK).strip() if policy == AUTO_APPROVE_ASK or _is_sensitive_auto_approve_path(proposal.path): return False @@ -211,84 +163,60 @@ def should_auto_approve_edit(proposal: EditProposal, policy: str, cwd: str | Non if policy == AUTO_APPROVE_SESSION: return True if policy == AUTO_APPROVE_WORKSPACE: - # `/tmp` is the POSIX path but tempfile.gettempdir() is the real one on - # every platform: `/private/tmp` on macOS (because `/tmp` is a symlink - # and Path.resolve() follows it) and the per-user Temp dir on Windows. - tmp_root = Path(tempfile.gettempdir()).resolve(strict=False) - try: - path.relative_to(tmp_root) + # tempfile.gettempdir() is the real temp root on every platform + # (``/private/tmp`` on macOS since resolve() follows the symlink). + if _is_under(path, Path(tempfile.gettempdir()).resolve(strict=False)): return True - except ValueError: - pass if cwd: - root = Path(cwd).expanduser().resolve(strict=False) - try: - path.relative_to(root) - return True - except ValueError: - return False + return _is_under(path, Path(cwd).expanduser().resolve(strict=False)) return False +def _denied(message: str) -> str: + return json.dumps({"error": message}, ensure_ascii=False) + + def maybe_require_edit_approval(tool_name: str, arguments: dict[str, Any]) -> str | None: """Run ACP edit approval if bound. Returns a JSON tool-error string when the edit must be blocked, otherwise ``None`` so dispatch can continue. Requester exceptions deny by default. """ - - requester = get_edit_approval_requester() + requester = _EDIT_APPROVAL_REQUESTER.get() if requester is None: return None - try: proposal = build_edit_proposal(tool_name, arguments) except Exception as exc: logger.warning("Could not build ACP edit approval proposal for %s: %s", tool_name, exc) - return json.dumps({"error": f"Edit approval denied: could not prepare diff ({exc})"}, ensure_ascii=False) - + return _denied(f"Edit approval denied: could not prepare diff ({exc})") if proposal is None: return None - try: approved = bool(requester(proposal)) except Exception as exc: logger.warning("ACP edit approval requester failed: %s", exc) approved = False - - if approved: - return None - return json.dumps({"error": "Edit approval denied by ACP client; file was not modified."}, ensure_ascii=False) + return None if approved else _denied("Edit approval denied by ACP client; file was not modified.") def build_acp_edit_tool_call(proposal: EditProposal): """Build the ToolCallUpdate payload for ACP request_permission.""" - import acp - tool_call_id = f"edit-approval-{next(_PERMISSION_REQUEST_IDS)}" return acp.update_tool_call( - tool_call_id, + f"edit-approval-{next(_PERMISSION_REQUEST_IDS)}", title=f"Approve edit: {proposal.path}", kind="edit", status="pending", - content=[ - acp.tool_diff_content( - path=proposal.path, - old_text=proposal.old_text, - new_text=proposal.new_text, - ) - ], + content=[acp.tool_diff_content(path=proposal.path, old_text=proposal.old_text, new_text=proposal.new_text)], raw_input={"tool": proposal.tool_name, "arguments": proposal.arguments}, ) def make_acp_edit_approval_requester( - request_permission_fn: Callable, - loop: asyncio.AbstractEventLoop, - session_id: str, - timeout: float = 60.0, - auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None, + request_permission_fn: Callable, loop: asyncio.AbstractEventLoop, session_id: str, + timeout: float = 60.0, auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None, ) -> EditApprovalRequester: """Return a sync requester that bridges edit proposals to ACP permissions.""" @@ -305,21 +233,14 @@ def make_acp_edit_approval_requester( except Exception: logger.debug("ACP edit auto-approval policy check failed", exc_info=True) - options = [ - PermissionOption(option_id="allow_once", kind="allow_once", name="Allow edit"), - PermissionOption(option_id="deny", kind="reject_once", name="Deny"), - ] - tool_call = build_acp_edit_tool_call(proposal) coro = request_permission_fn( session_id=session_id, - tool_call=tool_call, - options=options, + tool_call=build_acp_edit_tool_call(proposal), + options=[PermissionOption(option_id="allow_once", kind="allow_once", name="Allow edit"), + PermissionOption(option_id="deny", kind="reject_once", name="Deny")], ) future = safe_schedule_threadsafe( - coro, - loop, - logger=logger, - log_message="Edit approval request: failed to schedule on loop", + coro, loop, logger=logger, log_message="Edit approval request: failed to schedule on loop", ) if future is None: return False @@ -330,9 +251,6 @@ def make_acp_edit_approval_requester( logger.warning("Edit approval request timed out or failed: %s", exc) return False outcome = getattr(response, "outcome", None) - return ( - getattr(outcome, "outcome", None) == "selected" - and getattr(outcome, "option_id", None) == "allow_once" - ) + return getattr(outcome, "outcome", None) == "selected" and getattr(outcome, "option_id", None) == "allow_once" return _requester diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 40ad336abc..784575154a 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -1,16 +1,11 @@ """CLI entry point for the hermes-agent ACP adapter. -Loads environment variables from ``~/.hermes/.env``, configures logging -to write to stderr (so stdout is reserved for ACP JSON-RPC transport), -and starts the ACP agent server. +Loads ``~/.hermes/.env``, routes logging to stderr (stdout is reserved for ACP +JSON-RPC), and starts the ACP agent server. Usage:: - python -m acp_adapter.entry - # or - hermes acp - # or - hermes-acp + python -m acp_adapter.entry # or: hermes acp / hermes-acp """ # IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio @@ -18,15 +13,11 @@ Usage:: try: import hermes_bootstrap # noqa: F401 except ModuleNotFoundError: - # Graceful fallback when hermes_bootstrap isn't registered in the venv - # yet — happens during partial ``hermes update`` where git-reset landed - # new code but ``uv pip install -e .`` didn't finish. Missing bootstrap - # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. + # Partial ``hermes update`` (git-reset landed, ``uv pip install -e .`` did not): + # UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. pass else: - # Stop a ``utils/``/``proxy/``/``ui/`` package in the launch directory from - # shadowing Hermes's own modules — ``hermes acp`` can be started from any - # cwd, including a project that has same-named packages on its path. + # Stop a ``utils/``/``proxy/``/``ui/`` package in the launch cwd from shadowing Hermes modules. hermes_bootstrap.harden_import_path() import argparse @@ -38,40 +29,27 @@ from pathlib import Path from hermes_constants import get_hermes_home -# Methods clients send as periodic liveness probes. They are not part of the -# ACP schema, so the acp router correctly returns JSON-RPC -32601 to the -# caller — but the supervisor task that dispatches the request then surfaces -# the raised RequestError via ``logging.exception("Background task failed")``, -# which dumps a traceback to stderr every probe interval. Clients like -# acp-bridge already treat the -32601 response as "agent alive", so the -# traceback is pure noise. We keep the protocol response intact and only -# silence the stderr noise for this specific benign case. +# Liveness-probe methods outside the ACP schema. The router correctly answers JSON-RPC -32601 +# (clients treat that as "agent alive"), but the dispatching supervisor task also logs +# ``"Background task failed"`` with a traceback every probe. Keep the response; silence the noise. _BENIGN_PROBE_METHODS = frozenset({"ping", "health", "healthcheck"}) class _BenignProbeMethodFilter(logging.Filter): - """Suppress acp 'Background task failed' tracebacks caused by unknown - liveness-probe methods (e.g. ``ping``) while leaving every other - background-task error — including method_not_found for any non-probe - method — visible in stderr. - """ + """Suppress acp 'Background task failed' tracebacks caused by unknown liveness-probe methods + (e.g. ``ping``); every other background-task error, incl. method_not_found for non-probe + methods, stays visible.""" def filter(self, record: logging.LogRecord) -> bool: - if record.getMessage() != "Background task failed": + if record.getMessage() != "Background task failed" or not record.exc_info: return True - exc_info = record.exc_info - if not exc_info: - return True - exc = exc_info[1] - # Imported lazily so this module stays importable when the optional - # ``agent-client-protocol`` dependency is not installed. + # Lazy import keeps this module importable without ``agent-client-protocol``. try: from acp.exceptions import RequestError except ImportError: return True - if not isinstance(exc, RequestError): - return True - if getattr(exc, "code", None) != -32601: + exc = record.exc_info[1] + if not isinstance(exc, RequestError) or getattr(exc, "code", None) != -32601: return True data = getattr(exc, "data", None) method = data.get("method") if isinstance(data, dict) else None @@ -83,22 +61,16 @@ def _setup_logging() -> None: from agent.redact import RedactingFormatter handler = logging.StreamHandler(sys.stderr) - handler.setFormatter( - RedactingFormatter( - "%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - ) + handler.setFormatter(RedactingFormatter( + "%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", + )) handler.addFilter(_BenignProbeMethodFilter()) root = logging.getLogger() root.handlers.clear() root.addHandler(handler) root.setLevel(logging.INFO) - - # Quiet down noisy libraries - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) - logging.getLogger("openai").setLevel(logging.WARNING) + for noisy in ("httpx", "httpcore", "openai"): + logging.getLogger(noisy).setLevel(logging.WARNING) def _load_env() -> None: @@ -107,45 +79,27 @@ def _load_env() -> None: hermes_home = get_hermes_home() loaded = load_hermes_dotenv(hermes_home=hermes_home) + log = logging.getLogger(__name__) if loaded: for env_file in loaded: - logging.getLogger(__name__).info("Loaded env from %s", env_file) + log.info("Loaded env from %s", env_file) else: - logging.getLogger(__name__).info( - "No .env found at %s, using system env", hermes_home / ".env" - ) + log.info("No .env found at %s, using system env", hermes_home / ".env") def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog="hermes-acp", - description="Run Hermes Agent as an ACP stdio server.", - ) + parser = argparse.ArgumentParser(prog="hermes-acp", description="Run Hermes Agent as an ACP stdio server.") parser.add_argument("--version", action="store_true", help="Print Hermes version and exit") - parser.add_argument( - "--check", - action="store_true", - help="Verify ACP dependencies and adapter imports, then exit", - ) - parser.add_argument( - "--setup", - action="store_true", - help="Run interactive Hermes provider/model setup for ACP terminal auth", - ) - parser.add_argument( - "--setup-browser", - action="store_true", - help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ " - "for browser tool support. Idempotent.", - ) - parser.add_argument( - "--yes", - "-y", - action="store_true", - dest="assume_yes", - help="Accept all prompts (currently used by --setup-browser to skip the " - "~400 MB Chromium download confirmation).", - ) + parser.add_argument("--check", action="store_true", + help="Verify ACP dependencies and adapter imports, then exit") + parser.add_argument("--setup", action="store_true", + help="Run interactive Hermes provider/model setup for ACP terminal auth") + parser.add_argument("--setup-browser", action="store_true", + help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ " + "for browser tool support. Idempotent.") + parser.add_argument("--yes", "-y", action="store_true", dest="assume_yes", + help="Accept all prompts (currently used by --setup-browser to skip the " + "~400 MB Chromium download confirmation).") return parser.parse_args(argv) @@ -172,45 +126,35 @@ def _run_setup() -> None: finally: sys.argv = old_argv - # Offer browser-tools install as a follow-up. The terminal auth method - # is the one supported first-run UX for registry installs, so this is - # the natural moment to ask. Skip silently if stdin isn't a TTY (the - # answer can't be collected anyway). + # Terminal auth is the first-run UX for registry installs, so offer the browser-tools + # install here. Skip silently without a TTY. if not sys.stdin.isatty(): return try: - reply = input( - "\nInstall browser tools? Downloads agent-browser (npm) and " - "optionally Playwright Chromium (~400 MB). [y/N] " - ).strip().lower() + reply = input("\nInstall browser tools? Downloads agent-browser (npm) and " + "optionally Playwright Chromium (~400 MB). [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): return if reply in {"y", "yes"}: _run_setup_browser(assume_yes=False) +_SETUP_BROWSER_STEPS = ( + ("node", "Node.js installation failed — cannot proceed with browser tools."), + ("browser", "Browser tools installation failed."), +) + + def _run_setup_browser(assume_yes: bool = False) -> int: - """Bootstrap agent-browser + Chromium. - - Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code - with the runtime lazy installer. - - Returns 0 on success, 1 on failure. - """ + """Bootstrap agent-browser + Chromium via dep_ensure -> install.{sh,ps1} + --ensure (shared with the runtime lazy installer). Returns 0 on success, 1 on failure.""" from hermes_cli.dep_ensure import ensure_dependency try: - node_ok = ensure_dependency("node", interactive=not assume_yes) - if not node_ok: - print("Node.js installation failed — cannot proceed with browser tools.", - file=sys.stderr) - return 1 - - browser_ok = ensure_dependency("browser", interactive=not assume_yes) - if not browser_ok: - print("Browser tools installation failed.", file=sys.stderr) - return 1 - + for dep, failure_msg in _SETUP_BROWSER_STEPS: + if not ensure_dependency(dep, interactive=not assume_yes): + print(failure_msg, file=sys.stderr) + return 1 return 0 except OSError as exc: print(f"Browser bootstrap failed: {exc}", file=sys.stderr) @@ -221,14 +165,11 @@ def main(argv: list[str] | None = None) -> None: """Entry point: load env, configure logging, run the ACP agent.""" args = _parse_args(argv) if args.version: - _print_version() - return + return _print_version() if args.check: - _run_check() - return + return _run_check() if args.setup: - _run_setup() - return + return _run_setup() if args.setup_browser: rc = _run_setup_browser(assume_yes=args.assume_yes) if rc != 0: @@ -249,22 +190,14 @@ def main(argv: list[str] | None = None) -> None: import acp from .server import HermesACPAgent - # MCP tool discovery from config.yaml — fire-and-forget in a - # background daemon thread so the ACP server becomes responsive - # immediately while MCP servers connect. Previously this blocked - # asyncio.run() for 2-5 s. (ACP also registers per-session MCP - # servers dynamically via asyncio.to_thread inside the event loop; - # that path is unaffected.) Moved from model_tools.py module scope - # to avoid freezing the gateway's loop on lazy import (#16856). - # Metadata-only hosts can opt out of unrelated global MCP startup. + # MCP discovery from config.yaml runs in a background daemon thread so the ACP server is + # responsive immediately (blocking here cost 2-5 s); per-session MCP servers registered via + # asyncio.to_thread are unaffected. Metadata-only hosts can opt out of the global startup. if os.environ.get("HERMES_ACP_SKIP_CONFIGURED_MCP", "").strip() != "1": try: from hermes_cli.mcp_startup import start_background_mcp_discovery - start_background_mcp_discovery( - logger=logger, - thread_name="acp-mcp-discovery", - ) + start_background_mcp_discovery(logger=logger, thread_name="acp-mcp-discovery") except Exception: logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) diff --git a/acp_adapter/events.py b/acp_adapter/events.py index ab82c0e7e3..e9a89fc4e1 100644 --- a/acp_adapter/events.py +++ b/acp_adapter/events.py @@ -1,10 +1,9 @@ """Callback factories for bridging AIAgent events to ACP notifications. -Each factory returns a callable with the signature that AIAgent expects -for its callbacks. Internally, the callbacks push ACP session updates -to the client via ``conn.session_update()`` using -``asyncio.run_coroutine_threadsafe()`` (since AIAgent runs in a worker -thread while the event loop lives on the main thread). +Each factory returns a callable with the signature AIAgent expects for its +callbacks. AIAgent runs in a worker thread while the event loop lives on the +main thread, so updates are pushed via ``conn.session_update()`` scheduled +thread-safely onto the loop. """ import asyncio @@ -16,88 +15,47 @@ from typing import Any, Callable, Deque, Dict import acp from acp.schema import AgentPlanUpdate, PlanEntry -from .tools import ( - build_tool_complete, - build_tool_start, - make_tool_call_id, -) +from .tools import _json_loads_maybe, build_tool_complete, build_tool_start, make_tool_call_id logger = logging.getLogger(__name__) - -def _json_loads_maybe_prefix(value: str) -> Any: - """Parse a JSON object even when Hermes appended a human hint after it.""" - text = value.strip() - try: - return json.loads(text) - except Exception: - decoder = json.JSONDecoder() - data, _ = decoder.raw_decode(text) - return data +# ACP plans only support pending/in_progress/completed. Cancelled tasks are kept +# as terminal entries so the client's full-list replacement doesn't drop them. +_PLAN_STATUS = {"pending": "pending", "in_progress": "in_progress", "completed": "completed", "cancelled": "completed"} def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None: """Translate Hermes' todo tool result into ACP's native plan update. - Zed renders ``sessionUpdate: plan`` as its first-class task/todo panel. The - Hermes agent already maintains task state through the ``todo`` tool, so the - ACP adapter should expose that state natively instead of only as a generic - tool-call transcript block. + Zed renders ``sessionUpdate: plan`` as its first-class task panel, so the + todo state is exposed natively rather than only as a tool-call transcript. """ if not isinstance(result, str) or not result.strip(): return None - - try: - data = _json_loads_maybe_prefix(result) - except Exception: - return None - + data = _json_loads_maybe(result) if not isinstance(data, dict) or not isinstance(data.get("todos"), list): return None - todos = data["todos"] - if not todos: - return AgentPlanUpdate(session_update="plan", entries=[]) - - status_map = { - "pending": "pending", - "in_progress": "in_progress", - "completed": "completed", - # ACP plans only support pending/in_progress/completed. Preserve - # cancelled tasks as terminal entries instead of dropping them and - # making the client's full-list replacement lose visible context. - "cancelled": "completed", - } entries: list[PlanEntry] = [] - for item in todos: + for item in data["todos"]: if not isinstance(item, dict): continue content = str(item.get("content") or item.get("id") or "").strip() if not content: continue raw_status = str(item.get("status") or "pending").strip() - status = status_map.get(raw_status, "pending") if raw_status == "cancelled": content = f"[cancelled] {content}" - entries.append(PlanEntry(content=content, priority="medium", status=status)) - + entries.append(PlanEntry(content=content, priority="medium", status=_PLAN_STATUS.get(raw_status, "pending"))) return AgentPlanUpdate(session_update="plan", entries=entries) -def _send_update( - conn: acp.Client, - session_id: str, - loop: asyncio.AbstractEventLoop, - update: Any, -) -> None: +def _send_update(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, update: Any) -> None: """Fire-and-forget an ACP session update from a worker thread.""" from agent.async_utils import safe_schedule_threadsafe future = safe_schedule_threadsafe( - conn.session_update(session_id, update), - loop, - logger=logger, - log_message="Failed to send ACP update", + conn.session_update(session_id, update), loop, logger=logger, log_message="Failed to send ACP update", ) if future is None: return @@ -107,9 +65,14 @@ def _send_update( logger.debug("Failed to send ACP update", exc_info=True) -# ------------------------------------------------------------------ -# Tool progress callback -# ------------------------------------------------------------------ +def _upgrade_queue(tool_call_ids: Dict[str, Deque[str]], key: Any, store_key: Any) -> Deque[str] | None: + """Fetch the per-tool FIFO of pending call IDs, upgrading a legacy bare-string entry in place.""" + queue = tool_call_ids.get(key) + if isinstance(queue, str): + queue = deque([queue]) + tool_call_ids[store_key] = queue + return queue + def make_tool_progress_cb( conn: acp.Client, @@ -121,18 +84,13 @@ def make_tool_progress_cb( ) -> Callable: """Create a ``tool_progress_callback`` for AIAgent. - Signature expected by AIAgent:: - - tool_progress_callback(event_type: str, name: str, preview: str, args: dict, **kwargs) - - Emits ``ToolCallStart`` for ``tool.started`` events and tracks IDs in a FIFO - queue per tool name so duplicate/parallel same-name calls still complete - against the correct ACP tool call. Other event types (``tool.completed``, - ``reasoning.available``) are silently ignored. + Signature: ``tool_progress_callback(event_type, name, preview, args, **kwargs)``. + Emits ``ToolCallStart`` for ``tool.started`` and tracks IDs in a FIFO per tool + name so parallel same-name calls complete against the right ACP tool call. + Other event types (``tool.completed``, ``reasoning.available``) are ignored. """ def _tool_progress(event_type: str, name: str = None, preview: str = None, args: Any = None, **kwargs) -> None: - # Only emit ACP ToolCallStart for tool.started; ignore other event types if event_type != "tool.started": return if isinstance(args, str): @@ -144,13 +102,9 @@ def make_tool_progress_cb( args = {} tc_id = make_tool_call_id() - queue = tool_call_ids.get(name) + queue = _upgrade_queue(tool_call_ids, name, name) if queue is None: - queue = deque() - tool_call_ids[name] = queue - elif isinstance(queue, str): - queue = deque([queue]) - tool_call_ids[name] = queue + queue = tool_call_ids[name] = deque() queue.append(tc_id) snapshot = None @@ -176,35 +130,28 @@ def make_tool_progress_cb( except Exception: logger.debug("Failed to prepare auto-approved ACP edit diff for %s", name, exc_info=True) - update = build_tool_start(tc_id, name, args, edit_diff=edit_diff) - _send_update(conn, session_id, loop, update) + _send_update(conn, session_id, loop, build_tool_start(tc_id, name, args, edit_diff=edit_diff)) return _tool_progress -# ------------------------------------------------------------------ -# Thinking callback -# ------------------------------------------------------------------ +def _make_text_cb(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, wrap: Callable[[str], Any]) -> Callable: + def _cb(text: str) -> None: + if text: + _send_update(conn, session_id, loop, wrap(text)) -def make_thinking_cb( - conn: acp.Client, - session_id: str, - loop: asyncio.AbstractEventLoop, -) -> Callable: + return _cb + + +def make_thinking_cb(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop) -> Callable: """Create a ``thinking_callback`` for AIAgent.""" - - def _thinking(text: str) -> None: - if not text: - return - update = acp.update_agent_thought_text(text) - _send_update(conn, session_id, loop, update) - - return _thinking + return _make_text_cb(conn, session_id, loop, acp.update_agent_thought_text) -# ------------------------------------------------------------------ -# Step callback -# ------------------------------------------------------------------ +def make_message_cb(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop) -> Callable: + """Create a callback that streams agent response text to the editor.""" + return _make_text_cb(conn, session_id, loop, acp.update_agent_message_text) + def make_step_cb( conn: acp.Client, @@ -213,67 +160,34 @@ def make_step_cb( tool_call_ids: Dict[str, Deque[str]], tool_call_meta: Dict[str, Dict[str, Any]], ) -> Callable: - """Create a ``step_callback`` for AIAgent. - - Signature expected by AIAgent:: - - step_callback(api_call_count: int, prev_tools: list) - """ + """Create a ``step_callback(api_call_count: int, prev_tools: list)`` for AIAgent.""" def _step(api_call_count: int, prev_tools: Any = None) -> None: - if prev_tools and isinstance(prev_tools, list): - for tool_info in prev_tools: - tool_name = None - result = None - function_args = None + if not prev_tools or not isinstance(prev_tools, list): + return + for tool_info in prev_tools: + tool_name = result = function_args = None + if isinstance(tool_info, dict): + tool_name = tool_info.get("name") or tool_info.get("function_name") + result = tool_info.get("result") or tool_info.get("output") + function_args = tool_info.get("arguments") or tool_info.get("args") + elif isinstance(tool_info, str): + tool_name = tool_info - if isinstance(tool_info, dict): - tool_name = tool_info.get("name") or tool_info.get("function_name") - result = tool_info.get("result") or tool_info.get("output") - function_args = tool_info.get("arguments") or tool_info.get("args") - elif isinstance(tool_info, str): - tool_name = tool_info - - queue = tool_call_ids.get(tool_name or "") - if isinstance(queue, str): - queue = deque([queue]) - tool_call_ids[tool_name] = queue - if tool_name and queue: - tc_id = queue.popleft() - meta = tool_call_meta.pop(tc_id, {}) - update = build_tool_complete( - tc_id, - tool_name, - result=str(result) if result is not None else None, - function_args=function_args or meta.get("args"), - snapshot=meta.get("snapshot"), - ) - _send_update(conn, session_id, loop, update) - if tool_name == "todo": - plan_update = _build_plan_update_from_todo_result(result) - if plan_update is not None: - _send_update(conn, session_id, loop, plan_update) - if not queue: - tool_call_ids.pop(tool_name, None) + queue = _upgrade_queue(tool_call_ids, tool_name or "", tool_name) + if not tool_name or not queue: + continue + tc_id = queue.popleft() + meta = tool_call_meta.pop(tc_id, {}) + _send_update(conn, session_id, loop, build_tool_complete( + tc_id, tool_name, result=str(result) if result is not None else None, + function_args=function_args or meta.get("args"), snapshot=meta.get("snapshot"), + )) + if tool_name == "todo": + plan_update = _build_plan_update_from_todo_result(result) + if plan_update is not None: + _send_update(conn, session_id, loop, plan_update) + if not queue: + tool_call_ids.pop(tool_name, None) return _step - - -# ------------------------------------------------------------------ -# Agent message callback -# ------------------------------------------------------------------ - -def make_message_cb( - conn: acp.Client, - session_id: str, - loop: asyncio.AbstractEventLoop, -) -> Callable: - """Create a callback that streams agent response text to the editor.""" - - def _message(text: str) -> None: - if not text: - return - update = acp.update_agent_message_text(text) - _send_update(conn, session_id, loop, update) - - return _message diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index 72ff7dbfac..1cb5418703 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -8,16 +8,12 @@ from concurrent.futures import TimeoutError as FutureTimeout from itertools import count from typing import Callable -from acp.schema import ( - AllowedOutcome, - PermissionOption, -) +from acp.schema import AllowedOutcome, PermissionOption logger = logging.getLogger(__name__) -# Maps ACP permission option ids to Hermes approval result strings. -# Option ids are stable across both the ``allow_permanent=True`` and -# ``allow_permanent=False`` paths even though the option list differs. +# ACP permission option id -> Hermes approval result. Ids are stable across the +# ``allow_permanent=True`` and ``False`` paths even though the option list differs. _OPTION_ID_TO_HERMES = { "allow_once": "once", "allow_session": "session", @@ -39,61 +35,34 @@ def _permission_option_supports_kind(kind: str) -> bool: def _build_permission_options( - *, allow_permanent: bool, allow_session: bool = True, - smart_denied: bool = False, + *, allow_permanent: bool, allow_session: bool = True, smart_denied: bool = False, ) -> list[PermissionOption]: """Return ACP options that match Hermes approval semantics.""" # A gate that re-asks every time (allow_session=False, e.g. protected - # agent-instruction writes) collapses to the same two options as a - # Smart DENY override — the editor must not offer a scope Hermes - # discards, or every subsequent write re-prompts (#81887). + # agent-instruction writes) collapses to the same two options as a Smart + # DENY override — offering a scope Hermes discards would re-prompt every write. once_only = smart_denied or not allow_session - options = [PermissionOption( - option_id="allow_once", kind="allow_once", name="Allow once", - )] + options = [PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once")] if not once_only: - options.append(PermissionOption( - option_id="allow_session", - # ACP has no session-scoped kind, so use the closest persistent - # hint while keeping Hermes semantics in the option id. - kind="allow_always", - name="Allow for session", - )) - if allow_permanent and not once_only: - options.append( - PermissionOption( - option_id="allow_always", - kind="allow_always", - name="Allow always", - ), - ) + # ACP has no session-scoped kind: closest persistent hint, Hermes semantics in the id. + options.append(PermissionOption(option_id="allow_session", kind="allow_always", name="Allow for session")) + if allow_permanent: + options.append(PermissionOption(option_id="allow_always", kind="allow_always", name="Allow always")) options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny")) if not once_only and _permission_option_supports_kind("reject_always"): - options.append( - PermissionOption( - option_id="deny_always", - kind="reject_always", - name="Deny always", - ), - ) + options.append(PermissionOption(option_id="deny_always", kind="reject_always", name="Deny always")) return options def _build_permission_tool_call(command: str, description: str): - """Return the ACP tool-call update attached to a permission request. - - ``request_permission`` expects a ``ToolCallUpdate`` payload — produced - by ``_acp.update_tool_call`` — not a ``ToolCallStart``. Each request - gets a unique ``perm-check-N`` id so concurrent requests don't collide. - """ + """Return the ``ToolCallUpdate`` (not ``ToolCallStart``) payload attached to a + permission request; unique ``perm-check-N`` ids keep concurrent requests apart.""" import acp as _acp - tool_call_id = f"perm-check-{next(_PERMISSION_REQUEST_IDS)}" - title = f"{description}: {command}" if description else command content_text = f"{description}\n$ {command}" if description else f"$ {command}" return _acp.update_tool_call( - tool_call_id, - title=title, + f"perm-check-{next(_PERMISSION_REQUEST_IDS)}", + title=f"{description}: {command}" if description else command, kind="execute", status="pending", content=[_acp.tool_content(_acp.text_block(content_text))], @@ -105,86 +74,51 @@ def _map_outcome_to_hermes(outcome: object, *, allowed_option_ids: set[str]) -> """Map an ACP permission outcome into Hermes approval strings.""" if not isinstance(outcome, AllowedOutcome): return "deny" - - option_id = outcome.option_id - if option_id not in allowed_option_ids: - logger.warning("Permission request returned unknown option_id: %s", option_id) + if outcome.option_id not in allowed_option_ids: + logger.warning("Permission request returned unknown option_id: %s", outcome.option_id) return "deny" - return _OPTION_ID_TO_HERMES.get(option_id, "deny") + return _OPTION_ID_TO_HERMES.get(outcome.option_id, "deny") -def make_approval_callback( - request_permission_fn: Callable, - loop: asyncio.AbstractEventLoop, - session_id: str, - timeout: float = 60.0, -) -> Callable[..., str]: - """ - Return a Hermes-compatible approval callback that bridges to ACP. +def make_approval_callback(request_permission_fn: Callable, loop: asyncio.AbstractEventLoop, + session_id: str, timeout: float = 60.0) -> Callable[..., str]: + """Return a Hermes approval callback (``command, description, **kw`` as used by + ``tools.approval.prompt_dangerous_approval()``) that bridges to the ACP + connection's ``request_permission`` coroutine on ``loop``; auto-denies after ``timeout`` s.""" - The callback accepts ``command`` and ``description`` plus optional - keyword arguments such as ``allow_permanent`` used by - ``tools.approval.prompt_dangerous_approval()``. - - Args: - request_permission_fn: The ACP connection's ``request_permission`` coroutine. - loop: The event loop on which the ACP connection lives. - session_id: Current ACP session id. - timeout: Seconds to wait for a response before auto-denying. - """ - - def _callback( - command: str, - description: str, - *, - allow_permanent: bool = True, - allow_session: bool = True, - smart_denied: bool = False, - **_: object, - ) -> str: + def _callback(command: str, description: str, *, allow_permanent: bool = True, + allow_session: bool = True, smart_denied: bool = False, **_: object) -> str: from agent.async_utils import safe_schedule_threadsafe options = _build_permission_options( - allow_permanent=allow_permanent, - allow_session=allow_session, - smart_denied=smart_denied, + allow_permanent=allow_permanent, allow_session=allow_session, smart_denied=smart_denied, ) - - tool_call = _build_permission_tool_call(command, description) coro = request_permission_fn( session_id=session_id, - tool_call=tool_call, + tool_call=_build_permission_tool_call(command, description), options=options, ) future = safe_schedule_threadsafe( - coro, loop, - logger=logger, - log_message="Permission request: failed to schedule on loop", + coro, loop, logger=logger, log_message="Permission request: failed to schedule on loop", ) if future is None: return "deny" - try: response = future.result(timeout=timeout) except FutureTimeout: future.cancel() logger.warning("Permission request timed out after %ss", timeout) - # Distinct from an explicit deny: the client never answered. - # tools.approval callers report this as "timed out without user - # response" instead of a user denial. + # Distinct from an explicit deny: tools.approval reports "timed out + # without user response" instead of a user denial. return "timeout" except Exception as exc: future.cancel() logger.warning("Permission request failed: %s", exc) return "deny" - if response is None: return "deny" - - allowed_option_ids = {option.option_id for option in options} return _map_outcome_to_hermes( - response.outcome, - allowed_option_ids=allowed_option_ids, + response.outcome, allowed_option_ids={option.option_id for option in options}, ) return _callback diff --git a/acp_adapter/provenance.py b/acp_adapter/provenance.py index 58b05daf5a..b2f9e13597 100644 --- a/acp_adapter/provenance.py +++ b/acp_adapter/provenance.py @@ -1,14 +1,13 @@ """Derive ACP session-provenance metadata from the existing compression chain. -This is an additive Hermes extension surfaced under ACP ``_meta.hermes`` so -existing ACP clients ignore it. It carries no new persisted state: everything -is derived on demand from the ``sessions`` table (``parent_session_id`` / -``end_reason``), which already models compression-continuation chains. +Additive Hermes extension under ACP ``_meta.hermes`` (unknown to other clients, +so ignored). No new persisted state: everything is derived from the ``sessions`` +table (``parent_session_id`` / ``end_reason``), which already models +compression-continuation chains. -The ACP/editor ``session_id`` stays the stable public handle. When context -compression rotates the internal Hermes head, ``build_session_provenance`` lets -a client see the previous/current internal ids and the lineage root without -parsing status text, guessing from token drops, or reading ``state.db``. +The ACP/editor ``session_id`` stays the stable public handle; when compression +rotates the internal Hermes head, ``build_session_provenance`` exposes the +previous/current internal ids and lineage root without parsing status text. """ from __future__ import annotations @@ -19,6 +18,17 @@ from typing import Any, Dict, Optional _MAX_WALK = 100 +def _get_row(db: Any, session_id: str) -> Optional[Dict[str, Any]]: + try: + return db.get_session(session_id) + except Exception: + return None + + +def _is_compression_end(row: Any) -> bool: + return bool(row) and row.get("end_reason") == "compression" + + def build_session_provenance( db: Any, acp_session_id: str, @@ -28,32 +38,19 @@ def build_session_provenance( ) -> Optional[Dict[str, Any]]: """Build ``_meta.hermes.sessionProvenance`` for an ACP session. - Args: - db: A ``SessionDB`` (must expose ``get_session``). - acp_session_id: The stable ACP/editor-facing session handle. - current_hermes_session_id: The live internal Hermes DB session id - (``state.agent.session_id``). - previous_hermes_session_id: The internal id from before the most recent - turn, when known. Supplied by ``prompt()`` to flag a rotation. - - Returns: - A dict suitable for ``{"hermes": {"sessionProvenance": }}`` under - ACP ``_meta``, or ``None`` if the session can't be read. + ``db`` must expose ``get_session``. ``current_hermes_session_id`` is the live + internal id (``state.agent.session_id``); ``previous_hermes_session_id`` is + the id before the most recent turn, supplied by ``prompt()`` to flag a + rotation. Returns ``None`` if the session can't be read. """ - try: - row = db.get_session(current_hermes_session_id) - except Exception: - return None + row = _get_row(db, current_hermes_session_id) if not row: return None - parent_id = row.get("parent_session_id") - end_reason = row.get("end_reason") - # Walk parents to the lineage root and count compression depth. Only - # compression-split parents (parent.end_reason == 'compression') count - # toward depth — delegate/branch children share the parent_session_id - # column but are not compaction boundaries. + # Walk parents to the lineage root. Only compression-split parents + # (parent.end_reason == 'compression') count toward depth — delegate/branch + # children share the parent_session_id column but are not compaction boundaries. root_id = current_hermes_session_id compression_depth = 0 cursor_parent = parent_id @@ -62,32 +59,15 @@ def build_session_provenance( if not cursor_parent or cursor_parent in seen: break seen.add(cursor_parent) - try: - prow = db.get_session(cursor_parent) - except Exception: - prow = None + prow = _get_row(db, cursor_parent) if not prow: break root_id = cursor_parent - if prow.get("end_reason") == "compression": - compression_depth += 1 + compression_depth += _is_compression_end(prow) cursor_parent = prow.get("parent_session_id") - # A session is a compression continuation when its parent was ended with - # end_reason='compression'. Determine that from the immediate parent. - is_continuation = False - if parent_id: - try: - immediate_parent = db.get_session(parent_id) - except Exception: - immediate_parent = None - if immediate_parent and immediate_parent.get("end_reason") == "compression": - is_continuation = True - - rotated = bool( - previous_hermes_session_id - and previous_hermes_session_id != current_hermes_session_id - ) + # A continuation is a session whose immediate parent ended with end_reason='compression'. + is_continuation = bool(parent_id) and _is_compression_end(_get_row(db, parent_id)) provenance: Dict[str, Any] = { "acpSessionId": acp_session_id, @@ -99,12 +79,11 @@ def build_session_provenance( } if previous_hermes_session_id: provenance["previousHermesSessionId"] = previous_hermes_session_id - if rotated: - # The head moved during the last turn. The only mechanism that rotates - # the internal id mid-turn is compression-driven session splitting. - provenance["reason"] = "compression" - provenance["creatorKind"] = "compression" - + if previous_hermes_session_id != current_hermes_session_id: + # The only mechanism that rotates the internal id mid-turn is + # compression-driven session splitting. + provenance["reason"] = "compression" + provenance["creatorKind"] = "compression" return provenance @@ -117,11 +96,7 @@ def session_provenance_meta( ) -> Optional[Dict[str, Any]]: """Return a ready ``_meta`` payload: ``{"hermes": {"sessionProvenance": ...}}``.""" prov = build_session_provenance( - db, - acp_session_id, - current_hermes_session_id, + db, acp_session_id, current_hermes_session_id, previous_hermes_session_id=previous_hermes_session_id, ) - if prov is None: - return None - return {"hermes": {"sessionProvenance": prov}} + return None if prov is None else {"hermes": {"sessionProvenance": prov}} diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 792e44b70d..3cf2083719 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -11,15 +11,15 @@ import logging import os from collections import defaultdict, deque from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Deque, Optional +from typing import Any, Callable, Deque, Optional from urllib.parse import unquote, urlparse import acp from acp.schema import ( AgentCapabilities, AgentMessageChunk, - AgentThoughtChunk, AuthenticateResponse, AvailableCommand, AvailableCommandsUpdate, @@ -86,27 +86,32 @@ from tools.approval import ( logger = logging.getLogger(__name__) +PromptBlock = ( + TextContentBlock + | ImageContentBlock + | AudioContentBlock + | ResourceContentBlock + | EmbeddedResourceContentBlock +) + def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, str]]]]: """Return ``(slug, label, [(model_id, description), ...])`` for named endpoints. - Covers both the v12 ``providers:`` mapping and the legacy - ``custom_providers:`` list. These endpoints never appear in canonical - provider enumeration, so without this the ACP model selector hides every - named endpoint that the TUI ``/model`` picker already renders (#47039 - implemented named-endpoint rows for the TUI surface only). + Covers both the v12 ``providers:`` mapping and the legacy ``custom_providers:`` + list. These endpoints never appear in canonical provider enumeration, so + without this the ACP model selector hides every named endpoint the TUI + ``/model`` picker renders. Model lists come from the entry's declared models (``default_model`` + ``models``), refreshed from the endpoint's live ``/models`` listing when a - credential is available and ``discover_models`` is not disabled. Declared - models are kept even when live discovery fails — some OpenAI-compatible - endpoints (e.g. Bedrock Mantle Responses) expose no ``/models`` route at - all yet serve the declared models fine. + credential is available and ``discover_models`` is not disabled. Declared + models survive a failed live discovery — some OpenAI-compatible endpoints + expose no ``/models`` route yet serve the declared models fine. Slugs use the ``custom:`` shape that ``parse_model_input`` and ``resolve_runtime_provider`` already resolve, so encoded choice ids - (``custom::``) round-trip through ``set_session_model`` - unchanged. + (``custom::``) round-trip through ``set_session_model``. """ try: from hermes_cli.config import ( @@ -134,93 +139,70 @@ def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, st return [] # ``get_compatible_custom_providers`` drops the ``enabled`` flag during - # normalization, so collect explicitly disabled provider keys from the - # raw config and skip their entries below. - disabled_keys: set[str] = set() + # normalization; collect disabled keys from the raw config instead. raw_providers = cfg.get("providers") if isinstance(cfg, dict) else None - if isinstance(raw_providers, dict): - for raw_key, raw_entry in raw_providers.items(): - if isinstance(raw_entry, dict) and not is_provider_enabled(raw_entry): - disabled_keys.add(str(raw_key).strip().lower()) + disabled_keys = { + str(key).strip().lower() + for key, raw in (raw_providers.items() if isinstance(raw_providers, dict) else ()) + if isinstance(raw, dict) and not is_provider_enabled(raw) + } - catalogs: list[tuple[str, str, list[tuple[str, str]]]] = [] - for entry in entries: - if not isinstance(entry, dict): - continue + def _entry_catalog(entry: dict) -> tuple[str, str, list[tuple[str, str]]] | None: provider_key = str(entry.get("provider_key", "") or "").strip() - if provider_key.lower() in disabled_keys: - continue name = str(entry.get("name", "") or "").strip() base_url = str(entry.get("base_url", "") or "").strip() - if not name or not base_url: - continue + if provider_key.lower() in disabled_keys or not name or not base_url: + return None slug = custom_provider_slug(name, provider_key) api_key = str(entry.get("api_key", "") or "").strip() if not api_key: - key_env = str( - entry.get("key_env") or entry.get("api_key_env") or "" - ).strip() + key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip() api_key = os.environ.get(key_env, "").strip() if key_env else "" declared: list[str] = [] - default_model = str(entry.get("model", "") or "").strip() - if default_model: - declared.append(default_model) models_cfg = entry.get("models") - for mid in _declared_model_ids(models_cfg): - if mid not in declared: + for mid in [str(entry.get("model", "") or "").strip(), *_declared_model_ids(models_cfg)]: + if mid and mid not in declared: declared.append(mid) native_headers = entry.get("extra_headers") or None - native_catalog_provider = ( - provider_key - if provider_key.lower() in {"ollama", "custom:ollama"} - else "custom" - ) + is_ollama_key = provider_key.lower() in {"ollama", "custom:ollama"} is_native_ollama = should_use_ollama_native_catalog( - native_catalog_provider, base_url, headers=native_headers - ) - explicit_catalog = _models_config_is_allowlist( - models_cfg, _entry_models_discovered(entry) + provider_key if is_ollama_key else "custom", base_url, headers=native_headers ) if not api_key and not declared and not is_native_ollama: - # No credential to discover with and nothing declared: - # not addressable from the selector. - continue + return None # nothing to discover with and nothing declared: not addressable - model_ids = list(declared) discover = entry.get("discover_models", True) if isinstance(discover, str): discover = discover.lower() not in {"false", "no", "0"} - native_catalog_provider = native_catalog_provider if is_native_ollama else "custom" + model_ids = list(declared) live = None if discover and (api_key or is_native_ollama): try: live = _fetch_picker_live_models( api_key, base_url, - native_catalog_provider, - explicit_catalog, + provider_key if is_native_ollama and is_ollama_key else "custom", + _models_config_is_allowlist(models_cfg, _entry_models_discovered(entry)), headers=native_headers, timeout=1.5, api_mode=entry.get("api_mode"), ) except Exception: live = None - if live is not None: - if isinstance(live, _NativePickerModelList): - model_ids = list(live) - else: - model_ids = declared + [m for m in live if m not in declared] + if isinstance(live, _NativePickerModelList): + model_ids = list(live) + elif live is not None: + model_ids = declared + [m for m in live if m not in declared] - if not model_ids: - if isinstance(live if "live" in locals() else None, _NativePickerModelList): - catalogs.append((slug, name, [])) - continue - catalogs.append((slug, name, [(mid, "") for mid in model_ids])) + if not model_ids and not isinstance(live, _NativePickerModelList): + return None + return slug, name, [(mid, "") for mid in model_ids] - return catalogs + catalogs = [_entry_catalog(entry) for entry in entries if isinstance(entry, dict)] + return [c for c in catalogs if c is not None] try: from hermes_cli import __version__ as HERMES_VERSION @@ -230,16 +212,13 @@ except Exception: # Thread pool for running AIAgent (synchronous) in parallel. _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent") -# Server-side page size for list_sessions. The ACP ListSessionsRequest schema -# does not expose a client-side limit, so this is a fixed cap that clients -# paginate against using `cursor` / `next_cursor`. +# ACP ListSessionsRequest has no client-side limit; clients paginate this fixed +# page via `cursor` / `next_cursor`. _LIST_SESSIONS_PAGE_SIZE = 50 -# Per-provider cap for the ACP model selector. ACP clients (Zed, Buzz) render -# the whole `availableModels` array in one dropdown, so an unbounded -# cross-provider catalog degrades the picker. Mirrors the cap the MoA picker -# already uses (`hermes_cli/moa_cmd.py`). This bounds each provider's row, not -# the total; aggregator providers stay intentionally uncapped inside the shared -# inventory, and the current model is always kept via the fallback insert below. +# Per-provider cap for the ACP model selector: clients (Zed, Buzz) render the +# whole `availableModels` array in one dropdown. Mirrors the MoA picker cap +# (`hermes_cli/moa_cmd.py`). Bounds each provider's row, not the total; the +# current model is always kept via the fallback insert in `_build_model_state`. ACP_MAX_MODELS_PER_PROVIDER = 200 _MAX_ACP_RESOURCE_BYTES = 512 * 1024 _TEXT_RESOURCE_MIME_PREFIXES = ("text/",) @@ -270,29 +249,30 @@ def _resource_display_name(uri: str, name: str | None = None, title: str | None return Path(unquote(candidate)).name or uri or "resource" +def _mime_main(mime_type: str | None) -> str: + return (mime_type or "").split(";", 1)[0].strip().lower() + + def _is_text_resource(mime_type: str | None) -> bool: - mime = (mime_type or "").split(";", 1)[0].strip().lower() + mime = _mime_main(mime_type) if not mime: return False return mime.startswith(_TEXT_RESOURCE_MIME_PREFIXES) or mime in _TEXT_RESOURCE_MIME_TYPES def _is_image_resource(mime_type: str | None) -> bool: - mime = (mime_type or "").split(";", 1)[0].strip().lower() - return mime.startswith("image/") + return _mime_main(mime_type).startswith("image/") -def _guess_image_mime_from_path(path: Path) -> str | None: - suffix = path.suffix.lower() - return { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".svg": "image/svg+xml", - }.get(suffix) +_IMAGE_SUFFIX_MIME = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", +} def _image_data_url(data: bytes, mime_type: str) -> str: @@ -303,8 +283,7 @@ def _path_from_file_uri(uri: str) -> Path | None: """Convert local file URIs/paths from ACP clients into a readable Path. Zed may send POSIX file URIs from Linux/WSL workspaces or Windows-ish paths - when launched through wsl.exe. Translate the common Windows drive form to - /mnt//... so Hermes running in WSL can read it. + when launched through wsl.exe; the Windows drive form becomes /mnt//... """ raw = (uri or "").strip() if not raw: @@ -314,24 +293,18 @@ def _path_from_file_uri(uri: str) -> Path | None: if parsed.scheme and parsed.scheme != "file": return None - if parsed.scheme == "file": - if parsed.netloc and parsed.netloc not in {"", "localhost"}: - return None - path_text = unquote(parsed.path or "") - else: - path_text = unquote(raw) + if parsed.scheme == "file" and parsed.netloc and parsed.netloc not in {"", "localhost"}: + return None + path_text = unquote(parsed.path or "") if parsed.scheme == "file" else unquote(raw) # file:///C:/Users/... or C:\Users\... if len(path_text) >= 3 and path_text[0] == "/" and path_text[2] == ":" and path_text[1].isalpha(): - drive = path_text[1].lower() - rest = path_text[3:].lstrip("/\\").replace("\\", "/") - return Path("/mnt") / drive / rest - if len(path_text) >= 2 and path_text[1] == ":" and path_text[0].isalpha(): - drive = path_text[0].lower() - rest = path_text[2:].lstrip("/\\").replace("\\", "/") - return Path("/mnt") / drive / rest - - return Path(path_text) + drive, rest = path_text[1], path_text[3:] + elif len(path_text) >= 2 and path_text[1] == ":" and path_text[0].isalpha(): + drive, rest = path_text[0], path_text[2:] + else: + return Path(path_text) + return Path("/mnt") / drive.lower() / rest.lstrip("/\\").replace("\\", "/") def _decode_text_bytes(data: bytes, mime_type: str | None) -> str | None: @@ -347,12 +320,7 @@ def _decode_text_bytes(data: bytes, mime_type: str | None) -> str | None: def _format_resource_text( - *, - uri: str, - body: str, - name: str | None = None, - title: str | None = None, - note: str | None = None, + *, uri: str, body: str, name: str | None = None, title: str | None = None, note: str | None = None ) -> str: display = _resource_display_name(uri, name=name, title=title) header = f"[Attached file: {display}]" @@ -361,13 +329,25 @@ def _format_resource_text( return f"{header}\nURI: {uri}\n\n{body}" +def _text_parts(**kwargs: Any) -> list[dict[str, Any]]: + """Single OpenAI text part wrapping ``_format_resource_text(**kwargs)``.""" + return [{"type": "text", "text": _format_resource_text(**kwargs)}] + + +def _image_parts(uri: str, display: str, data: bytes, mime: str) -> list[dict[str, Any]]: + """Text header + image_url data URL so vision models can see the attachment.""" + return [ + {"type": "text", "text": f"[Attached image: {display}]" + (f"\nURI: {uri}" if uri else "")}, + {"type": "image_url", "image_url": {"url": _image_data_url(data, mime)}}, + ] + + def _resource_link_to_parts(block: ResourceContentBlock) -> list[dict[str, Any]]: """Convert an ACP resource_link block to OpenAI content parts. - Returns a list of {"type": "text", ...} and/or {"type": "image_url", ...} - parts. Image resources produce an image_url part with a small text header - so the model knows which attachment it is. Non-image resources return a - single text part with the inlined file body (or a binary-omit note). + Image resources produce an image_url part with a small text header so the + model knows which attachment it is; other resources return a single text + part with the inlined file body (or a binary-omit note). """ uri = str(getattr(block, "uri", "") or "").strip() if not uri: @@ -377,87 +357,40 @@ def _resource_link_to_parts(block: ResourceContentBlock) -> list[dict[str, Any]] title = str(getattr(block, "title", "") or "").strip() or None mime_type = str(getattr(block, "mime_type", "") or "").strip() or None path = _path_from_file_uri(uri) + ident = dict(uri=uri, name=name, title=title) if path is None: - return [{ - "type": "text", - "text": _format_resource_text( - uri=uri, - name=name, - title=title, - body="[Resource link only; Hermes cannot read non-file ACP resource URIs directly.]", - ), - }] + return _text_parts( + **ident, body="[Resource link only; Hermes cannot read non-file ACP resource URIs directly.]" + ) - # Image files: emit a short text header + image_url data URL so vision - # models can see the attachment instead of a "binary omitted" note. - image_mime = mime_type if _is_image_resource(mime_type) else _guess_image_mime_from_path(path) + image_mime = mime_type if _is_image_resource(mime_type) else _IMAGE_SUFFIX_MIME.get(path.suffix.lower()) if image_mime and _is_image_resource(image_mime): try: size = path.stat().st_size if size > _MAX_ACP_RESOURCE_BYTES: - return [{ - "type": "text", - "text": _format_resource_text( - uri=uri, - name=name, - title=title, - body=f"[Image too large to inline: {size} bytes, cap={_MAX_ACP_RESOURCE_BYTES}]", - ), - }] + return _text_parts( + **ident, body=f"[Image too large to inline: {size} bytes, cap={_MAX_ACP_RESOURCE_BYTES}]" + ) with path.open("rb") as fh: data = fh.read() except OSError as exc: logger.warning("ACP image resource read failed: %s", uri, exc_info=True) - return [{ - "type": "text", - "text": _format_resource_text( - uri=uri, - name=name, - title=title, - body=f"[Could not read attached image: {exc}]", - ), - }] - display = _resource_display_name(uri, name=name, title=title) - return [ - {"type": "text", "text": f"[Attached image: {display}]\nURI: {uri}"}, - {"type": "image_url", "image_url": {"url": _image_data_url(data, image_mime)}}, - ] + return _text_parts(**ident, body=f"[Could not read attached image: {exc}]") + return _image_parts(uri, _resource_display_name(uri, name=name, title=title), data, image_mime) try: size = path.stat().st_size - read_size = min(size, _MAX_ACP_RESOURCE_BYTES) with path.open("rb") as fh: - data = fh.read(read_size) + data = fh.read(min(size, _MAX_ACP_RESOURCE_BYTES)) text = _decode_text_bytes(data, mime_type) if text is None: - return [{ - "type": "text", - "text": _format_resource_text( - uri=uri, - name=name, - title=title, - body=f"[Binary file omitted: {size} bytes, mime={mime_type or 'unknown'}]", - ), - }] - note = None - if size > _MAX_ACP_RESOURCE_BYTES: - note = f"truncated to {_MAX_ACP_RESOURCE_BYTES} of {size} bytes" - return [{ - "type": "text", - "text": _format_resource_text(uri=uri, name=name, title=title, body=text, note=note), - }] + return _text_parts(**ident, body=f"[Binary file omitted: {size} bytes, mime={mime_type or 'unknown'}]") + note = f"truncated to {_MAX_ACP_RESOURCE_BYTES} of {size} bytes" if size > _MAX_ACP_RESOURCE_BYTES else None + return _text_parts(**ident, body=text, note=note) except OSError as exc: logger.warning("ACP resource read failed: %s", uri, exc_info=True) - return [{ - "type": "text", - "text": _format_resource_text( - uri=uri, - name=name, - title=title, - body=f"[Could not read attached file: {exc}]", - ), - }] + return _text_parts(**ident, body=f"[Could not read attached file: {exc}]") def _embedded_resource_to_parts(block: EmbeddedResourceContentBlock) -> list[dict[str, Any]]: @@ -469,7 +402,7 @@ def _embedded_resource_to_parts(block: EmbeddedResourceContentBlock) -> list[dic mime_type = str(getattr(resource, "mime_type", "") or "").strip() or None if isinstance(resource, TextResourceContents): - return [{"type": "text", "text": _format_resource_text(uri=uri, body=resource.text)}] + return _text_parts(uri=uri, body=resource.text) if isinstance(resource, BlobResourceContents): blob = resource.blob or "" @@ -478,21 +411,13 @@ def _embedded_resource_to_parts(block: EmbeddedResourceContentBlock) -> list[dic except Exception: data = blob.encode("utf-8", errors="replace") - # Image blobs go through as image_url so vision models can see them. if _is_image_resource(mime_type): if len(data) > _MAX_ACP_RESOURCE_BYTES: - return [{ - "type": "text", - "text": _format_resource_text( - uri=uri, - body=f"[Embedded image too large to inline: {len(data)} bytes, cap={_MAX_ACP_RESOURCE_BYTES}]", - ), - }] - display = _resource_display_name(uri) - return [ - {"type": "text", "text": f"[Attached image: {display}]" + (f"\nURI: {uri}" if uri else "")}, - {"type": "image_url", "image_url": {"url": _image_data_url(data, mime_type or "image/png")}}, - ] + return _text_parts( + uri=uri, + body=f"[Embedded image too large to inline: {len(data)} bytes, cap={_MAX_ACP_RESOURCE_BYTES}]", + ) + return _image_parts(uri, _resource_display_name(uri), data, mime_type or "image/png") text = _decode_text_bytes(data[:_MAX_ACP_RESOURCE_BYTES], mime_type) if text is None: @@ -501,23 +426,15 @@ def _embedded_resource_to_parts(block: EmbeddedResourceContentBlock) -> list[dic body = text if len(data) > _MAX_ACP_RESOURCE_BYTES: body += f"\n\n[Truncated to {_MAX_ACP_RESOURCE_BYTES} of {len(data)} bytes]" - return [{"type": "text", "text": _format_resource_text(uri=uri, body=body)}] + return _text_parts(uri=uri, body=body) text = getattr(resource, "text", None) if text: - return [{"type": "text", "text": _format_resource_text(uri=uri, body=str(text))}] + return _text_parts(uri=uri, body=str(text)) return [] -def _extract_text( - prompt: list[ - TextContentBlock - | ImageContentBlock - | AudioContentBlock - | ResourceContentBlock - | EmbeddedResourceContentBlock - ], -) -> str: +def _extract_text(prompt: list[PromptBlock]) -> str: """Extract plain text from ACP content blocks for display/commands.""" parts: list[str] = [] for block in prompt: @@ -544,15 +461,7 @@ def _image_block_to_openai_part(block: ImageContentBlock) -> dict[str, Any] | No return {"type": "image_url", "image_url": {"url": url}} -def _content_blocks_to_openai_user_content( - prompt: list[ - TextContentBlock - | ImageContentBlock - | AudioContentBlock - | ResourceContentBlock - | EmbeddedResourceContentBlock - ], -) -> str | list[dict[str, Any]]: +def _content_blocks_to_openai_user_content(prompt: list[PromptBlock]) -> str | list[dict[str, Any]]: """Convert ACP prompt blocks into a Hermes/OpenAI-compatible user content payload.""" parts: list[dict[str, Any]] = [] text_parts: list[str] = [] @@ -562,109 +471,348 @@ def _content_blocks_to_openai_user_content( if block.text: parts.append({"type": "text", "text": block.text}) text_parts.append(block.text) - continue - if isinstance(block, ImageContentBlock): + elif isinstance(block, ImageContentBlock): image_part = _image_block_to_openai_part(block) if image_part is not None: parts.append(image_part) - continue - if isinstance(block, ResourceContentBlock): - resource_parts = _resource_link_to_parts(block) - for part in resource_parts: + elif isinstance(block, (ResourceContentBlock, EmbeddedResourceContentBlock)): + is_link = isinstance(block, ResourceContentBlock) + for part in (_resource_link_to_parts if is_link else _embedded_resource_to_parts)(block): parts.append(part) if part.get("type") == "text": text_parts.append(part["text"]) - continue - if isinstance(block, EmbeddedResourceContentBlock): - resource_parts = _embedded_resource_to_parts(block) - for part in resource_parts: - parts.append(part) - if part.get("type") == "text": - text_parts.append(part["text"]) - continue if not parts: return _extract_text(prompt) - # Keep pure text prompts as strings so slash-command handling and text-only - # providers keep the exact legacy path. Switch to structured content only - # when an actual non-text block is present. + # Pure text prompts stay strings so slash-command handling and text-only + # providers keep the legacy path; structured content only for real media. if all(part.get("type") == "text" for part in parts): return "\n".join(text_parts) return parts +def _semantic_provider(provider_id: str, normalize_provider: Callable[[str], str]) -> str: + raw = str(provider_id or "").strip().lower() + if raw in {"ollama", "custom:ollama"}: + return "ollama" + if raw.startswith("custom:"): + return raw + return normalize_provider(raw) + + +def _empty_catalog_applies( + provider_id: str, empty_authoritative: set[str], normalize_provider: Callable[[str], str] +) -> bool: + """True when a named endpoint with an authoritative-empty catalog owns ``provider_id``.""" + raw = str(provider_id or "").strip().lower() + normalized = normalize_provider(raw) + if normalized == "custom": + return any( + candidate == raw + or f"custom:{candidate}" == raw + or (raw == "custom" and candidate == "custom") + for candidate in empty_authoritative + ) + return any( + candidate == raw + or candidate == f"custom:{normalized}" + or candidate == f"custom:{raw}" + or normalize_provider(candidate) == normalized + for candidate in empty_authoritative + ) + + +def _choice_provider(model_id: str) -> str: + """Provider prefix of an encoded choice id; longest configured ``custom:`` slug wins.""" + parts = model_id.split(":") + if parts[:1] == ["custom"] and len(parts) > 1: + from hermes_cli.models import _configured_custom_provider_ids + + lowered = model_id.lower() + for candidate in sorted( + (p for p in _configured_custom_provider_ids() if p.startswith("custom:")), + key=len, + reverse=True, + ): + if lowered.startswith(candidate + ":"): + return candidate + return "custom" + return parts[0] + + +def _estimate_tokens(history: list, agent: Any, system_prompt: str | None = None, tools: Any = None) -> int: + """Rough request-token estimate over history + system prompt + tool schemas.""" + from agent.model_metadata import estimate_request_tokens_rough + + if system_prompt is None: + system_prompt = getattr(agent, "_cached_system_prompt", "") or "" + if tools is None: + tools = getattr(agent, "tools", None) or None + return estimate_request_tokens_rough(history, system_prompt=system_prompt, tools=tools) + + +def _flatten_history_text(value: Any) -> str: + """Normalize a persisted text-or-text-parts value into one stripped string. + + Content (and provider reasoning fields) may be a scalar string or a list of + ``{"text": ...}`` / ``{"type": "text", "content": ...}`` parts. Whitespace-only + input collapses to ``""`` so callers can treat that as "nothing to emit". + """ + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + parts: list[str] = [] + for item in value: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + elif item.get("type") == "text" and isinstance(item.get("content"), str): + parts.append(item["content"]) + elif isinstance(item, str): + parts.append(item) + return "\n".join(part.strip() for part in parts if part and part.strip()).strip() + return "" + + +def _history_reasoning_text(message: dict[str, Any]) -> str: + """First non-empty of ``reasoning_content`` (DeepSeek/Moonshot, chat-completions + normalizer) and ``reasoning`` (codex projector and other transports). Both are + live keys for different transports, not old-vs-new.""" + for key in ("reasoning_content", "reasoning"): + text = _flatten_history_text(message.get(key)) + if text: + return text + return "" + + +def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None: + """``_meta`` for a replayed compaction summary, else None. + + Summaries are persisted as ordinary history messages — standalone handoffs + under either role (whichever keeps alternation valid) or merged into the + first preserved tail message. Two distinct ``_meta.hermes`` keys so clients + cannot accidentally hide real content: ``compactionSummary`` (whole chunk is + the summary; safe to collapse) vs ``containsCompactionSummary`` (real turn + content followed by the summary; collapsing would hide preserved content). + Honors the in-process ``_compressed_summary`` flag and falls back to content + classification so DB-reloaded sessions still tag correctly. + """ + kind = ContextCompressor.classify_summary_content(text) + if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY): + # Flagged but unclassified (prefix drift): the flag is only ever set on + # summary-bearing messages, so treat as standalone. + kind = "standalone" + if kind == "standalone": + return {"hermes": {"compactionSummary": True}} + if kind == "merged": + return {"hermes": {"containsCompactionSummary": True}} + return None + + +_HISTORY_CHUNK_TYPES = { + "user": (UserMessageChunk, "user_message_chunk"), + "assistant": (AgentMessageChunk, "agent_message_chunk"), +} + + +def _history_message_update( + *, role: str, text: str, field_meta: dict[str, Any] | None = None +) -> UserMessageChunk | AgentMessageChunk | None: + """ACP history replay update for a user/assistant message.""" + spec = _HISTORY_CHUNK_TYPES.get(role) + if spec is None: + return None + cls, session_update = spec + return cls(session_update=session_update, content=TextContentBlock(type="text", text=text), field_meta=field_meta) + + +def _history_tool_call_name_args(tool_call: dict[str, Any]) -> tuple[str, dict[str, Any]]: + """Extract function name/arguments from an OpenAI-style tool_call.""" + function = tool_call.get("function") if isinstance(tool_call.get("function"), dict) else {} + name = str(function.get("name") or tool_call.get("name") or "unknown_tool") + raw_args = function.get("arguments") or tool_call.get("arguments") or tool_call.get("args") or {} + if isinstance(raw_args, str): + try: + raw_args = json.loads(raw_args) + except Exception: + raw_args = {"raw": raw_args} + if not isinstance(raw_args, dict): + raw_args = {} + return name, raw_args + + +def _history_tool_call_id(tool_call: dict[str, Any]) -> str: + return str(tool_call.get("id") or tool_call.get("call_id") or tool_call.get("tool_call_id") or "").strip() + + +def _mcp_server_config(server: McpServerStdio | McpServerHttp | McpServerSse) -> dict: + if isinstance(server, McpServerStdio): + return {"command": server.command, "args": list(server.args), "env": {i.name: i.value for i in server.env}} + return {"url": server.url, "headers": {i.name: i.value for i in server.headers}} + + +def _attach_interrupted_prompt(interrupted_prompt: str, guidance: str) -> str: + return f"{interrupted_prompt}\n\nUser correction/guidance after interrupt: {guidance}" + + +@dataclass +class _ModelCatalog: + """Deduplicated ACP model rows collected from the inventory + named endpoints. + + Dedupes on both the encoded choice id and a semantic ``provider:model`` id + (``ollama``/``custom:ollama`` are one provider). Also resolves the current + provider identity: a bare/``custom`` current provider whose base_url matches + an ollama inventory row is really ``custom:ollama``. + """ + + normalize_provider: Callable[[str], str] + current_model: str + current_choice_provider: str + current_base_url: str + models: list[ModelInfo] = field(default_factory=list) + seen_ids: set[str] = field(default_factory=set) + seen_semantic_ids: set[str] = field(default_factory=set) + empty_authoritative: set[str] = field(default_factory=set) + + def __post_init__(self) -> None: + if self.current_choice_provider == "ollama": + self.current_choice_provider = "custom:ollama" + self._identity_resolved = self.current_choice_provider not in {"", "custom"} + + def semantic(self, provider_id: str) -> str: + return _semantic_provider(provider_id, self.normalize_provider) + + def add(self, provider_id: str, model_id: str, name: str, description: str) -> None: + choice_id = HermesACPAgent._encode_model_choice(provider_id, model_id) + semantic_id = f"{self.semantic(provider_id)}:{model_id}" + if not choice_id or choice_id in self.seen_ids or semantic_id in self.seen_semantic_ids: + return + self.models.append(ModelInfo(model_id=choice_id, name=name, description=description)) + self.seen_ids.add(choice_id) + self.seen_semantic_ids.add(semantic_id) + + def add_inventory_rows(self, rows: list, provider_label: Callable[[str], str]) -> None: + for row in rows: + raw_row_provider = str(row.get("slug") or "").strip().lower() + row_provider = self.normalize_provider(raw_row_provider) + row_base_url = str(row.get("api_url") or "").strip().rstrip("/").lower() + if row.get("native_catalog_empty"): + self.empty_authoritative.add(raw_row_provider) + if ( + not self._identity_resolved + and raw_row_provider in {"ollama", "custom:ollama"} + and self.current_base_url + and row_base_url == self.current_base_url + ): + self.current_choice_provider = "custom:ollama" + self._identity_resolved = True + if not row_provider: + continue + provider_name = str(row.get("name") or "").strip() or provider_label(row_provider) + row_models = row.get("models") + if not isinstance(row_models, (list, tuple)): + continue + if raw_row_provider == "ollama": + encoded_provider = "custom:ollama" + elif raw_row_provider.startswith("custom:"): + encoded_provider = raw_row_provider + else: + encoded_provider = row_provider + for model_entry in row_models: + if isinstance(model_entry, dict): + model_entry = model_entry.get("id") or model_entry.get("model") or model_entry.get("name") + rendered_model = str(model_entry or "").strip() + if not rendered_model: + continue + is_current = ( + self.semantic(encoded_provider) == self.semantic(self.current_choice_provider) + and rendered_model == self.current_model + ) + self.add( + encoded_provider, rendered_model, f"{provider_name} · {rendered_model}", + f"Provider: {provider_name}" + (" • current" if is_current else ""), + ) + + def add_named_catalogs(self, catalogs: list, normalized_provider: str) -> None: + """Named user-defined endpoints (providers: / custom_providers:) are invisible + to canonical enumeration — append them like the TUI /model picker. An empty + catalog marks that slug authoritative-empty.""" + for named_slug, named_label, named_catalog in catalogs: + if not named_catalog: + self.empty_authoritative.add(str(named_slug).strip().lower()) + continue + for named_model, named_desc in named_catalog: + named_parts = [f"Provider: {named_label}"] + if named_desc: + named_parts.append(str(named_desc).strip()) + if named_slug == normalized_provider and named_model == self.current_model: + named_parts.append("current") + self.add(named_slug, named_model, named_model, " • ".join(part for part in named_parts if part)) + + +@dataclass +class _TurnCallbacks: + """Per-turn ACP streaming callbacks; all None when no client is connected.""" + + tool_progress_cb: Any = None + reasoning_cb: Any = None + step_cb: Any = None + stream_delta_cb: Any = None + approval_cb: Any = None + edit_approval_requester: Any = None + streamed: bool = False + + class HermesACPAgent(acp.Agent): """ACP Agent implementation wrapping Hermes AIAgent.""" - _SLASH_COMMANDS = { - "help": "Show available commands", - "model": "Show or change current model", - "tools": "List available tools", - "context": "Show conversation context info", - "reset": "Clear conversation history", - "compress": "Compress conversation context", - "steer": "Inject guidance into the currently running agent turn", - "queue": "Queue a prompt to run after the current turn finishes", - "version": "Show Hermes version", + # name -> (help text, advertised description, input hint) + _COMMANDS: dict[str, tuple[str, str, str | None]] = { + "help": ("Show available commands", "List available commands", None), + "model": ( + "Show or change current model", + "Show current model and provider, or switch models", + "model name to switch to", + ), + "tools": ("List available tools", "List available tools with descriptions", None), + "context": ("Show conversation context info", "Show conversation message counts by role", None), + "reset": ("Clear conversation history", "Clear conversation history", None), + "compress": ("Compress conversation context", "Compress conversation context", None), + "steer": ( + "Inject guidance into the currently running agent turn", + "Inject guidance into the currently running agent turn", + "guidance for the active turn", + ), + "queue": ( + "Queue a prompt to run after the current turn finishes", + "Queue a prompt to run after the current turn finishes", + "prompt to run next", + ), + "version": ("Show Hermes version", "Show Hermes version", None), } - _ADVERTISED_COMMANDS = ( - { - "name": "help", - "description": "List available commands", - }, - { - "name": "model", - "description": "Show current model and provider, or switch models", - "input_hint": "model name to switch to", - }, - { - "name": "tools", - "description": "List available tools with descriptions", - }, - { - "name": "context", - "description": "Show conversation message counts by role", - }, - { - "name": "reset", - "description": "Clear conversation history", - }, - { - "name": "compress", - "description": "Compress conversation context", - }, - { - "name": "steer", - "description": "Inject guidance into the currently running agent turn", - "input_hint": "guidance for the active turn", - }, - { - "name": "queue", - "description": "Queue a prompt to run after the current turn finishes", - "input_hint": "prompt to run next", - }, - { - "name": "version", - "description": "Show Hermes version", - }, - ) - _EDIT_APPROVAL_POLICY_CONFIG_ID = "edit_approval_policy" _EDIT_APPROVAL_POLICY_DEFAULT = "ask" _MODE_DEFAULT = "default" - _MODE_ACCEPT_EDITS = "accept_edits" - _MODE_DONT_ASK = "dont_ask" - _MODE_TO_EDIT_APPROVAL_POLICY = { - _MODE_DEFAULT: "ask", - _MODE_ACCEPT_EDITS: "workspace_session", - _MODE_DONT_ASK: "session", - } - _EDIT_APPROVAL_POLICY_TO_MODE = { - value: key for key, value in _MODE_TO_EDIT_APPROVAL_POLICY.items() + # mode id -> (edit approval policy, display name, description) + _MODES: dict[str, tuple[str, str, str]] = { + "default": ("ask", "Default", "Ask before edits."), + "accept_edits": ( + "workspace_session", + "Accept Edits", + "Auto-allow workspace and /tmp edits; still asks for sensitive paths.", + ), + "dont_ask": ( + "session", + "Don't Ask", + "Auto-allow file edits for this session except sensitive paths.", + ), } + _MODE_TO_EDIT_APPROVAL_POLICY = {mode: spec[0] for mode, spec in _MODES.items()} + _EDIT_APPROVAL_POLICY_TO_MODE = {spec[0]: mode for mode, spec in _MODES.items()} def __init__(self, session_manager: SessionManager | None = None): super().__init__() @@ -678,38 +826,35 @@ class HermesACPAgent(acp.Agent): self._conn = conn logger.info("ACP client connected") + async def _send(self, session_id: str, update: Any, *, fail_msg: str, level: int = logging.WARNING) -> bool: + """``session_update`` that logs instead of raising; False on failure.""" + try: + await self._conn.session_update(session_id=session_id, update=update) + return True + except Exception: + logger.log(level, fail_msg, session_id, exc_info=True) + return False + + def _schedule_soon(self, make_coro: Callable[[], Any]) -> None: + """Run a notification coroutine right after the current response is queued.""" + if not self._conn: + return + loop = asyncio.get_running_loop() + loop.call_soon(asyncio.create_task, make_coro()) def _session_modes(self, state: SessionState) -> SessionModeState: - """Return ACP session modes while preserving Zed's separate model picker. + """ACP session modes carrying the edit-approval policy. Zed renders ``config_options`` in the prominent selector slot where the - model picker was visible. Claude/Codex expose policy-like controls as ACP - modes, which coexist with the model picker, so Hermes maps edit approval - policy onto modes instead of advertising config options. + model picker lives; Claude/Codex expose policy controls as ACP modes, + which coexist with the picker, so Hermes maps edit approval onto modes. """ - current = str(getattr(state, "mode", "") or self._MODE_DEFAULT) - if current not in self._MODE_TO_EDIT_APPROVAL_POLICY: + if current not in self._MODES: current = self._MODE_DEFAULT return SessionModeState( current_mode_id=current, - available_modes=[ - SessionMode( - id=self._MODE_DEFAULT, - name="Default", - description="Ask before edits.", - ), - SessionMode( - id=self._MODE_ACCEPT_EDITS, - name="Accept Edits", - description="Auto-allow workspace and /tmp edits; still asks for sensitive paths.", - ), - SessionMode( - id=self._MODE_DONT_ASK, - name="Don't Ask", - description="Auto-allow file edits for this session except sensitive paths.", - ), - ], + available_modes=[SessionMode(id=m, name=n, description=d) for m, (_p, n, d) in self._MODES.items()], ) def _edit_approval_policy_for_state(self, state: SessionState) -> tuple[str, str | None]: @@ -719,21 +864,19 @@ class HermesACPAgent(acp.Agent): @staticmethod def _encode_model_choice(provider: str | None, model: str | None) -> str: - """Encode a model selection so ACP clients can keep provider context.""" + """``provider:model`` so ACP clients keep provider context.""" raw_model = str(model or "").strip() if not raw_model: return "" raw_provider = str(provider or "").strip().lower() - if not raw_provider: - return raw_model - return f"{raw_provider}:{raw_model}" + return f"{raw_provider}:{raw_model}" if raw_provider else raw_model def _build_model_state(self, state: SessionState) -> SessionModelState | None: - """Return authenticated providers and their models for ACP clients. + """Authenticated providers and their models for ACP clients. - The shared Hermes inventory is also used by ``hermes model``, the TUI, - and the dashboard. Keeping ACP on that substrate prevents its selector - from silently collapsing to the current provider's curated list. + Uses the shared Hermes inventory (also behind ``hermes model``, the TUI + and the dashboard) so the selector doesn't collapse to the current + provider's curated list. """ model = str(state.model or getattr(state.agent, "model", "") or "").strip() provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter" @@ -749,220 +892,53 @@ class HermesACPAgent(acp.Agent): current_base_url=str(getattr(state.agent, "base_url", "") or ""), ) payload = build_models_payload( - context, - explicit_only=True, - include_unconfigured=False, - picker_hints=False, - canonical_order=True, - pricing=False, - capabilities=False, - refresh=False, - probe_custom_providers=False, - probe_current_custom_provider=False, + context, explicit_only=True, include_unconfigured=False, picker_hints=False, + canonical_order=True, pricing=False, capabilities=False, refresh=False, + probe_custom_providers=False, probe_current_custom_provider=False, max_models=ACP_MAX_MODELS_PER_PROVIDER, ) - available_models: list[ModelInfo] = [] - seen_ids: set[str] = set() - current_choice_provider = str(provider or "").strip().lower() - if current_choice_provider == "ollama": - current_choice_provider = "custom:ollama" - current_base_url = str( - getattr(state.agent, "base_url", "") or "" - ).strip().rstrip("/").lower() + cat = _ModelCatalog( + normalize_provider=normalize_provider, + current_model=model, + current_choice_provider=str(provider or "").strip().lower(), + current_base_url=str(getattr(state.agent, "base_url", "") or "").strip().rstrip("/").lower(), + ) + cat.add_inventory_rows(payload.get("providers") or [], provider_label) + cat.add_named_catalogs(_named_custom_provider_catalogs(), normalized_provider) + available_models = cat.models + seen_ids = cat.seen_ids + current_choice_provider = cat.current_choice_provider + named_empty_authoritative = cat.empty_authoritative - def semantic_provider(provider_id: str) -> str: - raw = str(provider_id or "").strip().lower() - if raw in {"ollama", "custom:ollama"}: - return "ollama" - if raw.startswith("custom:"): - return raw - return normalize_provider(raw) - - seen_semantic_ids: set[str] = set() - native_empty_rows: set[str] = set() - current_identity_resolved = current_choice_provider not in {"", "custom"} - for row in payload.get("providers") or []: - raw_row_provider = str(row.get("slug") or "").strip().lower() - row_provider = normalize_provider(raw_row_provider) - row_base_url = str(row.get("api_url") or "").strip().rstrip("/").lower() - if row.get("native_catalog_empty"): - native_empty_rows.add(raw_row_provider) - if ( - not current_identity_resolved - and raw_row_provider in {"ollama", "custom:ollama"} - and current_base_url - and row_base_url == current_base_url - ): - current_choice_provider = "custom:ollama" - current_identity_resolved = True - if not row_provider: - continue - provider_name = str(row.get("name") or "").strip() or provider_label( - row_provider - ) - row_models = row.get("models") - if not isinstance(row_models, (list, tuple)): - continue - for model_entry in row_models: - if isinstance(model_entry, dict): - rendered_model = str( - model_entry.get("id") - or model_entry.get("model") - or model_entry.get("name") - or "" - ).strip() - else: - rendered_model = str(model_entry or "").strip() - if not rendered_model: - continue - encoded_provider = ( - "custom:ollama" - if raw_row_provider == "ollama" - else raw_row_provider - if raw_row_provider == "custom:ollama" - else raw_row_provider - if raw_row_provider.startswith("custom:") - else row_provider - ) - choice_id = self._encode_model_choice( - encoded_provider, rendered_model - ) - semantic_id = f"{semantic_provider(encoded_provider)}:{rendered_model}" - if choice_id in seen_ids or semantic_id in seen_semantic_ids: - continue - is_current = ( - semantic_provider(encoded_provider) - == semantic_provider(current_choice_provider) - and rendered_model == model - ) - description = f"Provider: {provider_name}" - if is_current: - description += " • current" - available_models.append( - ModelInfo( - model_id=choice_id, - name=f"{provider_name} · {rendered_model}", - description=description, - ) - ) - seen_ids.add(choice_id) - seen_semantic_ids.add(semantic_id) - - # Named user-defined endpoints (providers: / custom_providers:) - # are invisible to canonical provider enumeration — append them - # so editor clients can select them like the TUI /model picker. - named_empty_authoritative: set[str] = set(native_empty_rows) - for named_slug, named_label, named_catalog in _named_custom_provider_catalogs(): - if not named_catalog: - named_empty_authoritative.add(str(named_slug).strip().lower()) - continue - for named_model, named_desc in named_catalog: - named_choice = self._encode_model_choice(named_slug, named_model) - named_semantic_id = ( - f"{semantic_provider(named_slug)}:{named_model}" - ) - if ( - not named_choice - or named_choice in seen_ids - or named_semantic_id in seen_semantic_ids - ): - continue - named_parts = [f"Provider: {named_label}"] - if named_desc: - named_parts.append(str(named_desc).strip()) - if named_slug == normalized_provider and named_model == model: - named_parts.append("current") - available_models.append( - ModelInfo( - model_id=named_choice, - name=named_model, - description=" • ".join(part for part in named_parts if part), - ) - ) - seen_ids.add(named_choice) - seen_semantic_ids.add(named_semantic_id) - - def empty_catalog_applies(provider_id: str) -> bool: - raw = str(provider_id or "").strip().lower() - normalized = normalize_provider(raw) - if normalized == "custom": - return any( - candidate == raw - or f"custom:{candidate}" == raw - or (raw == "custom" and candidate == "custom") - for candidate in named_empty_authoritative - ) - return any( - candidate == raw - or candidate == f"custom:{normalized}" - or candidate == f"custom:{raw}" - or normalize_provider(candidate) == normalized - for candidate in named_empty_authoritative - ) - - def choice_provider(model_id: str) -> str: - parts = model_id.split(":") - if parts[:1] == ["custom"] and len(parts) > 1: - from hermes_cli.models import _configured_custom_provider_ids - - lowered = model_id.lower() - for candidate in sorted( - ( - provider_id - for provider_id in _configured_custom_provider_ids() - if provider_id.startswith("custom:") - ), - key=len, - reverse=True, - ): - if lowered.startswith(candidate + ":"): - return candidate - return "custom" - return parts[0] + def empty_applies(provider_id: str) -> bool: + return _empty_catalog_applies(provider_id, named_empty_authoritative, normalize_provider) if named_empty_authoritative: - available_models = [ - item - for item in available_models - if not empty_catalog_applies(choice_provider(item.model_id)) - ] + available_models = [m for m in available_models if not empty_applies(_choice_provider(m.model_id))] seen_ids = {item.model_id for item in available_models} - current_is_empty = empty_catalog_applies(current_choice_provider) + current_is_empty = empty_applies(current_choice_provider) if current_is_empty: - available_models = [ - item - for item in available_models - if " • current" not in str(item.description or "") - ] + available_models = [m for m in available_models if " • current" not in str(m.description or "")] seen_ids = {item.model_id for item in available_models} - current_model_id = ( - "" if current_is_empty else self._encode_model_choice(current_choice_provider, model) - ) - if ( - current_model_id - and current_model_id not in seen_ids - and not current_is_empty - ): + current_model_id = "" if current_is_empty else self._encode_model_choice(current_choice_provider, model) + if current_model_id and current_model_id not in seen_ids: provider_name = provider_label(normalized_provider) - available_models.insert( - 0, - ModelInfo( - model_id=current_model_id, - name=f"{provider_name} · {model}", - description=f"Provider: {provider_name} • current", - ), - ) + available_models.insert(0, ModelInfo( + model_id=current_model_id, + name=f"{provider_name} · {model}", + description=f"Provider: {provider_name} • current", + )) if not available_models and current_is_empty: return SessionModelState(available_models=[], current_model_id="") if available_models: return SessionModelState( available_models=available_models, - current_model_id=current_model_id - if current_model_id or current_is_empty - else available_models[0].model_id, + current_model_id=( + current_model_id if current_model_id or current_is_empty else available_models[0].model_id + ), ) except Exception: logger.debug("Could not build ACP model state", exc_info=True) @@ -972,8 +948,7 @@ class HermesACPAgent(acp.Agent): fallback_choice = self._encode_model_choice(provider, model) return SessionModelState( - available_models=[ModelInfo(model_id=fallback_choice, name=model)], - current_model_id=fallback_choice, + available_models=[ModelInfo(model_id=fallback_choice, name=model)], current_model_id=fallback_choice ) @staticmethod @@ -995,16 +970,35 @@ class HermesACPAgent(acp.Agent): return target_provider, new_model + def _switch_model( + self, state: SessionState, raw_model: str, *, keep_endpoint: bool = False + ) -> tuple[str | None, str, str]: + """Rebuild the session agent on a new model; returns (old provider, new provider, model). + + ``keep_endpoint`` carries the current base_url/api_mode over when the + provider is unchanged (ACP ``set_session_model``). + """ + current_provider = getattr(state.agent, "provider", None) + target_provider, new_model = self._resolve_model_selection(raw_model, current_provider or "openrouter") + state.model = new_model + endpoint: dict[str, Any] = {} + if keep_endpoint and not (current_provider and target_provider != current_provider): + endpoint = { + "base_url": getattr(state.agent, "base_url", None), + "api_mode": getattr(state.agent, "api_mode", None), + } + state.agent = self.session_manager._make_agent( + session_id=state.session_id, cwd=state.cwd, model=new_model, + requested_provider=target_provider, **endpoint, + ) + self.session_manager.save_session(state.session_id) + return current_provider, target_provider, new_model + @staticmethod def _build_usage_update(state: SessionState) -> UsageUpdate | None: - """Build ACP native context-usage data for clients like Zed. - - Zed's circular context indicator is driven by ACP ``usage_update`` - session updates: ``size`` is the model context window and ``used`` is - the current request pressure. Hermes estimates ``used`` from the same - buckets it sends to providers: system prompt, conversation history, and - tool schemas. - """ + """ACP ``usage_update`` driving Zed's context indicator: ``size`` is the + model context window, ``used`` the estimated request pressure (system + prompt + history + tool schemas — the same buckets sent to providers).""" agent = state.agent compressor = getattr(agent, "context_compressor", None) size = int(getattr(compressor, "context_length", 0) or 0) @@ -1012,75 +1006,41 @@ class HermesACPAgent(acp.Agent): return None try: - from agent.model_metadata import estimate_request_tokens_rough - - used = estimate_request_tokens_rough( - state.history, - system_prompt=getattr(agent, "_cached_system_prompt", "") or "", - tools=getattr(agent, "tools", None) or None, - ) + used = _estimate_tokens(state.history, agent) except Exception: logger.debug("Could not estimate ACP native context usage", exc_info=True) used = int(getattr(compressor, "last_prompt_tokens", 0) or 0) - return UsageUpdate( - session_update="usage_update", - size=max(size, 0), - used=max(used, 0), - ) + return UsageUpdate(session_update="usage_update", size=max(size, 0), used=max(used, 0)) async def _send_usage_update(self, state: SessionState) -> None: - """Send ACP native context usage to the connected client.""" if not self._conn: return update = self._build_usage_update(state) if update is None: return - try: - await self._conn.session_update( - session_id=state.session_id, - update=update, - ) - except Exception: - logger.warning( - "Failed to send ACP usage update for session %s", - state.session_id, - exc_info=True, - ) + await self._send(state.session_id, update, fail_msg="Failed to send ACP usage update for session %s") def _provenance_meta( - self, - acp_session_id: str, - current_hermes_session_id: str, - previous_hermes_session_id: Optional[str] = None, + self, acp_session_id: str, current_hermes_session_id: str, previous_hermes_session_id: Optional[str] = None ) -> Optional[dict]: """Best-effort ``_meta.hermes.sessionProvenance`` for an ACP session.""" try: return session_provenance_meta( - self.session_manager._get_db(), - acp_session_id, - current_hermes_session_id, + self.session_manager._get_db(), acp_session_id, current_hermes_session_id, previous_hermes_session_id=previous_hermes_session_id, ) except Exception: - logger.debug( - "Could not build ACP session provenance for %s", acp_session_id, exc_info=True - ) + logger.debug("Could not build ACP session provenance for %s", acp_session_id, exc_info=True) return None async def _send_session_info_update( - self, - session_id: str, - *, - current_hermes_session_id: Optional[str] = None, - previous_hermes_session_id: Optional[str] = None, + self, session_id: str, *, + current_hermes_session_id: Optional[str] = None, previous_hermes_session_id: Optional[str] = None, ) -> None: - """Send ACP native session metadata after Hermes changes it. - - When the internal Hermes head rotated (e.g. compression-driven session - split during a turn), pass ``previous_hermes_session_id`` so the - attached ``_meta.hermes.sessionProvenance`` flags the rotation reason. - """ + """Send ACP session metadata after Hermes changes it. Pass + ``previous_hermes_session_id`` when the internal head rotated + (compression split) so the provenance meta flags the reason.""" if not self._conn: return try: @@ -1092,41 +1052,25 @@ class HermesACPAgent(acp.Agent): return title = row.get("title") - # The `sessions` table does not have an `updated_at` column (see - # hermes_state.py schema — only started_at/ended_at). Use "now" as - # the updated_at since we're emitting this notification precisely - # because the title was just refreshed. - updated_at = datetime.now(timezone.utc).isoformat() - meta = self._provenance_meta( - session_id, - current_hermes_session_id or session_id, - previous_hermes_session_id, - ) + # `sessions` has no `updated_at` column (only started_at/ended_at); "now" + # is right because this fires precisely when the title was refreshed. update = SessionInfoUpdate( session_update="session_info_update", title=title if isinstance(title, str) and title.strip() else None, - updated_at=updated_at, - field_meta=meta, + updated_at=datetime.now(timezone.utc).isoformat(), + field_meta=self._provenance_meta( + session_id, current_hermes_session_id or session_id, previous_hermes_session_id + ), + ) + await self._send( + session_id, update, fail_msg="Could not send ACP session info update for %s", level=logging.DEBUG ) - try: - await self._conn.session_update( - session_id=session_id, - update=update, - ) - except Exception: - logger.debug("Could not send ACP session info update for %s", session_id, exc_info=True) def _schedule_usage_update(self, state: SessionState) -> None: - """Schedule native context indicator refresh after ACP responses.""" - if not self._conn: - return - loop = asyncio.get_running_loop() - loop.call_soon(asyncio.create_task, self._send_usage_update(state)) + self._schedule_soon(lambda: self._send_usage_update(state)) async def _register_session_mcp_servers( - self, - state: SessionState, - mcp_servers: list[McpServerStdio | McpServerHttp | McpServerSse] | None, + self, state: SessionState, mcp_servers: list[McpServerStdio | McpServerHttp | McpServerSse] | None ) -> None: """Register ACP-provided MCP servers and refresh the agent tool surface.""" if not mcp_servers: @@ -1135,29 +1079,10 @@ class HermesACPAgent(acp.Agent): try: from tools.mcp_tool import register_mcp_servers - config_map: dict[str, dict] = {} - for server in mcp_servers: - name = server.name - if isinstance(server, McpServerStdio): - config = { - "command": server.command, - "args": list(server.args), - "env": {item.name: item.value for item in server.env}, - } - else: - config = { - "url": server.url, - "headers": {item.name: item.value for item in server.headers}, - } - config_map[name] = config - + config_map = {server.name: _mcp_server_config(server) for server in mcp_servers} await asyncio.to_thread(register_mcp_servers, config_map) except Exception: - logger.warning( - "Session %s: failed to register ACP MCP servers", - state.session_id, - exc_info=True, - ) + logger.warning("Session %s: failed to register ACP MCP servers", state.session_id, exc_info=True) return try: @@ -1169,57 +1094,39 @@ class HermesACPAgent(acp.Agent): mcp_server_names=[server.name for server in mcp_servers], ) state.agent.enabled_toolsets = enabled_toolsets - disabled_toolsets = getattr(state.agent, "disabled_toolsets", None) state.agent.tools = get_tool_definitions( enabled_toolsets=enabled_toolsets, - disabled_toolsets=disabled_toolsets, - quiet_mode=True, + disabled_toolsets=getattr(state.agent, "disabled_toolsets", None), quiet_mode=True, ) - state.agent.valid_tool_names = { - tool["function"]["name"] for tool in state.agent.tools or [] - } + state.agent.valid_tool_names = {tool["function"]["name"] for tool in state.agent.tools or []} inject_memory_provider_tools(state.agent) invalidate = getattr(state.agent, "_invalidate_system_prompt", None) if callable(invalidate): invalidate() logger.info( "Session %s: refreshed tool surface after ACP MCP registration (%d tools)", - state.session_id, - len(state.agent.tools or []), + state.session_id, len(state.agent.tools or []), ) except Exception: logger.warning( "Session %s: failed to refresh tool surface after ACP MCP registration", - state.session_id, - exc_info=True, + state.session_id, exc_info=True, ) def _schedule_mcp_late_refresh(self, state: SessionState) -> None: """Refresh the agent's tool snapshot when background MCP discovery lands late. - ACP entry.py starts MCP tool discovery in a background daemon thread so a - slow/dead configured server can't block ``asyncio.run()``. ``_make_agent`` - briefly joins that thread (``wait_for_mcp_discovery``, bounded ~1.5s) so - already-spawning fast servers land in the snapshot — but a server slower - than the bound lands *after* the agent is built, leaving its tools absent - for the whole session. + entry.py runs MCP discovery in a daemon thread; ``_make_agent`` joins it + only briefly (~1.5s), so a slower server lands after the agent is built + and its tools would be absent for the whole session. This waits for + discovery (bounded 30s) off the critical path, then rebuilds via the same + ``refresh_agent_mcp_tools`` that ``/reload-mcp`` uses. - This schedules an off-critical-path daemon that waits for discovery to - finish (bounded 30s), then rebuilds the snapshot via the shared - ``refresh_agent_mcp_tools`` helper — the same rebuild ``/reload-mcp`` - performs, but automatic. Mirrors the TUI late-refresh (PR #48403). - - Cache safety: the rebuild only runs while the session is still - pre-first-turn (no API call made yet → nothing cached to invalidate). - Once the user has sent a message we leave the snapshot frozen rather - than break the cached prompt prefix mid-conversation; servers that land - later are picked up cache-safely by the between-turns prologue refresh - (``agent/turn_context.py``) at the next turn boundary. The marginal - value of this pre-first-turn daemon is therefore freshness in the - window [session created → first message] — e.g. the "Available tools" - listing a client may request before the first prompt. - No-op when discovery already finished, when the join times out, when the - registry was unchanged, or when the session was closed while waiting. + Cache safety: rebuild only while the session is pre-first-turn (nothing + cached yet). After the first message the snapshot stays frozen; later + servers are picked up cache-safely by the between-turns prologue refresh + (``agent/turn_context.py``). No-op when discovery already finished, the + join times out, the registry was unchanged, or the session was closed. """ try: from hermes_cli.mcp_startup import mcp_discovery_in_flight @@ -1240,24 +1147,17 @@ class HermesACPAgent(acp.Agent): if not join_mcp_discovery(timeout=30.0): return - # Session may have been closed while we waited. In-memory-only - # lookup on purpose: ``get_session()`` falls through to a DB - # restore that builds a whole new AIAgent as a side effect just - # to decide "no-op" here (the TUI equivalent also checks its - # in-memory dict only). + # In-memory-only lookup on purpose: ``get_session()`` would + # restore from DB and build a whole new AIAgent just to say no-op. with self.session_manager._lock: current = self.session_manager._sessions.get(session_id) if current is None or current.agent is not agent: return - # Cache safety: never rebuild the tool list once the conversation - # has started — that would invalidate the cached prompt prefix. # Serialized with turn start: ``prompt()`` flips ``is_running`` # under ``runtime_lock`` before dispatching, so holding it here - # (and bailing when a turn is already running) closes the window - # where the guard passes but the first prompt starts before the - # refresh publishes — which would swap ``tools=`` mid-turn and - # break the just-created cache prefix. + # (and bailing when a turn is running) closes the window where + # the refresh would swap ``tools=`` mid-turn and break the cache. with current.runtime_lock: if current.is_running: return @@ -1273,21 +1173,13 @@ class HermesACPAgent(acp.Agent): if added: logger.info( "Session %s: late MCP refresh added %d tools: %s", - session_id, - len(added), - ", ".join(sorted(added)), + session_id, len(added), ", ".join(sorted(added)), ) except Exception: - logger.debug( - "Session %s: late MCP refresh failed", - session_id, - exc_info=True, - ) + logger.debug("Session %s: late MCP refresh failed", session_id, exc_info=True) threading.Thread( - target=_wait_then_refresh, - name=f"acp-mcp-late-refresh-{session_id}", - daemon=True, + target=_wait_then_refresh, name=f"acp-mcp-late-refresh-{session_id}", daemon=True ).start() # ---- ACP lifecycle ------------------------------------------------------ @@ -1299,16 +1191,11 @@ class HermesACPAgent(acp.Agent): client_info: Implementation | None = None, **kwargs: Any, ) -> InitializeResponse: - resolved_protocol_version = ( - protocol_version if isinstance(protocol_version, int) else acp.PROTOCOL_VERSION - ) auth_methods = build_auth_methods() - - client_name = client_info.name if client_info else "unknown" logger.info( "Initialize from %s (protocol v%s)", - client_name, - resolved_protocol_version, + client_info.name if client_info else "unknown", + protocol_version if isinstance(protocol_version, int) else acp.PROTOCOL_VERSION, ) return InitializeResponse( @@ -1318,30 +1205,23 @@ class HermesACPAgent(acp.Agent): load_session=True, prompt_capabilities=PromptCapabilities(image=True), session_capabilities=SessionCapabilities( - fork=SessionForkCapabilities(), - list=SessionListCapabilities(), - resume=SessionResumeCapabilities(), + fork=SessionForkCapabilities(), list=SessionListCapabilities(), resume=SessionResumeCapabilities(), ), ), auth_methods=auth_methods, ) async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None: - # Only accept authenticate() calls whose method_id matches the - # provider we advertised in initialize(). Without this check, - # authenticate() would acknowledge any method_id as long as the - # server has provider credentials configured — harmless under - # Hermes' threat model (ACP is stdio-only, local-trust), but poor - # API hygiene and confusing if ACP ever grows multi-method auth. + # Only acknowledge the method_id advertised in initialize(); accepting + # any id would be poor hygiene if ACP ever grows multi-method auth. if not isinstance(method_id, str): return None normalized_method = method_id.strip().lower() provider = detect_provider() if normalized_method == TERMINAL_SETUP_AUTH_METHOD_ID: - # Terminal auth launches Hermes setup/model selection out-of-band. - # Only report success once that flow has produced usable runtime - # credentials for the normal ACP session. + # Terminal auth runs Hermes setup out-of-band; succeed only once it + # has produced usable runtime credentials. return AuthenticateResponse() if provider else None if not provider or normalized_method != provider: @@ -1350,221 +1230,57 @@ class HermesACPAgent(acp.Agent): # ---- Session management ------------------------------------------------- - @staticmethod - def _flatten_history_text(value: Any) -> str: - """Normalize a persisted text-or-text-parts value into a single string. - - OpenAI-style assistant content (and provider reasoning fields) can arrive - as either a scalar string or a list of ``{"text": ...}`` / - ``{"type": "text", "content": ...}`` parts. Whitespace-only inputs - collapse to an empty string so callers can treat ``""`` as "nothing to - emit". - """ - if isinstance(value, str): - return value.strip() - if isinstance(value, list): - parts: list[str] = [] - for item in value: - if isinstance(item, dict): - text = item.get("text") - if isinstance(text, str): - parts.append(text) - elif item.get("type") == "text" and isinstance(item.get("content"), str): - parts.append(item["content"]) - elif isinstance(item, str): - parts.append(item) - return "\n".join(part.strip() for part in parts if part and part.strip()).strip() - return "" - - @classmethod - def _history_message_text(cls, message: dict[str, Any]) -> str: - """Extract displayable text from a persisted OpenAI-style message.""" - return cls._flatten_history_text(message.get("content")) - - @classmethod - def _history_reasoning_text(cls, message: dict[str, Any]) -> str: - """Extract displayable reasoning/thought text from a persisted assistant message. - - Returns the first non-empty value among ``reasoning_content`` (the - canonical field used by DeepSeek / Moonshot and the post-#16892 - chat-completions normalizer) and ``reasoning`` (used by the codex - event projector and several other transports). Both keys are - actively written by live code paths, so neither branch is - deprecated — they cover different transports rather than old vs. - new sessions. - """ - for key in ("reasoning_content", "reasoning"): - text = cls._flatten_history_text(message.get(key)) - if text: - return text - return "" - - @staticmethod - def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None: - """Build the ``_meta`` payload for a replayed compaction summary. - - Compaction summaries are persisted as ordinary history messages — - standalone handoffs under ``role="user"`` OR ``role="assistant"`` - (the compressor picks whichever role keeps alternation valid), and - merge-into-tail messages where the summary is appended after the - first preserved tail message's real content. Without a wire flag, - ACP frontends render all of these as ordinary turns. - - Two distinct keys under ``_meta.hermes`` (ACP's extensibility - channel), so clients cannot accidentally hide real content: - - * ``compactionSummary: true`` — the entire chunk is the handoff - summary. Safe to restyle or collapse wholesale. - * ``containsCompactionSummary: true`` — a merged-tail message: real - preserved turn content followed by the summary. Clients may style - it, but collapsing the whole chunk would hide the preserved - content, hence the separate key. - - Detection honors the in-process ``_compressed_summary`` flag and - falls back to content classification, so it also works for a - DB-reloaded session that lost the in-memory flag. - """ - kind = ContextCompressor.classify_summary_content(text) - if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY): - # Flagged in-process but content didn't classify (e.g. future - # prefix drift): treat as a standalone summary — the flag is only - # ever set on summary-bearing messages. - kind = "standalone" - if kind == "standalone": - return {"hermes": {"compactionSummary": True}} - if kind == "merged": - return {"hermes": {"containsCompactionSummary": True}} - return None - - @staticmethod - def _history_message_update( - *, - role: str, - text: str, - field_meta: dict[str, Any] | None = None, - ) -> UserMessageChunk | AgentMessageChunk | None: - """Build an ACP history replay update for a user/assistant message.""" - block = TextContentBlock(type="text", text=text) - if role == "user": - return UserMessageChunk( - session_update="user_message_chunk", - content=block, - field_meta=field_meta, - ) - if role == "assistant": - return AgentMessageChunk( - session_update="agent_message_chunk", - content=block, - field_meta=field_meta, - ) - return None - - @staticmethod - def _history_thought_update(text: str) -> AgentThoughtChunk: - """Build an ACP history replay update for an assistant thought.""" - return acp.update_agent_thought_text(text) - - @staticmethod - def _history_tool_call_name_args(tool_call: dict[str, Any]) -> tuple[str, dict[str, Any]]: - """Extract function name/arguments from an OpenAI-style tool_call.""" - function = tool_call.get("function") if isinstance(tool_call.get("function"), dict) else {} - name = str(function.get("name") or tool_call.get("name") or "unknown_tool") - raw_args = function.get("arguments") or tool_call.get("arguments") or tool_call.get("args") or {} - if isinstance(raw_args, str): - try: - parsed = json.loads(raw_args) - except Exception: - parsed = {"raw": raw_args} - raw_args = parsed - if not isinstance(raw_args, dict): - raw_args = {} - return name, raw_args - - @staticmethod - def _history_tool_call_id(tool_call: dict[str, Any]) -> str: - """Return the stable provider tool call id for ACP history replay.""" - return str( - tool_call.get("id") - or tool_call.get("call_id") - or tool_call.get("tool_call_id") - or "" - ).strip() - async def _replay_session_history(self, state: SessionState) -> None: - """Replay persisted user/assistant history during session/load or session/resume. - - Invoked inline (``await``) from both ``load_session`` and - ``resume_session`` so that spec-compliant ACP clients receive the - full transcript within the request's lifetime — see the comment at - the call sites for the rationale and prior-art citations. - - Replays the conversation as user/assistant chunks, thinking-mode - thought chunks, plus reconstructed tool-call start/completion - notifications. Merely restoring server-side state makes Hermes - remember context, but leaves the editor looking like a clean thread. - """ + """Replay persisted history as user/assistant chunks, thought chunks and + reconstructed tool-call start/completion notifications, so the editor + shows the transcript instead of a clean thread. Awaited inline from + ``load_session``/``resume_session`` (see there for why).""" if not self._conn or not state.history: return active_tool_calls: dict[str, tuple[str, dict[str, Any]]] = {} - async def _send(update: Any) -> bool: - try: - await self._conn.session_update(session_id=state.session_id, update=update) + async def send(update: Any) -> bool: + return await self._send( + state.session_id, update, fail_msg="Failed to replay ACP history for session %s" + ) + + async def send_message(role: str, message: dict[str, Any]) -> bool: + text = _flatten_history_text(message.get("content")) + if not text: return True - except Exception: - logger.warning( - "Failed to replay ACP history for session %s", - state.session_id, - exc_info=True, - ) - return False + update = _history_message_update( + role=role, text=text, field_meta=_history_summary_meta(message, text) + ) + return update is None or await send(update) for message in state.history: role = str(message.get("role") or "") if role == "user": - text = self._history_message_text(message) - if text: - update = self._history_message_update( - role=role, - text=text, - field_meta=self._history_summary_meta(message, text), - ) - if update is not None and not await _send(update): - return - continue - - if role == "assistant": - thought = self._history_reasoning_text(message) - if thought and not await _send(self._history_thought_update(thought)): + if not await send_message(role, message): return - text = self._history_message_text(message) - if text: - update = self._history_message_update( - role=role, - text=text, - field_meta=self._history_summary_meta(message, text), - ) - if update is not None and not await _send(update): - return - + elif role == "assistant": + thought = _history_reasoning_text(message) + if thought and not await send(acp.update_agent_thought_text(thought)): + return + if not await send_message(role, message): + return tool_calls = message.get("tool_calls") if isinstance(tool_calls, list): for tool_call in tool_calls: if not isinstance(tool_call, dict): continue - tool_call_id = self._history_tool_call_id(tool_call) + tool_call_id = _history_tool_call_id(tool_call) if not tool_call_id: continue - tool_name, args = self._history_tool_call_name_args(tool_call) + tool_name, args = _history_tool_call_name_args(tool_call) active_tool_calls[tool_call_id] = (tool_name, args) - if not await _send(build_tool_start(tool_call_id, tool_name, args)): + if not await send(build_tool_start(tool_call_id, tool_name, args)): return - continue - if role == "tool": + elif role == "tool": tool_call_id = str(message.get("tool_call_id") or "").strip() tool_name = str(message.get("tool_name") or "").strip() function_args: dict[str, Any] | None = None @@ -1574,47 +1290,53 @@ class HermesACPAgent(acp.Agent): continue result = message.get("content") result_text = result if isinstance(result, str) else None - if not await _send( - build_tool_complete( - tool_call_id, - tool_name, - result=result_text, - function_args=function_args, - ) - ): + update = build_tool_complete(tool_call_id, tool_name, result=result_text, function_args=function_args) + if not await send(update): return if tool_name == "todo": plan_update = _build_plan_update_from_todo_result(result_text) - if plan_update is not None and not await _send(plan_update): + if plan_update is not None and not await send(plan_update): return - async def new_session( - self, - cwd: str, - mcp_servers: list | None = None, - **kwargs: Any, - ) -> NewSessionResponse: + async def _replay_history_guarded(self, state: SessionState, verb: str) -> None: + """Per ACP spec, ``session/load`` and ``session/resume`` must stream the + prior conversation via ``session/update`` BEFORE responding, so clients + get the transcript within the request's lifetime (Codex, Claude Code, + OpenCode, Zed all rely on this; deferring via ``call_soon`` broke them). + Replay is best-effort: a corrupt message shape must not turn a + successful load into a JSON-RPC error.""" + try: + await self._replay_session_history(state) + except Exception: + logger.warning( + f"ACP history replay raised during session/{verb} for %s — " + f"{verb} will still succeed, partial transcript may be missing", + state.session_id, + exc_info=True, + ) + + def _session_response_fields(self, state: SessionState) -> dict[str, Any]: + """Common ``models``/``modes``/``field_meta`` for session responses; also + schedules the command advertisement and usage refresh.""" + self._schedule_available_commands_update(state.session_id) + self._schedule_usage_update(state) + return { + "models": self._build_model_state(state), + "modes": self._session_modes(state), + "field_meta": self._provenance_meta( + state.session_id, getattr(state.agent, "session_id", state.session_id) + ), + } + + async def new_session(self, cwd: str, mcp_servers: list | None = None, **kwargs: Any) -> NewSessionResponse: state = self.session_manager.create_session(cwd=cwd) await self._register_session_mcp_servers(state, mcp_servers) self._schedule_mcp_late_refresh(state) logger.info("New session %s (cwd=%s)", state.session_id, cwd) - self._schedule_available_commands_update(state.session_id) - self._schedule_usage_update(state) - return NewSessionResponse( - session_id=state.session_id, - models=self._build_model_state(state), - modes=self._session_modes(state), - field_meta=self._provenance_meta( - state.session_id, getattr(state.agent, "session_id", state.session_id) - ), - ) + return NewSessionResponse(session_id=state.session_id, **self._session_response_fields(state)) async def load_session( - self, - cwd: str, - session_id: str, - mcp_servers: list | None = None, - **kwargs: Any, + self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any ) -> LoadSessionResponse | None: state = self.session_manager.update_cwd(session_id, cwd) if state is None: @@ -1623,46 +1345,11 @@ class HermesACPAgent(acp.Agent): await self._register_session_mcp_servers(state, mcp_servers) self._schedule_mcp_late_refresh(state) logger.info("Loaded session %s", session_id) - # Per ACP spec, `session/load` must stream the prior conversation back - # to the client via `session/update` notifications BEFORE responding, - # so the client receives the full transcript within the load request's - # lifetime. Awaiting the replay here matches Codex / Claude Code / - # OpenCode / Pi and the Zed client (which registers the session-update - # routing entry before awaiting the loadSession RPC specifically so - # in-call history replay updates can find the thread). Deferring this - # via `loop.call_soon` (as we did briefly in May 2026) broke every - # spec-compliant ACP client that measures notifications synchronously - # against the load response — see #12285 follow-up. - try: - await self._replay_session_history(state) - except Exception: - # Replay is best-effort — a corrupted or unexpected message shape - # must not turn a successful session/load into a JSON-RPC error - # response. Per-notification failures are already caught inside - # ``_replay_session_history``; this outer guard covers anything - # raised by the helpers themselves before reaching ``_send``. - logger.warning( - "ACP history replay raised during session/load for %s — " - "load will still succeed, partial transcript may be missing", - session_id, - exc_info=True, - ) - self._schedule_available_commands_update(session_id) - self._schedule_usage_update(state) - return LoadSessionResponse( - models=self._build_model_state(state), - modes=self._session_modes(state), - field_meta=self._provenance_meta( - session_id, getattr(state.agent, "session_id", session_id) - ), - ) + await self._replay_history_guarded(state, "load") + return LoadSessionResponse(**self._session_response_fields(state)) async def resume_session( - self, - cwd: str, - session_id: str, - mcp_servers: list | None = None, - **kwargs: Any, + self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any ) -> ResumeSessionResponse: state = self.session_manager.update_cwd(session_id, cwd) if state is None: @@ -1671,27 +1358,8 @@ class HermesACPAgent(acp.Agent): await self._register_session_mcp_servers(state, mcp_servers) self._schedule_mcp_late_refresh(state) logger.info("Resumed session %s", state.session_id) - # See `load_session` above for the spec rationale — replay must - # complete before the response so clients receive the full transcript - # within the request's lifetime. - try: - await self._replay_session_history(state) - except Exception: - logger.warning( - "ACP history replay raised during session/resume for %s — " - "resume will still succeed, partial transcript may be missing", - state.session_id, - exc_info=True, - ) - self._schedule_available_commands_update(state.session_id) - self._schedule_usage_update(state) - return ResumeSessionResponse( - models=self._build_model_state(state), - modes=self._session_modes(state), - field_meta=self._provenance_meta( - state.session_id, getattr(state.agent, "session_id", state.session_id) - ), - ) + await self._replay_history_guarded(state, "resume") + return ResumeSessionResponse(**self._session_response_fields(state)) async def cancel(self, session_id: str, **kwargs: Any) -> None: state = self.session_manager.get_session(session_id) @@ -1704,22 +1372,14 @@ class HermesACPAgent(acp.Agent): # redirectable work. state.cancel_event.set() try: - if getattr(state, "agent", None): + if state.agent: request_hard_interrupt(state.agent) except Exception: - logger.debug( - "Failed to interrupt ACP session %s", - session_id, - exc_info=True, - ) + logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True) logger.info("Cancelled session %s", session_id) async def fork_session( - self, - cwd: str, - session_id: str, - mcp_servers: list | None = None, - **kwargs: Any, + self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any ) -> ForkSessionResponse: state = self.session_manager.fork_session(session_id, cwd=cwd) new_id = state.session_id if state else "" @@ -1735,19 +1395,12 @@ class HermesACPAgent(acp.Agent): ) async def list_sessions( - self, - cursor: str | None = None, - cwd: str | None = None, - **kwargs: Any, + self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any ) -> ListSessionsResponse: - """List ACP sessions with optional ``cwd`` filtering and cursor pagination. - - ``cwd`` is passed through to ``SessionManager.list_sessions`` which already - normalizes and filters by working directory. ``cursor`` is a ``session_id`` - previously returned as ``next_cursor``; results resume after that entry. - Server-side page size is capped at ``_LIST_SESSIONS_PAGE_SIZE``; when more - results remain, ``next_cursor`` is set to the last returned ``session_id``. - """ + """``cwd`` filtering is done by ``SessionManager.list_sessions``. ``cursor`` + is a ``session_id`` previously returned as ``next_cursor``; results + resume after it (unknown cursor -> empty page, never the full list). + Pages are capped at ``_LIST_SESSIONS_PAGE_SIZE``.""" infos = self.session_manager.list_sessions(cwd=cwd) if cursor: @@ -1756,7 +1409,6 @@ class HermesACPAgent(acp.Agent): infos = infos[idx + 1:] break else: - # Unknown cursor -> empty page (do not fall back to full list). infos = [] has_more = len(infos) > _LIST_SESSIONS_PAGE_SIZE @@ -1768,12 +1420,7 @@ class HermesACPAgent(acp.Agent): if updated_at is not None and not isinstance(updated_at, str): updated_at = str(updated_at) sessions.append( - SessionInfo( - session_id=s["session_id"], - cwd=s["cwd"], - title=s.get("title"), - updated_at=updated_at, - ) + SessionInfo(session_id=s["session_id"], cwd=s["cwd"], title=s.get("title"), updated_at=updated_at) ) next_cursor = sessions[-1].session_id if has_more and sessions else None @@ -1781,47 +1428,26 @@ class HermesACPAgent(acp.Agent): # ---- Prompt (core) ------------------------------------------------------ - async def prompt( - self, - prompt: list[ - TextContentBlock - | ImageContentBlock - | AudioContentBlock - | ResourceContentBlock - | EmbeddedResourceContentBlock - ], - session_id: str, - **kwargs: Any, - ) -> PromptResponse: - """Run Hermes on the user's prompt and stream events back to the editor.""" - state = self.session_manager.get_session(session_id) - if state is None: - logger.error("prompt: session %s not found", session_id) - return PromptResponse(stop_reason="refusal") + def _rewrite_prompt_for_interrupt( + self, state: SessionState, user_text: str, user_content: Any, text_only: bool + ) -> tuple[str, Any]: + """Attach a client-cancelled prompt to the follow-up text, and run idle + ``/steer`` as a normal prompt. - user_text = _extract_text(prompt).strip() - user_content = _content_blocks_to_openai_user_content(prompt) - text_only_prompt = all(isinstance(block, TextContentBlock) for block in prompt) - has_content = bool(user_text) or ( - isinstance(user_content, list) and bool(user_content) - ) - if not has_content: - return PromptResponse(stop_reason="end_turn") + ``/steer`` on an idle session has no in-flight tool call to inject into + (matching the gateway): if a prior prompt was just cancelled, replay it + with the steer text as explicit correction so the in-flight work isn't + lost; otherwise run the steer payload as a plain prompt instead of + silently queueing it ("No active turn — queued") as if the user typed + ``/queue``. Plain text after a cancel likewise keeps the cancelled request + attached ("stop and send" clients) so deictic follow-ups have a target. + """ + if not (text_only and isinstance(user_content, str)): + return user_text, user_content - # /steer on an idle session has no in-flight tool call to inject into. - # Rewrite it so the payload runs as a normal user prompt, matching the - # gateway's behavior (gateway/run.py ~L4898). Two sub-cases: - # 1. Zed-interrupt salvage — a prior prompt was cancelled by the - # client right before /steer arrived; replay it with the steer - # text attached as explicit correction/guidance so the user's - # in-flight work isn't lost. - # 2. Plain idle — no prior work to salvage; just run the steer - # payload as a regular prompt. Without this, _cmd_steer would - # silently append to state.queued_prompts and respond with - # "No active turn — queued for the next turn", which looks like - # /queue even though the user never typed /queue. - if text_only_prompt and isinstance(user_content, str) and user_text.startswith("/steer"): - steer_text = user_text.split(maxsplit=1)[1].strip() if len(user_text.split(maxsplit=1)) > 1 else "" + if user_text.startswith("/steer"): + split = user_text.split(maxsplit=1) + steer_text = split[1].strip() if len(split) > 1 else "" interrupted_prompt = "" rewrite_idle = False with state.runtime_lock: @@ -1832,95 +1458,181 @@ class HermesACPAgent(acp.Agent): else: rewrite_idle = True if interrupted_prompt: - user_text = ( - f"{interrupted_prompt}\n\n" - f"User correction/guidance after interrupt: {steer_text}" - ) - user_content = user_text - elif rewrite_idle: - user_text = steer_text - user_content = steer_text - elif ( - text_only_prompt - and isinstance(user_content, str) - and not user_text.startswith("/") - ): - # Some ACP clients implement "stop and send" as two protocol calls: - # cancel the active prompt, then submit plain correction text. Keep - # the cancelled request attached so deictic follow-ups ("not that - # file") still have an explicit target. + return (_attach_interrupted_prompt(interrupted_prompt, steer_text),) * 2 + if rewrite_idle: + return steer_text, steer_text + return user_text, user_content + + if not user_text.startswith("/"): interrupted_prompt = "" with state.runtime_lock: if not state.is_running and state.interrupted_prompt_text: interrupted_prompt = state.interrupted_prompt_text state.interrupted_prompt_text = "" if interrupted_prompt: - user_text = ( - f"{interrupted_prompt}\n\n" - f"User correction/guidance after interrupt: {user_text}" - ) - user_content = user_text + return (_attach_interrupted_prompt(interrupted_prompt, user_text),) * 2 - # Intercept slash commands — handle locally without calling the LLM. - # Slash commands are text-only; if the client included images/resources, - # send the whole multimodal prompt to the agent instead of treating it as - # an ACP command. - if text_only_prompt and isinstance(user_content, str) and user_text.startswith("/"): - response_text = self._handle_slash_command(user_text, state) - if response_text is not None: - if self._conn: - update = acp.update_agent_message_text(response_text) - await self._conn.session_update(session_id, update) - await self._send_usage_update(state) - return PromptResponse(stop_reason="end_turn") + return user_text, user_content - # If the client sends another regular text prompt while this ACP session - # is running, route it through the core active-turn redirect. Rich media - # and older runtimes retain the proven next-turn queue fallback. + def _claim_turn_or_queue( + self, state: SessionState, session_id: str, user_text: str, user_content: Any, text_only: bool + ) -> str | None: + """Mark the session running, or — if a turn is active — redirect it + (text-only, runtime supports it) or queue for the next turn. Returns the + message to send the client when the prompt was absorbed, else None.""" redirected = False queued_depth: int | None = None with state.runtime_lock: if state.is_running: if ( - text_only_prompt + text_only and isinstance(user_content, str) - and getattr( - state.agent, - "_supports_active_turn_redirect", - False, - ) - is True + and getattr(state.agent, "_supports_active_turn_redirect", False) is True and hasattr(state.agent, "redirect") ): try: redirected = bool(state.agent.redirect(user_content)) except Exception: - logger.debug( - "ACP active-turn redirect failed for %s", - session_id, - exc_info=True, - ) + logger.debug("ACP active-turn redirect failed for %s", session_id, exc_info=True) if not redirected: - queued_text = user_text or "[Image attachment]" - state.queued_prompts.append(queued_text) + state.queued_prompts.append(user_text or "[Image attachment]") queued_depth = len(state.queued_prompts) else: state.is_running = True state.current_prompt_text = user_text or "[Image attachment]" if redirected: - if self._conn: - update = acp.update_agent_message_text( - "Redirected the active turn with your correction." - ) - await self._conn.session_update(session_id, update) - return PromptResponse(stop_reason="end_turn") + return "Redirected the active turn with your correction." if queued_depth is not None: + return f"Queued for the next turn. ({queued_depth} queued)" + return None + + def _run_agent_turn( + self, *, state: SessionState, session_id: str, user_text: str, user_content: Any, conn: Any, + loop: asyncio.AbstractEventLoop, approval_cb: Any, edit_approval_requester: Any, + ) -> dict: + """Executor-thread body of one turn. Runs inside ``contextvars.copy_context()`` + so every ContextVar write below is isolated from concurrent sessions. + + Approval routing is thread-local, so it MUST be bound here (executor + thread), not on the event-loop thread. Interactive routing uses the + ``tools.approval`` contextvar rather than ``os.environ["HERMES_INTERACTIVE"]`` + so concurrent workers can't race a process-global flag and drop another + session onto the non-interactive auto-approve path (GHSA-96vc-wcxf-jjff). + """ + agent = state.agent + # Bind HERMES_SESSION_KEY so per-session caches (e.g. the interactive + # sudo password cache) scope to this ACP session, not the reused thread. + # ``cwd`` pins the logical working directory the system prompt reports + # (resolve_agent_cwd); without it the prompt advertises the global + # Hermes workspace while tools are rooted at the client's project, and + # edits land outside the editor's workspace. ``cron_session=""`` masks + # any leaked process-global HERMES_CRON_SESSION. + try: + from gateway.session_context import clear_session_vars, set_session_vars + + session_tokens = set_session_vars( + session_key=session_id, session_id=session_id, cwd=state.cwd, cron_session="", + ) + except Exception: + session_tokens = None + clear_session_vars = None # type: ignore[assignment] + logger.debug("Could not set ACP session context", exc_info=True) + previous_approval_cb = None + if approval_cb: + try: + from tools import terminal_tool as _terminal_tool + previous_approval_cb = _terminal_tool._get_approval_callback() + _terminal_tool.set_approval_callback(approval_cb) + except Exception: + logger.debug("Could not set ACP approval callback", exc_info=True) + edit_approval_token = None + if edit_approval_requester: + try: + from acp_adapter.edit_approval import set_edit_approval_requester + + edit_approval_token = set_edit_approval_requester(edit_approval_requester) + except Exception: + logger.debug("Could not set ACP edit approval requester", exc_info=True) + interactive_token = set_hermes_interactive_context(True) + # Tools tag side-effects with the originating ACP session (e.g. + # ``kanban_create``); save/restore so a reused thread never leaks it. + previous_session_id = os.environ.get("HERMES_SESSION_ID") + os.environ["HERMES_SESSION_ID"] = session_id + + # Auto-titling fires in the turn prologue; deliver the new title now + # as a session-info update instead of waiting for the next one. + def _notify_title_update(_title: str, _source: str) -> None: + if conn: + loop.call_soon_threadsafe(asyncio.create_task, self._send_session_info_update(session_id)) + + agent._on_session_title = _notify_title_update + try: + return agent.run_conversation( + user_message=user_content, conversation_history=state.history, task_id=session_id, + persist_user_message=user_text or "[Image attachment]", + ) + except Exception as e: + logger.exception("Agent error in session %s", session_id) + return {"final_response": f"Error: {e}", "messages": state.history} + finally: + if interactive_token is not None: + reset_hermes_interactive_context(interactive_token) + if previous_session_id is None: + os.environ.pop("HERMES_SESSION_ID", None) + else: + os.environ["HERMES_SESSION_ID"] = previous_session_id + if approval_cb: + try: + from tools import terminal_tool as _terminal_tool + _terminal_tool.set_approval_callback(previous_approval_cb) + except Exception: + logger.debug("Could not restore approval callback", exc_info=True) + if edit_approval_token is not None: + try: + from acp_adapter.edit_approval import reset_edit_approval_requester + + reset_edit_approval_requester(edit_approval_token) + except Exception: + logger.debug("Could not restore ACP edit approval requester", exc_info=True) + if session_tokens is not None and clear_session_vars is not None: + try: + clear_session_vars(session_tokens) + except Exception: + logger.debug("Could not clear ACP session context", exc_info=True) + + async def prompt(self, prompt: list[PromptBlock], session_id: str, **kwargs: Any) -> PromptResponse: + """Run Hermes on the user's prompt and stream events back to the editor.""" + state = self.session_manager.get_session(session_id) + if state is None: + logger.error("prompt: session %s not found", session_id) + return PromptResponse(stop_reason="refusal") + + user_text = _extract_text(prompt).strip() + user_content = _content_blocks_to_openai_user_content(prompt) + text_only_prompt = all(isinstance(block, TextContentBlock) for block in prompt) + has_content = bool(user_text) or (isinstance(user_content, list) and bool(user_content)) + if not has_content: + return PromptResponse(stop_reason="end_turn") + + user_text, user_content = self._rewrite_prompt_for_interrupt( + state, user_text, user_content, text_only_prompt + ) + + # Slash commands are text-only and handled locally without the LLM; a + # prompt with images/resources goes to the agent even if it starts with "/". + if text_only_prompt and isinstance(user_content, str) and user_text.startswith("/"): + response_text = self._handle_slash_command(user_text, state) + if response_text is not None: + if self._conn: + await self._conn.session_update(session_id, acp.update_agent_message_text(response_text)) + await self._send_usage_update(state) + return PromptResponse(stop_reason="end_turn") + + absorbed = self._claim_turn_or_queue(state, session_id, user_text, user_content, text_only_prompt) + if absorbed is not None: if self._conn: - update = acp.update_agent_message_text( - f"Queued for the next turn. ({queued_depth} queued)" - ) - await self._conn.session_update(session_id, update) + await self._conn.session_update(session_id, acp.update_agent_message_text(absorbed)) return PromptResponse(stop_reason="end_turn") logger.info("Prompt on session %s: %s", session_id, user_text[:100]) @@ -1931,195 +1643,21 @@ class HermesACPAgent(acp.Agent): if state.cancel_event: state.cancel_event.clear() - tool_call_ids: dict[str, Deque[str]] = defaultdict(deque) - tool_call_meta: dict[str, dict[str, Any]] = {} - previous_approval_cb = None - edit_approval_requester = None - - streamed_message = False - - if conn: - tool_progress_cb = make_tool_progress_cb( - conn, - session_id, - loop, - tool_call_ids, - tool_call_meta, - edit_approval_policy_getter=lambda: self._edit_approval_policy_for_state(state), - ) - reasoning_cb = make_thinking_cb(conn, session_id, loop) - step_cb = make_step_cb(conn, session_id, loop, tool_call_ids, tool_call_meta) - message_cb = make_message_cb(conn, session_id, loop) - - def stream_delta_cb(text: str) -> None: - nonlocal streamed_message - if text: - streamed_message = True - message_cb(text) - - approval_cb = make_approval_callback(conn.request_permission, loop, session_id) - try: - from acp_adapter.edit_approval import make_acp_edit_approval_requester - - edit_approval_requester = make_acp_edit_approval_requester( - conn.request_permission, - loop, - session_id, - auto_approve_getter=lambda: self._edit_approval_policy_for_state(state), - ) - except Exception: - logger.debug("Could not create ACP edit approval requester", exc_info=True) - else: - tool_progress_cb = None - reasoning_cb = None - step_cb = None - stream_delta_cb = None - approval_cb = None - - agent = state.agent - agent.tool_progress_callback = tool_progress_cb - # ACP thought panes should not receive Hermes' local kawaii waiting/status - # updates. Route provider/model reasoning deltas instead; if the provider - # emits no reasoning, Zed should not get a fake "thinking" accordion. - agent.thinking_callback = None - agent.reasoning_callback = reasoning_cb - agent.step_callback = step_cb - agent.stream_delta_callback = stream_delta_cb - - # Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr). - # Set it INSIDE _run_agent so the TLS write happens in the executor - # thread — setting it here would write to the event-loop thread's TLS, - # not the executor's. Interactive routing uses a contextvar in - # tools.approval (set_hermes_interactive_context) rather than - # os.environ["HERMES_INTERACTIVE"], so concurrent executor workers can't - # race on a process-global flag — one session's restore can't drop - # another onto the non-interactive auto-approve path mid-run - # (GHSA-96vc-wcxf-jjff). The contextvar write is isolated by the - # contextvars.copy_context() wrapper around the executor call below. - # ACP's conn.request_permission maps cleanly to the interactive - # callback shape — not the gateway-queue HERMES_EXEC_ASK path, - # which requires a notify_cb registered in _gateway_notify_cbs. - previous_approval_cb = None - interactive_token = None - edit_approval_token = None - previous_session_id = None + cbs = self._wire_turn_callbacks(state, session_id, conn, loop) def _run_agent() -> dict: - nonlocal previous_approval_cb, interactive_token, edit_approval_token, previous_session_id - # Bind HERMES_SESSION_KEY for this session so per-session caches - # (e.g. the interactive sudo password cache in tools.terminal_tool) - # scope to the ACP session rather than leaking across sessions - # that land on the same reused executor thread. This call runs - # inside a contextvars.copy_context() below, so the ContextVar - # write is isolated from other concurrent ACP sessions. - try: - from gateway.session_context import ( - clear_session_vars, - set_session_vars, - ) - # ``cwd`` pins the logical working directory for this context, - # which is what the system prompt's "Current working directory" - # line reports (agent/prompt_builder.py -> resolve_agent_cwd). - # Without it the prompt advertises the global Hermes workspace - # while the tools are rooted at the client's project, so the - # model emits absolute paths under ~/.hermes/workspace and the - # edit silently lands outside the editor's workspace. - # cron_session="" explicitly marks this as a non-cron context, - # masking any leaked process-global HERMES_CRON_SESSION (#37968). - session_tokens = set_session_vars( - session_key=session_id, session_id=session_id, cwd=state.cwd, - cron_session="", - ) - except Exception: - session_tokens = None - clear_session_vars = None # type: ignore[assignment] - logger.debug("Could not set ACP session context", exc_info=True) - if approval_cb: - try: - from tools import terminal_tool as _terminal_tool - previous_approval_cb = _terminal_tool._get_approval_callback() - _terminal_tool.set_approval_callback(approval_cb) - except Exception: - logger.debug("Could not set ACP approval callback", exc_info=True) - if edit_approval_requester: - try: - from acp_adapter.edit_approval import set_edit_approval_requester - - edit_approval_token = set_edit_approval_requester(edit_approval_requester) - except Exception: - logger.debug("Could not set ACP edit approval requester", exc_info=True) - # Signal to tools.approval that we have an interactive callback - # and the non-interactive auto-approve path must not fire. Uses a - # contextvar (not os.environ) so concurrent executor workers don't - # race on the flag (GHSA-96vc-wcxf-jjff). - interactive_token = set_hermes_interactive_context(True) - # Propagate the originating ACP session id to tools that want to - # tag side-effects with it (e.g. ``kanban_create`` stamps it on - # the new task so clients can render a per-session board). Save - # and restore around the agent call so a re-used executor thread - # never leaks one session's id into the next session's tools. - previous_session_id = os.environ.get("HERMES_SESSION_ID") - os.environ["HERMES_SESSION_ID"] = session_id - # Auto-titling fires inside the turn prologue now; give the agent - # this session's notifier so a new title reaches the client as a - # session-info update instead of waiting for the next one. - def _notify_title_update(_title: str, _source: str) -> None: - if conn: - loop.call_soon_threadsafe( - asyncio.create_task, - self._send_session_info_update(session_id), - ) - - agent._on_session_title = _notify_title_update - try: - result = agent.run_conversation( - user_message=user_content, - conversation_history=state.history, - task_id=session_id, - persist_user_message=user_text or "[Image attachment]", - ) - return result - except Exception as e: - logger.exception("Agent error in session %s", session_id) - return {"final_response": f"Error: {e}", "messages": state.history} - finally: - # Restore the interactive contextvar for this context. - if interactive_token is not None: - reset_hermes_interactive_context(interactive_token) - # Restore HERMES_SESSION_ID symmetrically. - if previous_session_id is None: - os.environ.pop("HERMES_SESSION_ID", None) - else: - os.environ["HERMES_SESSION_ID"] = previous_session_id - if approval_cb: - try: - from tools import terminal_tool as _terminal_tool - _terminal_tool.set_approval_callback(previous_approval_cb) - except Exception: - logger.debug("Could not restore approval callback", exc_info=True) - if edit_approval_token is not None: - try: - from acp_adapter.edit_approval import reset_edit_approval_requester - - reset_edit_approval_requester(edit_approval_token) - except Exception: - logger.debug("Could not restore ACP edit approval requester", exc_info=True) - if session_tokens is not None and clear_session_vars is not None: - try: - clear_session_vars(session_tokens) - except Exception: - logger.debug("Could not clear ACP session context", exc_info=True) + return self._run_agent_turn( + state=state, session_id=session_id, user_text=user_text, user_content=user_content, conn=conn, + loop=loop, approval_cb=cbs.approval_cb, edit_approval_requester=cbs.edit_approval_requester, + ) try: - # Snapshot the internal Hermes DB session id before the turn so we - # can detect a compression-driven session rotation afterwards. The - # ACP `session_id` stays the stable client handle; agent.session_id - # is the live internal head that compression may rotate. + # The ACP `session_id` is the stable client handle; agent.session_id + # is the live internal head that compression may rotate. Snapshot it + # to detect a rotation after the turn. pre_turn_hermes_id = getattr(state.agent, "session_id", None) - # Wrap the executor call in a fresh copy of the current context so - # concurrent ACP sessions on the shared ThreadPoolExecutor don't - # stomp on each other's ContextVar writes (HERMES_SESSION_KEY in - # particular — used by the interactive sudo password cache scope). + # Fresh context copy so concurrent sessions on the shared executor + # don't stomp on each other's ContextVar writes. ctx = contextvars.copy_context() result = await loop.run_in_executor(_executor, ctx.run, _run_agent) except Exception: @@ -2129,15 +1667,62 @@ class HermesACPAgent(acp.Agent): state.current_prompt_text = "" return PromptResponse(stop_reason="end_turn") + return await self._finish_turn(state, session_id, conn, result, pre_turn_hermes_id, cbs.streamed) + + def _wire_turn_callbacks( + self, state: SessionState, session_id: str, conn: Any, loop: asyncio.AbstractEventLoop + ) -> _TurnCallbacks: + """Install the ACP streaming callbacks on the session agent for one turn.""" + cbs = _TurnCallbacks() + if conn: + tool_call_ids: dict[str, Deque[str]] = defaultdict(deque) + tool_call_meta: dict[str, dict[str, Any]] = {} + policy_getter = lambda: self._edit_approval_policy_for_state(state) # noqa: E731 + cbs.tool_progress_cb = make_tool_progress_cb( + conn, session_id, loop, tool_call_ids, tool_call_meta, edit_approval_policy_getter=policy_getter + ) + cbs.reasoning_cb = make_thinking_cb(conn, session_id, loop) + cbs.step_cb = make_step_cb(conn, session_id, loop, tool_call_ids, tool_call_meta) + message_cb = make_message_cb(conn, session_id, loop) + + def stream_delta_cb(text: str) -> None: + if text: + cbs.streamed = True + message_cb(text) + + cbs.stream_delta_cb = stream_delta_cb + cbs.approval_cb = make_approval_callback(conn.request_permission, loop, session_id) + try: + from acp_adapter.edit_approval import make_acp_edit_approval_requester + + cbs.edit_approval_requester = make_acp_edit_approval_requester( + conn.request_permission, loop, session_id, auto_approve_getter=policy_getter + ) + except Exception: + logger.debug("Could not create ACP edit approval requester", exc_info=True) + + agent = state.agent + agent.tool_progress_callback = cbs.tool_progress_cb + # ACP thought panes get provider reasoning deltas only — never Hermes' + # local status updates, and no fake "thinking" accordion when the + # provider emits no reasoning. + agent.thinking_callback = None + agent.reasoning_callback = cbs.reasoning_cb + agent.step_callback = cbs.step_cb + agent.stream_delta_callback = cbs.stream_delta_cb + return cbs + + async def _finish_turn( + self, state: SessionState, session_id: str, conn: Any, result: dict, pre_turn_hermes_id: Any, + streamed_message: bool, + ) -> PromptResponse: + """Persist, emit provenance/final text, drain queued prompts, report usage.""" if result.get("messages"): state.history = result["messages"] - # Persist updated history so sessions survive process restarts. self.session_manager.save_session(session_id) - # Detect a compression-driven internal session rotation. If the agent's - # DB head moved during the turn, emit a session_info_update carrying - # _meta.hermes.sessionProvenance so ACP clients can render the boundary - # and keep old/new ids in lineage. The ACP session_id is unchanged. + # Internal head rotated (compression split): emit provenance so clients + # can render the boundary; the ACP session_id is unchanged. post_turn_hermes_id = getattr(state.agent, "session_id", None) if ( conn @@ -2147,43 +1732,32 @@ class HermesACPAgent(acp.Agent): ): try: await self._send_session_info_update( - session_id, - current_hermes_session_id=post_turn_hermes_id, + session_id, current_hermes_session_id=post_turn_hermes_id, previous_hermes_session_id=pre_turn_hermes_id, ) except Exception: - logger.debug( - "Could not emit ACP provenance update after rotation for %s", - session_id, - exc_info=True, - ) + logger.debug("Could not emit ACP provenance update after rotation for %s", session_id, exc_info=True) final_response = result.get("final_response", "") cancelled = bool(state.cancel_event and state.cancel_event.is_set()) interrupted = bool(result.get("interrupted")) or cancelled - # Hermes' local "waiting for model response" interrupt status is metadata, - # not assistant prose — clients get cancellation from stop_reason instead. + # The local "waiting for model response" interrupt status is metadata, + # not assistant prose — clients learn cancellation from stop_reason. from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX - suppress_interrupt_response = interrupted and final_response.startswith( - INTERRUPT_WAITING_FOR_MODEL_PREFIX - ) + suppress_interrupt_response = interrupted and final_response.startswith(INTERRUPT_WAITING_FOR_MODEL_PREFIX) + # Deliver the final response when streaming didn't already, or when a + # plugin hook transformed it after streaming (transform_llm_output). if ( final_response and conn and not suppress_interrupt_response and (not streamed_message or result.get("response_transformed")) ): - # Deliver the final response when streaming did not already send it, - # or when a plugin hook transformed the response after streaming - # finished (e.g. transform_llm_output) — otherwise the appended / - # rewritten text never reaches the client. - update = acp.update_agent_message_text(final_response) - await conn.session_update(session_id, update) + await conn.session_update(session_id, acp.update_agent_message_text(final_response)) - # Mark this turn idle before draining queued work so recursive prompt() - # calls can acquire the session. Queued turns are intentionally run as - # normal follow-up user prompts, preserving role alternation and history. + # Go idle before draining queued work so recursive prompt() calls can + # acquire the session; queued turns run as normal follow-up prompts. with state.runtime_lock: state.is_running = False state.current_prompt_text = "" @@ -2194,111 +1768,58 @@ class HermesACPAgent(acp.Agent): break next_prompt = state.queued_prompts.pop(0) if conn: - await conn.session_update( - session_id, - acp.update_user_message_text(next_prompt), - ) - await self.prompt( - prompt=[TextContentBlock(type="text", text=next_prompt)], - session_id=session_id, - ) + await conn.session_update(session_id, acp.update_user_message_text(next_prompt)) + await self.prompt(prompt=[TextContentBlock(type="text", text=next_prompt)], session_id=session_id) usage = None if any(result.get(key) is not None for key in ("prompt_tokens", "completion_tokens", "total_tokens")): usage = Usage( - input_tokens=result.get("prompt_tokens", 0), - output_tokens=result.get("completion_tokens", 0), - total_tokens=result.get("total_tokens", 0), - thought_tokens=result.get("reasoning_tokens"), + input_tokens=result.get("prompt_tokens", 0), output_tokens=result.get("completion_tokens", 0), + total_tokens=result.get("total_tokens", 0), thought_tokens=result.get("reasoning_tokens"), cached_read_tokens=result.get("cache_read_tokens"), ) await self._send_usage_update(state) - stop_reason = "cancelled" if cancelled else "end_turn" - return PromptResponse(stop_reason=stop_reason, usage=usage) + return PromptResponse(stop_reason="cancelled" if cancelled else "end_turn", usage=usage) # ---- Slash commands (headless) ------------------------------------------- @classmethod def _available_commands(cls) -> list[AvailableCommand]: - commands: list[AvailableCommand] = [] - for spec in cls._ADVERTISED_COMMANDS: - input_hint = spec.get("input_hint") - commands.append( - AvailableCommand( - name=spec["name"], - description=spec["description"], - input=UnstructuredCommandInput(hint=input_hint) - if input_hint - else None, - ) - ) - return commands + return [ + AvailableCommand(name=name, description=desc, input=UnstructuredCommandInput(hint=hint) if hint else None) + for name, (_help, desc, hint) in cls._COMMANDS.items() + ] async def _send_available_commands_update(self, session_id: str) -> None: """Advertise supported slash commands to the connected ACP client.""" if not self._conn: return - - try: - await self._conn.session_update( - session_id=session_id, - update=AvailableCommandsUpdate( - session_update="available_commands_update", - available_commands=self._available_commands(), - ), - ) - except Exception: - logger.warning( - "Failed to advertise ACP slash commands for session %s", - session_id, - exc_info=True, - ) + update = AvailableCommandsUpdate( + session_update="available_commands_update", available_commands=self._available_commands() + ) + await self._send(session_id, update, fail_msg="Failed to advertise ACP slash commands for session %s") def _schedule_available_commands_update(self, session_id: str) -> None: - """Send the command advertisement after the session response is queued.""" - if not self._conn: - return - loop = asyncio.get_running_loop() - loop.call_soon( - asyncio.create_task, self._send_available_commands_update(session_id) - ) + self._schedule_soon(lambda: self._send_available_commands_update(session_id)) def _handle_slash_command(self, text: str, state: SessionState) -> str | None: - """Dispatch a slash command and return the response text. - - Returns ``None`` for unrecognized commands so they fall through - to the LLM (the user may have typed ``/something`` as prose). - """ + """Dispatch a slash command; ``None`` for unknown commands so they fall + through to the LLM (the user may have typed ``/something`` as prose).""" parts = text.split(maxsplit=1) cmd = parts[0].lstrip("/").lower() args = parts[1].strip() if len(parts) > 1 else "" - handler = { - "help": self._cmd_help, - "model": self._cmd_model, - "tools": self._cmd_tools, - "context": self._cmd_context, - "reset": self._cmd_reset, - "compress": self._cmd_compress, - "steer": self._cmd_steer, - "queue": self._cmd_queue, - "version": self._cmd_version, - }.get(cmd) + if cmd not in self._COMMANDS: + return None + handler = getattr(self, f"_cmd_{cmd}") - if handler is None: - return None # not a known command — let the LLM handle it - - # Slash handlers run on the event-loop thread, OUTSIDE the per-turn - # contextvars.copy_context() that pins the session cwd for the agent - # call. ``/compress`` and ``/model`` reach code that REBUILDS the - # system prompt (agent._build_system_prompt -> resolve_agent_cwd), so - # an unpinned handler bakes the Hermes install tree into the session's - # cached prompt — persisted, and therefore poisoning every later turn - # even though the turn itself is pinned. Pin inside a fresh context so - # the write can't leak into other concurrent ACP sessions and needs no - # teardown. + # Handlers run on the event-loop thread, OUTSIDE the per-turn context + # that pins the session cwd. ``/compress`` and ``/model`` REBUILD the + # system prompt (resolve_agent_cwd), so an unpinned handler would bake + # the Hermes install tree into the persisted cached prompt and poison + # every later turn. Pin inside a fresh context: no leak, no teardown. def _dispatch() -> str | None: try: from agent.runtime_cwd import set_session_cwd @@ -2316,10 +1837,8 @@ class HermesACPAgent(acp.Agent): def _cmd_help(self, args: str, state: SessionState) -> str: lines = ["Available commands:", ""] - for cmd, desc in self._SLASH_COMMANDS.items(): - lines.append(f" /{cmd:10s} {desc}") - lines.append("") - lines.append("Unrecognized /commands are sent to the model as normal messages.") + lines.extend(f" /{cmd:10s} {desc}" for cmd, (desc, _adv, _hint) in self._COMMANDS.items()) + lines.extend(["", "Unrecognized /commands are sent to the model as normal messages."]) return "\n".join(lines) def _cmd_model(self, args: str, state: SessionState) -> str: @@ -2328,18 +1847,8 @@ class HermesACPAgent(acp.Agent): provider = getattr(state.agent, "provider", None) or "auto" return f"Current model: {model}\nProvider: {provider}" - current_provider = getattr(state.agent, "provider", None) or "openrouter" - target_provider, new_model = self._resolve_model_selection(args, current_provider) - - state.model = new_model - state.agent = self.session_manager._make_agent( - session_id=state.session_id, - cwd=state.cwd, - model=new_model, - requested_provider=target_provider, - ) - self.session_manager.save_session(state.session_id) - provider_label = getattr(state.agent, "provider", None) or target_provider or current_provider + current_provider, target_provider, new_model = self._switch_model(state, args) + provider_label = getattr(state.agent, "provider", None) or target_provider or current_provider or "openrouter" logger.info("Session %s: model switched to %s", state.session_id, new_model) return f"Model switched to: {new_model}\nProvider: {provider_label}" @@ -2349,17 +1858,11 @@ class HermesACPAgent(acp.Agent): from types import SimpleNamespace from agent.memory_manager import inject_memory_provider_tools - toolsets = _expand_acp_enabled_toolsets( - getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"] - ) + toolsets = _expand_acp_enabled_toolsets(getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]) tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True) tool_view = SimpleNamespace( tools=list(tools or []), - valid_tool_names={ - tool.get("function", {}).get("name") - for tool in tools or [] - if isinstance(tool, dict) - }, + valid_tool_names={t.get("function", {}).get("name") for t in tools or [] if isinstance(t, dict)}, enabled_toolsets=toolsets, _memory_manager=getattr(state.agent, "_memory_manager", None), ) @@ -2371,7 +1874,6 @@ class HermesACPAgent(acp.Agent): for t in tools: name = (t.get("function") or {}).get("name", "?") desc = (t.get("function") or {}).get("description", "") - # Truncate long descriptions if len(desc) > 80: desc = desc[:77] + "..." lines.append(f" {name}: {desc}") @@ -2383,7 +1885,6 @@ class HermesACPAgent(acp.Agent): """Show ACP session context pressure and compression guidance.""" n_messages = len(state.history) - # Count by role. roles: dict[str, int] = {} for msg in state.history: role = msg.get("role", "unknown") @@ -2397,15 +1898,7 @@ class HermesACPAgent(acp.Agent): threshold_tokens = int(getattr(compressor, "threshold_tokens", 0) or 0) try: - from agent.model_metadata import estimate_request_tokens_rough - - system_prompt = getattr(agent, "_cached_system_prompt", "") or "" - tools = getattr(agent, "tools", None) or None - approx_tokens = estimate_request_tokens_rough( - state.history, - system_prompt=system_prompt, - tools=tools, - ) + approx_tokens = _estimate_tokens(state.history, agent) except Exception: logger.debug("Could not estimate ACP context usage", exc_info=True) approx_tokens = 0 @@ -2414,9 +1907,7 @@ class HermesACPAgent(acp.Agent): threshold_tokens = int(context_length * 0.80) lines = [ - f"Conversation: {n_messages} messages" - if n_messages - else "Conversation is empty (no messages yet).", + f"Conversation: {n_messages} messages" if n_messages else "Conversation is empty (no messages yet).", f" user: {roles.get('user', 0)}, assistant: {roles.get('assistant', 0)}, " f"tool: {roles.get('tool', 0)}, system: {roles.get('system', 0)}", ] @@ -2427,28 +1918,21 @@ class HermesACPAgent(acp.Agent): if approx_tokens > 0: if context_length > 0: usage_pct = (approx_tokens / context_length) * 100 - lines.append( - f"Context usage: ~{approx_tokens:,} / {context_length:,} tokens ({usage_pct:.1f}%)" - ) + lines.append(f"Context usage: ~{approx_tokens:,} / {context_length:,} tokens ({usage_pct:.1f}%)") else: lines.append(f"Context usage: ~{approx_tokens:,} tokens") if threshold_tokens > 0: if approx_tokens > 0: threshold_pct = (threshold_tokens / context_length) * 100 if context_length > 0 else 0 - remaining = max(threshold_tokens - approx_tokens, 0) + pct_note = f", {threshold_pct:.0f}%" if threshold_pct else "" if approx_tokens >= threshold_tokens: - lines.append( - f"Compression: due now (threshold ~{threshold_tokens:,}" - + (f", {threshold_pct:.0f}%" if threshold_pct else "") - + "). Run /compress." - ) + lines.append(f"Compression: due now (threshold ~{threshold_tokens:,}{pct_note}). Run /compress.") else: + remaining = max(threshold_tokens - approx_tokens, 0) lines.append( f"Compression: ~{remaining:,} tokens until threshold " - f"(~{threshold_tokens:,}" - + (f", {threshold_pct:.0f}%" if threshold_pct else "") - + ")." + f"(~{threshold_tokens:,}{pct_note})." ) else: lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens") @@ -2484,34 +1968,26 @@ class HermesACPAgent(acp.Agent): return "Nothing to compress — conversation is empty." try: agent = state.agent - # No compression_enabled gate: the flag disables *automatic* - # compaction only; manual /compress must keep working (matches - # the CLI /compress and gateway handlers). + # No compression_enabled gate: that flag only disables *automatic* + # compaction; manual /compress must keep working (CLI/gateway parity). if not hasattr(agent, "_compress_context"): return "Context compression not available for this agent." - from agent.model_metadata import estimate_request_tokens_rough - original_count = len(state.history) - # Include system prompt + tool schemas so the figure reflects real - # request pressure, not a transcript-only underestimate (#6217). + # System prompt + tool schemas included so the figure reflects real + # request pressure, not a transcript-only underestimate. _sys_prompt = getattr(agent, "_cached_system_prompt", "") or "" _tools = getattr(agent, "tools", None) or None - approx_tokens = estimate_request_tokens_rough( - state.history, system_prompt=_sys_prompt, tools=_tools - ) + approx_tokens = _estimate_tokens(state.history, agent, _sys_prompt, _tools) original_session_db = getattr(agent, "_session_db", None) try: - # ACP sessions must keep a stable session id, so avoid the - # SQLite session-splitting side effect inside _compress_context. + # ACP sessions keep a stable session id: suppress the SQLite + # session-splitting side effect inside _compress_context. agent._session_db = None compressed, _ = agent._compress_context( - state.history, - getattr(agent, "_cached_system_prompt", "") or "", - approx_tokens=approx_tokens, - task_id=state.session_id, - force=True, + state.history, getattr(agent, "_cached_system_prompt", "") or "", + approx_tokens=approx_tokens, task_id=state.session_id, force=True, ) finally: agent._session_db = original_session_db @@ -2520,12 +1996,10 @@ class HermesACPAgent(acp.Agent): self.session_manager.save_session(state.session_id) new_count = len(state.history) - _sys_prompt_after = getattr(agent, "_cached_system_prompt", "") or _sys_prompt - _tools_after = getattr(agent, "tools", None) or _tools - new_tokens = estimate_request_tokens_rough( - state.history, - system_prompt=_sys_prompt_after, - tools=_tools_after, + new_tokens = _estimate_tokens( + state.history, agent, + getattr(agent, "_cached_system_prompt", "") or _sys_prompt, + getattr(agent, "tools", None) or _tools, ) return ( f"Context compressed: {original_count} -> {new_count} messages\n" @@ -2534,6 +2008,11 @@ class HermesACPAgent(acp.Agent): except Exception as e: return f"Compression failed: {e}" + def _queue_prompt(self, state: SessionState, text: str) -> int: + with state.runtime_lock: + state.queued_prompts.append(text) + return len(state.queued_prompts) + def _cmd_steer(self, args: str, state: SessionState) -> str: steer_text = args.strip() if not steer_text: @@ -2548,71 +2027,43 @@ class HermesACPAgent(acp.Agent): logger.warning("ACP steer failed for session %s: %s", state.session_id, exc) return f"⚠️ Steer failed: {exc}" - with state.runtime_lock: - state.queued_prompts.append(steer_text) - depth = len(state.queued_prompts) + depth = self._queue_prompt(state, steer_text) return f"No active turn — queued for the next turn. ({depth} queued)" def _cmd_queue(self, args: str, state: SessionState) -> str: queued_text = args.strip() if not queued_text: return "Usage: /queue " - with state.runtime_lock: - state.queued_prompts.append(queued_text) - depth = len(state.queued_prompts) + depth = self._queue_prompt(state, queued_text) return f"Queued for the next turn. ({depth} queued)" def _cmd_version(self, args: str, state: SessionState) -> str: return f"Hermes Agent v{HERMES_VERSION}" - # ---- Model switching (ACP protocol method) ------------------------------- + # ---- Session settings (ACP protocol methods) ----------------------------- - async def set_session_model( - self, model_id: str, session_id: str, **kwargs: Any - ) -> SetSessionModelResponse | None: + async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> SetSessionModelResponse | None: """Switch the model for a session (called by ACP protocol).""" state = self.session_manager.get_session(session_id) if state: - current_provider = getattr(state.agent, "provider", None) - requested_provider, resolved_model = self._resolve_model_selection( - model_id, - current_provider or "openrouter", - ) - state.model = resolved_model - provider_changed = bool(current_provider and requested_provider != current_provider) - current_base_url = None if provider_changed else getattr(state.agent, "base_url", None) - current_api_mode = None if provider_changed else getattr(state.agent, "api_mode", None) - state.agent = self.session_manager._make_agent( - session_id=session_id, - cwd=state.cwd, - model=resolved_model, - requested_provider=requested_provider, - base_url=current_base_url, - api_mode=current_api_mode, - ) - self.session_manager.save_session(session_id) + _old, requested_provider, resolved_model = self._switch_model(state, model_id, keep_endpoint=True) logger.info( - "Session %s: model switched to %s via provider %s", - session_id, - resolved_model, - requested_provider, + "Session %s: model switched to %s via provider %s", session_id, resolved_model, requested_provider ) return SetSessionModelResponse() logger.warning("Session %s: model switch requested for missing session", session_id) return None - async def set_session_mode( - self, mode_id: str, session_id: str, **kwargs: Any - ) -> SetSessionModeResponse | None: + async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> SetSessionModeResponse | None: """Persist the editor-requested mode so ACP clients do not fail on mode switches.""" state = self.session_manager.get_session(session_id) if state is None: logger.warning("Session %s: mode switch requested for missing session", session_id) return None normalized_mode = str(mode_id or "").strip() - if normalized_mode not in self._MODE_TO_EDIT_APPROVAL_POLICY: + if normalized_mode not in self._MODES: normalized_mode = self._MODE_DEFAULT - setattr(state, "mode", normalized_mode) + state.mode = normalized_mode self.session_manager.save_session(session_id) logger.info("Session %s: mode switched to %s", session_id, normalized_mode) return SetSessionModeResponse() @@ -2627,14 +2078,13 @@ class HermesACPAgent(acp.Agent): return None if str(config_id) == self._EDIT_APPROVAL_POLICY_CONFIG_ID: - mode = self._EDIT_APPROVAL_POLICY_TO_MODE.get(str(value), self._MODE_DEFAULT) - setattr(state, "mode", mode) + state.mode = self._EDIT_APPROVAL_POLICY_TO_MODE.get(str(value), self._MODE_DEFAULT) else: options = getattr(state, "config_options", None) if not isinstance(options, dict): options = {} options[str(config_id)] = value - setattr(state, "config_options", options) + state.config_options = options self.session_manager.save_session(session_id) logger.info("Session %s: config option %s updated", session_id, config_id) return SetSessionConfigOptionResponse(config_options=[]) diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 870ec950bf..1871752ab6 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -1,10 +1,8 @@ """ACP session manager — maps ACP sessions to Hermes AIAgent instances. Sessions are persisted to the shared SessionDB (``~/.hermes/state.db``) so they -survive process restarts and appear in ``session_search``. When the editor -reconnects after idle/restart, the ``load_session`` / ``resume_session`` calls -find the persisted session in the database and restore the full conversation -history. +survive process restarts and appear in ``session_search``; ``load_session`` / +``resume_session`` after an editor reconnect restore the full history from there. """ from __future__ import annotations @@ -16,6 +14,7 @@ import logging import os import re import sys +import threading import time import uuid from datetime import datetime, timezone @@ -27,27 +26,17 @@ logger = logging.getLogger(__name__) def _translate_acp_cwd(cwd: str) -> str: - """Translate Windows ACP cwd values when Hermes itself is running in WSL. - - Windows ACP clients can launch ``hermes acp`` inside WSL while still sending - editor workspaces as Windows drive paths (``E:\\Projects``) or - ``\\\\wsl.localhost\\`` UNC paths. Store and execute against the POSIX form so - agents, tools, and persisted ACP sessions all agree on the usable workspace. - Native Linux/macOS keeps the original cwd unchanged. - """ + """Translate Windows ACP cwd values (``E:\\Projects``, ``\\\\wsl.localhost\\``) to POSIX form + when Hermes runs in WSL so agents, tools, and persisted sessions agree; no-op elsewhere.""" from hermes_constants import translate_cwd_for_wsl_backend return translate_cwd_for_wsl_backend(str(cwd)) def _normalize_cwd_for_compare(cwd: str | None) -> str: - raw = str(cwd or ".").strip() - if not raw: - raw = "." - expanded = os.path.expanduser(raw) + expanded = os.path.expanduser(str(cwd or ".").strip() or ".") - # Normalize Windows drive paths into the equivalent WSL mount form so - # ACP history filters match the same workspace across Windows and WSL. + # Windows drive paths -> WSL mount form so history filters match across hosts. from hermes_constants import windows_path_to_wsl translated = windows_path_to_wsl(expanded) @@ -56,14 +45,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str: elif re.match(r"^/mnt/[A-Za-z]/", expanded): expanded = f"/mnt/{expanded[5].lower()}/{expanded[7:]}" - # Resolve symlink aliases so equivalent spellings of the same directory - # compare equal — macOS reports editor workspaces as ``/var/...`` while - # sessions get stored under ``/private/var/...`` (and ``/tmp`` vs - # ``/private/tmp``), which made ACP history filters silently drop a - # workspace's own sessions. ``os.path.realpath`` is lexical for missing - # paths (strict=False), so cwds that don't exist on this host — e.g. - # WSL-translated Windows drives — keep the previous normpath behavior. - # Ported from PrimeIntellect-ai/prime-agent#628. + # realpath resolves symlink aliases (macOS ``/var`` vs ``/private/var``, ``/tmp`` vs + # ``/private/tmp``) that otherwise drop a workspace's own sessions; it is lexical + # for missing paths (e.g. WSL-translated drives). try: return os.path.realpath(expanded) except OSError: @@ -71,20 +55,12 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str: def _build_session_title(title: Any, preview: Any, cwd: str | None) -> str: - explicit = str(title or "").strip() - if explicit: - return explicit - preview_text = str(preview or "").strip() - if preview_text: - return preview_text leaf = os.path.basename(str(cwd or "").rstrip("/\\")) - return leaf or "New thread" + return str(title or "").strip() or str(preview or "").strip() or leaf or "New thread" def _format_updated_at(value: Any) -> str | None: - if value is None: - return None - if isinstance(value, str) and value.strip(): + if value is None or (isinstance(value, str) and value.strip()): return value try: return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat() @@ -93,41 +69,29 @@ def _format_updated_at(value: Any) -> str | None: def _updated_at_sort_key(value: Any) -> float: - if value is None: - return float("-inf") if isinstance(value, (int, float)): return float(value) - raw = str(value).strip() + raw = str(value).strip() if value is not None else "" if not raw: return float("-inf") - try: - return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() - except Exception: + for parse in (lambda s: datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp(), float): try: - return float(raw) + return parse(raw) except Exception: - return float("-inf") + continue + return float("-inf") def _acp_stderr_print(*args, **kwargs) -> None: - """Best-effort human-readable output sink for ACP stdio sessions. - - ACP reserves stdout for JSON-RPC frames, so any incidental CLI/status output - from AIAgent must be redirected away from stdout. Route it to stderr instead. - """ + """Route incidental AIAgent output to stderr; ACP reserves stdout for JSON-RPC.""" kwargs = dict(kwargs) kwargs.setdefault("file", sys.stderr) print(*args, **kwargs) def _register_task_cwd(task_id: str, cwd: str) -> None: - """Bind a task/session id to the editor's working directory for tools. - - Zed can launch Hermes from a Windows workspace while the ACP process runs - inside WSL. In that case ACP sends cwd as e.g. ``E:\\Projects\\POTI``; - local tools need the WSL mount equivalent or subprocess creation fails - before the command can run. - """ + """Bind a task/session id to the editor cwd for tools. Zed may send a Windows cwd while + the ACP process runs in WSL; tools need the WSL mount or subprocess creation fails.""" if not task_id: return try: @@ -137,33 +101,44 @@ def _register_task_cwd(task_id: str, cwd: str) -> None: logger.debug("Failed to register ACP task cwd override", exc_info=True) -def _expand_acp_enabled_toolsets( - toolsets: List[str] | None = None, - mcp_server_names: List[str] | None = None, -) -> List[str]: +def _expand_acp_enabled_toolsets(toolsets: List[str] | None = None, + mcp_server_names: List[str] | None = None) -> List[str]: """Return ACP toolsets plus explicit MCP server toolsets for this session.""" expanded: List[str] = [] for name in list(toolsets or ["hermes-acp"]): if name and name not in expanded: expanded.append(name) - for server_name in list(mcp_server_names or []): toolset_name = f"mcp-{server_name}" if server_name and toolset_name not in expanded: expanded.append(toolset_name) - return expanded -def _clear_task_cwd(task_id: str) -> None: - """Remove task-specific cwd overrides for an ACP session.""" - if not task_id: - return +def _parse_model_config(mc: Any) -> dict: + """Decode a persisted model_config JSON blob; ``{}`` when absent/invalid/non-dict.""" try: - from tools.terminal_tool import clear_task_env_overrides - clear_task_env_overrides(task_id) - except Exception: - logger.debug("Failed to clear ACP task cwd override", exc_info=True) + meta = json.loads(mc) if mc else None + except (json.JSONDecodeError, TypeError): + meta = None + return meta if isinstance(meta, dict) else {} + + +def _session_info(sid: str, cwd: str, model: Any, history_len: int, title: Any, preview: Any, + updated_at: Any) -> Dict[str, Any]: + return { + "session_id": sid, + "cwd": cwd, + "model": model, + "history_len": history_len, + "title": _build_session_title(title, preview, cwd), + "updated_at": _format_updated_at(updated_at), + } + + +def _first_user_preview(history: List[Dict[str, Any]], default: str) -> str: + return next((str(m.get("content") or "").strip() for m in history + if m.get("role") == "user" and str(m.get("content") or "").strip()), default) @dataclass @@ -186,20 +161,13 @@ class SessionState: class SessionManager: """Thread-safe manager for ACP sessions backed by Hermes AIAgent instances. - Sessions are held in-memory for fast access **and** persisted to the - shared SessionDB so they survive process restarts and are searchable - via ``session_search``. + Sessions are held in-memory for fast access **and** persisted to the shared + SessionDB so they survive restarts and are searchable via ``session_search``. """ def __init__(self, agent_factory=None, db=None): - """ - Args: - agent_factory: Optional callable that creates an AIAgent-like object. - Used by tests. When omitted, a real AIAgent is created - using the current Hermes runtime provider configuration. - db: Optional SessionDB instance. When omitted, the default - SessionDB (``~/.hermes/state.db``) is lazily created. - """ + """``agent_factory``: AIAgent-like factory (tests); default builds a real AIAgent from + the runtime provider config. ``db``: SessionDB; default lazily opens ``~/.hermes/state.db``.""" self._sessions: Dict[str, SessionState] = {} self._lock = Lock() self._agent_factory = agent_factory @@ -209,74 +177,30 @@ class SessionManager: def create_session(self, cwd: str = ".") -> SessionState: """Create a new session with a unique ID and a fresh AIAgent.""" - import threading - cwd = _translate_acp_cwd(cwd) session_id = str(uuid.uuid4()) agent = self._make_agent(session_id=session_id, cwd=cwd) - state = SessionState( - session_id=session_id, - agent=agent, - cwd=cwd, - model=getattr(agent, "model", "") or "", - cancel_event=threading.Event(), - ) - with self._lock: - self._sessions[session_id] = state - _register_task_cwd(session_id, cwd) - self._persist(state) + state = self._install_state(session_id, agent, cwd, getattr(agent, "model", "") or "", []) logger.info("Created ACP session %s (cwd=%s)", session_id, cwd) return state def get_session(self, session_id: str) -> Optional[SessionState]: - """Return the session for *session_id*, or ``None``. - - If the session is not in memory but exists in the database (e.g. after - a process restart), it is transparently restored. - """ + """Return the session, transparently restoring it from the DB (e.g. after + a process restart) when it is not in memory; ``None`` if unknown.""" with self._lock: state = self._sessions.get(session_id) - if state is not None: - return state - # Attempt to restore from database. - return self._restore(session_id) - - def remove_session(self, session_id: str) -> bool: - """Remove a session from memory and database. Returns True if it existed.""" - with self._lock: - existed = self._sessions.pop(session_id, None) is not None - db_existed = self._delete_persisted(session_id) - if existed or db_existed: - _clear_task_cwd(session_id) - return existed or db_existed + return state if state is not None else self._restore(session_id) def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState]: """Deep-copy a session's history into a new session.""" - import threading - cwd = _translate_acp_cwd(cwd) original = self.get_session(session_id) # checks DB too if original is None: return None - new_id = str(uuid.uuid4()) - agent = self._make_agent( - session_id=new_id, - cwd=cwd, - model=original.model or None, - ) - state = SessionState( - session_id=new_id, - agent=agent, - cwd=cwd, - model=getattr(agent, "model", original.model) or original.model, - history=copy.deepcopy(original.history), - cancel_event=threading.Event(), - ) - with self._lock: - self._sessions[new_id] = state - _register_task_cwd(new_id, cwd) - self._persist(state) + agent = self._make_agent(session_id=new_id, cwd=cwd, model=original.model or None) + model = getattr(agent, "model", original.model) or original.model + state = self._install_state(new_id, agent, cwd, model, copy.deepcopy(original.history)) logger.info("Forked ACP session %s -> %s", session_id, new_id) return state @@ -285,71 +209,39 @@ class SessionManager: normalized_cwd = _normalize_cwd_for_compare(cwd) if cwd else None db = self._get_db() persisted_rows: dict[str, dict[str, Any]] = {} + try: + for row in (db.list_sessions_rich(source="acp", limit=1000) if db is not None else ()): + persisted_rows[str(row["id"])] = dict(row) + except Exception: + logger.debug("Failed to load ACP sessions from DB", exc_info=True) - if db is not None: - try: - for row in db.list_sessions_rich(source="acp", limit=1000): - persisted_rows[str(row["id"])] = dict(row) - except Exception: - logger.debug("Failed to load ACP sessions from DB", exc_info=True) + def _matches(session_cwd: str) -> bool: + return not normalized_cwd or _normalize_cwd_for_compare(session_cwd) == normalized_cwd - # Collect in-memory sessions first. + # In-memory sessions first. with self._lock: seen_ids = set(self._sessions.keys()) results = [] for s in self._sessions.values(): - history_len = len(s.history) - if history_len <= 0: - continue - if normalized_cwd and _normalize_cwd_for_compare(s.cwd) != normalized_cwd: + if len(s.history) <= 0 or not _matches(s.cwd): continue persisted = persisted_rows.get(s.session_id, {}) - preview = next( - ( - str(msg.get("content") or "").strip() - for msg in s.history - if msg.get("role") == "user" and str(msg.get("content") or "").strip() - ), - persisted.get("preview") or "", - ) - results.append( - { - "session_id": s.session_id, - "cwd": s.cwd, - "model": s.model, - "history_len": history_len, - "title": _build_session_title(persisted.get("title"), preview, s.cwd), - "updated_at": _format_updated_at( - persisted.get("last_active") or persisted.get("started_at") or time.time() - ), - } - ) + results.append(_session_info( + s.session_id, s.cwd, s.model, len(s.history), persisted.get("title"), + _first_user_preview(s.history, persisted.get("preview") or ""), + persisted.get("last_active") or persisted.get("started_at") or time.time(), + )) - # Merge any persisted sessions not currently in memory. + # Then persisted sessions not currently in memory. for sid, row in persisted_rows.items(): - if sid in seen_ids: - continue message_count = int(row.get("message_count") or 0) - if message_count <= 0: + session_cwd = _parse_model_config(row.get("model_config")).get("cwd", ".") + if sid in seen_ids or message_count <= 0 or not _matches(session_cwd): continue - # Extract cwd from model_config JSON. - session_cwd = "." - mc = row.get("model_config") - if mc: - try: - session_cwd = json.loads(mc).get("cwd", ".") - except (json.JSONDecodeError, TypeError): - pass - if normalized_cwd and _normalize_cwd_for_compare(session_cwd) != normalized_cwd: - continue - results.append({ - "session_id": sid, - "cwd": session_cwd, - "model": row.get("model") or "", - "history_len": message_count, - "title": _build_session_title(row.get("title"), row.get("preview"), session_cwd), - "updated_at": _format_updated_at(row.get("last_active") or row.get("started_at")), - }) + results.append(_session_info( + sid, session_cwd, row.get("model") or "", message_count, row.get("title"), + row.get("preview"), row.get("last_active") or row.get("started_at"), + )) results.sort(key=lambda item: _updated_at_sort_key(item.get("updated_at")), reverse=True) return results @@ -365,32 +257,9 @@ class SessionManager: self._persist(state) return state - def cleanup(self) -> None: - """Remove all sessions (memory and database) and clear task-specific cwd overrides.""" - with self._lock: - session_ids = list(self._sessions.keys()) - self._sessions.clear() - for session_id in session_ids: - _clear_task_cwd(session_id) - self._delete_persisted(session_id) - # Also remove any DB-only ACP sessions not currently in memory. - db = self._get_db() - if db is not None: - try: - rows = db.search_sessions(source="acp", limit=10000) - for row in rows: - sid = row["id"] - _clear_task_cwd(sid) - db.delete_session(sid) - except Exception: - logger.debug("Failed to cleanup ACP sessions from DB", exc_info=True) - def save_session(self, session_id: str) -> None: - """Persist the current state of a session to the database. - - Called by the server after prompt completion, slash commands that - mutate history, and model switches. - """ + """Persist a session; called by the server after prompt completion, + history-mutating slash commands, and model switches.""" with self._lock: state = self._sessions.get(session_id) if state is not None: @@ -398,34 +267,32 @@ class SessionManager: # ---- persistence via SessionDB ------------------------------------------ + def _install_state(self, session_id: str, agent: Any, cwd: str, model: str, + history: List[Dict[str, Any]], *, persist: bool = True) -> SessionState: + """Build a SessionState, register it in memory, bind its cwd for tools, optionally persist.""" + state = SessionState(session_id=session_id, agent=agent, cwd=cwd, model=model, + history=history, cancel_event=threading.Event()) + with self._lock: + self._sessions[session_id] = state + _register_task_cwd(session_id, cwd) + if persist: + self._persist(state) + return state + def _get_db(self): - """Lazily initialise and return the SessionDB instance. - - Returns ``None`` if the DB is unavailable (e.g. import error in a - minimal test environment). - - Note: we resolve ``HERMES_HOME`` dynamically rather than relying on - the module-level ``DEFAULT_DB_PATH`` constant, because that constant - is evaluated at import time and won't reflect env-var changes made - later (e.g. by the test fixture ``_isolate_hermes_home``). - """ - if self._db_instance is not None: - return self._db_instance - try: - from hermes_state import SessionDB - hermes_home = get_hermes_home() - self._db_instance = SessionDB(db_path=hermes_home / "state.db") - return self._db_instance - except Exception: - logger.debug("SessionDB unavailable for ACP persistence", exc_info=True) - return None + """Lazily initialise the SessionDB; ``None`` if unavailable (e.g. import error in a + minimal test env). ``HERMES_HOME`` is resolved here, not via the import-time + ``DEFAULT_DB_PATH``, so test fixtures that change the env var later are honoured.""" + if self._db_instance is None: + try: + from hermes_state import SessionDB + self._db_instance = SessionDB(db_path=get_hermes_home() / "state.db") + except Exception: + logger.debug("SessionDB unavailable for ACP persistence", exc_info=True) + return self._db_instance def _persist(self, state: SessionState) -> None: - """Write session state to the database. - - Creates the session record if it doesn't exist, then replaces all - stored messages with the current in-memory history. - """ + """Create/update the session record, then sync the live message set.""" db = self._get_db() if db is None: return @@ -433,182 +300,81 @@ class SessionManager: # Ensure model is a plain string (not a MagicMock or other proxy). model_str = str(state.model) if state.model else None session_meta = {"cwd": state.cwd} - provider = getattr(state.agent, "provider", None) - base_url = getattr(state.agent, "base_url", None) - api_mode = getattr(state.agent, "api_mode", None) - if isinstance(provider, str) and provider.strip(): - session_meta["provider"] = provider.strip() - if isinstance(base_url, str) and base_url.strip(): - session_meta["base_url"] = base_url.strip() - if isinstance(api_mode, str) and api_mode.strip(): - session_meta["api_mode"] = api_mode.strip() - cwd_json = json.dumps(session_meta) + for key in ("provider", "base_url", "api_mode"): + value = getattr(state.agent, key, None) + if isinstance(value, str) and value.strip(): + session_meta[key] = value.strip() try: - # Ensure the session record exists. - existing = db.get_session(state.session_id) - if existing is None: - db.create_session( - session_id=state.session_id, - source="acp", - model=model_str, - model_config={"cwd": state.cwd}, - ) + if db.get_session(state.session_id) is None: + db.create_session(session_id=state.session_id, source="acp", model=model_str, + model_config={"cwd": state.cwd}) else: - # Update model_config (contains cwd) if changed. try: - db.update_session_meta(state.session_id, cwd_json, model_str) + db.update_session_meta(state.session_id, json.dumps(session_meta), model_str) except Exception: logger.debug("Failed to update ACP session metadata", exc_info=True) - # When the agent owns persistence to this same SessionDB it has - # already flushed the live transcript incrementally during - # run_conversation (append_message), and it preserves pre-compaction - # turns non-destructively via archive_and_compact() — keeping them on - # disk as searchable active=0/compacted=1 rows. Calling - # replace_messages() here would then be a redundant double-write that - # DELETEs exactly those archived rows (and, after a compression-driven - # id rotation where agent.session_id no longer equals - # state.session_id, clobbers the ended parent transcript) — silent - # data loss for any ACP conversation long enough to compress. - # - # Only fall back to the destructive atomic replace when the agent is - # NOT persisting itself to this DB (e.g. a test agent factory, or a - # fresh create/fork whose copied history the agent has not flushed - # yet). That path still rolls back on a mid-rewrite failure so the - # previously persisted conversation survives (salvaged from #13675). + # An agent that owns persistence to this same DB already flushed the transcript + # incrementally (append_message) and keeps pre-compaction turns as archived + # active=0 rows; replace_messages() would DELETE those (and, after a compression + # id rotation, clobber the ended parent transcript). Skip it in that case. agent = state.agent agent_db = getattr(agent, "_session_db", None) - agent_owns_persistence = ( - agent_db is not None - and agent_db is db - and bool(getattr(agent, "_session_db_created", False)) - ) - if not agent_owns_persistence: - # Even when the current agent doesn't "own" persistence, the - # session on disk may already carry compaction-archived rows — - # e.g. after a model switch or a /restore, both of which mint a - # fresh agent with _session_db_created=False (so the check above - # is False) yet leave the durable archived transcript in place. - # A full-history replace would DELETE those archived rows just - # like the owned-agent case. Guard against it by replacing ONLY - # the live (active=1) set unconditionally: on a fresh - # create/fork every row is active=1, so active-only replace is - # behaviorally identical to the full replace — and when archived - # rows DO exist they survive. An existence probe here - # (has_archived_messages) would fail OPEN into the destructive - # replace on any DB error and can race a concurrent - # archive_and_compact — the same probe failure mode #80216's - # /retry fix (gateway/slash_commands.py) deliberately avoids. - db.replace_messages( - state.session_id, state.history, active_only=True - ) + if agent_db is not None and agent_db is db and bool(getattr(agent, "_session_db_created", False)): + return + # A non-owning agent (model switch, /restore: fresh agent, _session_db_created=False) + # may still sit on archived rows, so replace ONLY the active=1 set: on a fresh + # create/fork every row is active (== full replace), and archived rows survive. + # Unconditional because an existence probe would fail OPEN on DB error and can + # race a concurrent archive_and_compact. Still rolls back on mid-rewrite failure. + db.replace_messages(state.session_id, state.history, active_only=True) except Exception: logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True) def _restore(self, session_id: str) -> Optional[SessionState]: - """Load a session from the database into memory, recreating the AIAgent.""" - import threading - + """Load an ACP session from the database into memory, recreating the AIAgent.""" db = self._get_db() if db is None: return None - try: row = db.get_session(session_id) except Exception: logger.debug("Failed to query DB for ACP session %s", session_id, exc_info=True) return None - - if row is None: + if row is None or row.get("source") != "acp": return None - # Only restore ACP sessions. - if row.get("source") != "acp": - return None + meta = _parse_model_config(row.get("model_config")) + cwd, model = meta.get("cwd", "."), row.get("model") or None - # Extract cwd from model_config. - cwd = "." - requested_provider = row.get("billing_provider") - restored_base_url = row.get("billing_base_url") - restored_api_mode = None - mc = row.get("model_config") - if mc: - try: - meta = json.loads(mc) - if isinstance(meta, dict): - cwd = meta.get("cwd", ".") - requested_provider = meta.get("provider") or requested_provider - restored_base_url = meta.get("base_url") or restored_base_url - restored_api_mode = meta.get("api_mode") or restored_api_mode - except (json.JSONDecodeError, TypeError): - pass - - model = row.get("model") or None - - # Load conversation history. repair_alternation: this restore feeds - # LIVE REPLAY — the loaded list becomes the resumed agent's working - # conversation. A durable ``user;user`` violation left in state.db would - # otherwise re-fire the pre-request defensive repair on every request - # for the rest of the session (see hermes_state.get_messages_as_conversation). + # repair_alternation: this list becomes the resumed agent's LIVE conversation; a durable + # ``user;user`` violation in state.db would otherwise re-fire the pre-request repair every request. try: - history = db.get_messages_as_conversation( - session_id, repair_alternation=True - ) + history = db.get_messages_as_conversation(session_id, repair_alternation=True) except Exception: logger.warning("Failed to load messages for ACP session %s", session_id, exc_info=True) history = [] try: agent = self._make_agent( - session_id=session_id, - cwd=cwd, - model=model, - requested_provider=requested_provider, - base_url=restored_base_url, - api_mode=restored_api_mode, - ) + session_id=session_id, cwd=cwd, model=model, + requested_provider=meta.get("provider") or row.get("billing_provider"), + base_url=meta.get("base_url") or row.get("billing_base_url"), + api_mode=meta.get("api_mode") or None) except Exception: logger.warning("Failed to recreate agent for ACP session %s", session_id, exc_info=True) return None - - state = SessionState( - session_id=session_id, - agent=agent, - cwd=cwd, - model=model or getattr(agent, "model", "") or "", - history=history, - cancel_event=threading.Event(), - ) - with self._lock: - self._sessions[session_id] = state - _register_task_cwd(session_id, cwd) + state = self._install_state(session_id, agent, cwd, model or getattr(agent, "model", "") or "", + history, persist=False) logger.info("Restored ACP session %s from DB (%d messages)", session_id, len(history)) return state - def _delete_persisted(self, session_id: str) -> bool: - """Delete a session from the database. Returns True if it existed.""" - db = self._get_db() - if db is None: - return False - try: - return db.delete_session(session_id) - except Exception: - logger.debug("Failed to delete ACP session %s from DB", session_id, exc_info=True) - return False - # ---- internal ----------------------------------------------------------- - def _make_agent( - self, - *, - session_id: str, - cwd: str, - model: str | None = None, - requested_provider: str | None = None, - base_url: str | None = None, - api_mode: str | None = None, - ): + def _make_agent(self, *, session_id: str, cwd: str, model: str | None = None, + requested_provider: str | None = None, base_url: str | None = None, + api_mode: str | None = None): if self._agent_factory is not None: return self._agent_factory() @@ -618,78 +384,56 @@ class SessionManager: config = load_config() model_cfg = config.get("model") - default_model = "" - config_provider = None + default_model, config_provider = "", None if isinstance(model_cfg, dict): - default_model = str(model_cfg.get("default") or default_model) - config_provider = model_cfg.get("provider") + default_model, config_provider = str(model_cfg.get("default") or ""), model_cfg.get("provider") elif isinstance(model_cfg, str) and model_cfg.strip(): default_model = model_cfg.strip() configured_mcp_servers = [ - name - for name, cfg in (config.get("mcp_servers") or {}).items() + name for name, cfg in (config.get("mcp_servers") or {}).items() if not isinstance(cfg, dict) or cfg.get("enabled", True) is not False ] - kwargs = { "platform": "acp", - "enabled_toolsets": _expand_acp_enabled_toolsets( - ["hermes-acp"], - mcp_server_names=configured_mcp_servers, - ), + "enabled_toolsets": _expand_acp_enabled_toolsets(["hermes-acp"], mcp_server_names=configured_mcp_servers), "quiet_mode": True, "session_id": session_id, "session_db": self._get_db(), "model": model or default_model, } - try: runtime = resolve_runtime_provider(requested=requested_provider or config_provider) - kwargs.update( - { - "provider": runtime.get("provider"), - "api_mode": api_mode or runtime.get("api_mode"), - "base_url": base_url or runtime.get("base_url"), - "api_key": runtime.get("api_key"), - "command": runtime.get("command"), - "args": list(runtime.get("args") or []), - } - ) + kwargs.update({ + "provider": runtime.get("provider"), + "api_mode": api_mode or runtime.get("api_mode"), + "base_url": base_url or runtime.get("base_url"), + "api_key": runtime.get("api_key"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), + }) except Exception: logger.debug("ACP session falling back to default provider resolution", exc_info=True) _register_task_cwd(session_id, cwd) - # Bounded wait for background MCP discovery so already-spawning fast - # servers land in the agent's tool snapshot. ACP entry.py fires - # discovery in a background daemon thread (start_background_mcp_discovery); - # the agent snapshots tools once at build (run_agent/agent_init) and - # never re-reads the registry, so without this join a reachable-but- - # slow configured server would be invisible for the whole session. - # ``ensure_mcp_discovery_before_agent_build`` also (re)starts discovery - # when the entry.py spawn never ran or exited with zero connected - # servers (the retry-after-zero-connected allowance), making this - # construction site self-sufficient. Bounded by - # ``mcp_discovery_timeout`` (config.yaml, default ~1.5s) so a dead - # server can't block — servers that miss the bound are picked up by - # the automatic late-refresh (see HermesACPAgent._schedule_mcp_late_refresh). + # Bounded wait for the background MCP discovery started by entry.py: the agent + # snapshots tools once at build and never re-reads the registry, so without this + # join a slow-but-reachable server would be invisible all session. ensure_* also + # (re)starts discovery if the entry spawn never ran or connected zero servers. + # Bounded by ``mcp_discovery_timeout`` (config.yaml, ~1.5s); late servers are + # picked up by HermesACPAgent._schedule_mcp_late_refresh. try: from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build - ensure_mcp_discovery_before_agent_build( - logger=logger, - thread_name="acp-mcp-discovery", - ) + ensure_mcp_discovery_before_agent_build(logger=logger, thread_name="acp-mcp-discovery") except Exception: logger.debug("ACP: bounded MCP discovery wait failed", exc_info=True) agent = AIAgent(**kwargs) - # Codex app-server sessions are spawned lazily on the first turn. Stamp - # the ACP workspace onto the agent so the Codex runtime starts from the - # editor/session cwd instead of the Hermes daemon's process cwd. + # Codex app-server sessions spawn lazily on the first turn; stamp the ACP + # workspace so the Codex runtime starts from the editor cwd, not ours. agent.session_cwd = cwd - # ACP stdio transport requires stdout to remain protocol-only JSON-RPC. - # Route any incidental human-readable agent output to stderr instead. + # ACP stdio: stdout is protocol-only JSON-RPC; agent chatter goes to stderr. agent._print_fn = _acp_stderr_print return agent diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index e3ce3b1149..2fe2d67bb3 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -5,60 +5,32 @@ from __future__ import annotations import json import logging import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import acp -from acp.schema import ( - ToolCallLocation, - ToolCallStart, - ToolCallProgress, - ToolKind, -) +from acp.schema import ToolCallLocation, ToolCallProgress, ToolCallStart, ToolKind logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Map hermes tool names -> ACP ToolKind -# --------------------------------------------------------------------------- - +# Hermes tool name -> ACP ToolKind (anything unlisted is "other"). TOOL_KIND_MAP: Dict[str, ToolKind] = { - # File operations - "read_file": "read", - "write_file": "edit", - "patch": "edit", - "search_files": "search", - # Terminal / execution - "terminal": "execute", - "process": "execute", - "execute_code": "execute", - # Session/meta tools - "todo": "other", - "skill_view": "read", - "skills_list": "read", - "skill_manage": "edit", - # Web / fetch - "web_search": "fetch", - "web_extract": "fetch", - # Browser - "browser_navigate": "fetch", - "browser_click": "execute", - "browser_type": "execute", - "browser_snapshot": "read", - "browser_vision": "read", - "browser_scroll": "execute", - "browser_press": "execute", - "browser_back": "execute", - "browser_get_images": "read", - # Agent internals - "delegate_task": "execute", - "vision_analyze": "read", - "image_generate": "execute", - "text_to_speech": "execute", - # Thinking / meta - "_thinking": "think", + name: kind + for kind, names in { + "read": ("read_file", "skill_view", "skills_list", "browser_snapshot", "browser_vision", + "browser_get_images", "vision_analyze"), + "edit": ("write_file", "patch", "skill_manage"), + "search": ("search_files",), + "execute": ("terminal", "process", "execute_code", "browser_click", "browser_type", "browser_scroll", + "browser_press", "browser_back", "delegate_task", "image_generate", "text_to_speech"), + "fetch": ("web_search", "web_extract", "browser_navigate"), + "other": ("todo",), + "think": ("_thinking",), + }.items() + for name in names } - +# Tools whose results render through the curated formatters below (raw JSON is +# suppressed for these); unknown/plugin tools stay conservative. _POLISHED_TOOLS = { # Core operator loop "todo", "memory", "session_search", "delegate_task", @@ -81,6 +53,9 @@ _POLISHED_TOOLS = { "yb_send_dm", "yb_send_sticker", } +_EMPTYISH = (None, "", [], {}) +Args = Dict[str, Any] + def get_tool_kind(tool_name: str) -> ToolKind: """Return the ACP ToolKind for a hermes tool, defaulting to 'other'.""" @@ -88,167 +63,73 @@ def get_tool_kind(tool_name: str) -> ToolKind: def make_tool_call_id() -> str: - """Generate a unique tool call ID.""" return f"tc-{uuid.uuid4().hex[:12]}" -def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str: - """Build a human-readable title for a tool call.""" - if tool_name == "terminal": - cmd = args.get("command", "") - if len(cmd) > 80: - cmd = cmd[:77] + "..." - return f"terminal: {cmd}" - if tool_name == "read_file": - return f"read: {args.get('path', '?')}" - if tool_name == "write_file": - return f"write: {args.get('path', '?')}" - if tool_name == "patch": - mode = args.get("mode", "replace") - path = args.get("path", "?") - return f"patch ({mode}): {path}" - if tool_name == "search_files": - return f"search: {args.get('pattern', '?')}" - if tool_name == "web_search": - return f"web search: {args.get('query', '?')}" - if tool_name == "web_extract": - urls = args.get("urls", []) - if urls: - first = urls[0] - if isinstance(first, dict): - first = first.get("url") or first.get("href") or "?" - elif not isinstance(first, str): - first = "?" - return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") - return "web extract" - if tool_name == "process": - action = str(args.get("action") or "").strip() or "manage" - sid = str(args.get("session_id") or "").strip() - return f"process {action}: {sid}" if sid else f"process {action}" - if tool_name == "delegate_task": - tasks = args.get("tasks") - if isinstance(tasks, list) and tasks: - return f"delegate batch ({len(tasks)} tasks)" - goal = args.get("goal", "") - if goal and len(goal) > 60: - goal = goal[:57] + "..." - return f"delegate: {goal}" if goal else "delegate task" - if tool_name == "session_search": - query = str(args.get("query") or "").strip() - return f"session search: {query}" if query else "recent sessions" - if tool_name == "memory": - action = str(args.get("action") or "manage").strip() or "manage" - target = str(args.get("target") or "memory").strip() or "memory" - return f"memory {action}: {target}" - if tool_name == "execute_code": - code = str(args.get("code") or "").strip() - first_line = next((line.strip() for line in code.splitlines() if line.strip()), "") - if first_line: - if len(first_line) > 70: - first_line = first_line[:67] + "..." - return f"python: {first_line}" - return "python code" - if tool_name == "todo": - items = args.get("todos") - if isinstance(items, list): - return f"todo ({len(items)} item{'s' if len(items) != 1 else ''})" - return "todo" - if tool_name == "skill_view": - name = str(args.get("name") or "?").strip() or "?" - file_path = str(args.get("file_path") or "").strip() - suffix = f"/{file_path}" if file_path else "" - return f"skill view ({name}{suffix})" - if tool_name == "skills_list": - category = str(args.get("category") or "").strip() - return f"skills list ({category})" if category else "skills list" - if tool_name == "skill_manage": - action = str(args.get("action") or "manage").strip() or "manage" - name = str(args.get("name") or "?").strip() or "?" - file_path = str(args.get("file_path") or "").strip() - target = f"{name}/{file_path}" if file_path else name - if len(target) > 64: - target = target[:61] + "..." - return f"skill {action}: {target}" - if tool_name == "browser_navigate": - return f"navigate: {args.get('url', '?')}" - if tool_name == "browser_snapshot": - return "browser snapshot" - if tool_name == "browser_vision": - return f"browser vision: {str(args.get('question', '?'))[:50]}" - if tool_name == "browser_get_images": - return "browser images" - if tool_name == "vision_analyze": - return f"analyze image: {str(args.get('question', '?'))[:50]}" - if tool_name == "image_generate": - prompt = str(args.get("prompt") or args.get("description") or "").strip() - return f"generate image: {prompt[:50]}" if prompt else "generate image" - if tool_name == "cronjob": - action = str(args.get("action") or "manage").strip() or "manage" - job_id = str(args.get("job_id") or args.get("id") or "").strip() - return f"cron {action}: {job_id}" if job_id else f"cron {action}" - return tool_name +# --- small shared helpers --------------------------------------------------- def _text(content: str) -> Any: return acp.tool_content(acp.text_block(content)) +def _arg(args: Optional[Args], *keys: str, default: str = "") -> str: + """First truthy ``args[key]`` as a stripped string, else ``default``.""" + a = args or {} + value = next((a.get(k) for k in keys if a.get(k)), None) + return str(value or default).strip() or default + + +def _clip(text: str, limit: int) -> str: + """Hard-truncate to ``limit`` chars with a trailing ellipsis.""" + return text if len(text) <= limit else text[: limit - 3] + "..." + + +def _fmt(value: Any, template: str, fallback: str) -> str: + """``template.format(value)`` when value is truthy, else ``fallback``.""" + return template.format(value) if value else fallback + + +def _nonempty(result: Optional[str]) -> Optional[str]: + return result if isinstance(result, str) and result.strip() else None + + +def _plural(count: int, word: str, suffix: str = "s") -> str: + return f"{count} {word}{suffix if count != 1 else ''}" + + +def _failure(data: Args, prefix: str) -> Optional[str]: + """Structured tool-level failure text (``success: false`` or ``error`` set).""" + if data.get("success") is False or data.get("error"): + return f"{prefix}: {data.get('error', 'unknown error')}" + return None + + +def _args_json(arguments: Any) -> str: + try: + return json.dumps(arguments, indent=2, default=str) + except (TypeError, ValueError): + return str(arguments) + + def _json_loads_maybe(value: Optional[str]) -> Any: + """Decode a JSON string; non-strings pass through, undecodable strings yield None. + + Some Hermes tools append a human hint after the payload (``{...}\\n\\n[Hint: ...]``), + so fall back to decoding the first JSON value to keep the structured rendering path. + """ if not isinstance(value, str): return value try: return json.loads(value) except Exception: pass - - # Some Hermes tools append a human hint after a JSON payload, e.g. - # ``{...}\n\n[Hint: Results truncated...]``. Keep the structured rendering path - # by decoding the first JSON value instead of falling back to raw text. try: - decoded, _ = json.JSONDecoder().raw_decode(value.lstrip()) - return decoded + return json.JSONDecoder().raw_decode(value.lstrip())[0] except Exception: return None -def _tool_result_failed(result: Optional[str], tool_name: str | None = None) -> bool: - """Return True when a structured Hermes tool result clearly failed. - - Keep this deliberately conservative. Plain text can contain words like - "error" because tests failed or a command printed diagnostics; Zed should - only receive ACP failed status for structured tool-level failures. - """ - # Raised exceptions from the agent's tool executor get wrapped in a - # canonical "Error executing tool '': ..." prefix (see - # agent/tool_executor.py around the try/except). That prefix is uniquely - # produced by the wrapper itself — it cannot legitimately appear in - # well-behaved tool output. Catch it so a tool that blew up shows as - # failed in Zed instead of misleadingly green. - if isinstance(result, str) and result.startswith("Error executing tool '"): - return True - - data = _json_loads_maybe(result) - if not isinstance(data, dict): - return False - - for key in ("success", "ok"): - if data.get(key) is False: - return True - - exit_code = data.get("exit_code", data.get("returncode")) - if isinstance(exit_code, int) and exit_code != 0: - return True - - # Hermes core/polished tools commonly report tool-level failures as a - # structured {"error": "..."} payload without an explicit success flag. - # Keep generic plugin/unknown tool payloads conservative to avoid marking - # optional diagnostic messages as failed. - if tool_name in _POLISHED_TOOLS and data.get("error") and not data.get("content"): - return True - - return False - - def _truncate_text(text: str, limit: int = 5000) -> str: if len(text) <= limit: return text @@ -262,53 +143,138 @@ def _fenced_text(text: str, language: str = "") -> str: return f"{fence}{language}\n{text}\n{fence}" -def _format_todo_result(result: Optional[str]) -> Optional[str]: +def _tool_result_failed(result: Optional[str], tool_name: str | None = None) -> bool: + """Return True when a structured Hermes tool result clearly failed. + + Deliberately conservative: plain text may legitimately contain "error", so + only structured tool-level failures map to ACP failed status. + """ + # The agent's tool executor wraps raised exceptions in a canonical + # "Error executing tool '': ..." prefix that well-behaved tool output + # cannot produce; catch it so a tool that blew up is not shown green. + if isinstance(result, str) and result.startswith("Error executing tool '"): + return True + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return False + if any(data.get(key) is False for key in ("success", "ok")): + return True + exit_code = data.get("exit_code", data.get("returncode")) + if isinstance(exit_code, int) and exit_code != 0: + return True + # Polished tools report failures as {"error": ...} without a success flag; + # generic/plugin payloads stay conservative so diagnostics aren't marked failed. + return bool(tool_name in _POLISHED_TOOLS and data.get("error") and not data.get("content")) + + +# --- tool-call titles ------------------------------------------------------- + + +def _title_web_extract(args: Args) -> str: + urls = args.get("urls", []) + if not urls: + return "web extract" + first = urls[0] + if isinstance(first, dict): + first = first.get("url") or first.get("href") or "?" + elif not isinstance(first, str): + first = "?" + return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") + + +def _title_delegate(args: Args) -> str: + tasks = args.get("tasks") + if isinstance(tasks, list) and tasks: + return f"delegate batch ({len(tasks)} tasks)" + goal = args.get("goal", "") + return f"delegate: {_clip(goal, 60)}" if goal else "delegate task" + + +def _title_execute_code(args: Args) -> str: + first_line = next((line.strip() for line in _arg(args, "code").splitlines() if line.strip()), "") + return _fmt(_clip(first_line, 70), "python: {}", "python code") + + +def _title_skill_manage(args: Args) -> str: + name, file_path = _arg(args, "name", default="?"), _arg(args, "file_path") + target = _clip(f"{name}/{file_path}" if file_path else name, 64) + return f"skill {_arg(args, 'action', default='manage')}: {target}" + + +_TITLE_BUILDERS: Dict[str, Callable[[Args], str]] = { + "terminal": lambda a: f"terminal: {_clip(a.get('command', ''), 80)}", + "read_file": lambda a: f"read: {a.get('path', '?')}", + "write_file": lambda a: f"write: {a.get('path', '?')}", + "patch": lambda a: f"patch ({a.get('mode', 'replace')}): {a.get('path', '?')}", + "search_files": lambda a: f"search: {a.get('pattern', '?')}", + "web_search": lambda a: f"web search: {a.get('query', '?')}", + "web_extract": _title_web_extract, + "process": lambda a: _fmt(_arg(a, "session_id"), f"process {_arg(a, 'action', default='manage')}: {{}}", + f"process {_arg(a, 'action', default='manage')}"), + "delegate_task": _title_delegate, + "session_search": lambda a: _fmt(_arg(a, "query"), "session search: {}", "recent sessions"), + "memory": lambda a: f"memory {_arg(a, 'action', default='manage')}: {_arg(a, 'target', default='memory')}", + "execute_code": _title_execute_code, + "todo": lambda a: f"todo ({_plural(len(a['todos']), 'item')})" if isinstance(a.get("todos"), list) else "todo", + "skill_view": lambda a: f"skill view ({_arg(a, 'name', default='?')}{_fmt(_arg(a, 'file_path'), '/{}', '')})", + "skills_list": lambda a: _fmt(_arg(a, "category"), "skills list ({})", "skills list"), + "skill_manage": _title_skill_manage, + "browser_navigate": lambda a: f"navigate: {a.get('url', '?')}", + "browser_snapshot": lambda a: "browser snapshot", + "browser_vision": lambda a: f"browser vision: {str(a.get('question', '?'))[:50]}", + "browser_get_images": lambda a: "browser images", + "vision_analyze": lambda a: f"analyze image: {str(a.get('question', '?'))[:50]}", + "image_generate": lambda a: _fmt(_arg(a, "prompt", "description")[:50], "generate image: {}", "generate image"), + "cronjob": lambda a: _fmt(_arg(a, "job_id", "id"), f"cron {_arg(a, 'action', default='manage')}: {{}}", + f"cron {_arg(a, 'action', default='manage')}"), +} + + +def build_tool_title(tool_name: str, args: Args) -> str: + """Build a human-readable title for a tool call (defaults to the tool name).""" + builder = _TITLE_BUILDERS.get(tool_name) + return builder(args) if builder is not None else tool_name + + +# --- completion formatters; all share the signature (tool_name, result, args) -- + + +def _format_todo_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict) or not isinstance(data.get("todos"), list): return None summary = data.get("summary") if isinstance(data.get("summary"), dict) else {} - icon = { - "completed": "✅", - "in_progress": "🔄", - "pending": "⏳", - "cancelled": "✗", - } - lines = ["**Todo list**", ""] + icon = {"completed": "✅", "in_progress": "🔄", "pending": "⏳", "cancelled": "✗"} todos = [t for t in data["todos"] if isinstance(t, dict)] - ids = {str(t.get("id") or "") for t in todos} + by_id = {str(t.get("id") or ""): t for t in todos} - def _depth(item: Dict[str, Any]) -> int: - depth, seen = 0, set() - node: Optional[Dict[str, Any]] = item - by_id = {str(t.get("id") or ""): t for t in todos} + def _depth(item: Args) -> int: + depth, seen, node = 0, set(), item while node is not None: parent = str(node.get("parent") or "") - if not parent or parent not in ids or parent in seen: + if not parent or parent not in by_id or parent in seen: break seen.add(parent) depth += 1 node = by_id.get(parent) return min(depth, 4) + lines = ["**Todo list**", ""] for item in todos: - status = str(item.get("status") or "pending") content = str(item.get("content") or item.get("id") or "").strip() if content: - lines.append(f"{' ' * _depth(item)}- {icon.get(status, '•')} {content}") + lines.append(f"{' ' * _depth(item)}- {icon.get(str(item.get('status') or 'pending'), '•')} {content}") if summary: cancelled = summary.get("cancelled", 0) lines.extend([ "", - "**Progress:** " - f"{summary.get('completed', 0)} completed, " - f"{summary.get('in_progress', 0)} in progress, " - f"{summary.get('pending', 0)} pending" - + (f", {cancelled} cancelled" if cancelled else ""), + f"**Progress:** {summary.get('completed', 0)} completed, {summary.get('in_progress', 0)} in progress, " + f"{summary.get('pending', 0)} pending" + (f", {cancelled} cancelled" if cancelled else ""), ]) return "\n".join(lines) -def _format_read_file_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: +def _format_read_file_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None @@ -317,193 +283,132 @@ def _format_read_file_result(result: Optional[str], args: Optional[Dict[str, Any content = data.get("content") if not isinstance(content, str): return None - path = str((args or {}).get("path") or data.get("path") or "file").strip() - offset = (args or {}).get("offset") - limit = (args or {}).get("limit") - range_bits = [] - if offset: - range_bits.append(f"from line {offset}") - if limit: - range_bits.append(f"limit {limit}") - suffix = f" ({', '.join(range_bits)})" if range_bits else "" - header = f"Read {path}{suffix}" + a = args or {} + range_bits = [f"from line {a['offset']}"] if a.get("offset") else [] + if a.get("limit"): + range_bits.append(f"limit {a['limit']}") + header = f"Read {str(a.get('path') or data.get('path') or 'file').strip()}" + header += f" ({', '.join(range_bits)})" if range_bits else "" if data.get("total_lines") is not None: header += f" — {data.get('total_lines')} total lines" - # Hermes read_file output is line-numbered with `|`. If we send it as raw - # Markdown, Zed can interpret pipes as tables and collapse the layout. - # Fence the payload so file lines stay readable and literal. + # read_file output is `|`-line-numbered; raw Markdown would let Zed parse + # pipes as tables, so fence the payload to keep lines literal. return _truncate_text(f"{header}\n\n{_fenced_text(content)}") -def _format_search_files_result(result: Optional[str]) -> Optional[str]: +def _format_search_files_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None - - files = data.get("files") + files, matches = data.get("files"), data.get("matches") if isinstance(files, list): - total = data.get("total_count", len(files)) shown = min(len(files), 20) - truncated = bool(data.get("truncated")) or len(files) > shown - lines = [ - "File search results", - f"Found {total} file{'s' if total != 1 else ''}; showing {shown}.", - "", - ] - for path in files[:shown]: - lines.append(f"- {path}") - if truncated: - lines.extend([ - "", - "Results truncated. Narrow the search, add path/file_glob, or use offset to page.", - ]) + lines = ["File search results", f"Found {_plural(data.get('total_count', len(files)), 'file')}; showing {shown}.", ""] + lines.extend(f"- {path}" for path in files[:shown]) + if bool(data.get("truncated")) or len(files) > shown: + lines.extend(["", "Results truncated. Narrow the search, add path/file_glob, or use offset to page."]) return _truncate_text("\n".join(lines), limit=7000) - - matches = data.get("matches") if not isinstance(matches, list): return None - - total = data.get("total_count", len(matches)) shown = min(len(matches), 12) - truncated = bool(data.get("truncated")) or len(matches) > shown - lines = [ - "Search results", - f"Found {total} match{'es' if total != 1 else ''}; showing {shown}.", - "", - ] - + lines = ["Search results", f"Found {_plural(data.get('total_count', len(matches)), 'match', 'es')}; showing {shown}.", ""] for match in matches[:shown]: if not isinstance(match, dict): lines.append(f"- {match}") continue - path = str(match.get("path") or match.get("file") or match.get("filename") or "?") line = match.get("line") or match.get("line_number") content = str(match.get("content") or match.get("text") or "").strip() - loc = f"{path}:{line}" if line else path - lines.append(f"- {loc}") + lines.append(f"- {path}:{line}" if line else f"- {path}") if content: - snippet = _truncate_text(" ".join(content.split()), 300) - lines.append(f" {snippet}") - - if truncated: - lines.extend([ - "", - "Results truncated. Narrow the search, add file_glob, or use offset to page.", - ]) + lines.append(f" {_truncate_text(' '.join(content.split()), 300)}") + if bool(data.get("truncated")) or len(matches) > shown: + lines.extend(["", "Results truncated. Narrow the search, add file_glob, or use offset to page."]) return _truncate_text("\n".join(lines), limit=7000) -def _format_execute_code_result(result: Optional[str]) -> Optional[str]: +def _format_execute_code_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): - return result if isinstance(result, str) and result.strip() else None - output = str(data.get("output") or "") - error = str(data.get("error") or "") + return _nonempty(result) exit_code = data.get("exit_code") parts = [f"Exit code: {exit_code}" if exit_code is not None else "Execution complete"] if data.get("stdout_truncated"): - total = data.get("stdout_bytes_total") - captured = data.get("stdout_bytes_captured") - omitted = data.get("stdout_bytes_omitted") + total, captured, omitted = (data.get(k) for k in ("stdout_bytes_total", "stdout_bytes_captured", "stdout_bytes_omitted")) if all(isinstance(v, int) for v in (captured, total, omitted)): - parts.extend([ - "", - ( - "Output truncated: " - f"captured {captured:,} of {total:,} bytes " - f"({omitted:,} omitted)." - ), - ]) + parts.extend(["", f"Output truncated: captured {captured:,} of {total:,} bytes ({omitted:,} omitted)."]) else: parts.extend(["", "Output truncated."]) - warning = str(data.get("warning") or "").strip() - if warning: - parts.extend(["", "Warning:", warning]) - if output: - parts.extend(["", "Output:", output]) - if error: - parts.extend(["", "Error:", error]) + for key, label, value in ( + ("warning", "Warning:", str(data.get("warning") or "").strip()), + ("output", "Output:", str(data.get("output") or "")), + ("error", "Error:", str(data.get("error") or "")), + ): + if value: + parts.extend(["", label, value]) return _truncate_text("\n".join(parts)) def _extract_markdown_headings(content: str, limit: int = 8) -> list[str]: headings: list[str] = [] for line in content.splitlines(): - stripped = line.strip() - if stripped.startswith("#"): - heading = stripped.lstrip("#").strip() - if heading: - headings.append(heading) + heading = line.strip().lstrip("#").strip() if line.strip().startswith("#") else "" + if heading: + headings.append(heading) if len(headings) >= limit: break return headings -def _format_skill_view_result(result: Optional[str]) -> Optional[str]: +def _format_skill_view_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None if data.get("success") is False: return f"Skill view failed: {data.get('error', 'unknown error')}" - name = str(data.get("name") or "skill") - file_path = str(data.get("file") or data.get("path") or "SKILL.md") - description = str(data.get("description") or "").strip() content = str(data.get("content") or "") linked = data.get("linked_files") if isinstance(data.get("linked_files"), dict) else None - - lines = ["**Skill loaded**", "", f"- **Name:** `{name}`", f"- **File:** `{file_path}`"] + lines = ["**Skill loaded**", "", f"- **Name:** `{data.get('name') or 'skill'}`", + f"- **File:** `{data.get('file') or data.get('path') or 'SKILL.md'}`"] + description = str(data.get("description") or "").strip() if description: lines.append(f"- **Description:** {description}") if content: lines.append(f"- **Content:** {len(content):,} chars loaded into agent context") if linked: - linked_count = sum(len(v) for v in linked.values() if isinstance(v, list)) - lines.append(f"- **Linked files:** {linked_count}") - + lines.append(f"- **Linked files:** {sum(len(v) for v in linked.values() if isinstance(v, list))}") headings = _extract_markdown_headings(content) if headings: - lines.extend(["", "**Sections**"]) - lines.extend(f"- {heading}" for heading in headings) - - lines.extend([ - "", - "_Full skill content is available to the agent but hidden here to keep ACP readable._", - ]) + lines.extend(["", "**Sections**", *(f"- {heading}" for heading in headings)]) + lines.extend(["", "_Full skill content is available to the agent but hidden here to keep ACP readable._"]) return "\n".join(lines) -def _format_skill_manage_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: +def _format_skill_manage_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None - - action = str((args or {}).get("action") or "manage").strip() or "manage" - name = str((args or {}).get("name") or data.get("name") or "skill").strip() or "skill" - file_path = str((args or {}).get("file_path") or data.get("file_path") or "SKILL.md").strip() or "SKILL.md" - success = data.get("success") - status = "✅ Skill updated" if success is not False else "✗ Skill update failed" - + a = args or {} + action = _arg(a, "action", default="manage") + name = str(a.get("name") or data.get("name") or "skill").strip() or "skill" + file_path = str(a.get("file_path") or data.get("file_path") or "SKILL.md").strip() or "SKILL.md" + status = "✅ Skill updated" if data.get("success") is not False else "✗ Skill update failed" lines = [f"**{status}**", "", f"- **Action:** `{action}`", f"- **Skill:** `{name}`"] - if action not in {"delete"}: + if action != "delete": lines.append(f"- **File:** `{file_path}`") - message = str(data.get("message") or data.get("error") or "").strip() if message: lines.append(f"- **Result:** {message}") - replacements = data.get("replacements") or data.get("replacement_count") if replacements is not None: lines.append(f"- **Replacements:** {replacements}") - path = str(data.get("path") or "").strip() if path: lines.append(f"- **Path:** `{path}`") - return "\n".join(lines) -def _format_web_search_result(result: Optional[str]) -> Optional[str]: +def _format_web_search_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None @@ -514,10 +419,9 @@ def _format_web_search_result(result: Optional[str]) -> Optional[str]: for item in web[:10]: if not isinstance(item, dict): continue - title = str(item.get("title") or item.get("url") or "result").strip() url = str(item.get("url") or "").strip() desc = str(item.get("description") or "").strip() - lines.append(f"• {title}" + (f" — {url}" if url else "")) + lines.append(f"• {str(item.get('title') or item.get('url') or 'result').strip()}" + (f" — {url}" if url else "")) if desc: lines.append(f" {desc}") return _truncate_text("\n".join(lines)) @@ -533,12 +437,9 @@ def _format_web_extract_result(result: Optional[str]) -> Optional[str]: results = data.get("results") if not isinstance(results, list): return None - failures: list[str] = [] for item in results[:10]: - if not isinstance(item, dict): - continue - error = str(item.get("error") or "").strip() + error = str(item.get("error") or "").strip() if isinstance(item, dict) else "" if not error or error in {"None", "null"}: continue url = str(item.get("url") or "").strip() @@ -546,38 +447,30 @@ def _format_web_extract_result(result: Optional[str]) -> Optional[str]: failures.append( f"- {title}" + (f" — {url}" if url and url != title else "") + f"\n Error: {_truncate_text(error, limit=500)}" ) - if not failures: return None - lines = [f"Web extract failed for {len(failures)} URL{'s' if len(failures) != 1 else ''}"] - lines.extend(failures) - return "\n".join(lines) + return "\n".join([f"Web extract failed for {_plural(len(failures), 'URL')}", *failures]) -def _format_process_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: +def _format_process_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): - return result if isinstance(result, str) and result.strip() else None + return _nonempty(result) if data.get("success") is False and data.get("error"): return f"Process error: {data.get('error')}" - action = str((args or {}).get("action") or "process").strip() or "process" - if isinstance(data.get("processes"), list): - processes = data["processes"] + action = _arg(args, "action", default="process") + processes = data.get("processes") + if isinstance(processes, list): lines = [f"Processes: {len(processes)}"] for proc in processes[:20]: if not isinstance(proc, dict): lines.append(f"- {proc}") continue - sid = str(proc.get("session_id") or proc.get("id") or "?") - status = str(proc.get("status") or ("exited" if proc.get("exited") else "running")) cmd = str(proc.get("command") or "").strip() - pid = proc.get("pid") - code = proc.get("exit_code") - bits = [status] - if pid is not None: - bits.append(f"pid {pid}") - if code is not None: - bits.append(f"exit {code}") + bits = [str(proc.get("status") or ("exited" if proc.get("exited") else "running"))] + bits += [f"pid {proc['pid']}"] if proc.get("pid") is not None else [] + bits += [f"exit {proc['exit_code']}"] if proc.get("exit_code") is not None else [] + sid = proc.get("session_id") or proc.get("id") or "?" lines.append(f"- `{sid}` — {', '.join(bits)}" + (f" — {cmd[:120]}" if cmd else "")) if len(processes) > 20: lines.append(f"... {len(processes) - 20} more process(es)") @@ -595,59 +488,46 @@ def _format_process_result(result: Optional[str], args: Optional[Dict[str, Any]] lines.extend(["", "Output:", _truncate_text(str(output), limit=5000)]) if error: lines.extend(["", "Error:", _truncate_text(str(error), limit=2000)]) - msg = data.get("message") - if msg and not output and not error: - lines.append(str(msg)) + if data.get("message") and not output and not error: + lines.append(str(data.get("message"))) return _truncate_text("\n".join(lines), limit=7000) -def _format_delegate_result(result: Optional[str]) -> Optional[str]: +def _format_delegate_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None - if data.get("error") and not isinstance(data.get("results"), list): - return f"Delegation failed: {data.get('error')}" results = data.get("results") + if data.get("error") and not isinstance(results, list): + return f"Delegation failed: {data.get('error')}" if not isinstance(results, list): return None total = data.get("total_duration_seconds") - lines = [f"Delegation results: {len(results)} task{'s' if len(results) != 1 else ''}" + (f" in {total}s" if total is not None else "")] + lines = [f"Delegation results: {_plural(len(results), 'task')}" + (f" in {total}s" if total is not None else "")] icon = {"completed": "✅", "failed": "✗", "error": "✗", "timeout": "⏱", "interrupted": "⚠"} for item in results: if not isinstance(item, dict): lines.append(f"- {item}") continue - idx = item.get("task_index") - status = str(item.get("status") or "unknown") - model = item.get("model") - dur = item.get("duration_seconds") - role = item.get("_child_role") + idx, status = item.get("task_index"), str(item.get("status") or "unknown") header = f"{icon.get(status, '•')} Task {idx + 1 if isinstance(idx, int) else '?'}: {status}" - bits = [] - if model: - bits.append(str(model)) - if role: - bits.append(f"role={role}") - if dur is not None: - bits.append(f"{dur}s") - if bits: - header += " (" + ", ".join(bits) + ")" - lines.extend(["", header]) - summary = str(item.get("summary") or "").strip() - error = str(item.get("error") or "").strip() + bits = [str(item["model"])] if item.get("model") else [] + bits += [f"role={item['_child_role']}"] if item.get("_child_role") else [] + bits += [f"{item['duration_seconds']}s"] if item.get("duration_seconds") is not None else [] + lines.extend(["", header + (" (" + ", ".join(bits) + ")" if bits else "")]) + summary, error = str(item.get("summary") or "").strip(), str(item.get("error") or "").strip() if summary: lines.append(_truncate_text(summary, limit=1200)) if error: lines.append("Error: " + _truncate_text(error, limit=800)) trace = item.get("tool_trace") - if isinstance(trace, list) and trace: - names = [str(t.get("tool") or "?") for t in trace if isinstance(t, dict)] - if names: - lines.append("Tools: " + ", ".join(names[:12]) + (f" (+{len(names)-12})" if len(names) > 12 else "")) + names = [str(t.get("tool") or "?") for t in trace if isinstance(t, dict)] if isinstance(trace, list) else [] + if names: + lines.append("Tools: " + ", ".join(names[:12]) + (f" (+{len(names)-12})" if len(names) > 12 else "")) return _truncate_text("\n".join(lines), limit=8000) -def _format_session_search_result(result: Optional[str]) -> Optional[str]: +def _format_session_search_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None @@ -656,40 +536,38 @@ def _format_session_search_result(result: Optional[str]) -> Optional[str]: results = data.get("results") if not isinstance(results, list): return None - mode = data.get("mode") or "search" - query = data.get("query") - lines = ["Recent sessions" if mode == "recent" else "Session search results" + (f" for `{query}`" if query else "")] + if (data.get("mode") or "search") == "recent": + lines = ["Recent sessions"] + else: + lines = ["Session search results" + _fmt(data.get("query"), " for `{}`", "")] if not results: lines.append(str(data.get("message") or "No matching sessions found.")) return "\n".join(lines) for item in results: if not isinstance(item, dict): continue - sid = str(item.get("session_id") or "?") title = str(item.get("title") or item.get("when") or "Untitled session").strip() when = str(item.get("last_active") or item.get("started_at") or item.get("when") or "").strip() count = item.get("message_count") - source = str(item.get("source") or "").strip() - meta = ", ".join(str(x) for x in [when, source, f"{count} msgs" if count is not None else ""] if x) - lines.append(f"- **{title}** (`{sid}`)" + (f" — {meta}" if meta else "")) + meta = ", ".join(str(x) for x in [when, str(item.get("source") or "").strip(), f"{count} msgs" if count is not None else ""] if x) + lines.append(f"- **{title}** (`{item.get('session_id') or '?'}`)" + (f" — {meta}" if meta else "")) summary = str(item.get("summary") or item.get("preview") or "").strip() if summary: lines.append(" " + _truncate_text(" ".join(summary.split()), limit=500)) return _truncate_text("\n".join(lines), limit=7000) -def _format_memory_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: +def _format_memory_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None - action = str((args or {}).get("action") or "memory").strip() or "memory" + action = _arg(args, "action", default="memory") target = str(data.get("target") or (args or {}).get("target") or "memory") if data.get("success") is False: lines = [f"✗ Memory {action} failed ({target})", str(data.get("error") or "unknown error")] matches = data.get("matches") if isinstance(matches, list) and matches: - lines.append("Matches:") - lines.extend(f"- {_truncate_text(str(m), 160)}" for m in matches[:5]) + lines.extend(["Matches:", *(f"- {_truncate_text(str(m), 160)}" for m in matches[:5])]) return "\n".join(lines) lines = [f"✅ Memory {action} saved ({target})"] if data.get("message"): @@ -698,52 +576,51 @@ def _format_memory_result(result: Optional[str], args: Optional[Dict[str, Any]]) lines.append(f"Entries: {data.get('entry_count')}") if data.get("usage"): lines.append(f"Usage: {data.get('usage')}") - # Avoid dumping all memory entries into ACP UI; show only the explicit new value preview. - preview = str((args or {}).get("content") or (args or {}).get("old_text") or "").strip() + # Never dump all memory entries into the ACP UI; only preview the new value. + preview = _arg(args, "content", "old_text") if preview: lines.append("Preview: " + _truncate_text(preview, limit=300)) return "\n".join(lines) -def _format_edit_result(tool_name: str, result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: +def _format_edit_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) path = str((args or {}).get("path") or "file").strip() - if isinstance(data, dict): - if data.get("success") is False or data.get("error"): - return f"{tool_name} failed for {path}: {data.get('error', 'unknown error')}" - message = str(data.get("message") or "").strip() - replacements = data.get("replacements") or data.get("replacement_count") - lines = [f"✅ {tool_name} completed" + (f" for `{path}`" if path else "")] - if message: - lines.append(message) - if replacements is not None: - lines.append(f"Replacements: {replacements}") - if data.get("files_modified"): - files = data.get("files_modified") - if isinstance(files, list): - lines.append("Files: " + ", ".join(f"`{f}`" for f in files[:8])) - return "\n".join(lines) - if isinstance(result, str) and result.strip(): - return _truncate_text(result, limit=3000) - return f"✅ {tool_name} completed" + (f" for `{path}`" if path else "") + done = f"✅ {tool_name} completed" + (f" for `{path}`" if path else "") + if not isinstance(data, dict): + text = _nonempty(result) + return _truncate_text(text, limit=3000) if text else done + failed = _failure(data, f"{tool_name} failed for {path}") + if failed: + return failed + lines = [done] + message = str(data.get("message") or "").strip() + if message: + lines.append(message) + replacements = data.get("replacements") or data.get("replacement_count") + if replacements is not None: + lines.append(f"Replacements: {replacements}") + files = data.get("files_modified") + if files and isinstance(files, list): + lines.append("Files: " + ", ".join(f"`{f}`" for f in files[:8])) + return "\n".join(lines) -def _format_browser_result(tool_name: str, result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: +def _format_browser_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): - return result if isinstance(result, str) and result.strip() else None - if data.get("success") is False or data.get("error"): - return f"{tool_name} failed: {data.get('error', 'unknown error')}" - if tool_name == "browser_get_images": - images = data.get("images") or data.get("data") - if isinstance(images, list): - lines = [f"Images found: {len(images)}"] - for img in images[:12]: - if isinstance(img, dict): - alt = str(img.get("alt") or "").strip() - url = str(img.get("url") or img.get("src") or "").strip() - lines.append(f"- {alt or 'image'}" + (f" — {url}" if url else "")) - return _truncate_text("\n".join(lines), limit=5000) + return _nonempty(result) + failed = _failure(data, f"{tool_name} failed") + if failed: + return failed + images = (data.get("images") or data.get("data")) if tool_name == "browser_get_images" else None + if isinstance(images, list): + lines = [f"Images found: {len(images)}"] + for img in images[:12]: + if isinstance(img, dict): + url = str(img.get("url") or img.get("src") or "").strip() + lines.append(f"- {str(img.get('alt') or '').strip() or 'image'}" + (f" — {url}" if url else "")) + return _truncate_text("\n".join(lines), limit=5000) title = str(data.get("title") or data.get("url") or data.get("status") or tool_name) text = str(data.get("text") or data.get("content") or data.get("snapshot") or data.get("analysis") or data.get("message") or "").strip() lines = [title] @@ -754,120 +631,92 @@ def _format_browser_result(tool_name: str, result: Optional[str], args: Optional return _truncate_text("\n".join(lines), limit=7000) -def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optional[str]: +def _format_media_or_cron_result(tool_name: str, result: Optional[str], args: Optional[Args]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): - return result if isinstance(result, str) and result.strip() else None - if data.get("success") is False or data.get("error"): - return f"{tool_name} failed: {data.get('error', 'unknown error')}" - lines = [f"✅ {tool_name} completed"] - for key in ("file_path", "path", "url", "image_url", "job_id", "id", "status", "message", "next_run"): - if data.get(key): - lines.append(f"- **{key}:** {data.get(key)}") - return "\n".join(lines) + return _nonempty(result) + failed = _failure(data, f"{tool_name} failed") + if failed: + return failed + keys = ("file_path", "path", "url", "image_url", "job_id", "id", "status", "message", "next_run") + return "\n".join([f"✅ {tool_name} completed", *(f"- **{k}:** {data.get(k)}" for k in keys if data.get(k))]) -def _format_structured_value( - key: str, - value: Any, - *, - indent: int = 0, - max_depth: int = 3, - max_items: int = 8, -) -> List[str]: +def _format_structured_value(key: str, value: Any, *, indent: int = 0, max_depth: int = 3, max_items: int = 8) -> List[str]: """Render nested JSON-ish values as compact Markdown bullets, not inline blobs.""" - prefix = " " * indent - bullet = f"{prefix}- " + pad = " " * indent + bullet = f"{pad}- " label = f"**{key}:**" if key else "" - if value in (None, "", [], {}): - return [] + def _line(text: str) -> str: + return f"{bullet}{label} {text}" if label else f"{bullet}{text}" + def _child(child_key: str, child_value: Any, extra_indent: int) -> List[str]: + return _format_structured_value( + child_key, child_value, indent=indent + extra_indent, max_depth=max_depth - 1, max_items=max_items, + ) + + if value in _EMPTYISH: + return [] if max_depth <= 0: - if isinstance(value, (dict, list)): - preview = json.dumps(value, ensure_ascii=False, default=str) - else: - preview = str(value) - return [f"{bullet}{label} {_truncate_text(preview, limit=240)}" if label else f"{bullet}{_truncate_text(preview, limit=240)}"] + preview = json.dumps(value, ensure_ascii=False, default=str) if isinstance(value, (dict, list)) else str(value) + return [_line(_truncate_text(preview, limit=240))] if isinstance(value, dict): lines = [f"{bullet}{label}" if label else f"{bullet}{len(value)} fields"] shown = 0 for child_key, child_value in value.items(): - if child_value in (None, "", [], {}): + if child_value in _EMPTYISH: continue - lines.extend( - _format_structured_value( - str(child_key), - child_value, - indent=indent + 1, - max_depth=max_depth - 1, - max_items=max_items, - ) - ) + lines.extend(_child(str(child_key), child_value, 1)) shown += 1 if shown >= max_items: - remaining = max(0, len(value) - shown) - if remaining: - lines.append(f"{' ' * (indent + 1)}- ... {remaining} more fields") + if len(value) > shown: + lines.append(f"{pad} - ... {len(value) - shown} more fields") break return lines if isinstance(value, list): - lines = [f"{bullet}{label} {len(value)} item{'s' if len(value) != 1 else ''}" if label else f"{bullet}{len(value)} item{'s' if len(value) != 1 else ''}"] + lines = [_line(_plural(len(value), "item"))] for idx, item in enumerate(value[:max_items], 1): if isinstance(item, dict): - headline = str(item.get("content") or item.get("message") or item.get("title") or item.get("name") or item.get("id") or "").strip() + headline = str( + item.get("content") or item.get("message") or item.get("title") or item.get("name") or item.get("id") or "" + ).strip() if headline: - lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(headline, limit=220)}") + lines.append(f"{pad} {idx}. {_truncate_text(headline, limit=220)}") for child_key in ("id", "status", "type", "scope", "quality_score", "score", "path", "url"): - child_value = item.get(child_key) - if child_value not in (None, "", [], {}): - lines.append(f"{' ' * (indent + 2)}- **{child_key}:** {_truncate_text(str(child_value), limit=180)}") + if item.get(child_key) not in _EMPTYISH: + lines.append(f"{pad} - **{child_key}:** {_truncate_text(str(item[child_key]), limit=180)}") else: - lines.append(f"{' ' * (indent + 1)}{idx}.") + lines.append(f"{pad} {idx}.") for child_key, child_value in list(item.items())[:max_items]: - lines.extend( - _format_structured_value( - str(child_key), - child_value, - indent=indent + 2, - max_depth=max_depth - 1, - max_items=max_items, - ) - ) + lines.extend(_child(str(child_key), child_value, 2)) elif isinstance(item, list): - lines.append(f"{' ' * (indent + 1)}{idx}. {len(item)} items") + lines.append(f"{pad} {idx}. {len(item)} items") for nested in item[:max_items]: - lines.extend( - _format_structured_value( - "", - nested, - indent=indent + 2, - max_depth=max_depth - 1, - max_items=max_items, - ) - ) + lines.extend(_child("", nested, 2)) else: - lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(str(item), limit=240)}") + lines.append(f"{pad} {idx}. {_truncate_text(str(item), limit=240)}") if len(value) > max_items: - lines.append(f"{' ' * (indent + 1)}... {len(value) - max_items} more items") + lines.append(f"{pad} ... {len(value) - max_items} more items") return lines - return [f"{bullet}{label} {_truncate_text(str(value), limit=500)}" if label else f"{bullet}{_truncate_text(str(value), limit=500)}"] + return [_line(_truncate_text(str(value), limit=500))] -def _format_generic_structured_result( - tool_name: str, - result: Optional[str], - *, - fallback_to_text: bool = True, -) -> Optional[str]: +_PRIORITY_KEYS = ( + "message", "status", "id", "task_id", "issue_id", "title", "name", "entity_id", + "state", "service", "url", "path", "file_path", "count", "total", "next_run", +) + + +def _format_generic_structured_result(tool_name: str, result: Optional[str], *, fallback_to_text: bool = True) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, (dict, list)): - return result if fallback_to_text and isinstance(result, str) and result.strip() else None + return _nonempty(result) if fallback_to_text else None if isinstance(data, list): - lines = [f"{tool_name}: {len(data)} item{'s' if len(data) != 1 else ''}"] + lines = [f"{tool_name}: {_plural(len(data), 'item')}"] for item in data[:12]: if isinstance(item, (dict, list)): lines.extend(_format_structured_value("", item, indent=0, max_depth=2, max_items=6)) @@ -877,486 +726,268 @@ def _format_generic_structured_result( lines.append(f"... {len(data) - 12} more items") return _truncate_text("\n".join(lines), limit=5000) - if data.get("success") is False or data.get("error"): - return f"{tool_name} failed: {data.get('error', 'unknown error')}" - + failed = _failure(data, f"{tool_name} failed") + if failed: + return failed lines = [f"✅ {tool_name} completed" if data.get("success") is True else f"{tool_name} result"] - priority_keys = ( - "message", "status", "id", "task_id", "issue_id", "title", "name", "entity_id", - "state", "service", "url", "path", "file_path", "count", "total", "next_run", - ) - seen = set() - for key in priority_keys: - value = data.get(key) - if value in (None, "", [], {}): - continue - seen.add(key) - lines.append(f"- **{key}:** {_truncate_text(str(value), limit=500)}") - + seen = {key for key in _PRIORITY_KEYS if data.get(key) not in _EMPTYISH} + lines.extend(f"- **{key}:** {_truncate_text(str(data[key]), limit=500)}" for key in _PRIORITY_KEYS if key in seen) for key, value in data.items(): - if key in seen or key in {"success", "raw", "content", "entries"}: - continue - if value in (None, "", [], {}): + if key in seen or key in {"success", "raw", "content", "entries"} or value in _EMPTYISH: continue lines.extend(_format_structured_value(str(key), value, indent=0, max_depth=3, max_items=8)) if len(lines) >= 40: lines.append("- ... more fields truncated") break - content = data.get("content") if isinstance(content, str) and content.strip(): lines.extend(["", _truncate_text(content.strip(), limit=1500)]) return _truncate_text("\n".join(lines), limit=7000) -def _build_polished_completion_content( - tool_name: str, - result: Optional[str], - function_args: Optional[Dict[str, Any]], -) -> Optional[List[Any]]: - formatter = { - "todo": lambda: _format_todo_result(result), - "read_file": lambda: _format_read_file_result(result, function_args), - "write_file": lambda: _format_edit_result(tool_name, result, function_args), - "patch": lambda: _format_edit_result(tool_name, result, function_args), - "search_files": lambda: _format_search_files_result(result), - "execute_code": lambda: _format_execute_code_result(result), - "process": lambda: _format_process_result(result, function_args), - "delegate_task": lambda: _format_delegate_result(result), - "session_search": lambda: _format_session_search_result(result), - "memory": lambda: _format_memory_result(result, function_args), - "skill_view": lambda: _format_skill_view_result(result), - "skill_manage": lambda: _format_skill_manage_result(result, function_args), - "web_search": lambda: _format_web_search_result(result), - "web_extract": lambda: _format_web_extract_result(result), - "browser_navigate": lambda: _format_browser_result(tool_name, result, function_args), - "browser_snapshot": lambda: _format_browser_result(tool_name, result, function_args), - "browser_vision": lambda: _format_browser_result(tool_name, result, function_args), - "browser_get_images": lambda: _format_browser_result(tool_name, result, function_args), - "vision_analyze": lambda: _format_media_or_cron_result(tool_name, result), - "image_generate": lambda: _format_media_or_cron_result(tool_name, result), - "cronjob": lambda: _format_media_or_cron_result(tool_name, result), - }.get(tool_name) - if formatter is None and tool_name in _POLISHED_TOOLS: - formatter = lambda: _format_generic_structured_result(tool_name, result) - if formatter is None: - text = _format_generic_structured_result(tool_name, result, fallback_to_text=False) +_Formatter = Callable[[str, Optional[str], Optional[Args]], Optional[str]] + +_COMPLETION_FORMATTERS: Dict[str, _Formatter] = { + "todo": _format_todo_result, + "read_file": _format_read_file_result, + "write_file": _format_edit_result, + "patch": _format_edit_result, + "search_files": _format_search_files_result, + "execute_code": _format_execute_code_result, + "process": _format_process_result, + "delegate_task": _format_delegate_result, + "session_search": _format_session_search_result, + "memory": _format_memory_result, + "skill_view": _format_skill_view_result, + "skill_manage": _format_skill_manage_result, + "web_search": _format_web_search_result, + "web_extract": lambda t, r, a: _format_web_extract_result(r), + **{n: _format_browser_result for n in ("browser_navigate", "browser_snapshot", "browser_vision", "browser_get_images")}, + **{n: _format_media_or_cron_result for n in ("vision_analyze", "image_generate", "cronjob")}, +} + + +def _build_polished_completion_content(tool_name: str, result: Optional[str], function_args: Optional[Args]) -> Optional[List[Any]]: + formatter = _COMPLETION_FORMATTERS.get(tool_name) + if formatter is not None: + text = formatter(tool_name, result, function_args) else: - text = formatter() - if not text: - return None - return [_text(text)] - - -def _strip_diff_prefix(path: str) -> str: - raw = str(path or "").strip() - if raw.startswith(("a/", "b/")): - return raw[2:] - return raw + text = _format_generic_structured_result(tool_name, result, fallback_to_text=tool_name in _POLISHED_TOOLS) + return [_text(text)] if text else None def _parse_unified_diff_content(diff_text: str) -> List[Any]: - """Convert unified diff text into ACP diff content blocks.""" - if not diff_text: - return [] - + """Convert unified diff text into ACP diff content blocks (one per ``---``/``+++`` pair).""" content: List[Any] = [] - current_old_path: Optional[str] = None - current_new_path: Optional[str] = None - old_lines: list[str] = [] - new_lines: list[str] = [] + if not diff_text: + return content + state: Dict[str, Any] = {"old": None, "new": None, "old_lines": [], "new_lines": []} def _flush() -> None: - nonlocal current_old_path, current_new_path, old_lines, new_lines - if current_old_path is None and current_new_path is None: - return - path = current_new_path if current_new_path and current_new_path != "/dev/null" else current_old_path - if not path or path == "/dev/null": - current_old_path = None - current_new_path = None - old_lines = [] - new_lines = [] - return - content.append( - acp.tool_diff_content( - path=_strip_diff_prefix(path), - old_text="\n".join(old_lines) if old_lines else None, - new_text="\n".join(new_lines), - ) - ) - current_old_path = None - current_new_path = None - old_lines = [] - new_lines = [] + old_path, new_path = state["old"], state["new"] + if old_path is not None or new_path is not None: + path = new_path if new_path and new_path != "/dev/null" else old_path + if path and path != "/dev/null": + path = str(path).strip() + content.append(acp.tool_diff_content( + path=path[2:] if path.startswith(("a/", "b/")) else path, + old_text="\n".join(state["old_lines"]) if state["old_lines"] else None, + new_text="\n".join(state["new_lines"]), + )) + state.update(old=None, new=None, old_lines=[], new_lines=[]) for line in diff_text.splitlines(): if line.startswith("--- "): _flush() - current_old_path = line[4:].strip() + state["old"] = line[4:].strip() + elif line.startswith("+++ "): + state["new"] = line[4:].strip() + elif line.startswith("@@") or (state["old"] is None and state["new"] is None): continue - if line.startswith("+++ "): - current_new_path = line[4:].strip() - continue - if line.startswith("@@"): - continue - if current_old_path is None and current_new_path is None: - continue - if line.startswith("+"): - new_lines.append(line[1:]) + elif line.startswith("+"): + state["new_lines"].append(line[1:]) elif line.startswith("-"): - old_lines.append(line[1:]) + state["old_lines"].append(line[1:]) elif line.startswith(" "): - shared = line[1:] - old_lines.append(shared) - new_lines.append(shared) - + state["old_lines"].append(line[1:]) + state["new_lines"].append(line[1:]) _flush() return content def _build_tool_complete_content( - tool_name: str, - result: Optional[str], - *, - function_args: Optional[Dict[str, Any]] = None, - snapshot: Any = None, + tool_name: str, result: Optional[str], *, function_args: Optional[Args] = None, snapshot: Any = None ) -> List[Any]: """Build structured ACP completion content, falling back to plain text.""" - display_result = result or "" - if len(display_result) > 5000: - display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)" - if tool_name == "skill_manage": try: from agent.display import extract_edit_diff - diff_text = extract_edit_diff( - tool_name, - result, - function_args=function_args, - snapshot=snapshot, - ) + diff_text = extract_edit_diff(tool_name, result, function_args=function_args, snapshot=snapshot) if isinstance(diff_text, str) and diff_text.strip(): diff_content = _parse_unified_diff_content(diff_text) if diff_content: return diff_content except Exception: pass - - polished_content = _build_polished_completion_content(tool_name, result, function_args) - if polished_content: - return polished_content - - return [_text(display_result)] + return _build_polished_completion_content(tool_name, result, function_args) or [_text(_truncate_text(result or ""))] -# --------------------------------------------------------------------------- -# Build ACP content objects for tool-call events -# --------------------------------------------------------------------------- +# --- ToolCallStart / ToolCallProgress events --------------------------------- -def build_tool_start( - tool_call_id: str, - tool_name: str, - arguments: Dict[str, Any], - *, - edit_diff: Any = None, -) -> ToolCallStart: +def _start_todo(args: Args) -> List[Any]: + items = args.get("todos") + if not isinstance(items, list): + return [_text("Reading todo list")] + lines = ["Updating todo list", ""] + lines.extend(f"- {i.get('status', 'pending')}: {i.get('content', i.get('id', ''))}" for i in items[:8] if isinstance(i, dict)) + if len(items) > 8: + lines.append(f"... {len(items) - 8} more") + return [_text("\n".join(lines))] + + +def _start_skill_manage(args: Args) -> List[Any]: + action = _arg(args, "action", default="manage") + name = _arg(args, "name", default="?") + file_path = _arg(args, "file_path", default="SKILL.md") + path = f"skills/{name}/{file_path}" + if action == "patch": + old = str(args.get("old_string") or "") + return [acp.tool_diff_content(path=path, old_text=old or None, new_text=str(args.get("new_string") or ""))] + if action in {"edit", "create"}: + return [acp.tool_diff_content(path=path, new_text=str(args.get("content") or ""))] + if action == "write_file": + target = str(args.get("file_path") or "file") + return [acp.tool_diff_content(path=f"skills/{name}/{target}", new_text=str(args.get("file_content") or ""))] + if action in {"delete", "remove_file"}: + return [_text(f"Removing {str(args.get('file_path') or file_path)} from skill '{name}'")] + return [_text(f"Running skill_manage action '{action}' on skill '{name}' ({file_path})")] + + +def _start_execute_code(args: Args) -> List[Any]: + code = _arg(args, "code") + preview = code[:1200] + (f"\n... ({len(code)} chars total, truncated)" if len(code) > 1200 else "") + return [_text(_fmt(preview, "Running Python helper script:\n\n```python\n{}\n```", "Running Python helper script"))] + + +def _start_process(args: Args) -> List[Any]: + text = f"Process action: {_arg(args, 'action', default='manage')}" + _fmt(_arg(args, "session_id"), "\nSession: {}", "") + data_preview = _arg(args, "data") + return [_text(text + (f"\nInput: {_truncate_text(data_preview, limit=500)}" if data_preview else ""))] + + +def _start_delegate(args: Args) -> List[Any]: + tasks = args.get("tasks") + if not (isinstance(tasks, list) and tasks): + goal = _arg(args, "goal") + return [_text("Delegating task" + (f":\n{_truncate_text(goal, limit=800)}" if goal else ""))] + lines = [f"Delegating {len(tasks)} tasks", ""] + for i, task in enumerate(tasks[:8], 1): + if isinstance(task, dict): + lines.append(f"{i}. " + _truncate_text(_arg(task, "goal"), limit=160) + _fmt(_arg(task, "role"), " ({})", "")) + if len(tasks) > 8: + lines.append(f"... {len(tasks) - 8} more") + return [_text("\n".join(lines))] + + +def _start_memory(args: Args) -> List[Any]: + text = f"Memory {_arg(args, 'action', default='manage')} ({_arg(args, 'target', default='memory')})" + preview = _arg(args, "content", "old_text") + return [_text(text + (f"\nPreview: {_truncate_text(preview, limit=500)}" if preview else ""))] + + +# Per-tool start-content builders. ``None`` means the title/location already +# identify the target (read_file, web_extract): a synthetic content block would +# make Zed render an unhelpful Output section before completion. +_START_CONTENT_BUILDERS: Dict[str, Optional[Callable[[Args], List[Any]]]] = { + "patch": lambda a: [_text( + f"Preparing {a.get('mode', 'replace')} edit for {a.get('path') or 'patch input'}. Approval prompt shows the diff." + )], + "write_file": lambda a: [_text(_fmt(a.get("path", ""), "Preparing write to {}. Approval prompt shows the diff.", + "Preparing file write. Approval prompt shows the diff."))], + "terminal": lambda a: [_text(f"$ {a.get('command', '')}")], + "read_file": None, + "search_files": lambda a: [_text( + f"Searching for '{a.get('pattern', '')}' ({a.get('target', 'content')})" + _fmt(a.get("path"), " in {}", "") + )], + "todo": _start_todo, + "skill_view": lambda a: [_text(f"Loading skill '{_arg(a, 'name', default='?')}' ({_arg(a, 'file_path', default='SKILL.md')})")], + "skill_manage": _start_skill_manage, + "execute_code": _start_execute_code, + "web_search": lambda a: [_text(_fmt(_arg(a, "query"), "Searching the web for: {}", "Searching the web"))], + "web_extract": None, + "process": _start_process, + "delegate_task": _start_delegate, + "session_search": lambda a: [_text(_fmt(_arg(a, "query"), "Searching past sessions for: {}", "Loading recent sessions"))], + "memory": _start_memory, +} + + +def build_tool_start(tool_call_id: str, tool_name: str, arguments: Args, *, edit_diff: Any = None) -> ToolCallStart: """Create a ToolCallStart event for the given hermes tool invocation. - A malformed tool argument (e.g. a non-string ``command``/``path`` from a - model that ignores the schema) must never abort the ACP tool-call render — - ``build_tool_start`` runs on the live tool-progress callback and during - session history replay. On any failure in the title/content/location - builders, fall back to a minimal, valid start event. Mirrors - ``get_cute_tool_message`` in ``agent/display.py``, wrapped for the same - reason on the CLI side. + A malformed argument (e.g. a non-string ``command``/``path`` from a model + ignoring the schema) must never abort the render — this runs on the live + tool-progress callback and during history replay — so any failure in the + title/content/location builders falls back to a minimal valid start event + (mirrors ``get_cute_tool_message`` in ``agent/display.py``). """ try: - return _build_tool_start( - tool_call_id, tool_name, arguments, edit_diff=edit_diff - ) + return _build_tool_start(tool_call_id, tool_name, arguments, edit_diff=edit_diff) except Exception as exc: # noqa: BLE001 — a tool-call render must never abort the turn logger.debug("ACP tool-start render failed for %r: %s", tool_name, exc) safe_name = tool_name if isinstance(tool_name, str) and tool_name else "tool" - return acp.start_tool_call( - tool_call_id, safe_name, kind=get_tool_kind(safe_name), - content=None, locations=[], raw_input=None, - ) + return acp.start_tool_call(tool_call_id, safe_name, kind=get_tool_kind(safe_name), content=None, locations=[]) -def _build_tool_start( - tool_call_id: str, - tool_name: str, - arguments: Dict[str, Any], - *, - edit_diff: Any = None, -) -> ToolCallStart: +def _build_tool_start(tool_call_id: str, tool_name: str, arguments: Args, *, edit_diff: Any = None) -> ToolCallStart: """Build the ToolCallStart event (unguarded; see ``build_tool_start``).""" - kind = get_tool_kind(tool_name) - title = build_tool_title(tool_name, arguments) - locations = extract_locations(arguments) - - if tool_name == "patch": - if edit_diff is not None: - content = [ - acp.tool_diff_content( - path=edit_diff.path, - old_text=edit_diff.old_text, - new_text=edit_diff.new_text, - ) - ] - else: - mode = arguments.get("mode", "replace") - path = arguments.get("path") or "patch input" - content = [_text(f"Preparing {mode} edit for {path}. Approval prompt shows the diff.")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "write_file": - if edit_diff is not None: - content = [ - acp.tool_diff_content( - path=edit_diff.path, - old_text=edit_diff.old_text, - new_text=edit_diff.new_text, - ) - ] - else: - path = arguments.get("path", "") - content = [_text(f"Preparing write to {path}. Approval prompt shows the diff." if path else "Preparing file write. Approval prompt shows the diff.")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "terminal": - command = arguments.get("command", "") - content = [_text(f"$ {command}")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "read_file": - # The title and location already identify the file. Sending a synthetic - # "Reading ..." content block makes Zed render an unhelpful Output - # section before the real file contents arrive on completion. - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=None, locations=locations, - ) - - if tool_name == "search_files": - pattern = arguments.get("pattern", "") - target = arguments.get("target", "content") - search_path = arguments.get("path") - where = f" in {search_path}" if search_path else "" - content = [_text(f"Searching for '{pattern}' ({target}){where}")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "todo": - items = arguments.get("todos") - if isinstance(items, list): - preview_lines = ["Updating todo list", ""] - for item in items[:8]: - if isinstance(item, dict): - preview_lines.append(f"- {item.get('status', 'pending')}: {item.get('content', item.get('id', ''))}") - if len(items) > 8: - preview_lines.append(f"... {len(items) - 8} more") - content = [_text("\n".join(preview_lines))] - else: - content = [_text("Reading todo list")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "skill_view": - name = str(arguments.get("name") or "?").strip() or "?" - file_path = str(arguments.get("file_path") or "SKILL.md").strip() or "SKILL.md" - content = [_text(f"Loading skill '{name}' ({file_path})")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "skill_manage": - action = str(arguments.get("action") or "manage").strip() or "manage" - name = str(arguments.get("name") or "?").strip() or "?" - file_path = str(arguments.get("file_path") or "SKILL.md").strip() or "SKILL.md" - path = f"skills/{name}/{file_path}" if file_path else f"skills/{name}" - - if action == "patch": - old = str(arguments.get("old_string") or "") - new = str(arguments.get("new_string") or "") - content = [acp.tool_diff_content(path=path, old_text=old or None, new_text=new)] - elif action in {"edit", "create"}: - content = [ - acp.tool_diff_content( - path=path, - new_text=str(arguments.get("content") or ""), - ) - ] - elif action == "write_file": - target = str(arguments.get("file_path") or "file") - content = [ - acp.tool_diff_content( - path=f"skills/{name}/{target}", - new_text=str(arguments.get("file_content") or ""), - ) - ] - elif action in {"delete", "remove_file"}: - target = str(arguments.get("file_path") or file_path or name) - content = [_text(f"Removing {target} from skill '{name}'")] - else: - content = [_text(f"Running skill_manage action '{action}' on skill '{name}' ({file_path})")] - - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "execute_code": - code = str(arguments.get("code") or "").strip() - preview = code[:1200] + (f"\n... ({len(code)} chars total, truncated)" if len(code) > 1200 else "") - content = [_text(f"Running Python helper script:\n\n```python\n{preview}\n```" if preview else "Running Python helper script")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "web_search": - query = str(arguments.get("query") or "").strip() - content = [_text(f"Searching the web for: {query}" if query else "Searching the web")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "web_extract": - # The title identifies the URL(s). Avoid a duplicate content block so - # Zed renders this like read_file: compact start, concise completion. - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=None, locations=locations, - ) - - if tool_name == "process": - action = str(arguments.get("action") or "").strip() or "manage" - sid = str(arguments.get("session_id") or "").strip() - data_preview = str(arguments.get("data") or "").strip() - text = f"Process action: {action}" + (f"\nSession: {sid}" if sid else "") - if data_preview: - text += "\nInput: " + _truncate_text(data_preview, limit=500) - content = [_text(text)] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "delegate_task": - tasks = arguments.get("tasks") - if isinstance(tasks, list) and tasks: - lines = [f"Delegating {len(tasks)} tasks", ""] - for i, task in enumerate(tasks[:8], 1): - if isinstance(task, dict): - goal = str(task.get("goal") or "").strip() - role = str(task.get("role") or "").strip() - lines.append(f"{i}. " + _truncate_text(goal, limit=160) + (f" ({role})" if role else "")) - if len(tasks) > 8: - lines.append(f"... {len(tasks) - 8} more") - content = [_text("\n".join(lines))] - else: - goal = str(arguments.get("goal") or "").strip() - content = [_text("Delegating task" + (f":\n{_truncate_text(goal, limit=800)}" if goal else ""))] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "session_search": - query = str(arguments.get("query") or "").strip() - content = [_text(f"Searching past sessions for: {query}" if query else "Loading recent sessions")] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name == "memory": - action = str(arguments.get("action") or "manage").strip() or "manage" - target = str(arguments.get("target") or "memory").strip() or "memory" - preview = str(arguments.get("content") or arguments.get("old_text") or "").strip() - text = f"Memory {action} ({target})" - if preview: - text += "\nPreview: " + _truncate_text(preview, limit=500) - content = [_text(text)] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if tool_name in _POLISHED_TOOLS: - try: - args_text = json.dumps(arguments, indent=2, default=str) - except (TypeError, ValueError): - args_text = str(arguments) - content = [_text(_truncate_text(args_text, limit=1200))] - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - ) - - if not arguments: - return acp.start_tool_call( - tool_call_id, title, kind=kind, content=None, locations=locations, raw_input=None, - ) - - # Generic fallback - try: - args_text = json.dumps(arguments, indent=2, default=str) - except (TypeError, ValueError): - args_text = str(arguments) - content = [acp.tool_content(acp.text_block(args_text))] + raw_input = None + if tool_name in ("patch", "write_file") and edit_diff is not None: + content = [acp.tool_diff_content(path=edit_diff.path, old_text=edit_diff.old_text, new_text=edit_diff.new_text)] + elif tool_name in _START_CONTENT_BUILDERS: + builder = _START_CONTENT_BUILDERS[tool_name] + content = builder(arguments) if builder is not None else None + elif tool_name in _POLISHED_TOOLS: + content = [_text(_truncate_text(_args_json(arguments), limit=1200))] + elif not arguments: + content = None + else: # unknown tool with arguments: echo them as content and raw_input + content = [_text(_args_json(arguments))] + raw_input = arguments return acp.start_tool_call( - tool_call_id, title, kind=kind, content=content, locations=locations, - raw_input=None if tool_name in _POLISHED_TOOLS else arguments, + tool_call_id, build_tool_title(tool_name, arguments), kind=get_tool_kind(tool_name), + content=content, locations=extract_locations(arguments), raw_input=raw_input, ) -def _is_structured_json_result(result: Optional[str]) -> bool: - return isinstance(_json_loads_maybe(result), (dict, list)) - - def build_tool_complete( tool_call_id: str, tool_name: str, result: Optional[str] = None, - function_args: Optional[Dict[str, Any]] = None, + function_args: Optional[Args] = None, snapshot: Any = None, ) -> ToolCallProgress: """Create a ToolCallUpdate (progress) event for a completed tool call.""" - kind = get_tool_kind(tool_name) if tool_name == "web_extract": error_text = _format_web_extract_result(result) content = [_text(error_text)] if error_text else None else: - content = _build_tool_complete_content( - tool_name, - result, - function_args=function_args, - snapshot=snapshot, - ) + content = _build_tool_complete_content(tool_name, result, function_args=function_args, snapshot=snapshot) + structured = isinstance(_json_loads_maybe(result), (dict, list)) return acp.update_tool_call( tool_call_id, - kind=kind, + kind=get_tool_kind(tool_name), status="failed" if _tool_result_failed(result, tool_name) else "completed", content=content, - raw_output=None if tool_name in _POLISHED_TOOLS or _is_structured_json_result(result) else result, + raw_output=None if tool_name in _POLISHED_TOOLS or structured else result, ) -# --------------------------------------------------------------------------- -# Location extraction -# --------------------------------------------------------------------------- - - -def extract_locations( - arguments: Dict[str, Any], -) -> List[ToolCallLocation]: +def extract_locations(arguments: Args) -> List[ToolCallLocation]: """Extract file-system locations from tool arguments.""" - locations: List[ToolCallLocation] = [] path = arguments.get("path") - if path: - line = arguments.get("offset") or arguments.get("line") - locations.append(ToolCallLocation(path=path, line=line)) - return locations + if not path: + return [] + return [ToolCallLocation(path=path, line=arguments.get("offset") or arguments.get("line"))] diff --git a/tests/acp/test_auth.py b/tests/acp/test_auth.py index f7f43fd849..4d04dfdde7 100644 --- a/tests/acp/test_auth.py +++ b/tests/acp/test_auth.py @@ -3,22 +3,10 @@ from acp_adapter.auth import ( TERMINAL_SETUP_AUTH_METHOD_ID, build_auth_methods, - has_provider, detect_provider, ) -class TestHasProvider: - def test_has_provider_with_resolved_runtime(self, monkeypatch): - monkeypatch.setattr( - "hermes_cli.runtime_provider.resolve_runtime_provider", - lambda: {"provider": "openrouter", "api_key": "sk-or-test"}, - ) - assert has_provider() is True - - - - class TestDetectProvider: def test_detect_openrouter(self, monkeypatch): monkeypatch.setattr( diff --git a/tests/acp/test_edit_approval.py b/tests/acp/test_edit_approval.py index a7c53d6d89..8cdbf82fd2 100644 --- a/tests/acp/test_edit_approval.py +++ b/tests/acp/test_edit_approval.py @@ -9,7 +9,6 @@ from pathlib import Path from acp_adapter.edit_approval import ( EditProposal, build_acp_edit_tool_call, - clear_edit_approval_requester, set_edit_approval_requester, should_auto_approve_edit, ) @@ -17,7 +16,7 @@ from model_tools import handle_function_call def teardown_function() -> None: - clear_edit_approval_requester() + set_edit_approval_requester(None) def test_acp_permission_tool_call_uses_edit_kind_and_diff_content(): diff --git a/tests/acp/test_session.py b/tests/acp/test_session.py index 7922846725..dc6180fbea 100644 --- a/tests/acp/test_session.py +++ b/tests/acp/test_session.py @@ -241,25 +241,6 @@ class TestListAndCleanup: assert isinstance(messages[0].get("timestamp"), (int, float)) - - - def test_cleanup_clears_all(self, manager): - s1 = manager.create_session() - s2 = manager.create_session() - s1.history.append({"role": "user", "content": "one"}) - s2.history.append({"role": "user", "content": "two"}) - assert len(manager.list_sessions()) == 2 - manager.cleanup() - assert manager.list_sessions() == [] - - def test_remove_session(self, manager): - state = manager.create_session() - assert manager.remove_session(state.session_id) is True - assert manager.get_session(state.session_id) is None - # Removing again returns False - assert manager.remove_session(state.session_id) is False - - # --------------------------------------------------------------------------- # persistence — sessions survive process restarts (via SessionDB) # --------------------------------------------------------------------------- diff --git a/tests/acp_adapter/test_detect_provider_entra.py b/tests/acp_adapter/test_detect_provider_entra.py index 6d9409ab9d..5ffd0b3886 100644 --- a/tests/acp_adapter/test_detect_provider_entra.py +++ b/tests/acp_adapter/test_detect_provider_entra.py @@ -34,7 +34,6 @@ class TestDetectProviderEntra: side_effect=_fake_runtime, ): assert _acp_auth.detect_provider() == "azure-foundry" - assert _acp_auth.has_provider() is True def test_string_api_key_still_works(self): from acp_adapter import auth as _acp_auth diff --git a/tui_gateway/hosted_room_peer_transport.py b/tui_gateway/hosted_room_peer_transport.py index 8dcdf5cd68..6040b8f10f 100644 --- a/tui_gateway/hosted_room_peer_transport.py +++ b/tui_gateway/hosted_room_peer_transport.py @@ -40,44 +40,20 @@ class HostedRoomPeerClient(Protocol): expected_session_id: str | None = None, ) -> Mapping[str, Any] | None: ... - def dispatch( - self, - *, - dispatch: Mapping[str, Any], - grant: str, - ) -> Mapping[str, Any]: ... + def dispatch(self, *, dispatch: Mapping[str, Any], grant: str) -> Mapping[str, Any]: ... def history( - self, - *, - room_id: str, - profile: str, - session_id: str, - grant: str, + self, *, room_id: str, profile: str, session_id: str, grant: str ) -> Sequence[Mapping[str, Any]]: ... def status( - self, - *, - room_id: str, - profile: str, - session_id: str, - grant: str, + self, *, room_id: str, profile: str, session_id: str, grant: str ) -> Mapping[str, Any]: ... - def stop( - self, - *, - dispatch: Mapping[str, Any], - grant: str, - ) -> Mapping[str, Any] | None: ... + def stop(self, *, dispatch: Mapping[str, Any], grant: str) -> Mapping[str, Any] | None: ... def stop_receipt( - self, - *, - task_id: str, - execution_generation: int, - grant: str, + self, *, task_id: str, execution_generation: int, grant: str ) -> Mapping[str, Any] | None: ... @@ -103,8 +79,7 @@ class FailoverHostedRoomPeerClient: ) -> None: if not candidates: raise ValueError("at least one RoomLink candidate is required") - targets = {candidate.target_install_id for candidate in candidates} - if len(targets) != 1: + if len({candidate.target_install_id for candidate in candidates}) != 1: raise ValueError("RoomLink candidates must target one installation") if reprobe_interval_seconds <= 0: raise ValueError("reprobe_interval_seconds must be positive") @@ -119,28 +94,23 @@ class FailoverHostedRoomPeerClient: return self.candidates[self._active] def _call(self, method: str, **kwargs): + """Try the active link (re-probing the primary after a cooldown), then the rest. + + Ambiguous or non-retryable failures propagate immediately: failing over + after an ambiguous dispatch could run the same task twice. + """ now = self.clock() - probe_primary = ( - self._active != 0 - and now - self._last_primary_probe >= self.reprobe_interval_seconds - ) - if probe_primary: + order = [self._active] + if self._active != 0 and now - self._last_primary_probe >= self.reprobe_interval_seconds: self._last_primary_probe = now order = [0, self._active] - else: - order = [self._active] - order.extend( - index for index in range(len(self.candidates)) if index not in order - ) + order.extend(index for index in range(len(self.candidates)) if index not in order) last_error = None for index in order: - candidate = self.candidates[index] try: - result = getattr(candidate.client, method)(**kwargs) + result = getattr(self.candidates[index].client, method)(**kwargs) except Exception as exc: - if bool(getattr(exc, "ambiguous", False)): - raise - if not bool(getattr(exc, "retryable", False)): + if getattr(exc, "ambiguous", False) or not getattr(exc, "retryable", False): raise last_error = exc continue @@ -187,6 +157,40 @@ class PeerMemberRoute: execution_policy_digest: str = "" +def build_member_dispatch( + *, + binding: HostedRoomBinding, + route: PeerMemberRoute, + room_id: str, + task_id: str, + target_profile: str, + execution_generation: int, + source_event_seq: int, + prompt: str, + trace_id: str, +) -> HostedMemberDispatch: + """Build the fully fenced member dispatch shared by submit and recovery.""" + return HostedMemberDispatch.from_mapping({ + "protocol_version": PROTOCOL_VERSION, + "room_id": room_id, + "home_install_id": route.home_install_id, + "authority_gateway_id": binding.gateway_id, + "authority_epoch": binding.authority_epoch, + "member_id": route.member_id, + "target_install_id": route.target_install_id, + "target_profile": target_profile, + "task_id": task_id, + "execution_generation": execution_generation, + "source_event_seq": source_event_seq, + "cancellation_scope_id": route.cancellation_scope_id, + "prompt": prompt, + "prompt_digest": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + "capability_digest": route.capability_digest, + "execution_policy_digest": route.execution_policy_digest, + "trace_id": trace_id, + }) + + class PeerHostedRoomTransport(InternalSessionRPC): """Translate runtime session operations into recipient-validated peer RPC.""" @@ -222,53 +226,40 @@ class PeerHostedRoomTransport(InternalSessionRPC): target_profile=self.route.target_profile, ) - def _validate_coordinates(self, *, profile: str, source: str) -> None: + def _validate_coordinates(self, *, profile: str, source: str, title: str | None = None) -> None: if source != ROOM_SESSION_SOURCE: raise ValueError("peer room transport requires source=bot_room") if profile != self.route.target_profile: raise ValueError("peer room transport profile does not match its grant") - - def resolve_exact( - self, *, profile: str, title: str, source: str - ) -> Mapping[str, Any] | None: - self._validate_coordinates(profile=profile, source=source) - if title != room_session_title(self.binding.room_id): + if title is not None and title != room_session_title(self.binding.room_id): raise ValueError("peer room transport title does not match room identity") + + def _prepare(self, *, profile: str, source: str, create: bool, **extra): return self.client.prepare( room_id=self.binding.room_id, profile=profile, source=source, grant=self.route.grant, - create=False, + create=create, + **extra, ) + def resolve_exact(self, *, profile: str, title: str, source: str) -> Mapping[str, Any] | None: + self._validate_coordinates(profile=profile, source=source, title=title) + return self._prepare(profile=profile, source=source, create=False) + def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]: - self._validate_coordinates(profile=profile, source=source) - if title != room_session_title(self.binding.room_id): - raise ValueError("peer room transport title does not match room identity") - session = self.client.prepare( - room_id=self.binding.room_id, - profile=profile, - source=source, - grant=self.route.grant, - create=True, - ) + self._validate_coordinates(profile=profile, source=source, title=title) + session = self._prepare(profile=profile, source=source, create=True) if session is None: raise RuntimeError("peer did not create the room session") self._session_id = str(session.get("session_id") or session.get("id") or "") return session - def resume( - self, *, profile: str, session_id: str, source: str - ) -> Mapping[str, Any]: + def resume(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]: self._validate_coordinates(profile=profile, source=source) - session = self.client.prepare( - room_id=self.binding.room_id, - profile=profile, - source=source, - grant=self.route.grant, - create=False, - expected_session_id=session_id, + session = self._prepare( + profile=profile, source=source, create=False, expected_session_id=session_id ) if session is None: raise RuntimeError("peer room session is unavailable") @@ -289,38 +280,25 @@ class PeerHostedRoomTransport(InternalSessionRPC): self._validate_coordinates(profile=profile, source=source) if self._session_id not in {None, session_id}: raise ValueError("peer room session changed during admission") - dispatch = HostedMemberDispatch.from_mapping({ - "protocol_version": PROTOCOL_VERSION, - "room_id": task.room_id, - "home_install_id": self.route.home_install_id, - "authority_gateway_id": self.binding.gateway_id, - "authority_epoch": self.binding.authority_epoch, - "member_id": self.route.member_id, - "target_install_id": self.route.target_install_id, - "target_profile": profile, - "task_id": task.task_id, - "execution_generation": execution_generation, - "source_event_seq": self.source_event_seq, - "cancellation_scope_id": self.route.cancellation_scope_id, - "prompt": prompt, - "prompt_digest": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), - "capability_digest": self.route.capability_digest, - "execution_policy_digest": self.route.execution_policy_digest, - "trace_id": self.route.trace_id or f"trace-{uuid.uuid4().hex}", - }) + dispatch = build_member_dispatch( + binding=self.binding, + route=self.route, + room_id=task.room_id, + task_id=task.task_id, + target_profile=profile, + execution_generation=execution_generation, + source_event_seq=self.source_event_seq, + prompt=prompt, + trace_id=self.route.trace_id or f"trace-{uuid.uuid4().hex}", + ) self._dispatch = dispatch self._session_id = session_id - result = self.client.dispatch( - dispatch=dispatch.as_mapping(), - grant=self.route.grant, - ) + result = self.client.dispatch(dispatch=dispatch.as_mapping(), grant=self.route.grant) if result.get("status") in {"settled", "failed", "cancelled"}: on_terminal(result) return result - def history( - self, *, profile: str, session_id: str, source: str - ) -> Sequence[Mapping[str, Any]]: + def history(self, *, profile: str, session_id: str, source: str) -> Sequence[Mapping[str, Any]]: self._validate_coordinates(profile=profile, source=source) return self.client.history( room_id=self.binding.room_id, @@ -339,12 +317,7 @@ class PeerHostedRoomTransport(InternalSessionRPC): ) def interrupt( - self, - *, - profile: str, - session_id: str, - source: str, - expected_task_id: str, + self, *, profile: str, session_id: str, source: str, expected_task_id: str ) -> Mapping[str, Any] | None: self._validate_coordinates(profile=profile, source=source) dispatch = self._dispatch @@ -362,7 +335,4 @@ class PeerHostedRoomTransport(InternalSessionRPC): ) if dispatch.task_id != expected_task_id: return None - return self.client.stop( - dispatch=dispatch.as_mapping(), - grant=self.route.grant, - ) + return self.client.stop(dispatch=dispatch.as_mapping(), grant=self.route.grant) diff --git a/tui_gateway/hosted_room_server_rpc.py b/tui_gateway/hosted_room_server_rpc.py index a28dcd65a1..97d3925ed7 100644 --- a/tui_gateway/hosted_room_server_rpc.py +++ b/tui_gateway/hosted_room_server_rpc.py @@ -16,6 +16,8 @@ from typing import Any, Callable from gateway import hosted_room_driver as state +_LockType = type(threading.Lock()) + class HostedRoomSessionError(RuntimeError): """Raised when an in-process session operation is rejected.""" @@ -36,34 +38,33 @@ class HostedRoomServerRPC: def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: handler = self.server._methods[method] envelope = handler(f"hosted-room-{next(self._ids)}", params) - error = envelope.get("error") if isinstance(envelope, dict) else None + if not isinstance(envelope, dict): + envelope = {} + error = envelope.get("error") if isinstance(error, dict): raise HostedRoomSessionError( method, int(error.get("code") or 5000), str(error.get("message") or "gateway rejected the request"), ) - result = envelope.get("result") if isinstance(envelope, dict) else None + result = envelope.get("result") if not isinstance(result, dict): raise HostedRoomSessionError(method, 5000, "gateway returned no result") return result - def resolve_exact( - self, *, profile: str, title: str, source: str - ) -> Mapping[str, Any] | None: + def resolve_exact(self, *, profile: str, title: str, source: str) -> Mapping[str, Any] | None: del source result = self._call( - "session.list", - {"profile": profile, "title": title, "include_hidden": True}, + "session.list", {"profile": profile, "title": title, "include_hidden": True} ) rows = result.get("sessions") - if not isinstance(rows, list) or not rows: + if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict): return None row = rows[0] - if not isinstance(row, dict): - return None - session_id = row.get("resolved_id") or row.get("id") - return {"session_id": session_id, "title": row.get("title") or title} + return { + "session_id": row.get("resolved_id") or row.get("id"), + "title": row.get("title") or title, + } def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]: return self._call( @@ -79,17 +80,10 @@ class HostedRoomServerRPC: }, ) - def resume( - self, *, profile: str, session_id: str, source: str - ) -> Mapping[str, Any]: + def resume(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]: return self._call( "session.resume", - { - "profile": profile, - "session_id": session_id, - "omit_messages": True, - "source": source, - }, + {"profile": profile, "session_id": session_id, "omit_messages": True, "source": source}, ) def submit( @@ -128,14 +122,9 @@ class HostedRoomServerRPC: exc.not_admitted = True raise - def history( - self, *, profile: str, session_id: str, source: str - ) -> Sequence[Mapping[str, Any]]: + def history(self, *, profile: str, session_id: str, source: str) -> Sequence[Mapping[str, Any]]: del source - result = self._call( - "session.history", - {"profile": profile, "session_id": session_id}, - ) + result = self._call("session.history", {"profile": profile, "session_id": session_id}) rows = result.get("messages") return tuple(row for row in rows if isinstance(row, dict)) if isinstance(rows, list) else () @@ -155,7 +144,7 @@ class HostedRoomServerRPC: if record is None: return {"active": False, "task_id": None} lock = record.get("history_lock") - if not isinstance(lock, type(threading.Lock())): + if not isinstance(lock, _LockType): return {"active": bool(record.get("running")), "task_id": None} with lock: task = record.get("_hosted_room_task") @@ -163,9 +152,7 @@ class HostedRoomServerRPC: "active": bool(record.get("running")), "task_id": task.get("task_id") if isinstance(task, dict) else None, } - pending_reader = getattr( - self.server, "_pending_approval_request_payload", None - ) + pending_reader = getattr(self.server, "_pending_approval_request_payload", None) pending = ( pending_reader(str(record.get("session_key") or "")) if callable(pending_reader) @@ -176,38 +163,18 @@ class HostedRoomServerRPC: result["pending_approval"] = pending return result - def approve( - self, - *, - session_id: str, - request_id: str, - choice: str, - ) -> Mapping[str, Any]: + def approve(self, *, session_id: str, request_id: str, choice: str) -> Mapping[str, Any]: """Resolve one exact local room approval without broad policy changes.""" return self._call( "approval.respond", - { - "session_id": session_id, - "request_id": request_id, - "choice": choice, - "all": False, - }, + {"session_id": session_id, "request_id": request_id, "choice": choice, "all": False}, ) def interrupt( - self, - *, - profile: str, - session_id: str, - source: str, - expected_task_id: str, + self, *, profile: str, session_id: str, source: str, expected_task_id: str ) -> Mapping[str, Any] | None: del source return self._call( "session.interrupt", - { - "profile": profile, - "session_id": session_id, - "expected_hosted_task_id": expected_task_id, - }, + {"profile": profile, "session_id": session_id, "expected_hosted_task_id": expected_task_id}, ) diff --git a/tui_gateway/hosted_room_service.py b/tui_gateway/hosted_room_service.py index 52943879ca..8a86cb88e9 100644 --- a/tui_gateway/hosted_room_service.py +++ b/tui_gateway/hosted_room_service.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib -import hashlib import os import threading import time @@ -26,6 +25,7 @@ from gateway.hosted_room_peer import ( GatewayRoomCatalog, HostedMemberDispatch, PROTOCOL_VERSION, + room_grant_needs_dispatch_refresh, ) from tui_gateway.hosted_room_driver import HostedRoomBinding, HostedRoomRuntime from tui_gateway.hosted_room_server_rpc import HostedRoomServerRPC @@ -34,6 +34,7 @@ from tui_gateway.hosted_room_peer_transport import ( HostedRoomPeerClient, PeerHostedRoomTransport, PeerMemberRoute, + build_member_dispatch, ) @@ -41,6 +42,10 @@ _HOSTED_ROOM_IDLE_FALLBACK_SECONDS = 5.0 _HOSTED_ROOM_ACTIVE_POLL_SECONDS = 0.25 _HOSTED_ROOM_TERMINAL_GRACE_SECONDS = 30.0 +_TERMINAL_STATUSES = ("deferred", "settled", "failed", "cancelled") +_LIVE_STATUSES = ("queued", "running", "stopping") +_STOPPABLE_STATUSES = ("queued", "running", "indeterminate", "deferred", "stopping") + def _hosted_room_turn_timeout_seconds() -> float: try: @@ -54,7 +59,6 @@ def _hosted_room_turn_timeout_seconds() -> float: def _grant_revoke_is_terminal(exc: PeerRunsHTTPError) -> bool: """Return whether the peer proves the scoped grant is already unusable.""" - return exc.status_code in {401, 403} and exc.error_code in { "invalid_room_grant", "room_reauthorization_required", @@ -81,44 +85,10 @@ class HostedRoomService: self.rpc = HostedRoomServerRPC(server) self._link_load_error = None self._peer_route_status: dict[tuple[str, str], str] = {} - self.peer_routes = {} - self.peer_clients = {} + self.peer_routes: dict[tuple[str, str], PeerMemberRoute] = {} + self.peer_clients: dict[tuple[str, str], Any] = {} try: - stored_links, load_errors = hosted_room_links.load_room_links_tolerant( - self.db_path - ) - errors = list(load_errors) - for stored in stored_links: - if PROTOCOL_VERSION not in stored.catalog.protocol_versions: - errors.append( - f"{stored.room_id}:{stored.member_id}:protocol-upgrade-required" - ) - continue - client = PeerRunsHTTPClient( - base_url=stored.target_url, - api_key="", - receipt_db_path=self.db_path, - ) - route = PeerMemberRoute( - home_install_id=hosted_rooms.local_authority_gateway_id(), - member_id=stored.member_id, - target_install_id=stored.catalog.installation_id, - target_profile=stored.target_profile, - capability_digest=stored.catalog.catalog_digest, - execution_policy_digest=( - stored.catalog.execution_policy.policy_digest - ), - cancellation_scope_id=stored.cancellation_scope_id, - trace_id=stored.trace_id, - grant=stored.grant, - ) - self.peer_routes[(stored.room_id, stored.member_id)] = route - self.peer_clients[(stored.room_id, stored.member_id)] = client - self._peer_route_status[(stored.room_id, stored.member_id)] = ( - stored.status - ) - if errors: - self._link_load_error = ",".join(errors) + self._load_stored_links() except Exception as exc: self._link_load_error = str(exc) supplied_routes = dict(peer_routes or {}) @@ -144,6 +114,35 @@ class HostedRoomService: turn_timeout_seconds=_hosted_room_turn_timeout_seconds(), ) + def _load_stored_links(self) -> None: + """Rehydrate persisted peer routes; collect per-link errors into one string.""" + stored_links, load_errors = hosted_room_links.load_room_links_tolerant(self.db_path) + errors = list(load_errors) + for stored in stored_links: + key = (stored.room_id, stored.member_id) + if PROTOCOL_VERSION not in stored.catalog.protocol_versions: + errors.append(f"{stored.room_id}:{stored.member_id}:protocol-upgrade-required") + continue + self.peer_routes[key] = PeerMemberRoute( + home_install_id=hosted_rooms.local_authority_gateway_id(), + member_id=stored.member_id, + target_install_id=stored.catalog.installation_id, + target_profile=stored.target_profile, + capability_digest=stored.catalog.catalog_digest, + execution_policy_digest=stored.catalog.execution_policy.policy_digest, + cancellation_scope_id=stored.cancellation_scope_id, + trace_id=stored.trace_id, + grant=stored.grant, + ) + self.peer_clients[key] = PeerRunsHTTPClient( + base_url=stored.target_url, + api_key="", + receipt_db_path=self.db_path, + ) + self._peer_route_status[key] = stored.status + if errors: + self._link_load_error = ",".join(errors) + @property def root(self) -> Path: return self.db_path.parent @@ -152,9 +151,7 @@ class HostedRoomService: profiles = {"default"} profiles_dir = self.root / "profiles" if profiles_dir.is_dir(): - profiles.update( - path.name for path in profiles_dir.iterdir() if path.is_dir() - ) + profiles.update(path.name for path in profiles_dir.iterdir() if path.is_dir()) return tuple(sorted(profiles)) def bindings(self) -> tuple[HostedRoomBinding, ...]: @@ -171,9 +168,7 @@ class HostedRoomService: def _owned_room(self, room_id: str) -> dict[str, Any]: room = hosted_rooms.room_state(self.db_path, room_id=room_id) - if str(room["authority_gateway_id"]) != ( - hosted_rooms.local_authority_gateway_id() - ): + if str(room["authority_gateway_id"]) != hosted_rooms.local_authority_gateway_id(): raise hosted_rooms.AuthorityConflictError( "This Group Chat is managed by another gateway." ) @@ -195,6 +190,10 @@ class HostedRoomService: def wakeup(self) -> None: self.runtime.wakeup() + def _list_tasks(self, room_id: str, statuses) -> Iterator[Mapping[str, Any]]: + for status in statuses: + yield from driver.list_tasks(self.db_path, room_id=room_id, status=status) + def register_peer_route( self, *, @@ -213,32 +212,29 @@ class HostedRoomService: if not route.execution_policy_digest: route = replace( route, - execution_policy_digest=( - catalog.execution_policy.policy_digest - ), + execution_policy_digest=catalog.execution_policy.policy_digest, ) if ( route.capability_digest != catalog.catalog_digest - or route.execution_policy_digest - != catalog.execution_policy.policy_digest + or route.execution_policy_digest != catalog.execution_policy.policy_digest ): raise ValueError("peer route does not match its target catalog") - if target_url is not None and catalog is not None: - hosted_room_links.save_room_link( - self.db_path, - hosted_room_links.make_stored_link( - room_id=room_id, - member_id=member_id, - target_url=target_url, - target_profile=route.target_profile, - grant=route.grant, - catalog=catalog, - cancellation_scope_id=route.cancellation_scope_id, - trace_id=route.trace_id, - ), - ) - # Persistence is the publication boundary. A failed disk write must - # never leave a process-local route that disappears after restart. + if target_url is not None: + hosted_room_links.save_room_link( + self.db_path, + hosted_room_links.make_stored_link( + room_id=room_id, + member_id=member_id, + target_url=target_url, + target_profile=route.target_profile, + grant=route.grant, + catalog=catalog, + cancellation_scope_id=route.cancellation_scope_id, + trace_id=route.trace_id, + ), + ) + # Persistence is the publication boundary: a failed disk write must never + # leave a process-local route that disappears after restart. with self._policy_lock: self.peer_routes[(room_id, member_id)] = route self.peer_clients[(room_id, member_id)] = client @@ -248,19 +244,13 @@ class HostedRoomService: def revoke_room_routes(self, room_id: str) -> int: """Revoke and forget every scoped peer route for one room. - The remote revocation is the boundary: if a target is unreachable the - room remains intact and the user may retry rather than receiving a - false successful disband while a grant is still live. + Remote revocation is the boundary: an unreachable target leaves the room + intact for retry rather than reporting a false disband with a live grant. """ with self._policy_lock: - routes = [ - (key, route) - for key, route in self.peer_routes.items() - if key[0] == room_id - ] + routes = [(key, route) for key, route in self.peer_routes.items() if key[0] == room_id] for key, route in routes: - client = self.peer_clients.get(key) - revoke = getattr(client, "revoke_grant", None) + revoke = getattr(self.peer_clients.get(key), "revoke_grant", None) if not callable(revoke): raise RuntimeError("peer room grant cannot be revoked safely") try: @@ -271,7 +261,7 @@ class HostedRoomService: hosted_rooms.delete_room_link_records(self.db_path, room_id=room_id) with self._policy_lock: - for key, route in routes: + for key, _route in routes: self.peer_routes.pop(key, None) self._peer_route_status.pop(key, None) self.peer_clients.pop(key, None) @@ -283,9 +273,7 @@ class HostedRoomService: task: Mapping[str, Any], ): payload = task.get("payload", {}) - member_id = str( - payload.get("target_member_id") or payload.get("target_profile") or "" - ) + member_id = str(payload.get("target_member_id") or payload.get("target_profile") or "") route = self.peer_routes.get((binding.room_id, member_id)) if route is None: if self._member_is_peer(binding.room_id, member_id): @@ -302,21 +290,16 @@ class HostedRoomService: and isinstance(identity, driver.TaskIdentity) and execution_generation > 0 ): - bind_observation( - task_id=identity.task_id, - execution_generation=execution_generation, - ) + bind_observation(task_id=identity.task_id, execution_generation=execution_generation) + + def set_status(status: str): + return lambda: self._set_route_status(binding.room_id, member_id, status) + tracked_client = _RouteStatusPeerClient( client, - on_ready=lambda: self._set_route_status( - binding.room_id, member_id, "ready" - ), - on_reauthorization=lambda: self._set_route_status( - binding.room_id, member_id, "needs_reauthorization" - ), - on_unavailable=lambda: self._set_route_status( - binding.room_id, member_id, "unavailable" - ), + on_ready=set_status("ready"), + on_reauthorization=set_status("needs_reauthorization"), + on_unavailable=set_status("unavailable"), on_refreshed=lambda grant, catalog=None: self._rotate_route_grant( binding.room_id, member_id, grant, catalog ), @@ -327,8 +310,8 @@ class HostedRoomService: route=route, client=tracked_client, source_event_seq=int(payload.get("source_event_seq") or 0), - task_id=getattr(task.get("identity"), "task_id", None), - execution_generation=int(task.get("execution_generation") or 0), + task_id=getattr(identity, "task_id", None), + execution_generation=execution_generation, ) def _recover_peer_admission( @@ -355,25 +338,17 @@ class HostedRoomService: source_event_seq = int(payload.get("source_event_seq") or 0) if not isinstance(prompt, str) or source_event_seq < 1 or not route.trace_id: raise RuntimeError("peer room admission identity is unavailable for recovery") - dispatch = HostedMemberDispatch.from_mapping({ - "protocol_version": PROTOCOL_VERSION, - "room_id": identity.room_id, - "home_install_id": route.home_install_id, - "authority_gateway_id": binding.gateway_id, - "authority_epoch": binding.authority_epoch, - "member_id": route.member_id, - "target_install_id": route.target_install_id, - "target_profile": route.target_profile, - "task_id": identity.task_id, - "execution_generation": execution_generation, - "source_event_seq": source_event_seq, - "cancellation_scope_id": route.cancellation_scope_id, - "prompt": prompt, - "prompt_digest": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), - "capability_digest": route.capability_digest, - "execution_policy_digest": route.execution_policy_digest, - "trace_id": route.trace_id, - }) + dispatch = build_member_dispatch( + binding=binding, + route=route, + room_id=identity.room_id, + task_id=identity.task_id, + target_profile=route.target_profile, + execution_generation=execution_generation, + source_event_seq=source_event_seq, + prompt=prompt, + trace_id=route.trace_id, + ) recover(dispatch=dispatch.as_mapping(), grant=route.grant) def _member_is_peer(self, room_id: str, member_id: str) -> bool: @@ -394,10 +369,7 @@ class HostedRoomService: return self._peer_route_status[key] = status hosted_room_links.mark_room_link_status( - self.db_path, - room_id=room_id, - member_id=member_id, - status=status, + self.db_path, room_id=room_id, member_id=member_id, status=status ) def _set_pending_action( @@ -435,34 +407,25 @@ class HostedRoomService: ) if stored is None: raise RuntimeError("peer room route cannot be renewed before persistence") - effective_catalog = catalog or stored.catalog - if catalog is not None and ( - catalog.installation_id != route.target_install_id - or catalog.execution_policy.target_profile != route.target_profile - or PROTOCOL_VERSION not in catalog.protocol_versions - or "direct" not in catalog.link_modes - or not catalog.text - or catalog.execution_policy.policy_digest - != route.execution_policy_digest - ): - self._set_route_status(room_id, member_id, "needs_reauthorization") - raise RuntimeError( - "peer room execution policy changed; reauthorization is required" - ) - rotated_route = replace( - route, - grant=grant, - capability_digest=( - catalog.catalog_digest - if catalog is not None - else route.capability_digest - ), - execution_policy_digest=( - catalog.execution_policy.policy_digest - if catalog is not None - else route.execution_policy_digest - ), - ) + digests = {} + if catalog is not None: + if ( + catalog.installation_id != route.target_install_id + or catalog.execution_policy.target_profile != route.target_profile + or PROTOCOL_VERSION not in catalog.protocol_versions + or "direct" not in catalog.link_modes + or not catalog.text + or catalog.execution_policy.policy_digest != route.execution_policy_digest + ): + self._set_route_status(room_id, member_id, "needs_reauthorization") + raise RuntimeError( + "peer room execution policy changed; reauthorization is required" + ) + digests = { + "capability_digest": catalog.catalog_digest, + "execution_policy_digest": catalog.execution_policy.policy_digest, + } + rotated_route = replace(route, grant=grant, **digests) hosted_room_links.save_room_link( self.db_path, hosted_room_links.make_stored_link( @@ -471,7 +434,7 @@ class HostedRoomService: target_url=stored.target_url, target_profile=stored.target_profile, grant=grant, - catalog=effective_catalog, + catalog=catalog or stored.catalog, cancellation_scope_id=stored.cancellation_scope_id, trace_id=stored.trace_id, ), @@ -483,11 +446,7 @@ class HostedRoomService: def _route_statuses(self, room_id: str | None = None) -> list[dict[str, str]]: with self._policy_lock: rows = [ - { - "room_id": key[0], - "member_id": key[1], - "status": status, - } + {"room_id": key[0], "member_id": key[1], "status": status} for key, status in self._peer_route_status.items() if room_id is None or key[0] == room_id ] @@ -515,10 +474,7 @@ class HostedRoomService: def _append_plan(self, room_id: str, plan: discussion.PublicationPlan) -> None: for event in plan.events: - hosted_rooms.append_event( - self.db_path, - **event.append_kwargs(room_id), - ) + hosted_rooms.append_event(self.db_path, **event.append_kwargs(room_id)) def _policy_snapshot(self, room: Mapping[str, Any]) -> PolicySnapshot: return self.policy_checkpoint.snapshot( @@ -526,35 +482,26 @@ class HostedRoomService: latest_seq=int(room["latest_seq"]), ) - def _publish_terminal_tasks( - self, - room: Mapping[str, Any], - ) -> bool: + def _publish_terminal_tasks(self, room: Mapping[str, Any]) -> bool: changed = False + room_id = str(room["room_id"]) local_profiles = self.local_profiles() - for status in ("deferred", "settled", "failed", "cancelled"): - for task in driver.list_tasks( - self.db_path, - room_id=str(room["room_id"]), - status=status, - ): - identity = task["identity"] + for status in _TERMINAL_STATUSES: + for task in driver.list_tasks(self.db_path, room_id=room_id, status=status): + execution_generation = int(task["execution_generation"]) if self.policy_checkpoint.publication_exists( - room_id=str(room["room_id"]), - task_id=identity.task_id, + room_id=room_id, + task_id=task["identity"].task_id, status=status, - execution_generation=int(task["execution_generation"]), + execution_generation=execution_generation, ): continue task_events = self.policy_checkpoint.events_for_task( - room_id=str(room["room_id"]), + room_id=room_id, source_event_seq=int(task["payload"]["source_event_seq"]), ) plan = discussion.reconstruct_task_plan( - room, - task_events, - task, - local_profiles=local_profiles, + room, task_events, task, local_profiles=local_profiles ) publication = discussion.plan_publication( room, @@ -562,14 +509,10 @@ class HostedRoomService: plan, status=status, result=task.get("result"), - execution_generation=( - int(task["execution_generation"]) - if status == "deferred" - else None - ), + execution_generation=execution_generation if status == "deferred" else None, local_profiles=local_profiles, ) - self._append_plan(str(room["room_id"]), publication) + self._append_plan(room_id, publication) changed = True return changed @@ -600,32 +543,18 @@ class HostedRoomService: with self._policy_lock: room = hosted_rooms.room_state(self.db_path, room_id=binding.room_id) snapshot = self._policy_snapshot(room) - events = list(snapshot.events) if self._publish_terminal_tasks(room): - room = hosted_rooms.room_state( - self.db_path, - room_id=binding.room_id, - ) + room = hosted_rooms.room_state(self.db_path, room_id=binding.room_id) snapshot = self._policy_snapshot(room) - events = list(snapshot.events) self.policy_checkpoint.compact_completed(room_id=binding.room_id) driver.prune_published_terminal_tasks( - self.db_path, - room_id=binding.room_id, - clock=self.runtime.clock, + self.db_path, room_id=binding.room_id, clock=self.runtime.clock ) - if any( - driver.list_tasks( - self.db_path, - room_id=binding.room_id, - status=status, - ) - for status in ("queued", "running", "stopping") - ): + if any(True for _ in self._list_tasks(binding.room_id, _LIVE_STATUSES)): return decision = discussion.plan_next_task( room, - events, + list(snapshot.events), local_profiles=self.local_profiles(), initial_watermarks=snapshot.watermarks, ) @@ -639,13 +568,8 @@ class HostedRoomService: # A stop can race the policy read from another process. Re-read # after admission and cancel before the runtime can execute a # task whose source event is now behind the room stop fence. - fresh_room = hosted_rooms.room_state( - self.db_path, - room_id=binding.room_id, - ) - stopped_through_seq = self._policy_snapshot( - fresh_room - ).stopped_through_seq + fresh_room = hosted_rooms.room_state(self.db_path, room_id=binding.room_id) + stopped_through_seq = self._policy_snapshot(fresh_room).stopped_through_seq if ( decision.source_event_seq is not None and decision.source_event_seq < stopped_through_seq @@ -666,10 +590,7 @@ class HostedRoomService: self.runtime.wakeup() def create_room(self, *, room_id: str, name: str, members: Any) -> dict[str, Any]: - normalized = discussion.validate_roster( - members, - local_profiles=self.local_profiles(), - ) + normalized = discussion.validate_roster(members, local_profiles=self.local_profiles()) room = hosted_rooms.create_room( self.db_path, room_id=room_id, @@ -680,11 +601,7 @@ class HostedRoomService: "profile": member.profile, "handle": member.handle, "target": dict(member.target or {}), - **( - {"display_name": member.display_name} - if member.display_name - else {} - ), + **({"display_name": member.display_name} if member.display_name else {}), } for member in normalized ], @@ -693,13 +610,7 @@ class HostedRoomService: self.runtime.wakeup() return room - def send( - self, - *, - room_id: str, - event_id: str, - payload: Any, - ) -> dict[str, Any]: + def send(self, *, room_id: str, event_id: str, payload: Any) -> dict[str, Any]: normalized = discussion.validate_user_payload(payload) room = self._owned_room(room_id) event = hosted_rooms.append_event( @@ -712,14 +623,7 @@ class HostedRoomService: authority_gateway_id=str(room["authority_gateway_id"]), authority_epoch=int(room["authority_epoch"]), ) - binding = next( - ( - candidate - for candidate in self.bindings() - if candidate.room_id == room_id - ), - None, - ) + binding = next((b for b in self.bindings() if b.room_id == room_id), None) if binding is None: raise hosted_rooms.RoomNotFoundError("hosted room not found") self.prepare_room(binding) @@ -744,31 +648,15 @@ class HostedRoomService: cancelled = 0 pending = 0 with self._policy_lock: - tasks = {} - for status in ( - "queued", - "running", - "indeterminate", - "deferred", - "stopping", - ): - for task in driver.list_tasks( - self.db_path, - room_id=room_id, - status=status, - ): - identity = task["identity"] - tasks[(identity.room_id, identity.task_id)] = task + tasks = { + (task["identity"].room_id, task["identity"].task_id): task + for task in self._list_tasks(room_id, _STOPPABLE_STATUSES) + } for task in tasks.values(): task_cancel_id = ( - str(task.get("cancel_id") or "") - if task.get("status") == "stopping" - else "" - ) - result = self.runtime.cancel( - task["identity"], - cancel_id=task_cancel_id or cancel_id, + str(task.get("cancel_id") or "") if task.get("status") == "stopping" else "" ) + result = self.runtime.cancel(task["identity"], cancel_id=task_cancel_id or cancel_id) cancelled += 1 if result["status"] == "stopping": pending += 1 @@ -781,22 +669,16 @@ class HostedRoomService: def retry_room_task(self, room_id: str, *, task_id: str) -> dict[str, Any]: """Retry one uncertain or deferred task only after explicit user action.""" - task = next( ( candidate - for status in ("indeterminate", "deferred") - for candidate in driver.list_tasks( - self.db_path, room_id=room_id, status=status - ) + for candidate in self._list_tasks(room_id, ("indeterminate", "deferred")) if candidate["identity"].task_id == task_id ), None, ) if task is None: - raise driver.InvalidTaskTransitionError( - "no retryable room task matches task_id" - ) + raise driver.InvalidTaskTransitionError("no retryable room task matches task_id") return self.runtime.retry_indeterminate(task["identity"]) def approve_room_task( @@ -816,16 +698,16 @@ class HostedRoomService: with self._policy_lock: action = self._pending_actions.get(key) requested_approval_id = str(request_id or "") - pending_approval_id = str((action or {}).get("request_id") or "") - if ( - action is None - or action.get("task_id") != task_id - or int(action.get("execution_generation") or 0) - != execution_generation - or not requested_approval_id - or not pending_approval_id - or requested_approval_id != pending_approval_id - ): + + def matches(pending: Mapping[str, Any] | None) -> bool: + return ( + pending is not None + and str(pending.get("request_id") or "") == requested_approval_id + and pending.get("task_id") == task_id + and int(pending.get("execution_generation") or 0) == execution_generation + ) + + if not requested_approval_id or not matches(action): raise RuntimeError("room approval is no longer pending") if choice not in {"once", "deny"}: raise RuntimeError("room approval choice must be once or deny") @@ -843,49 +725,33 @@ class HostedRoomService: if not session_id: raise RuntimeError("local room approval identity is unavailable") result = self.rpc.approve( - session_id=session_id, - request_id=requested_approval_id, - choice=choice, + session_id=session_id, request_id=requested_approval_id, choice=choice ) if result is None: raise RuntimeError("room approval target is unavailable") with self._policy_lock: - current = self._pending_actions.get(key) - if ( - current is not None - and str(current.get("request_id") or "") == requested_approval_id - and current.get("task_id") == task_id - and int(current.get("execution_generation") or 0) - == execution_generation - ): + if matches(self._pending_actions.get(key)): self._pending_actions.pop(key, None) self.runtime.wakeup() return result def status(self, room_id: str | None = None) -> dict[str, Any]: - runtime = self.runtime.status() - runtime = {**runtime, "peer_routes": self._route_statuses(room_id)} + runtime = {**self.runtime.status(), "peer_routes": self._route_statuses(room_id)} if self._link_load_error: - runtime = {**runtime, "link_load_error": self._link_load_error} + runtime["link_load_error"] = self._link_load_error if room_id is None: return runtime tasks = driver.list_tasks(self.db_path, room_id=room_id) counts = Counter(str(task["status"]) for task in tasks) pending_actions = [ - { - "kind": "retry", - "task_id": task["identity"].task_id, - } + {"kind": "retry", "task_id": task["identity"].task_id} for task in tasks if task["status"] in {"indeterminate", "deferred"} ] with self._policy_lock: pending_actions.extend( dict(action) - for ( - action_room_id, - _member_id, - ), action in self._pending_actions.items() + for (action_room_id, _member_id), action in self._pending_actions.items() if action_room_id == room_id ) return { @@ -919,6 +785,53 @@ class _RouteStatusPeerClient: self._on_unavailable = on_unavailable self._on_refreshed = on_refreshed + def _refresh_grant(self, kwargs: dict) -> dict: + """Rotate an expiring grant before dispatch; return the kwargs to send. + + Refresh failures only escalate to reauthorization when the peer says so + or the grant is already past its hard expiry; otherwise the original + grant is tried as-is. A refreshed catalog whose digests drift from the + dispatch is a policy change and is refused before any dispatch. + """ + grant = kwargs["grant"] + if not room_grant_needs_dispatch_refresh(grant): + return kwargs + checked = HostedMemberDispatch.from_mapping(kwargs["dispatch"]) + refresh = getattr(self._client, "refresh_grant", None) + if not callable(refresh): + return kwargs + try: + refreshed = refresh( + grant=grant, + capability_digest=checked.capability_digest, + execution_policy_digest=checked.execution_policy_digest, + ) + except Exception as exc: + if getattr(exc, "needs_reauthorization", False) or ( + room_grant_needs_dispatch_refresh(grant, leeway_seconds=0) + ): + self._on_reauthorization() + raise + return kwargs + replacement = str(refreshed.get("grant") or "") + if not replacement: + raise RuntimeError("peer returned no refreshed room grant") + refreshed_catalog = None + if refreshed.get("catalog") is not None: + refreshed_catalog = GatewayRoomCatalog.from_mapping(refreshed.get("catalog")) + drift = None + if refreshed_catalog.execution_policy.policy_digest != checked.execution_policy_digest: + drift = ("peer room execution policy needs reauthorization", "room_execution_policy_changed") + elif refreshed_catalog.catalog_digest != checked.capability_digest: + drift = ("peer room capabilities need reauthorization", "room_capability_catalog_changed") + if drift is not None: + self._on_reauthorization() + raise PeerRunsHTTPError( + drift[0], status_code=403, error_code=drift[1], not_admitted=True + ) + self._on_refreshed(replacement, refreshed_catalog) + return {**kwargs, "grant": replacement} + def __getattr__(self, name): value = getattr(self._client, name) if not callable(value): @@ -926,86 +839,15 @@ class _RouteStatusPeerClient: def tracked(*args, **kwargs): if name in {"dispatch", "recover_dispatch"} and "grant" in kwargs: - from gateway.hosted_room_peer import ( - room_grant_needs_dispatch_refresh, - ) - - grant = kwargs["grant"] - if room_grant_needs_dispatch_refresh(grant): - checked = HostedMemberDispatch.from_mapping( - kwargs["dispatch"] - ) - refresh = getattr(self._client, "refresh_grant", None) - if callable(refresh): - try: - refreshed = refresh( - grant=grant, - capability_digest=checked.capability_digest, - execution_policy_digest=( - checked.execution_policy_digest - ), - ) - except Exception as exc: - if bool( - getattr(exc, "needs_reauthorization", False) - ): - self._on_reauthorization() - raise - if room_grant_needs_dispatch_refresh( - grant, leeway_seconds=0 - ): - self._on_reauthorization() - raise - else: - replacement = str(refreshed.get("grant") or "") - if not replacement: - raise RuntimeError( - "peer returned no refreshed room grant" - ) - refreshed_catalog = None - if refreshed.get("catalog") is not None: - from gateway.hosted_room_peer import ( - GatewayRoomCatalog, - ) - - refreshed_catalog = GatewayRoomCatalog.from_mapping( - refreshed.get("catalog") - ) - if ( - refreshed_catalog.execution_policy.policy_digest - != checked.execution_policy_digest - ): - self._on_reauthorization() - raise PeerRunsHTTPError( - "peer room execution policy needs reauthorization", - status_code=403, - error_code="room_execution_policy_changed", - not_admitted=True, - ) - if ( - refreshed_catalog.catalog_digest - != checked.capability_digest - ): - self._on_reauthorization() - raise PeerRunsHTTPError( - "peer room capabilities need reauthorization", - status_code=403, - error_code="room_capability_catalog_changed", - not_admitted=True, - ) - self._on_refreshed(replacement, refreshed_catalog) - kwargs = {**kwargs, "grant": replacement} + kwargs = self._refresh_grant(kwargs) try: result = value(*args, **kwargs) except Exception as exc: - if bool(getattr(exc, "needs_reauthorization", False)): + if getattr(exc, "needs_reauthorization", False): self._on_reauthorization() - raise - elif bool(getattr(exc, "not_admitted", False)): + elif getattr(exc, "not_admitted", False): self._on_unavailable() - raise - else: - raise + raise if name != "prepare": self._on_ready() return result diff --git a/tui_gateway/method_ctx.py b/tui_gateway/method_ctx.py index 866454666d..64c6c40851 100644 --- a/tui_gateway/method_ctx.py +++ b/tui_gateway/method_ctx.py @@ -87,27 +87,31 @@ class HandlerRegistry: _PLUMBING = {"HandlerRegistry", "method", "_profile_scoped", "register", "rebind", "logger"} -def bind_module(module, server, *, skip=()) -> None: - """Publish everything ``module`` defines onto ``server``, rebound to its globals. +def bind_module(module_globals: dict, server, *, skip=()) -> None: + """Publish everything a split module defines onto ``server``, rebound to its globals. - Functions are rebound; classes get their methods rebound in place; other - values (constants, ``global``-mutated state seeds) are copied as-is. + ``module_globals`` is the caller's ``globals()`` (not ``sys.modules[__name__]``: + tests that ``patch.dict(sys.modules)`` around the server import drop the + submodule entries while the package attribute survives, so a re-import would + KeyError). Functions are rebound; classes get their methods rebound in place; + other values (constants, ``global``-mutated state seeds) are copied as-is. Imported modules/functions, dunders and registry plumbing are skipped, so a split module needs no hand-maintained export list. Finally the module's ``_registry`` (if any) installs its @method handlers. """ g = vars(server) - for name, obj in list(vars(module).items()): + mod_name = module_globals["__name__"] + for name, obj in list(module_globals.items()): if name.startswith("__") or name in _PLUMBING or name in skip: continue if isinstance(obj, (types.ModuleType, HandlerRegistry)): continue if isinstance(obj, types.FunctionType): - if obj.__module__ != module.__name__: + if obj.__module__ != mod_name: continue obj = rebind(obj, g) elif isinstance(obj, type): - if obj.__module__ != module.__name__: + if obj.__module__ != mod_name: continue for attr, val in list(vars(obj).items()): if isinstance(val, types.FunctionType): @@ -115,6 +119,6 @@ def bind_module(module, server, *, skip=()) -> None: elif isinstance(val, (staticmethod, classmethod)): setattr(obj, attr, type(val)(rebind(val.__func__, g))) setattr(server, name, obj) - registry = getattr(module, "_registry", None) + registry = module_globals.get("_registry") if isinstance(registry, HandlerRegistry): registry.install(server) diff --git a/tui_gateway/methods_bot_relay.py b/tui_gateway/methods_bot_relay.py index 5044518759..a633c7eea7 100644 --- a/tui_gateway/methods_bot_relay.py +++ b/tui_gateway/methods_bot_relay.py @@ -21,17 +21,41 @@ connected gateway: Storage/validation plumbing lives in ``tools/bot_relay.py``. Handlers are rebound onto server.py's globals at install time (see method_ctx.py) and may -reference server module globals (``_ok``, ``_err``) not imported here. +reference server module globals (``_ok``, ``_err``) not imported here; this +module's own helpers reach them via keyword defaults. """ +import os +import subprocess +from pathlib import Path + from .method_ctx import HandlerRegistry _registry = HandlerRegistry() method = _registry.method +def _relay_root() -> Path: + """Install root shared by every profile (relay state is install-wide).""" + home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) + return home.parent.parent if home.parent.name == "profiles" else home + + +def _run_delivery(profile: str, tmp: str) -> subprocess.CompletedProcess: + from tools.bot_relay import local_delivery_command + + return subprocess.run( + local_delivery_command(profile, tmp), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=600, + ) + + @method("bot_relay.roster.sync") -def _(rid, params: dict) -> dict: +def _(rid, params: dict, _root=_relay_root) -> dict: """Replace this gateway's view of agents on OTHER connections. Params: ``agents`` — list of rows ``{profile, handle, connection_id, @@ -39,41 +63,30 @@ def _(rid, params: dict) -> dict: dropped, not fatal. Result: ``{count}`` (accepted rows). """ try: - import os - from pathlib import Path - from tools.bot_relay import write_remote_roster - home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) - root = home.parent.parent if home.parent.name == "profiles" else home - count = write_remote_roster(root, params.get("agents")) - return _ok(rid, {"count": count}) + return _ok(rid, {"count": write_remote_roster(_root(), params.get("agents"))}) except Exception as e: return _err(rid, 5090, str(e)) @method("bot_relay.outbox.drain") -def _(rid, params: dict) -> dict: +def _(rid, params: dict, _root=_relay_root) -> dict: """Claim every pending cross-connection envelope queued on this gateway. Claimed envelopes move to ``claimed/`` atomically, so concurrent drains (two Desktop windows) can't double-deliver. Result: ``{envelopes}``. """ try: - import os - from pathlib import Path - from tools.bot_relay import claim_pending_envelopes - home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) - root = home.parent.parent if home.parent.name == "profiles" else home - return _ok(rid, {"envelopes": claim_pending_envelopes(root)}) + return _ok(rid, {"envelopes": claim_pending_envelopes(_root())}) except Exception as e: return _err(rid, 5091, str(e)) @method("bot_relay.deliver") -def _(rid, params: dict) -> dict: +def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict: """Deliver a relayed DM into a profile's Bot Chat ON THIS GATEWAY. Params: ``profile`` (target on this install), ``message`` (already @@ -86,7 +99,6 @@ def _(rid, params: dict) -> dict: import os import subprocess import tempfile - from pathlib import Path profile = str(params.get("profile") or "").strip() message = str(params.get("message") or "").strip() @@ -94,13 +106,12 @@ def _(rid, params: dict) -> dict: return _err(rid, 4090, "profile and message required") try: from tools.bot_mode_dm import MESSAGE_MAX_CHARS - from tools.bot_relay import acquire_turn_lock, local_delivery_command + from tools.bot_relay import acquire_turn_lock if len(message) > MESSAGE_MAX_CHARS + 200: # + attribution headroom return _err(rid, 4091, "message too long") - home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) - root = home.parent.parent if home.parent.name == "profiles" else home + root = _root() known = {"default"} profiles_dir = root / "profiles" if profiles_dir.is_dir(): @@ -109,13 +120,13 @@ def _(rid, params: dict) -> dict: if resolved not in known: return _err(rid, 4092, f"no profile '{profile}' on this gateway") - # #100523: when THIS gateway already hosts the target's Bot Chat live - # (the Desktop has it open), the subprocess transport is fenced out by - # the single-owner lease ("already has a live owner") and the payload - # is dropped. Land the DM in the live session as a normal user turn - # via prompt.submit instead — same choke point the composer uses, so - # role alternation, persistence and streaming all behave as a typed - # message would. (Nested per method_ctx rebinding.) + # When THIS gateway already hosts the target's Bot Chat live (the + # Desktop has it open), the subprocess transport is fenced out by the + # single-owner lease and the payload dropped. Land the DM in the live + # session as a normal user turn via prompt.submit instead — the + # composer's choke point, so role alternation, persistence and + # streaming behave as a typed message would. (Nested: needs server + # globals via method_ctx rebinding.) def _live_bot_chat_sid(profile_name: str) -> str: from tools.bot_mode_probe import BOT_CHAT_TITLE @@ -148,30 +159,21 @@ def _(rid, params: dict) -> dict: try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(message) - # Per-profile turn lock (#93091): serialize with any other - # delivery turn into this profile (relay or local message_agent). - # The lock covers only the turn execution window. Worst-case - # handler hold is lock wait (bot_mode.turn_wait_seconds, default - # 120s) + the 600s turn timeout below — doubled when the retry - # policy grants one bounded re-run — so clients calling - # bot_relay.deliver must tolerate ~1320s before assuming failure. + # Per-profile turn lock serializes with any other delivery turn into + # this profile (relay or local message_agent) and covers only the + # turn execution window. Worst-case handler hold is lock wait + # (bot_mode.turn_wait_seconds, default 120s) + the 600s turn timeout, + # doubled when the retry policy grants one bounded re-run — callers + # must tolerate ~1320s before assuming failure. with acquire_turn_lock(root, resolved): - proc = subprocess.run( - local_delivery_command(resolved, tmp), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=600, - ) + proc = _run(resolved, tmp) if proc.returncode != 0: - # Retry session policy (#93091 item 5): transient classes - # re-run the SAME session once; context_overflow also - # re-runs the same session — the retried turn's pre-API - # compaction pass (agent/conversation_loop.py) compacts - # the over-threshold Bot Chat transcript first, which is - # the sanctioned compression lever (no fresh session is - # ever minted). Auth/quota/config classes never retry. + # Retry policy: transient classes re-run the SAME session + # once; context_overflow also re-runs the same session — the + # retried turn's pre-API compaction pass compacts the + # over-threshold Bot Chat transcript first (the sanctioned + # compression lever; no fresh session is ever minted). + # Auth/quota/config classes never retry. from tools.bot_failure_reasons import ( RETRY_NONE, classify_agent_error, @@ -180,14 +182,7 @@ def _(rid, params: dict) -> dict: first_detail = (proc.stderr or proc.stdout or "").strip()[-500:] if retry_action(classify_agent_error(first_detail)) != RETRY_NONE: - proc = subprocess.run( - local_delivery_command(resolved, tmp), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=600, - ) + proc = _run(resolved, tmp) finally: try: os.unlink(tmp) @@ -207,14 +202,14 @@ def _(rid, params: dict) -> dict: except subprocess.TimeoutExpired: return _err(rid, 5093, "delivery turn timed out") except Exception as e: - # 'target_busy' extends the #93091 item-1 structured refusal enum. + # 'target_busy' extends the structured refusal enum. if getattr(e, "reason", "") == "target_busy": return _err(rid, 5096, str(e)) return _err(rid, 5094, str(e)) @method("bot_relay.reply") -def _(rid, params: dict) -> dict: +def _(rid, params: dict, _root=_relay_root) -> dict: """Write a relayed reply (or delivery error) for a sender-side waiter. Params: ``id`` (envelope id), ``reply`` and/or ``error``, optional @@ -224,15 +219,10 @@ def _(rid, params: dict) -> dict: if not envelope_id: return _err(rid, 4093, "id required") try: - import os - from pathlib import Path - from tools.bot_relay import write_reply - home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) - root = home.parent.parent if home.parent.name == "profiles" else home write_reply( - root, + _root(), envelope_id, reply=str(params.get("reply") or ""), error=str(params.get("error") or ""), @@ -250,13 +240,14 @@ def register(server) -> None: from . import methods_groups server._LONG_HANDLERS = server._LONG_HANDLERS | methods_groups.LONG_HANDLERS - server.get_hosted_room_service = methods_groups.get_hosted_room_service - server._WORKER_UNAVAILABLE = methods_groups._WORKER_UNAVAILABLE - server._profile_name = methods_groups._profile_name - server._requested_profile = methods_groups._requested_profile - server._api_server_key = methods_groups._api_server_key - server._room_link_run_storage_durable = ( - methods_groups._room_link_run_storage_durable - ) + for name in ( + "get_hosted_room_service", + "_WORKER_UNAVAILABLE", + "_profile_name", + "_requested_profile", + "_api_server_key", + "_room_link_run_storage_durable", + ): + setattr(server, name, getattr(methods_groups, name)) methods_groups.bind_server(server) methods_groups.register(server) diff --git a/tui_gateway/methods_browser_control.py b/tui_gateway/methods_browser_control.py index abd7afa951..fb6e48880c 100644 --- a/tui_gateway/methods_browser_control.py +++ b/tui_gateway/methods_browser_control.py @@ -27,8 +27,8 @@ Note on handler globals: ``HandlerRegistry.install`` (method_ctx.py) rebinds each handler's ``__globals__`` onto server.py's namespace, so handler bodies may only reference names server.py defines/imports (``_ok``, ``_err``, ``_sessions``, ``_sessions_lock``, ``current_transport``, ``logger``, ...). -This module's own helpers and constants are therefore captured through -keyword-default arguments, which ``install`` preserves. +This module's own helpers and constants reach the handlers through closure +cells of :func:`_controller_method` (rebind preserves and re-targets them). """ from __future__ import annotations @@ -61,6 +61,10 @@ _CLOUD_TRANSPORT_FAMILY = "cloud-ticket-ws" #: JSON-RPC error code for identity / session / flag denials (forbidden). _ERR_FORBIDDEN = 4403 +_IDENTITY_REQUIRED = "authenticated controller identity required" +_NOT_OWNED = "controller is not owned by this transport" +_NO_CONTROLLER = "no controller registered for this session" + def _is_authenticated_identity(identity: object) -> bool: """True for a server-minted, non-internal ``{user_id, provider}`` identity.""" @@ -72,9 +76,7 @@ def _is_authenticated_identity(identity: object) -> bool: return False if not isinstance(provider, str) or not provider.strip(): return False - if user_id == _INTERNAL_USER_ID and provider == _INTERNAL_PROVIDER: - return False - return True + return not (user_id == _INTERNAL_USER_ID and provider == _INTERNAL_PROVIDER) def _principal_digest(identity: dict) -> str: @@ -125,68 +127,120 @@ def _broker_event_writer(transport: object, session_id: str): return send -@method("browser.controller.register") -def _( +def _controller_method( + name: str, + *, + identity_message: str = _IDENTITY_REQUIRED, + lookup_scope: bool = True, + missing_scope_message: str = _NO_CONTROLLER, + precheck=None, +): + """Register a handler behind the shared fail-closed (4403) controller gates. + + Order: ``precheck(rid, params)`` (may return an error envelope) → the + calling transport holds a server-authenticated, non-internal identity + (``WSTransport.auth_identity``, never the RPC params) → the named session + exists and its ``transport`` is exactly the caller → when ``lookup_scope``, + a controller scope is attached for this session/principal/family and the + caller owns it. ``fn(rid, params, transport, identity, session_id, broker, + scope)`` then runs (``scope`` is ``None`` when ``lookup_scope`` is off). + """ + forbidden = _ERR_FORBIDDEN + family = _CLOUD_TRANSPORT_FAMILY + identity_ok = _is_authenticated_identity + digest = _principal_digest + not_owned = _NOT_OWNED + + def dec(fn): + def handler(rid, params: dict) -> dict: + from gateway import browser_control_broker + + if precheck is not None: + denied = precheck(rid, params) + if denied is not None: + return denied + transport = current_transport() + identity = getattr(transport, "auth_identity", None) + if not identity_ok(identity): + return _err(rid, forbidden, identity_message) + session_id = str(params.get("session_id") or "") + with _sessions_lock: + session = _sessions.get(session_id) + if session is None or session.get("transport") is not transport: + return _err(rid, forbidden, "session is not owned by this transport") + broker = browser_control_broker.get_browser_control_broker() + scope = None + if lookup_scope: + scope = broker.scope_for_session( + session_id=session_id, + principal_id=digest(identity), + transport_family=family, + ) + if scope is None: + return _err(rid, forbidden, missing_scope_message) + # Defense in depth: the broker's exact-scope operations already + # reject foreign scopes; the owner check makes the "same + # transport" rule explicit at this layer too. + if not broker.is_owner(scope, transport): + return _err(rid, forbidden, not_owned) + return fn(rid, params, transport, identity, session_id, broker, scope, session) + + handler.__doc__ = fn.__doc__ + return method(name)(handler) + + return dec + + +def _register_precheck( rid, params: dict, - _family=_CLOUD_TRANSPORT_FAMILY, + _forbidden=_ERR_FORBIDDEN, _protocol_version=BROWSER_CONTROL_PROTOCOL_VERSION, _protocol_supported=browser_control_protocol_supported, - _filter_capabilities=filter_browser_control_capabilities, - _forbidden=_ERR_FORBIDDEN, - _identity_ok=_is_authenticated_identity, - _digest=_principal_digest, - _event_writer=_broker_event_writer, -) -> dict: - """Attach this connection as the browser controller for one session. - - Fails closed (4403) unless *every* gate passes: - - * the ``browser.extension_control.enabled`` feature flag is on; - * the calling transport holds a server-authenticated, non-internal - identity (``WSTransport.auth_identity`` — never the RPC params); - * the named session exists in the live session registry and its - ``transport`` is exactly the calling transport; - * at least one requested capability survives the shared allowlist. - - The returned ``scope`` names a server-derived ``principal_id``, the - ``cloud-ticket-ws`` transport family, and the filtered capability set. - """ +): from gateway import browser_control_broker if not browser_control_broker.browser_control_enabled(): - return _err( - rid, - _forbidden, - "browser.extension_control.enabled is not set", - ) - + return _err(rid, _forbidden, "browser.extension_control.enabled is not set") if not _protocol_supported(params.get("protocol_version")): return _err( rid, _forbidden, f"unsupported browser-control protocol version; expected {_protocol_version}", ) + return None - transport = current_transport() - identity = getattr(transport, "auth_identity", None) - if not _identity_ok(identity): - return _err( - rid, - _forbidden, - "browser.controller.register requires an authenticated " - "non-internal identity", - ) - session_id = str(params.get("session_id") or "") - with _sessions_lock: - session = _sessions.get(session_id) - if session is None or session.get("transport") is not transport: - return _err( - rid, - _forbidden, - "session is not owned by this transport", - ) +@_controller_method( + "browser.controller.register", + identity_message="browser.controller.register requires an authenticated non-internal identity", + lookup_scope=False, + precheck=_register_precheck, +) +def _( + rid, + params: dict, + transport, + identity, + session_id, + broker, + _scope, + session, + _family=_CLOUD_TRANSPORT_FAMILY, + _forbidden=_ERR_FORBIDDEN, + _filter_capabilities=filter_browser_control_capabilities, + _digest=_principal_digest, + _event_writer=_broker_event_writer, +) -> dict: + """Attach this connection as the browser controller for one session. + + Fails closed (4403) unless the ``browser.extension_control.enabled`` flag + is on, the protocol version is supported, the shared identity/session + gates pass, and at least one requested capability survives the allowlist. + The returned ``scope`` names a server-derived ``principal_id``, the + ``cloud-ticket-ws`` transport family, and the filtered capability set. + """ + from gateway import browser_control_broker controller_id = str(params.get("controller_id") or "").strip() browser_profile_id = str(params.get("browser_profile_id") or "").strip() @@ -200,11 +254,7 @@ def _( capabilities = _filter_capabilities(params.get("capabilities")) if not capabilities: - return _err( - rid, - _forbidden, - "no permitted controller capabilities requested", - ) + return _err(rid, _forbidden, "no permitted controller capabilities requested") scope = browser_control_broker.ControllerScope( principal_id=_digest(identity), @@ -215,14 +265,7 @@ def _( transport_family=_family, capabilities=capabilities, ) - - broker = browser_control_broker.get_browser_control_broker() - broker.attach( - scope, - _event_writer(transport, session_id), - owner=transport, - ) - + broker.attach(scope, _event_writer(transport, session_id), owner=transport) return _ok( rid, { @@ -239,15 +282,8 @@ def _( ) -@method("browser.controller.result") -def _( - rid, - params: dict, - _family=_CLOUD_TRANSPORT_FAMILY, - _forbidden=_ERR_FORBIDDEN, - _identity_ok=_is_authenticated_identity, - _digest=_principal_digest, -) -> dict: +@_controller_method("browser.controller.result") +def _(rid, params: dict, _transport, _identity, _session_id, broker, scope, _session, _forbidden=_ERR_FORBIDDEN) -> dict: """Deliver one controller command result back to the broker. Only the transport that owns the session may resolve its commands, and @@ -256,48 +292,9 @@ def _( ``False`` for unknown / already-resolved / cancelled command ids — the broker's idempotent answer, surfaced verbatim. """ - from gateway import browser_control_broker - - transport = current_transport() - identity = getattr(transport, "auth_identity", None) - if not _identity_ok(identity): - return _err(rid, _forbidden, "authenticated controller identity required") - session_id = str(params.get("session_id") or "") - with _sessions_lock: - session = _sessions.get(session_id) - if session is None or session.get("transport") is not transport: - return _err( - rid, - _forbidden, - "session is not owned by this transport", - ) - command_id = str(params.get("command_id") or "") if not command_id: return _err(rid, _forbidden, "command_id required") - - broker = browser_control_broker.get_browser_control_broker() - scope = broker.scope_for_session( - session_id=session_id, - principal_id=_digest(identity), - transport_family=_family, - ) - if scope is None: - return _err( - rid, - _forbidden, - "no controller registered for this session", - ) - # Defense in depth: the exact-scope complete below already rejects any - # foreign scope, but the owner check makes the "same transport" rule - # explicit at this layer too. - if not broker.is_owner(scope, transport): - return _err( - rid, - _forbidden, - "controller is not owned by this transport", - ) - ok = params.get("ok") is True accepted = broker.complete( command_id, @@ -308,71 +305,15 @@ def _( return _ok(rid, {"accepted": accepted}) -@method("browser.controller.heartbeat") -def _( - rid, - params: dict, - _family=_CLOUD_TRANSPORT_FAMILY, - _forbidden=_ERR_FORBIDDEN, - _identity_ok=_is_authenticated_identity, - _digest=_principal_digest, -) -> dict: +@_controller_method("browser.controller.heartbeat") +def _(rid, params: dict, *_gate) -> dict: """Acknowledge a heartbeat only for this transport's attached controller.""" - from gateway import browser_control_broker - - transport = current_transport() - identity = getattr(transport, "auth_identity", None) - if not _identity_ok(identity): - return _err(rid, _forbidden, "authenticated controller identity required") - session_id = str(params.get("session_id") or "") - with _sessions_lock: - session = _sessions.get(session_id) - if session is None or session.get("transport") is not transport: - return _err(rid, _forbidden, "session is not owned by this transport") - - broker = browser_control_broker.get_browser_control_broker() - scope = broker.scope_for_session( - session_id=session_id, - principal_id=_digest(identity), - transport_family=_family, - ) - if scope is None: - return _err(rid, _forbidden, "no controller registered for this session") - if not broker.is_owner(scope, transport): - return _err(rid, _forbidden, "controller is not owned by this transport") return _ok(rid, {"ok": True}) -@method("browser.controller.detach") -def _( - rid, - params: dict, - _family=_CLOUD_TRANSPORT_FAMILY, - _forbidden=_ERR_FORBIDDEN, - _identity_ok=_is_authenticated_identity, - _digest=_principal_digest, -) -> dict: +@_controller_method("browser.controller.detach", missing_scope_message=_NOT_OWNED) +def _(rid, params: dict, transport, _identity, _session_id, broker, scope, _session) -> dict: """Hard-detach only the controller owned by this authenticated transport.""" - from gateway import browser_control_broker - - transport = current_transport() - identity = getattr(transport, "auth_identity", None) - if not _identity_ok(identity): - return _err(rid, _forbidden, "authenticated controller identity required") - session_id = str(params.get("session_id") or "") - with _sessions_lock: - session = _sessions.get(session_id) - if session is None or session.get("transport") is not transport: - return _err(rid, _forbidden, "session is not owned by this transport") - - broker = browser_control_broker.get_browser_control_broker() - scope = broker.scope_for_session( - session_id=session_id, - principal_id=_digest(identity), - transport_family=_family, - ) - if scope is None or not broker.is_owner(scope, transport): - return _err(rid, _forbidden, "controller is not owned by this transport") broker.detach(scope, owner=transport, notify_controller=False) return _ok(rid, {"detached": True}) diff --git a/tui_gateway/methods_complete.py b/tui_gateway/methods_complete.py index d32dc407ed..d18a55e462 100644 --- a/tui_gateway/methods_complete.py +++ b/tui_gateway/methods_complete.py @@ -1,15 +1,37 @@ -"""Completion / model-key / paste JSON-RPC handlers (moved verbatim from server.py). +"""Completion / model-key / paste JSON-RPC handlers. -Handler bodies are byte-identical to their pre-split server.py form; they -are rebound onto server.py's globals at install time — see method_ctx.py. +Everything defined here is rebound onto server.py's globals at install time +(``method_ctx.bind_module``), so handlers and module-level helpers may +reference server globals bare (``_ok``, ``_err``, ``_sessions``, ...). """ -from .method_ctx import HandlerRegistry + +from .method_ctx import HandlerRegistry, bind_module _registry = HandlerRegistry() method = _registry.method _profile_scoped = _registry.profile_scoped +_BUILTIN_AT_PREFIXES = frozenset({"file", "folder", "url", "git", "diff", "staged"}) +_AT_DIRECTIVE_HINTS = [ + ("@diff", "git diff"), + ("@staged", "staged diff"), + ("@file:", "attach file"), + ("@folder:", "attach folder"), + ("@url:", "fetch url"), + ("@git:", "git log"), +] +_SLASH_EXTRAS = [ + ("/density", "Toggle compact display mode"), + ("/details", "Control agent detail visibility"), + ("/logs", "Show recent gateway log lines"), + ("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]"), +] + + +def _item(text: str, meta: str, display: str | None = None) -> dict: + return {"text": text, "display": display if display is not None else text, "meta": meta} + @method("paste.collapse") def _(rid, params: dict) -> dict: @@ -25,17 +47,113 @@ def _(rid, params: dict) -> dict: from datetime import datetime - paste_file = ( - paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt" - ) + paste_file = paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt" paste_file.write_text(text, encoding="utf-8") - placeholder = ( - f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]" - ) - return _ok( - rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count} - ) + placeholder = f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]" + return _ok(rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count}) + + +def _profile_mention_items(prefix: str) -> list[dict]: + """`@` completions: agent profiles as mentionable names (multi-agent + UIs and the Bot Mode plugin route `@` text to another profile). + Bare-word matches only, never for `@kind:` directives. The primary profile is + also offered as 'hermes' when no real profile claims that name.""" + out: list[dict] = [] + try: + from hermes_cli.profiles import list_profiles + + seen: set[str] = set() + for p in list_profiles(): + name = (p.name or "").strip() + if not name: + continue + seen.add(name.lower()) + desc = (getattr(p, "description", "") or "").strip() + if name.lower().startswith(prefix.lower()): + out.append(_item(f"@{name}", desc or "agent profile")) + if "hermes".startswith(prefix.lower()) and "hermes" not in seen: + out.append(_item("@hermes", "agent profile (primary)")) + except Exception: + return [] + return out + + +def _plugin_reference_items(pfx: str, qval: str) -> list[dict] | None: + """`@:` autocomplete for a plugin ContextReferenceProvider; None when + no provider owns ``pfx`` or it fails.""" + try: + from agent.context_references import get_context_reference_providers as _gcr + + _prov = _gcr().get(pfx) + if _prov is None: + return None + import asyncio as _asyncio + + _coro = _prov.autocomplete(qval, limit=20) + try: + _loop = _asyncio.get_running_loop() + except RuntimeError: + _loop = None + if _loop and _loop.is_running(): + import concurrent.futures as _cf + + with _cf.ThreadPoolExecutor(max_workers=1) as _pool: + _ac = _pool.submit(_asyncio.run, _coro).result() + else: + _ac = _asyncio.run(_coro) + return [{"text": f"@{pfx}:{it.text}", "display": it.display, "meta": it.meta} for it in _ac] + except Exception: + return None + + +def _fuzzy_basename_items(root: str, path_part: str, prefix_tag: str) -> list[dict]: + """Fuzzy basename search across the repo for a bare `@name` (what Cursor / + VS Code do for Cmd-P); path-ish queries take the directory-listing path.""" + ranked: list[tuple[tuple[int, int], str, str, bool]] = [] + walked_dirs: set[str] = set() + seen: set[str] = set() + want_hidden = path_part.startswith(".") + + def _consider(rel: str, name: str, is_dir: bool) -> None: + if rel in seen or (name.startswith(".") and not want_hidden): + return + rank = _fuzzy_basename_rank(name, path_part) + if rank is not None: + seen.add(rel) + ranked.append((rank, rel, name, is_dir)) + + # Seed with root's immediate children: `_list_repo_files` is capped at + # _FUZZY_CACHE_MAX_FILES and outside a git repo the fallback walk can burn + # the whole budget on one deep subtree before reaching a sibling. + try: + for entry in os.listdir(root): + if entry not in _FUZZY_FALLBACK_EXCLUDES: + _consider(entry, entry, os.path.isdir(os.path.join(root, entry))) + except OSError: + pass + + for rel in _list_repo_files(root): + _consider(rel, os.path.basename(rel), False) + # Directories are only implied by the file listing, so rank each ancestor + # too — otherwise a folder with no name-matching file inside is invisible. + parent = os.path.dirname(rel) + while parent and parent not in walked_dirs: + walked_dirs.add(parent) + _consider(parent, os.path.basename(parent), True) + parent = os.path.dirname(parent) + + # Same rank tier: folders first, so `@Desktop` leads with the folder. + ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1])) + tag = prefix_tag or "file" + return [ + _item( + f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}", + "dir" if is_dir else os.path.dirname(rel), + basename + ("/" if is_dir else ""), + ) + for _, rel, basename, is_dir in ranked[:30] + ] @method("complete.path") @@ -45,139 +163,44 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"items": []}) items: list[dict] = [] - - def _profile_mention_items(prefix: str) -> list[dict]: - """`@` completions: agent profiles as mentionable names. - - Multi-agent UIs (and the Bot Mode plugin) route `@` text to - another agent profile; completing profile names alongside path refs - makes that discoverable. Bare-word matches only — never for - `@kind:` directive queries. The primary profile is also offered - under the 'hermes' alias when no real profile claims that name. - """ - out: list[dict] = [] - try: - from hermes_cli.profiles import list_profiles - - seen: set[str] = set() - for p in list_profiles(): - name = (p.name or "").strip() - if not name: - continue - seen.add(name.lower()) - desc = (getattr(p, "description", "") or "").strip() - if name.lower().startswith(prefix.lower()): - out.append( - { - "text": f"@{name}", - "display": f"@{name}", - "meta": desc or "agent profile", - } - ) - if "hermes".startswith(prefix.lower()) and "hermes" not in seen: - out.append( - { - "text": "@hermes", - "display": "@hermes", - "meta": "agent profile (primary)", - } - ) - except Exception: - return [] - return out - try: root = _completion_cwd(params) is_context = word.startswith("@") query = word[1:] if is_context else word if is_context and not query: - items = [ - {"text": "@diff", "display": "@diff", "meta": "git diff"}, - {"text": "@staged", "display": "@staged", "meta": "staged diff"}, - {"text": "@file:", "display": "@file:", "meta": "attach file"}, - {"text": "@folder:", "display": "@folder:", "meta": "attach folder"}, - {"text": "@url:", "display": "@url:", "meta": "fetch url"}, - {"text": "@git:", "display": "@git:", "meta": "git log"}, - ] - # Agent profiles are mentionable — list them alongside the - # directive hints so `@` alone reveals them. + items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS] + # Agent profiles are mentionable — `@` alone reveals them too. items.extend(_profile_mention_items("")) - # Append plugin-registered context reference prefixes try: from agent.context_references import get_context_reference_providers for _pfx, _prov in sorted(get_context_reference_providers().items()): - items.append( - { - "text": f"@{_pfx}:", - "display": f"@{_pfx}:", - "meta": _prov.description or f"plugin: {_pfx}", - } - ) + items.append(_item(f"@{_pfx}:", _prov.description or f"plugin: {_pfx}")) except Exception: pass return _ok(rid, {"items": items}) - # Plugin context reference autocomplete: `@:` where the - # prefix belongs to a plugin-registered ContextReferenceProvider. - # Handled before the built-in file/folder branching so the elif/else - # chain below stays intact for built-in prefixes. + # Plugin `@:` runs before the built-in file/folder branching. if is_context and ":" in query: _pfx, _, _qval = query.partition(":") - if _pfx not in {"file", "folder", "url", "git", "diff", "staged"}: - try: - from agent.context_references import ( - get_context_reference_providers as _gcr, - ) + if _pfx not in _BUILTIN_AT_PREFIXES: + plugin_items = _plugin_reference_items(_pfx, _qval) + if plugin_items is not None: + return _ok(rid, {"items": plugin_items}) - _prov = _gcr().get(_pfx) - if _prov is not None: - import asyncio as _asyncio - - _coro = _prov.autocomplete(_qval, limit=20) - try: - _loop = _asyncio.get_running_loop() - except RuntimeError: - _loop = None - if _loop and _loop.is_running(): - import concurrent.futures as _cf - - with _cf.ThreadPoolExecutor(max_workers=1) as _pool: - _ac = _pool.submit(_asyncio.run, _coro).result() - else: - _ac = _asyncio.run(_coro) - items = [ - { - "text": f"@{_pfx}:{it.text}", - "display": it.display, - "meta": it.meta, - } - for it in _ac - ] - return _ok(rid, {"items": items}) - except Exception: - pass - - # Accept both `@folder:path` and the bare `@folder` form so the user - # sees directory listings as soon as they finish typing the keyword, - # without first accepting the static `@folder:` hint. + # Accept both `@folder:path` and bare `@folder` so listings appear as soon + # as the keyword is typed, without accepting the static `@folder:` hint. if is_context and query in {"file", "folder"}: prefix_tag, path_part = query, "" elif is_context and query.startswith(("file:", "folder:")): - prefix_tag, _, tail = query.partition(":") - path_part = tail + prefix_tag, _, path_part = query.partition(":") else: - prefix_tag = "" - path_part = query if is_context else query + prefix_tag, path_part = "", query - # `@/foo` almost always means "foo, from here" rather than the absolute - # `/foo`: the `@` already says "this is a path", so the slash reads as a - # separator people type out of habit. Take the absolute reading only - # when something is actually there, else drop the slash and resolve - # relative to the cwd — otherwise `@/Desktop` dead-ends on a directory - # that exists one level down. Real absolute paths (`@/usr/local`, - # `@/etc/hosts`) still resolve, since those prefixes do exist. + # `@/foo` almost always means "foo, from here": take the absolute reading + # only when that prefix exists, else drop the slash and resolve relative + # to cwd — otherwise `@/Desktop` dead-ends. `@/usr/local` still resolves. if ( is_context and path_part.startswith("/") @@ -186,76 +209,11 @@ def _(rid, params: dict) -> dict: ): path_part = path_part.lstrip("/") - # Fuzzy basename search across the repo when the user types a bare - # name with no path separator — `@appChrome` surfaces every file - # whose basename matches, regardless of directory depth. Matches what - # editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with - # `/`, `./`, `~/`, `/abs`) fall through to the directory-listing - # path so explicit navigation intent is preserved. - if ( - is_context - and path_part - and len(path_part.strip()) >= 2 - and "/" not in path_part - and prefix_tag != "folder" - ): - ranked: list[tuple[tuple[int, int], str, str, bool]] = [] - walked_dirs: set[str] = set() - seen: set[str] = set() - want_hidden = path_part.startswith(".") - - def _consider(rel: str, name: str, is_dir: bool) -> None: - if rel in seen or (name.startswith(".") and not want_hidden): - return - rank = _fuzzy_basename_rank(name, path_part) - if rank is not None: - seen.add(rel) - ranked.append((rank, rel, name, is_dir)) - - # Seed with root's immediate children. `_list_repo_files` is capped - # at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback - # walk can burn that whole budget on one deep subtree before ever - # reaching a sibling — which is why `@Desk` in a non-repo $HOME - # found nothing. One listdir keeps the top level always reachable. - try: - for entry in os.listdir(root): - if entry not in _FUZZY_FALLBACK_EXCLUDES: - _consider(entry, entry, os.path.isdir(os.path.join(root, entry))) - except OSError: - pass - - for rel in _list_repo_files(root): - _consider(rel, os.path.basename(rel), False) - - # Directories are only implied by the file listing, so rank each - # ancestor too. Without this a bare `@Desktop` finds nothing — - # a folder with no name-matching file inside it is invisible to - # a file-only scan, which is the "can't @ a folder by name" bug. - parent = os.path.dirname(rel) - while parent and parent not in walked_dirs: - walked_dirs.add(parent) - _consider(parent, os.path.basename(parent), True) - parent = os.path.dirname(parent) - - # Same rank tier: folders first, so `@Desktop` leads with the folder - # rather than a file that merely fuzzy-matches the same letters. - ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1])) - tag = prefix_tag or "file" - for _, rel, basename, is_dir in ranked[:30]: - items.append( - { - "text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}", - "display": basename + ("/" if is_dir else ""), - "meta": "dir" if is_dir else os.path.dirname(rel), - } - ) - - # Bare-word `@name` may equally be an agent mention — surface - # matching profiles ABOVE file hits (there are at most a handful, - # and a user typing `@tur` for a bot shouldn't have to dig). + if is_context and path_part and len(path_part.strip()) >= 2 and "/" not in path_part and prefix_tag != "folder": + items = _fuzzy_basename_items(root, path_part, prefix_tag) + # Bare `@name` may equally be an agent mention: profiles rank ABOVE file hits. if not prefix_tag: items = _profile_mention_items(path_part) + items - return _ok(rid, {"items": items}) expanded = _normalize_completion_path(path_part) if path_part else "." @@ -267,9 +225,7 @@ def _(rid, params: dict) -> dict: search_dir = os.path.dirname(expanded) or "." match = os.path.basename(expanded) - search_dir = ( - search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) - ) + search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) if not os.path.isdir(search_dir): return _ok(rid, {"items": []}) @@ -284,9 +240,8 @@ def _(rid, params: dict) -> dict: continue full = os.path.join(search_dir, entry) is_dir = os.path.isdir(full) - # Explicit `@folder:` / `@file:` — honour the user's filter. Skip - # the opposite kind instead of auto-rewriting the completion tag, - # which used to defeat the prefix and let `@folder:` list files. + # Explicit `@folder:` / `@file:` filters: skip the opposite kind rather + # than rewriting the tag (which let `@folder:` list files). if prefix_tag and want_dir != is_dir: continue rel = os.path.relpath(full, root).replace(os.sep, "/") @@ -295,8 +250,7 @@ def _(rid, params: dict) -> dict: if is_context and prefix_tag: text = f"@{prefix_tag}:{rel}{suffix}" elif is_context: - kind = "folder" if is_dir else "file" - text = f"@{kind}:{rel}{suffix}" + text = f"@{'folder' if is_dir else 'file'}:{rel}{suffix}" elif word.startswith("~"): text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix elif word.startswith("./"): @@ -304,20 +258,14 @@ def _(rid, params: dict) -> dict: else: text = rel + suffix - items.append( - { - "text": text, - "display": entry + suffix, - "meta": "dir" if is_dir else "", - } - ) + items.append(_item(text, "dir" if is_dir else "", entry + suffix)) if len(items) >= 30: break except Exception as e: return _err(rid, 5021, str(e)) - # Bare-word `@name` (including single characters, which skip the fuzzy - # branch) may be an agent mention — profiles rank above path entries. + # Bare-word `@name` (incl. single chars, which skip the fuzzy branch) may be + # an agent mention — profiles rank above path entries. try: if is_context and not prefix_tag and path_part and "/" not in path_part: items = _profile_mention_items(path_part) + items @@ -342,126 +290,61 @@ def _(rid, params: dict) -> dict: from agent.skill_bundles import get_skill_bundles completer = SlashCommandCompleter( - skill_commands_provider=lambda: get_skill_commands(), - skill_bundles_provider=lambda: get_skill_bundles(), + skill_commands_provider=lambda: get_skill_commands(), skill_bundles_provider=lambda: get_skill_bundles() ) - doc = Document(text, len(text)) - # Skill commands and bundles are the only completions offered for an - # inline `/skill` reference typed mid-message, so the class has to - # reach the TUI as data. Derived from the same providers the completer - # uses — no sniffing the ⚡/▣ meta glyphs, which are display text. - skill_names = { - key.lstrip("/").lower() - for key in (*get_skill_commands(), *get_skill_bundles()) - } - items = [ - { - "text": c.text, - # prompt_toolkit gives us FormattedText (a list of (style, - # text) tuples) for display/display_meta. Serialize both as - # plain strings — the TUI's CompletionItem.display contract - # is a string, and sending the raw list trips Ink's row - # layout into 1-char truncation of the next column. - "display": to_plain_text(c.display) if c.display else c.text, - "meta": to_plain_text(c.display_meta) if c.display_meta else "", - "kind": ( - "skill" - if c.text.strip().lstrip("/").lower() in skill_names - else "command" - ), - } - for c in completer.get_completions(doc, None) - ] + # Skills/bundles are the only completions for an inline `/skill` typed + # mid-message, so the class reaches the TUI as data — derived from the + # completer's own providers, not sniffed from the ⚡/▣ display glyphs. + skill_names = {key.lstrip("/").lower() for key in (*get_skill_commands(), *get_skill_bundles())} - # Rank and bound the list (see _rank_slash_completions) while a - # `/token` is under the cursor — the one stage skills are offered at. - # An argument stage (`/personality `, `/details c`) keeps the order - # its own command chose. + def to_items(doc: Document) -> list[dict]: + # prompt_toolkit's display/display_meta are FormattedText; the TUI's + # CompletionItem.display contract is a plain string (the raw list + # trips Ink's row layout into 1-char truncation of the next column). + return [ + { + "text": c.text, + "display": to_plain_text(c.display) if c.display else c.text, + "meta": to_plain_text(c.display_meta) if c.display_meta else "", + "kind": "skill" if c.text.strip().lstrip("/").lower() in skill_names else "command", + } + for c in completer.get_completions(doc, None) + ] + + items = to_items(Document(text, len(text))) + + # Rank and bound (see _rank_slash_completions) while a `/token` is under + # the cursor — the one stage skills are offered at. An argument stage + # (`/personality `, `/details c`) keeps the order its command chose. if text.rsplit(" ", 1)[-1].startswith("/"): score_of = None - # Description-aware fuzzy scoring (ported from grok-cli's slash - # menu) at the command-token stage: the completer above only - # emits name-prefix matches, so merge in catalog entries whose - # name SUBSTRING or DESCRIPTION words match the query — typing - # `/summary` surfaces a command whose description mentions - # summaries. Command matches always outrank description matches. + # Description-aware fuzzy scoring at the command-token stage: the + # completer only emits name-prefix matches, so merge in catalog entries + # whose name SUBSTRING or DESCRIPTION words match (`/summary` surfaces a + # command whose description mentions summaries). Name matches outrank + # description matches. if " " not in text and len(text) > 1: - from tui_gateway.slash_fuzzy import ( - fuzzy_rank_slash_items, - normalize_slash_search_query, - ) + from tui_gateway.slash_fuzzy import fuzzy_rank_slash_items, normalize_slash_search_query - universe = [ - { - "text": c.text, - "display": to_plain_text(c.display) if c.display else c.text, - "meta": to_plain_text(c.display_meta) if c.display_meta else "", - "kind": ( - "skill" - if c.text.strip().lstrip("/").lower() in skill_names - else "command" - ), - } - for c in completer.get_completions(Document("/", 1), None) - ] items, score_of = fuzzy_rank_slash_items( - items, universe, normalize_slash_search_query(text) + items, to_items(Document("/", 1)), normalize_slash_search_query(text) ) usage, origin_of = _skill_usage_lookup() - items = _rank_slash_completions( - items, usage, origin_of, browsing=text == "/", score_of=score_of - ) + items = _rank_slash_completions(items, usage, origin_of, browsing=text == "/", score_of=score_of) else: items = items[:_SLASH_COMPLETION_LIMIT] text_lower = text.lower() - extras = [ - { - "text": "/density", - "display": "/density", - "meta": "Toggle compact display mode", - "kind": "command", - }, - { - "text": "/details", - "display": "/details", - "meta": "Control agent detail visibility", - "kind": "command", - }, - { - "text": "/logs", - "display": "/logs", - "meta": "Show recent gateway log lines", - "kind": "command", - }, - { - "text": "/mouse", - "display": "/mouse", - "meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]", - "kind": "command", - }, - ] - for extra in extras: - if extra["text"].startswith(text_lower) and not any( - item["text"] == extra["text"] for item in items - ): - items.append(extra) + for extra_text, extra_meta in _SLASH_EXTRAS: + if extra_text.startswith(text_lower) and not any(item["text"] == extra_text for item in items): + items.append({"text": extra_text, "display": extra_text, "meta": extra_meta, "kind": "command"}) details_items = _details_completions(text) if details_items is not None: - return _ok( - rid, - { - "items": details_items, - "replace_from": text.rfind(" ") + 1 if " " in text else len(text), - }, - ) + return _ok(rid, {"items": details_items, "replace_from": text.rfind(" ") + 1 if " " in text else len(text)}) - return _ok( - rid, - {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}, - ) + return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}) except Exception as e: return _err(rid, 5020, str(e)) @@ -474,10 +357,8 @@ def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None - # Layer agent-session state on top of disk config — once an agent - # is spawned, IT owns the live provider/model/base_url. Empty - # agent attributes must NOT clobber disk config (with_overrides - # is truthy-only). + # Once an agent is spawned IT owns the live provider/model/base_url; empty + # agent attributes must NOT clobber disk config (with_overrides is truthy-only). ctx = _model_picker_context(agent) payload = build_model_options_payload( ctx, @@ -492,15 +373,8 @@ def _(rid, params: dict) -> dict: @method("model.save_key") def _(rid, params: dict) -> dict: - """Save an API key for a provider, then return its refreshed model list. - - Params: - slug: provider slug (e.g. "deepseek", "xai") - api_key: the key value to save - - Returns the provider dict with models populated (same shape as - model.options entries) on success. - """ + """Save an API key for provider ``slug`` and return its refreshed provider row + (same shape as model.options entries, with ``authenticated``).""" try: from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.config import is_managed @@ -518,42 +392,28 @@ def _(rid, params: dict) -> dict: if not pconfig: return _err(rid, 4002, f"unknown provider: {slug}") if pconfig.auth_type != "api_key": - return _err( - rid, - 4003, - f"{pconfig.name} uses {pconfig.auth_type} auth — " - f"run `hermes model` to configure", - ) + return _err(rid, 4003, f"{pconfig.name} uses {pconfig.auth_type} auth — run `hermes model` to configure") if not pconfig.api_key_env_vars: return _err(rid, 4004, f"no env var defined for {pconfig.name}") - # Save the key to ~/.hermes/.env via the unified credential lifecycle - # so any stale config.yaml mirror of the previous key (model.api_key, - # custom_providers[*].api_key) is rotated in the same action (#62269). + # Unified credential lifecycle so any stale config.yaml mirror of the old + # key (model.api_key, custom_providers[*].api_key) rotates in the same action. env_var = pconfig.api_key_env_vars[0] from hermes_cli.credential_lifecycle import save_provider_env_credential save_provider_env_credential(env_var, api_key) - # Also set in current process so the refreshed inventory sees it. import os - os.environ[env_var] = api_key + os.environ[env_var] = api_key # so the refreshed inventory sees it - # Refresh provider data via the shared inventory builder so this - # surface stays in lock-step with model.options + dashboard - # /api/model/options. picker_hints=True ensures the returned row - # carries `authenticated` for the TUI frontend. + # Shared inventory builder keeps this in lock-step with model.options and + # the dashboard; picker_hints=True carries `authenticated` for the TUI. session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None - ctx = _model_picker_context(agent) - payload = build_models_payload( - ctx, picker_hints=True, max_models=50, - ) - provider_data = next( - (p for p in payload["providers"] if p["slug"] == slug), None - ) + payload = build_models_payload(_model_picker_context(agent), picker_hints=True, max_models=50) + provider_data = next((p for p in payload["providers"] if p["slug"] == slug), None) if provider_data is None: - # Key was saved but provider didn't appear — still return success. + # Key saved but provider didn't appear — still success. provider_data = { "slug": slug, "name": pconfig.name, @@ -562,9 +422,7 @@ def _(rid, params: dict) -> dict: "total_models": 0, "authenticated": True, } - # picker_hints sets `authenticated` from the row state, but the - # synthetic fallback above doesn't go through that path. - provider_data["authenticated"] = True + provider_data["authenticated"] = True # synthetic fallback bypasses picker_hints return _ok(rid, {"provider": provider_data}) except Exception as e: return _err(rid, 5034, str(e)) @@ -572,13 +430,7 @@ def _(rid, params: dict) -> dict: @method("model.disconnect") def _(rid, params: dict) -> dict: - """Remove credentials for a provider. - - Params: - slug: provider slug (e.g. "deepseek", "xai") - - Returns success status and the provider's slug. - """ + """Remove all credentials (env keys AND OAuth/pool state) for provider ``slug``.""" try: from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth from hermes_cli.credential_lifecycle import remove_provider_env_credential @@ -589,39 +441,26 @@ def _(rid, params: dict) -> dict: pconfig = PROVIDER_REGISTRY.get(slug) cleared_env = False - cleared_auth = False - # Remove API key env vars from .env and process, plus every mirror - # (env-seeded credential_pool entries, provider model cache rows, - # value-matched config.yaml api_key copies) via the unified helper — - # otherwise the provider resurrects in the picker after restart - # (#51071 / #59761). + # Remove env vars from .env/process plus every mirror (env-seeded pool + # entries, model cache rows, value-matched config.yaml copies) — otherwise + # the provider resurrects in the picker after restart. if pconfig and pconfig.api_key_env_vars: for ev in pconfig.api_key_env_vars: if remove_provider_env_credential(ev).get("found"): cleared_env = True - # Clear OAuth / credential pool state. This is a full provider - # disconnect (TUI "disconnect" action), so removing OAuth grants - # here is the documented intent — unlike the key-only delete paths. + # Full disconnect: removing OAuth grants is intended here, unlike key-only deletes. cleared_auth = clear_provider_auth(slug) if not cleared_env and not cleared_auth: return _err(rid, 4005, f"no credentials found for {slug}") - provider_name = pconfig.name if pconfig else slug - return _ok( - rid, - { - "slug": slug, - "name": provider_name, - "disconnected": True, - }, - ) + return _ok(rid, {"slug": slug, "name": pconfig.name if pconfig else slug, "disconnected": True}) except Exception as e: return _err(rid, 5035, str(e)) def register(server) -> None: - """Bind this module's handlers onto ``server``'s globals and registry.""" - _registry.install(server) + """Rebind this module's helpers + handlers onto ``server`` and register the handlers.""" + bind_module(globals(), server, skip=("_",)) diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index d0ffb2396e..7ba7a45feb 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -1,13 +1,13 @@ -"""Config / projects / setup JSON-RPC handlers (moved verbatim from server.py). +"""Config / projects / setup JSON-RPC handlers. -NOTE: ``config.set`` stays in server.py for now — the in-flight -opt/model-resolution-core PR touches it; move it in a follow-up once merged. - -Handler bodies are byte-identical to their pre-split server.py form; they -are rebound onto server.py's globals at install time — see method_ctx.py. +Handlers and module-level helpers are rebound onto server.py's globals at +install time (see method_ctx.bind_module), so bodies reference server.py +globals bare (``_ok``, ``_err``, ``_load_cfg``, ``_sessions``, ...). +``config.set`` still lives in server.py. """ -from .method_ctx import HandlerRegistry + +from .method_ctx import HandlerRegistry, bind_module from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES @@ -16,6 +16,12 @@ method = _registry.method _profile_scoped = _registry.profile_scoped +def _reconcile_repo_discovery(pdb, conn, policy, policy_key): + pdb.reconcile_discovered_repos_policy( + conn, policy_key, preserve_unversioned=_repo_discovery_policy_is_default(policy) + ) + + @method("projects.discover_repos") @_profile_scoped def _(rid, params: dict) -> dict: @@ -27,23 +33,14 @@ def _(rid, params: dict) -> dict: from hermes_cli import projects_db as pdb policy = _repo_discovery_policy() - policy_key = _repo_discovery_policy_key(policy) with pdb.connect_closing() as conn: - pdb.reconcile_discovered_repos_policy( - conn, - policy_key, - preserve_unversioned=_repo_discovery_policy_is_default(policy), - ) - # `scan=true` (set by the desktop in remote-gateway mode): run a - # backend-side filesystem scan of the policy roots so repos with - # zero Hermes sessions still surface. The desktop's native scan - # only runs on the local filesystem; on a remote connection it - # must ask the host to scan itself (#81723). + _reconcile_repo_discovery(pdb, conn, policy, _repo_discovery_policy_key(policy)) + # `scan=true` (desktop in remote-gateway mode): the desktop's + # native scan only sees its local filesystem, so ask the host + # to scan the policy roots itself so zero-session repos surface. if params.get("scan") and policy["enabled"]: _scan_discovered_repos_remote(conn, policy) - repos = _discover_repos_payload( - db, conn=conn, include_cached=policy["enabled"] - ) + repos = _discover_repos_payload(db, conn=conn, include_cached=policy["enabled"]) return _ok(rid, {"repos": repos, "discovery_policy": policy}) except Exception as e: return _err(rid, 5061, str(e)) @@ -52,9 +49,8 @@ def _(rid, params: dict) -> dict: @method("projects.record_repos") @_profile_scoped def _(rid, params: dict) -> dict: - """Persist git repo roots found by the client's filesystem scan, then return - the merged repo list. The native crawl runs on the desktop (local fs); this - caches the result so later reads are instant instead of re-walking disk.""" + """Persist git repo roots found by the client's filesystem scan (the native + crawl runs on the desktop), then return the merged repo list.""" try: from hermes_cli import projects_db as pdb @@ -62,9 +58,7 @@ def _(rid, params: dict) -> dict: policy_key = _repo_discovery_policy_key(policy) incoming_raw = params.get("discovery_policy") incoming_policy = ( - _repo_discovery_policy(incoming_raw) - if isinstance(incoming_raw, dict) - else None + _repo_discovery_policy(incoming_raw) if isinstance(incoming_raw, dict) else None ) incoming_matches = ( incoming_policy is not None @@ -82,18 +76,10 @@ def _(rid, params: dict) -> dict: pairs.append((str(item["root"]), item.get("label"))) with pdb.connect_closing() as conn: - pdb.reconcile_discovered_repos_policy( - conn, - policy_key, - preserve_unversioned=_repo_discovery_policy_is_default(policy), - ) - accepted = bool( - policy["enabled"] and (incoming_matches or accept_legacy_default) - ) + _reconcile_repo_discovery(pdb, conn, policy, policy_key) + accepted = bool(policy["enabled"] and (incoming_matches or accept_legacy_default)) if accepted: - pdb.record_discovered_repos( - conn, pairs, replace=True, policy_key=policy_key - ) + pdb.record_discovered_repos(conn, pairs, replace=True, policy_key=policy_key) elif not policy["enabled"]: pdb.clear_discovered_repos(conn, policy_key=policy_key) @@ -101,9 +87,7 @@ def _(rid, params: dict) -> dict: return _ok( rid, { - "repos": _discover_repos_payload( - db, include_cached=policy["enabled"] - ) + "repos": _discover_repos_payload(db, include_cached=policy["enabled"]) if db is not None else [], "accepted": accepted, @@ -114,6 +98,15 @@ def _(rid, params: dict) -> dict: return _err(rid, 5061, str(e)) +def _stamped_project_tree(db, params, **kwargs): + """``_build_project_tree`` + profile stamping shared by the two tree RPCs.""" + from tui_gateway.project_tree import stamp_profile + + tree, active_id = _build_project_tree(db, **kwargs) + stamp_profile(tree["projects"], _response_profile_name(params.get("profile"))) + return tree, active_id + + @method("projects.tree") @_profile_scoped def _(rid, params: dict) -> dict: @@ -123,25 +116,17 @@ def _(rid, params: dict) -> dict: Lanes carry no session rows here; drill-in uses ``projects.project_sessions``. """ try: - from tui_gateway.project_tree import stamp_profile - from tui_gateway.server import _response_profile_name - with _profile_db(params) as db: if db is None: - return _ok( - rid, {"projects": [], "active_id": None, "scoped_session_ids": []} - ) - - tree, active_id = _build_project_tree( + return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []}) + tree, active_id = _stamped_project_tree( db, + params, preview_limit=int(params.get("preview_limit") or 3), hydrate=False, session_limit=int(params.get("session_limit") or 2000), include_discovered=True, ) - stamp_profile( - tree["projects"], _response_profile_name(params.get("profile")) - ) return _ok( rid, { @@ -159,11 +144,8 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Fully hydrated lanes (repo -> lane -> session rows) for one project, built from the same authoritative grouping as ``projects.tree`` so ids and - membership match exactly. Used when the user enters a project.""" + membership match exactly.""" try: - from tui_gateway.project_tree import stamp_profile - from tui_gateway.server import _response_profile_name - project_id = str(params.get("project_id") or "") if not project_id: return _err(rid, 5063, "project_id required") @@ -171,233 +153,227 @@ def _(rid, params: dict) -> dict: with _profile_db(params) as db: if db is None: return _ok(rid, {"project": None}) - - # Drill-in only needs the entered project (which has sessions), so skip - # the zero-session discovery tier entirely. - tree, _active = _build_project_tree( + # Drill-in only needs the entered project (which has sessions): + # skip the zero-session discovery tier. + tree, _active = _stamped_project_tree( db, + params, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000), include_discovered=False, ) - stamp_profile( - tree["projects"], _response_profile_name(params.get("profile")) - ) proj = next((p for p in tree["projects"] if p["id"] == project_id), None) return _ok(rid, {"project": proj}) except Exception as e: return _err(rid, 5061, str(e)) +# --------------------------------------------------------------------------- +# config.get — one getter per key. Each returns the result payload (a dict) or +# a full ``_err`` response (dicts containing "error" pass through untouched). +# --------------------------------------------------------------------------- + + +def _display_cfg() -> dict: + display = _load_cfg().get("display") + return display if isinstance(display, dict) else {} + + +def _display_mode(cfg: dict, key: str, allowed: frozenset, default: str) -> str: + raw = str((cfg.get("display") or {}).get(key, default) or default).strip().lower() + return raw if raw in allowed else default + + +_DETAILS_MODES = frozenset({"hidden", "collapsed", "expanded"}) +_THINKING_MODES = frozenset({"collapsed", "truncated", "full"}) + + +def _cfg_get_provider(rid, params): + try: + from hermes_cli.models import list_available_providers, normalize_provider + + model = _resolve_model() + parts = model.split("/", 1) + return { + "model": model, + "provider": normalize_provider(parts[0]) if len(parts) > 1 else "unknown", + "providers": list_available_providers(), + } + except Exception as e: + return _err(rid, 5013, str(e)) + + +def _cfg_get_profile(rid, params): + from hermes_constants import display_hermes_home + + return {"home": str(_hermes_home), "display": display_hermes_home()} + + +def _cfg_get_project(rid, params): + cfg_terminal = _load_cfg().get("terminal") or {} + raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip() + cwd = _completion_cwd({"cwd": raw} if raw else {}) + return {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)} + + +def _cfg_get_indicator(rid, params): + # Normalize so a hand-edited config.yaml (stray casing / unknown value) + # reads back the SAME value the TUI rendered (frontend falls back to + # DEFAULT_INDICATOR_STYLE for the same inputs). + norm = str((_load_cfg().get("display") or {}).get("tui_status_indicator", "")).strip().lower() + return {"value": norm if norm in INDICATOR_STYLES else DEFAULT_INDICATOR_STYLE} + + +def _cfg_get_personality(rid, params): + # EFFECTIVE personality via the single owner — a stale/unknown name in + # config must not display as active. + from hermes_cli.personality import active_personality_name + + return {"value": active_personality_name(_load_cfg()) or "none"} + + +def _cfg_get_reasoning(rid, params): + cfg = _load_cfg() + session = _sessions.get(params.get("session_id", "")) + reasoning_config = None + if session is not None: + if isinstance(session.get("create_reasoning_override"), dict): + reasoning_config = session.get("create_reasoning_override") + else: + agent_reasoning = getattr(session.get("agent"), "reasoning_config", None) + if isinstance(agent_reasoning, dict): + reasoning_config = agent_reasoning + + if isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: + effort = "none" + else: + effort = str(reasoning_config.get("effort") or "medium") + else: + raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "") + # YAML `reasoning_effort: false` means thinking disabled, not "unset". + effort = "none" if raw_effort is False else str(raw_effort or "medium") + display = "show" if bool((cfg.get("display") or {}).get("show_reasoning", True)) else "hide" + return {"value": effort, "display": display} + + +def _cfg_get_fast(rid, params): + # `config.set fast` is session-scoped, so prefer the session's live/pinned + # value over the global key; a pre-build session keeps its pin in + # create_service_tier_override. + session = _sessions.get(params.get("session_id", "")) + tier = None + if session is not None: + agent = session.get("agent") + if agent is not None: + tier = getattr(agent, "service_tier", None) + elif session.get("create_service_tier_override") is not None: + tier = session["create_service_tier_override"] + if tier is None: + tier = _load_service_tier() + return {"value": "fast" if tier == "priority" else "normal"} + + +def _cfg_get_approval_mode(rid, params): + try: + return {"value": _load_approval_mode()} + except Exception as e: + return _err(rid, 5001, str(e)) + + +def _cfg_get_thinking_mode(rid, params): + cfg = _load_cfg() + raw = str((cfg.get("display") or {}).get("thinking_mode", "") or "").strip().lower() + if raw in _THINKING_MODES: + return {"value": raw} + dm = _display_mode(cfg, "details_mode", _DETAILS_MODES, "collapsed") + return {"value": "full" if dm == "expanded" else "collapsed"} + + +def _cfg_get_theme(rid, params): + raw = str(_display_cfg().get("tui_theme", "auto")).strip().lower() + return {"value": raw if raw in {"auto", "light", "dark"} else "auto"} + + +def _cfg_get_focus(rid, params): + on = bool(_display_cfg().get("focus_view", False)) + return {"value": "on" if on else "off", "tool_progress": _load_tool_progress_mode()} + + +def _cfg_get_mtime(rid, params): + cfg_path = _hermes_home / "config.yaml" + try: + mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0 + except Exception: + return {"mtime": 0} + # mcp_rev: hash of the MCP-relevant config sections so the TUI's poller + # reloads MCP servers only when their config changed — a /skin write bumps + # mtime but must not cost a multi-second MCP reconnect. + return {"mtime": mtime, "mcp_rev": _compute_mcp_rev()} + + +def _config_getters() -> dict: + """key -> getter(rid, params). Built inside a function (not a module-level + dict) so, once rebound onto server.py, every entry resolves to the rebound + helper copies rather than this module's un-rebound originals.""" + return { + "provider": _cfg_get_provider, + "profile": _cfg_get_profile, + "project": _cfg_get_project, + "full": lambda rid, params: {"config": _load_cfg()}, + "prompt": lambda rid, params: {"prompt": _load_cfg().get("custom_prompt", "")}, + "skin": lambda rid, params: {"value": (_load_cfg().get("display") or {}).get("skin", "default")}, + "indicator": _cfg_get_indicator, + "personality": _cfg_get_personality, + "reasoning": _cfg_get_reasoning, + "fast": _cfg_get_fast, + "busy": lambda rid, params: {"value": _load_busy_input_mode()}, + "approval_mode": _cfg_get_approval_mode, + "approvals.mode": _cfg_get_approval_mode, + "details_mode": lambda rid, params: { + "value": _display_mode(_load_cfg(), "details_mode", _DETAILS_MODES, "collapsed") + }, + "thinking_mode": _cfg_get_thinking_mode, + "density": lambda rid, params: { + "value": "on" if bool((_load_cfg().get("display") or {}).get("tui_compact", False)) else "off" + }, + "theme": _cfg_get_theme, + "statusbar": lambda rid, params: { + "value": _coerce_statusbar(_display_cfg().get("tui_statusbar", "top")) + }, + "focus": _cfg_get_focus, + "mouse": lambda rid, params: {"value": _display_mouse_tracking(_load_cfg().get("display"))}, + "mtime": _cfg_get_mtime, + } + + @method("config.get") @_profile_scoped def _(rid, params: dict) -> dict: key = params.get("key", "") - if key == "provider": - try: - from hermes_cli.models import list_available_providers, normalize_provider + getter = _config_getters().get(key) + if getter is None: + return _err(rid, 4002, f"unknown config key: {key}") + payload = getter(rid, params) + if "error" in payload: + return payload + return _ok(rid, payload) - model = _resolve_model() - parts = model.split("/", 1) - return _ok( - rid, - { - "model": model, - "provider": ( - normalize_provider(parts[0]) if len(parts) > 1 else "unknown" - ), - "providers": list_available_providers(), - }, - ) - except Exception as e: - return _err(rid, 5013, str(e)) - if key == "profile": - from hermes_constants import display_hermes_home - return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()}) - if key == "project": - cfg_terminal = _load_cfg().get("terminal") or {} - raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip() - cwd = _completion_cwd({"cwd": raw} if raw else {}) - return _ok(rid, {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)}) - if key == "full": - return _ok(rid, {"config": _load_cfg()}) - if key == "prompt": - return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) - if key == "skin": - return _ok( - rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")} - ) - if key == "indicator": - # Normalize so a hand-edited config.yaml with stray casing or - # an unknown value reads back the SAME value the TUI actually - # rendered (frontend's `normalizeIndicatorStyle` falls back to - # `DEFAULT_INDICATOR_STYLE` for the same inputs). Otherwise - # `/indicator` would print one thing while the UI shows another. - raw = (_load_cfg().get("display") or {}).get("tui_status_indicator", "") - norm = str(raw).strip().lower() - return _ok( - rid, - {"value": norm if norm in INDICATOR_STYLES else DEFAULT_INDICATOR_STYLE}, - ) - if key == "personality": - # Report the EFFECTIVE personality via the single owner — a stale or - # unknown name in config must not display as active. - from hermes_cli.personality import active_personality_name - - return _ok( - rid, - {"value": active_personality_name(_load_cfg()) or "none"}, - ) - if key == "reasoning": - cfg = _load_cfg() - session = _sessions.get(params.get("session_id", "")) - reasoning_config = None - if session is not None: - if isinstance(session.get("create_reasoning_override"), dict): - reasoning_config = session.get("create_reasoning_override") - else: - agent = session.get("agent") - agent_reasoning = getattr(agent, "reasoning_config", None) - if isinstance(agent_reasoning, dict): - reasoning_config = agent_reasoning - - if isinstance(reasoning_config, dict): - if reasoning_config.get("enabled") is False: - effort = "none" - else: - effort = str(reasoning_config.get("effort") or "medium") - else: - raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "") - if raw_effort is False: - # YAML `reasoning_effort: false`/`off`/`no` — thinking - # disabled, not "unset, show the medium default". - effort = "none" - else: - effort = str(raw_effort or "medium") - display = ( - "show" - if bool((cfg.get("display") or {}).get("show_reasoning", True)) - else "hide" - ) - return _ok(rid, {"value": effort, "display": display}) - if key == "fast": - # Prefer the session's live/pinned value — `config.set fast` is - # session-scoped, so the global key may not reflect this chat. A - # pre-build session keeps its pin in create_service_tier_override. - session = _sessions.get(params.get("session_id", "")) - tier = None - if session is not None: - agent = session.get("agent") - if agent is not None: - tier = getattr(agent, "service_tier", None) - elif session.get("create_service_tier_override") is not None: - tier = session["create_service_tier_override"] - if tier is None: - tier = _load_service_tier() - return _ok(rid, {"value": "fast" if tier == "priority" else "normal"}) - if key == "busy": - return _ok(rid, {"value": _load_busy_input_mode()}) - if key in {"approval_mode", "approvals.mode"}: - try: - return _ok(rid, {"value": _load_approval_mode()}) - except Exception as e: - return _err(rid, 5001, str(e)) - if key == "details_mode": - allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) - raw = ( - str( - (_load_cfg().get("display") or {}).get("details_mode", "collapsed") - or "collapsed" - ) - .strip() - .lower() - ) - nv = raw if raw in allowed_dm else "collapsed" - return _ok(rid, {"value": nv}) - if key == "thinking_mode": - allowed_tm = frozenset({"collapsed", "truncated", "full"}) - cfg = _load_cfg() - raw = ( - str((cfg.get("display") or {}).get("thinking_mode", "") or "") - .strip() - .lower() - ) - if raw in allowed_tm: - nv = raw - else: - dm = ( - str( - (cfg.get("display") or {}).get("details_mode", "collapsed") - or "collapsed" - ) - .strip() - .lower() - ) - nv = "full" if dm == "expanded" else "collapsed" - return _ok(rid, {"value": nv}) - if key == "density": - on = bool((_load_cfg().get("display") or {}).get("tui_compact", False)) - return _ok(rid, {"value": "on" if on else "off"}) - if key == "theme": - display = _load_cfg().get("display") - raw = ( - str( - display.get("tui_theme", "auto") - if isinstance(display, dict) - else "auto" - ) - .strip() - .lower() - ) - return _ok(rid, {"value": raw if raw in {"auto", "light", "dark"} else "auto"}) - if key == "statusbar": - display = _load_cfg().get("display") - raw = ( - display.get("tui_statusbar", "top") if isinstance(display, dict) else "top" - ) - return _ok(rid, {"value": _coerce_statusbar(raw)}) - if key == "focus": - display = _load_cfg().get("display") - on = ( - bool(display.get("focus_view", False)) - if isinstance(display, dict) - else False - ) - return _ok( - rid, - { - "value": "on" if on else "off", - "tool_progress": _load_tool_progress_mode(), - }, - ) - if key == "mouse": - display = _load_cfg().get("display") - return _ok(rid, {"value": _display_mouse_tracking(display)}) - if key == "mtime": - cfg_path = _hermes_home / "config.yaml" - try: - mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0 - except Exception: - return _ok(rid, {"mtime": 0}) - # Revision hash of the MCP-relevant config sections. The TUI's - # config-change poller uses it to reload MCP servers only when their - # config actually changed — a /skin or /statusbar write bumps mtime - # but must not cost a multi-second MCP reconnect. - return _ok(rid, {"mtime": mtime, "mcp_rev": _compute_mcp_rev()}) - return _err(rid, 4002, f"unknown config key: {key}") +# --------------------------------------------------------------------------- +# setup readiness +# --------------------------------------------------------------------------- def _readiness_profile_scope(params: dict): """Resolve the optional ``profile`` param of the setup readiness RPCs. - Returns ``(profile, scope)`` where ``scope`` is a context manager binding - that profile's HERMES_HOME and ``.env`` secret scope (ContextVars, so - concurrent checks for different profiles stay isolated). The launch - profile / no param yields ``("", nullcontext())``. A profile unknown to - this host raises ``FileNotFoundError`` — a readiness check must never - quietly answer for the launch profile instead (#94071). + Returns ``(profile, scope)``: ``scope`` binds that profile's HERMES_HOME and + ``.env`` secret scope (ContextVars, so concurrent checks stay isolated); the + launch profile / no param yields ``("", nullcontext())``. A profile unknown + to this host raises ``FileNotFoundError`` — a readiness check must never + quietly answer for the launch profile instead. """ import contextlib @@ -405,14 +381,28 @@ def _readiness_profile_scope(params: dict): if not profile: return "", contextlib.nullcontext() from hermes_cli import profiles as profiles_mod - from tui_gateway import server as _server if not profiles_mod.profile_exists(profile): raise FileNotFoundError(f"Profile '{profile}' does not exist on this backend.") - home = _server._profile_home(profile) + home = _profile_home(profile) if home is None: return profile, contextlib.nullcontext() - return profile, _server._session_profile_runtime_scope({"profile_home": str(home)}) + return profile, _session_profile_runtime_scope({"profile_home": str(home)}) + + +def _readiness_check(rid, params, probe): + """Shared shell of setup.status / setup.runtime_check. + + ``probe(profile)`` runs inside the profile scope and returns the payload; + an unknown profile answers ``ok=False`` (never a JSON-RPC error). + """ + try: + profile, scope = _readiness_profile_scope(params) + except FileNotFoundError as e: + return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)}) + with scope: + payload = probe(profile) + return _ok(rid, payload) @method("setup.status") @@ -420,108 +410,86 @@ def _(rid, params: dict) -> dict: """Loose provider check; ``profile`` (optional) scopes it to that profile's home.""" try: from hermes_cli.main import _has_any_provider_configured - from tui_gateway.methods_config import _readiness_profile_scope - try: - profile, scope = _readiness_profile_scope(params) - except FileNotFoundError as e: - return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)}) - with scope: + def probe(profile): configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile))) - payload = {"provider_configured": configured} - if profile: - payload["profile"] = profile - return _ok(rid, payload) + payload = {"provider_configured": configured} + if profile: + payload["profile"] = profile + return payload + + return _readiness_check(rid, params, probe) except Exception as e: return _err(rid, 5016, str(e)) @method("setup.runtime_check") def _(rid, params: dict) -> dict: - """Strict provider check: does the configured/default model actually resolve to a usable runtime? + """Strict provider check: does the configured/default model resolve to a usable runtime? - Unlike setup.status (which returns True if ANY provider auth state is - discoverable, including indirect fallbacks like ``gh auth token`` for - Copilot), this runs the same resolve_runtime_provider() call the agent - uses on session creation. It returns ok=False with the auth error message - when the user's configured model cannot actually be served, so UIs can - surface onboarding before the user submits a doomed prompt. - - ``profile`` (optional): answer for THAT profile's home on this host — its - config.yaml model pin and its ``.env`` — instead of the launch profile's - (#94071). A profile unknown to this backend answers ``ok=False`` rather - than reporting the launch profile's readiness. + Unlike setup.status (True if ANY provider auth state is discoverable, incl. + indirect fallbacks like ``gh auth token``), this runs the same + resolve_runtime_provider() the agent uses on session creation and returns + ok=False with the auth error when the model cannot actually be served, so + UIs can surface onboarding before a doomed prompt. ``profile`` (optional) + answers for THAT profile's config.yaml pin and ``.env``; an unknown profile + answers ``ok=False`` rather than the launch profile's readiness. """ try: from hermes_cli.runtime_provider import resolve_runtime_provider from hermes_cli.auth import has_usable_secret from hermes_cli.main import _has_any_provider_configured - from tui_gateway.methods_config import _readiness_profile_scope requested = str(params.get("provider") or "").strip() or None - try: - profile, scope = _readiness_profile_scope(params) - except FileNotFoundError as e: - return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)}) - with scope: + + def probe(profile): runtime = resolve_runtime_provider(requested=requested) - provider_configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile))) - scoped = {"profile": profile} if profile else {} - provider = runtime.get("provider") or "provider" - source = str(runtime.get("source") or "") - if ( - not provider_configured - and provider == "bedrock" - and source - in { - "iam-role", - "aws-sdk-default-chain", - } - ): - return _ok( - rid, - { + provider_configured = bool( + _has_any_provider_configured(strict_profile_scope=bool(profile)) + ) + scoped = {"profile": profile} if profile else {} + provider = runtime.get("provider") or "provider" + source = str(runtime.get("source") or "") + if ( + not provider_configured + and provider == "bedrock" + and source in {"iam-role", "aws-sdk-default-chain"} + ): + return { "ok": False, "provider": provider, "model": runtime.get("model"), "source": source, "error": "No Hermes provider is configured.", **scoped, - }, + } + + api_key = runtime.get("api_key") + api_key_text = "" if callable(api_key) else str(api_key or "").strip() + credential_ok = ( + callable(api_key) + or api_key_text in {"aws-sdk", "no-key-required"} + or has_usable_secret(api_key_text) + or bool(runtime.get("command")) ) - - api_key = runtime.get("api_key") - api_key_text = "" if callable(api_key) else str(api_key or "").strip() - credential_ok = ( - callable(api_key) - or api_key_text in {"aws-sdk", "no-key-required"} - or has_usable_secret(api_key_text) - or bool(runtime.get("command")) - ) - - if not credential_ok: - return _ok( - rid, - { + if not credential_ok: + return { "ok": False, "provider": provider, "model": runtime.get("model"), "source": runtime.get("source"), "error": f"No usable credentials found for {provider}.", **scoped, - }, - ) - - return _ok( - rid, - { + } + return { "ok": True, "provider": runtime.get("provider"), "model": runtime.get("model"), "source": runtime.get("source"), **scoped, - }, - ) + } + + return _readiness_check(rid, params, probe) except Exception as e: return _ok(rid, {"ok": False, "error": str(e)}) @@ -530,32 +498,19 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Upload a redacted debug bundle to Nous-internal diagnostics storage. - Desktop's "Send Diagnostics" action (error card / diagnostics UI). Same - collection + force-redaction pipeline as ``hermes debug share --nous`` - (collect_share_bundle → build_nous_bundle → share_to_nous); redaction is - NOT client-controllable — this handler always redacts. + Same collection + force-redaction pipeline as ``hermes debug share --nous``; + redaction is NOT client-controllable. Consent lives with the CALLER (the + desktop shows the privacy notice + Upload button first). Structured + ``ok``/``error`` envelope rather than JSON-RPC errors so the client can + render upload failures inline. - Params (all optional): - - ``error_context``: short client-supplied text describing the failure - that prompted the report (the error card's layer/code/message blob). - Redacted server-side and attached as ``error-context.txt``. - - ``extra_files``: {label → text} of client-side artifacts the backend - can't see (e.g. the local desktop.log when this backend is remote). - Each value is force-redacted server-side before inclusion; labels are - sanitized and size-capped. - - ``log_lines``: report excerpt length (default 200). - - Consent lives with the CALLER: the desktop shows the privacy notice and - an explicit Upload button before invoking this. Structured envelope - (``ok``/``error``) rather than JSON-RPC errors so the client can render - upload failures inline. + Params (optional): ``error_context`` (client text about the failure, + redacted, attached as ``error-context.txt``), ``extra_files`` ({label → + text} client-side artifacts such as a remote desktop.log; force-redacted, + labels sanitized and size-capped), ``log_lines`` (default 200). """ try: - from hermes_cli.debug import ( - _redact_log_text, - build_nous_bundle, - collect_share_bundle, - ) + from hermes_cli.debug import _redact_log_text, build_nous_bundle, collect_share_bundle from hermes_cli.diagnostics_upload import share_to_nous log_lines = params.get("log_lines") @@ -564,29 +519,23 @@ def _(rid, params: dict) -> dict: bundle = collect_share_bundle(log_lines=log_lines, redact=True) - # Client-supplied text goes through the SAME upload-safe log redactor - # as backend-collected logs (_redact_log_text = force secret redaction - # + email masking) — never the weaker bare secret pass, so the remote - # path can't upload what the CLI pipeline would have removed. + # Client text goes through the SAME upload-safe redactor as backend + # logs (force secret redaction + email masking), never the weaker bare + # secret pass. error_context = params.get("error_context") if isinstance(error_context, str) and error_context.strip(): - bundle["error-context.txt"] = _redact_log_text( - error_context.strip()[:8_000] - ) + bundle["error-context.txt"] = _redact_log_text(error_context.strip()[:8_000]) - # Client-side artifacts (local desktop.log on remote connections). - # Bounded: at most 4 files, 512KB of text each, sanitized labels — - # this is a diagnostics channel, not an arbitrary upload surface. + # Bounded: at most 4 files, 512KB each, sanitized labels — a + # diagnostics channel, not an arbitrary upload surface. extra_files = params.get("extra_files") if isinstance(extra_files, dict): for label, text in list(extra_files.items())[:4]: if not isinstance(label, str) or not isinstance(text, str): continue - safe_label = "".join( - ch for ch in label if ch.isalnum() or ch in "._- ()" - ).strip()[:64] - # Collapse dot-runs and leading dots so traversal-shaped labels - # ("../../etc/passwd") can't survive even cosmetically. + safe_label = "".join(ch for ch in label if ch.isalnum() or ch in "._- ()").strip()[:64] + # Collapse dot-runs / leading dots so traversal-shaped labels + # can't survive even cosmetically. while ".." in safe_label: safe_label = safe_label.replace("..", ".") safe_label = safe_label.lstrip(".").strip() @@ -594,16 +543,13 @@ def _(rid, params: dict) -> dict: continue bundle[f"client/{safe_label}"] = _redact_log_text(text[:524_288]) - blob = build_nous_bundle(bundle, redact=True) - res = share_to_nous(blob) + res = share_to_nous(build_nous_bundle(bundle, redact=True)) view_url = res.get("viewUrl") or res.get("view_url") upload_id = res.get("id") if not view_url and not upload_id: - # An upload the user can't reference is useless to support — - # surface it as a failure instead of a linkless success. + # An upload the user can't reference is useless to support. return _ok( - rid, - {"ok": False, "error": "upload succeeded but returned no view URL or id"}, + rid, {"ok": False, "error": "upload succeeded but returned no view URL or id"} ) return _ok( rid, @@ -619,5 +565,5 @@ def _(rid, params: dict) -> dict: def register(server) -> None: - """Bind this module's handlers onto ``server``'s globals and registry.""" - _registry.install(server) + """Publish helpers + handlers onto ``server``, rebound to its globals.""" + bind_module(globals(), server, skip=("_",)) diff --git a/tui_gateway/methods_groups.py b/tui_gateway/methods_groups.py index 2130bee00c..e83f26a761 100644 --- a/tui_gateway/methods_groups.py +++ b/tui_gateway/methods_groups.py @@ -3,6 +3,12 @@ These methods expose durable room identity, replay, and the process-owned same-gateway Discussion driver. ``groups.capabilities`` keeps that boundary machine-readable so older clients stay on the renderer-owned room path. + +Handlers are rebound onto server.py's globals at install (see method_ctx.py), +so bodies see only server globals plus the names methods_bot_relay.register +publishes; module-private helpers reach them through keyword defaults. +``_room_method`` wraps each handler with the shared service-lookup / +error-code envelope so the bodies hold only the room logic. """ from .method_ctx import HandlerRegistry @@ -13,9 +19,10 @@ import threading _registry = HandlerRegistry() method = _registry.method -LONG_HANDLERS = frozenset({ - "groups.list", +#: Wire order of ``groups.capabilities.methods``; every one runs on the RPC pool. +_METHODS = ( "groups.capabilities", + "groups.list", "groups.create", "groups.state", "groups.send", @@ -32,17 +39,22 @@ LONG_HANDLERS = frozenset({ "groups.peer.invite", "groups.peer.revoke", "groups.peer.register", -}) +) +LONG_HANDLERS = frozenset(_METHODS) _service_lock = threading.Lock() _run_store_lock = threading.Lock() _bound_server = None _service = None +_WORKER_UNAVAILABLE = ( + "Group Chat worker is unavailable. Restart the Hermes gateway and try again." +) +_DRIVER_UNAVAILABLE = "hosted room driver is unavailable" + def bind_server(server) -> None: """Bind the fully initialized server module without starting a worker.""" - global _bound_server _bound_server = server server._profile_execution_policy = _profile_execution_policy @@ -50,7 +62,6 @@ def bind_server(server) -> None: def start_hosted_room_service(): """Start one process-owned hosted room service idempotently.""" - global _service if _bound_server is None: return None @@ -70,7 +81,6 @@ def start_hosted_room_service(): def stop_hosted_room_service(*, timeout: float = 5.0) -> bool: """Stop the process-owned worker without interrupting accepted turns.""" - global _service with _service_lock: service = _service @@ -84,7 +94,6 @@ def stop_hosted_room_service(*, timeout: float = 5.0) -> bool: def get_hosted_room_service(): """Return the active service, if its lifecycle owner started it.""" - service = _service if service is None: return None @@ -95,44 +104,37 @@ def get_hosted_room_service(): return service if status.get("running") and not status.get("stopping") else None -_WORKER_UNAVAILABLE = ( - "Group Chat worker is unavailable. Restart the Hermes gateway and try again." -) - - def _profile_name() -> str: return (os.getenv("HERMES_PROFILE") or "default").strip() or "default" +def _current_profile() -> str: + return str(_bound_server._current_profile_name() or "").strip() + + def _requested_profile(params: dict) -> str: requested = str(params.get("profile") or "").strip() if not requested: return _profile_name() if _bound_server is None: raise ValueError("profile routing is unavailable") - current = str(_bound_server._current_profile_name() or "").strip() - if requested == current: - return current - home = _bound_server._profile_home(requested) - if home is None: + if requested == _current_profile(): + return requested + if _bound_server._profile_home(requested) is None: raise ValueError(f"profile '{requested}' is unavailable") return str(_bound_server._response_profile_name(requested) or requested) def _api_server_key(profile: str | None = None) -> str: - if profile and _bound_server is not None: - current = str(_bound_server._current_profile_name() or "").strip() - if profile != current: - from agent.secret_scope import build_profile_secret_scope + if profile and _bound_server is not None and profile != _current_profile(): + from agent.secret_scope import build_profile_secret_scope - home = _bound_server._profile_home(profile) - if home is None: - return "" - # An explicit routed profile is authoritative. Never borrow the - # process/default profile's API key on a multiplexed gateway. - return str( - build_profile_secret_scope(home).get("API_SERVER_KEY") or "" - ).strip() + home = _bound_server._profile_home(profile) + if home is None: + return "" + # An explicit routed profile is authoritative. Never borrow the + # process/default profile's API key on a multiplexed gateway. + return str(build_profile_secret_scope(home).get("API_SERVER_KEY") or "").strip() try: from agent.secret_scope import get_secret @@ -146,21 +148,15 @@ def _api_server_key(profile: str | None = None) -> str: def _profile_execution_policy(profile: str) -> dict: """Resolve execution policy under the exact multiplexed profile home.""" - from gateway.hosted_room_execution_policy import execution_policy_mapping - from hermes_constants import ( - reset_hermes_home_override, - set_hermes_home_override, - ) + from hermes_constants import reset_hermes_home_override, set_hermes_home_override token = None - if _bound_server is not None: - current = str(_bound_server._current_profile_name() or "").strip() - if profile not in {current, _profile_name()}: - home = _bound_server._profile_home(profile) - if home is None: - raise ValueError(f"profile '{profile}' is unavailable") - token = set_hermes_home_override(str(home)) + if _bound_server is not None and profile not in {_current_profile(), _profile_name()}: + home = _bound_server._profile_home(profile) + if home is None: + raise ValueError(f"profile '{profile}' is unavailable") + token = set_hermes_home_override(str(home)) try: return execution_policy_mapping(target_profile=profile) finally: @@ -170,19 +166,16 @@ def _profile_execution_policy(profile: str) -> dict: def _room_link_run_storage_durable() -> bool: """Return whether peer-run replay survives this gateway process.""" - if _bound_server is None: # Direct method-contract tests and embedded callers without a bound API - # server do not expose peer-run transport. The production server always - # binds before advertising capabilities. + # server do not expose peer-run transport; production always binds first. return True store = getattr(_bound_server, "_run_idempotency_store", None) if store is None: - # The dashboard/TUI process owns groups.* but does not construct the - # API adapter that normally owns this store. Open the same shared - # SQLite-backed store lazily so capability negotiation reflects the - # real /v1/runs replay boundary instead of depending on test-only - # injection. A separately enabled API adapter uses the same file. + # The dashboard/TUI process owns groups.* but does not construct the API + # adapter that owns this store. Open the same shared SQLite-backed store + # lazily so capability negotiation reflects the real /v1/runs replay + # boundary; a separately enabled API adapter uses the same file. from gateway.platforms.api_server import RunIdempotencyStore with _run_store_lock: @@ -193,37 +186,85 @@ def _room_link_run_storage_durable() -> bool: return bool(getattr(store, "durable", False)) -@method("groups.capabilities") -def _(rid, params: dict) -> dict: - """Describe the hosted-room protocol implemented by this gateway.""" - from gateway.hosted_rooms import ( - MAX_LOG_LIMIT, - PROTOCOL_VERSION, - local_authority_gateway_id, +def _local_catalog(installation_id: str, profile: str, execution_policy: dict) -> dict: + """Advertise this gateway's direct-only, text-only RoomLink catalog.""" + from gateway.hosted_room_peer import PROTOCOL_VERSION, local_catalog_mapping + + return local_catalog_mapping( + installation_id=installation_id, + protocol_versions=(PROTOCOL_VERSION,), + link_modes=("direct",), + text=True, + attachments=False, + target_profile=profile, + execution_policy=execution_policy, ) + +def _room_method( + name: str, + *, + code: int, + room_code: int | None = None, + replica_only: bool = False, + with_reason: bool = True, + service_code: int | None = None, + service_message: str = _DRIVER_UNAVAILABLE, +): + """Register ``fn`` under ``name`` with the shared hosted-room error envelope. + + ``service_code`` set: the live service is required and passed as a third + argument; when absent the handler fails with that code. ``room_code`` + maps ``HostedRoomError`` (or only ``ReplicaError`` when ``replica_only``) + to a 4xxx client error, attaching ``{"reason"}`` data when ``with_reason``; + any other exception maps to ``code``. + """ + + def dec(fn): + def handler(rid, params: dict) -> dict: + args = (rid, params) + if service_code is not None: + service = get_hosted_room_service() + if service is None: + return _err(rid, service_code, service_message) + args += (service,) + try: + return fn(*args) + except Exception as exc: + if room_code is not None: + from gateway.hosted_rooms import HostedRoomError + + klass = HostedRoomError + if replica_only: + from gateway.hosted_room_replicas import ReplicaError + + klass = ReplicaError + if isinstance(exc, klass): + reason = getattr(exc, "reason", None) if with_reason else None + return _err(rid, room_code, str(exc), {"reason": reason} if reason else None) + return _err(rid, code, str(exc)) + + handler.__doc__ = fn.__doc__ + return method(name)(handler) + + return dec + + +@method("groups.capabilities") +def _(rid, params: dict, _catalog=_local_catalog, _methods=_METHODS) -> dict: + """Describe the hosted-room protocol implemented by this gateway.""" + from gateway.hosted_rooms import MAX_LOG_LIMIT, PROTOCOL_VERSION, local_authority_gateway_id + service = get_hosted_room_service() driver_ready = bool(service and service.runtime.status()["running"]) try: - from gateway.hosted_room_peer import ( - PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION, - gateway_room_grant_secret, - local_catalog_mapping, - ) + from gateway.hosted_room_peer import gateway_room_grant_secret profile = _requested_profile(params) if not _room_link_run_storage_durable(): raise ValueError("durable run idempotency storage is required") gateway_room_grant_secret() - catalog = local_catalog_mapping( - installation_id=local_authority_gateway_id(), - protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,), - link_modes=("direct",), - text=True, - attachments=False, - target_profile=profile, - execution_policy=_profile_execution_policy(profile), - ) + catalog = _catalog(local_authority_gateway_id(), profile, _profile_execution_policy(profile)) room_link = { "enabled": True, "profile": profile, @@ -261,516 +302,367 @@ def _(rid, params: dict) -> dict: "log_replication", "authority_takeover", ], - "methods": [ - "groups.capabilities", - "groups.list", - "groups.create", - "groups.state", - "groups.send", - "groups.rename", - "groups.log", - "groups.disband", - "groups.replicate", - "groups.replica_state", - "groups.promote", - "groups.demote", - "groups.stop", - "groups.retry", - "groups.approve", - "groups.peer.invite", - "groups.peer.revoke", - "groups.peer.register", - ], + "methods": list(_methods), "max_log_limit": MAX_LOG_LIMIT, }, ) -@method("groups.peer.invite") -def _(rid, params: dict) -> dict: +@_room_method("groups.peer.invite", code=4120) +def _(rid, params: dict, _catalog=_local_catalog) -> dict: """Mint one target-issued room/profile grant for a prospective home.""" - try: - from gateway.hosted_room_peer import ( - PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION, - decode_room_grant, - gateway_room_grant_secret, - issue_room_grant, - local_catalog_mapping, - ) - from gateway import hosted_rooms + from gateway.hosted_room_peer import ( + decode_room_grant, + gateway_room_grant_secret, + issue_room_grant, + ) + from gateway import hosted_rooms - if not _room_link_run_storage_durable(): - raise ValueError("durable run idempotency storage is required") - installation_id = hosted_rooms.local_authority_gateway_id() - profile = _requested_profile(params) - ttl = float(params.get("ttl_seconds", 3600)) - if not 60 <= ttl <= 24 * 60 * 60: - raise ValueError("ttl_seconds must be between 60 and 86400") - grant_secret = gateway_room_grant_secret() - execution_policy = _profile_execution_policy(profile) - token = issue_room_grant( - grant_secret, - grant_id=str(params.get("grant_id") or f"grant-{os.urandom(16).hex()}"), - room_id=str(params.get("room_id") or ""), - home_install_id=str(params.get("home_install_id") or ""), - authority_gateway_id=str( - params.get("authority_gateway_id") or "" - ), - authority_epoch=int(params.get("authority_epoch") or 0), - member_id=str(params.get("member_id") or ""), - target_install_id=installation_id, - target_profile=profile, - execution_policy_digest=execution_policy["policy_digest"], - ttl_seconds=ttl, - ) - claims = decode_room_grant(grant_secret, token, permission="status") - hosted_rooms.reserve_peer_room( - hosted_rooms.default_db_path(), - claims=claims, - expires_at=float(claims.get("status_expires_at", claims["expires_at"])), - ) - catalog = local_catalog_mapping( - installation_id=installation_id, - protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,), - link_modes=("direct",), - text=True, - attachments=False, - target_profile=profile, - execution_policy=execution_policy, - ) - return _ok( - rid, - { - "grant": token, - "target_profile": profile, - "catalog": catalog, - "endpoint": catalog["endpoint"], - }, - ) - except Exception as exc: - return _err(rid, 4120, str(exc)) + if not _room_link_run_storage_durable(): + raise ValueError("durable run idempotency storage is required") + installation_id = hosted_rooms.local_authority_gateway_id() + profile = _requested_profile(params) + ttl = float(params.get("ttl_seconds", 3600)) + if not 60 <= ttl <= 24 * 60 * 60: + raise ValueError("ttl_seconds must be between 60 and 86400") + grant_secret = gateway_room_grant_secret() + execution_policy = _profile_execution_policy(profile) + token = issue_room_grant( + grant_secret, + grant_id=str(params.get("grant_id") or f"grant-{os.urandom(16).hex()}"), + room_id=str(params.get("room_id") or ""), + home_install_id=str(params.get("home_install_id") or ""), + authority_gateway_id=str(params.get("authority_gateway_id") or ""), + authority_epoch=int(params.get("authority_epoch") or 0), + member_id=str(params.get("member_id") or ""), + target_install_id=installation_id, + target_profile=profile, + execution_policy_digest=execution_policy["policy_digest"], + ttl_seconds=ttl, + ) + claims = decode_room_grant(grant_secret, token, permission="status") + hosted_rooms.reserve_peer_room( + hosted_rooms.default_db_path(), + claims=claims, + expires_at=float(claims.get("status_expires_at", claims["expires_at"])), + ) + catalog = _catalog(installation_id, profile, execution_policy) + return _ok( + rid, + { + "grant": token, + "target_profile": profile, + "catalog": catalog, + "endpoint": catalog["endpoint"], + }, + ) -@method("groups.peer.revoke") +@_room_method("groups.peer.revoke", code=4122) def _(rid, params: dict) -> dict: """Revoke one target-issued grant using its exact profile scope.""" - try: - from gateway import hosted_rooms - from gateway.hosted_room_peer import decode_room_grant, gateway_room_grant_secret + from gateway import hosted_rooms + from gateway.hosted_room_peer import decode_room_grant, gateway_room_grant_secret - profile = _requested_profile(params) - claims = decode_room_grant( - gateway_room_grant_secret(), - str(params.get("grant") or ""), - permission="status", - ) - if ( - claims["target_profile"] != profile - or claims["target_install_id"] - != hosted_rooms.local_authority_gateway_id() - ): - raise ValueError("room grant target does not match this profile") - hosted_rooms.revoke_room_grant_scope( - hosted_rooms.default_db_path(), - claims=claims, - expires_at=float( - claims.get("status_expires_at", claims["expires_at"]) - ), - ) - return _ok(rid, {"revoked": True}) - except Exception as exc: - return _err(rid, 4122, str(exc)) + profile = _requested_profile(params) + claims = decode_room_grant( + gateway_room_grant_secret(), str(params.get("grant") or ""), permission="status" + ) + if ( + claims["target_profile"] != profile + or claims["target_install_id"] != hosted_rooms.local_authority_gateway_id() + ): + raise ValueError("room grant target does not match this profile") + hosted_rooms.revoke_room_grant_scope( + hosted_rooms.default_db_path(), + claims=claims, + expires_at=float(claims.get("status_expires_at", claims["expires_at"])), + ) + return _ok(rid, {"revoked": True}) -@method("groups.peer.register") -def _(rid, params: dict) -> dict: +@_room_method("groups.peer.register", code=5120, service_code=4121) +def _(rid, params: dict, service) -> dict: """Register and probe one scoped target route on the room home.""" - service = get_hosted_room_service() - if service is None: - return _err(rid, 4121, "hosted room driver is unavailable") - try: - from gateway.hosted_room_peer import ( - GatewayRoomCatalog, - PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION, - validate_room_link_url, - ) - from gateway.hosted_rooms import local_authority_gateway_id, room_state - from tui_gateway.hosted_room_peer_http import PeerRunsHTTPClient - from tui_gateway.hosted_room_peer_transport import PeerMemberRoute + from gateway.hosted_room_peer import ( + GatewayRoomCatalog, + PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION, + validate_room_link_url, + ) + from gateway.hosted_rooms import local_authority_gateway_id, room_state + from tui_gateway.hosted_room_peer_http import PeerRunsHTTPClient + from tui_gateway.hosted_room_peer_transport import PeerMemberRoute - target_url, transport_security = validate_room_link_url( - params.get("target_url") + target_url, transport_security = validate_room_link_url(params.get("target_url")) + catalog = GatewayRoomCatalog.from_mapping(params.get("catalog")) + if ROOM_LINK_PROTOCOL_VERSION not in catalog.protocol_versions: + raise ValueError( + f"target does not support RoomLink protocol v{ROOM_LINK_PROTOCOL_VERSION}" ) - catalog = GatewayRoomCatalog.from_mapping(params.get("catalog")) - if ROOM_LINK_PROTOCOL_VERSION not in catalog.protocol_versions: - raise ValueError( - f"target does not support RoomLink protocol v{ROOM_LINK_PROTOCOL_VERSION}" - ) - if "direct" not in catalog.link_modes: - raise ValueError("target does not support a direct RoomLink") - target_profile = str(params.get("target_profile") or "") - grant = str(params.get("grant") or "") - client = PeerRunsHTTPClient( - base_url=target_url, - api_key="", - receipt_db_path=service.db_path, - ) - probe = client.probe(grant=grant) - live_catalog = GatewayRoomCatalog.from_mapping(probe.get("catalog")) - if live_catalog != catalog: - raise ValueError("target capability catalog changed during setup") - if ( - ROOM_LINK_PROTOCOL_VERSION not in live_catalog.protocol_versions - or "direct" not in live_catalog.link_modes - ): - raise ValueError("target RoomLink capability is incompatible") - room_id = str(params.get("room_id") or "") - member_id = str(params.get("member_id") or "") - home_install_id = local_authority_gateway_id() - home_room = room_state(service.db_path, room_id=room_id) - if ( - probe.get("room_id") != room_id - or probe.get("home_install_id") != home_install_id - or probe.get("authority_gateway_id") - != home_room.get("authority_gateway_id") - or int(probe.get("authority_epoch") or 0) - != int(home_room.get("authority_epoch") or 0) - or probe.get("member_id") != member_id - or probe.get("target_profile") != target_profile - ): - raise ValueError("room grant scope does not match this route") - route = PeerMemberRoute( - home_install_id=home_install_id, - member_id=member_id, - target_install_id=catalog.installation_id, - target_profile=target_profile, - capability_digest=catalog.catalog_digest, - execution_policy_digest=catalog.execution_policy.policy_digest, - cancellation_scope_id=str( - params.get("cancellation_scope_id") - or f"cancel-{params.get('room_id') or ''}" - ), - trace_id=str(params.get("trace_id") or f"trace-{os.urandom(16).hex()}"), - grant=grant, - ) - service.register_peer_route( - room_id=room_id, - member_id=member_id, - route=route, - client=client, - target_url=target_url, - catalog=catalog, - ) - return _ok( - rid, - { - "registered": True, - "mode": "direct", - "transport_security": transport_security, - "target_install_id": catalog.installation_id, - "target_profile": target_profile, - }, - ) - except Exception as exc: - return _err(rid, 5120, str(exc)) + if "direct" not in catalog.link_modes: + raise ValueError("target does not support a direct RoomLink") + target_profile = str(params.get("target_profile") or "") + grant = str(params.get("grant") or "") + client = PeerRunsHTTPClient(base_url=target_url, api_key="", receipt_db_path=service.db_path) + probe = client.probe(grant=grant) + live_catalog = GatewayRoomCatalog.from_mapping(probe.get("catalog")) + if live_catalog != catalog: + raise ValueError("target capability catalog changed during setup") + if ( + ROOM_LINK_PROTOCOL_VERSION not in live_catalog.protocol_versions + or "direct" not in live_catalog.link_modes + ): + raise ValueError("target RoomLink capability is incompatible") + room_id = str(params.get("room_id") or "") + member_id = str(params.get("member_id") or "") + home_install_id = local_authority_gateway_id() + home_room = room_state(service.db_path, room_id=room_id) + if ( + probe.get("room_id") != room_id + or probe.get("home_install_id") != home_install_id + or probe.get("authority_gateway_id") != home_room.get("authority_gateway_id") + or int(probe.get("authority_epoch") or 0) != int(home_room.get("authority_epoch") or 0) + or probe.get("member_id") != member_id + or probe.get("target_profile") != target_profile + ): + raise ValueError("room grant scope does not match this route") + route = PeerMemberRoute( + home_install_id=home_install_id, + member_id=member_id, + target_install_id=catalog.installation_id, + target_profile=target_profile, + capability_digest=catalog.catalog_digest, + execution_policy_digest=catalog.execution_policy.policy_digest, + cancellation_scope_id=str( + params.get("cancellation_scope_id") or f"cancel-{params.get('room_id') or ''}" + ), + trace_id=str(params.get("trace_id") or f"trace-{os.urandom(16).hex()}"), + grant=grant, + ) + service.register_peer_route( + room_id=room_id, + member_id=member_id, + route=route, + client=client, + target_url=target_url, + catalog=catalog, + ) + return _ok( + rid, + { + "registered": True, + "mode": "direct", + "transport_security": transport_security, + "target_install_id": catalog.installation_id, + "target_profile": target_profile, + }, + ) -@method("groups.list") +@_room_method("groups.list", code=5110) def _(rid, params: dict) -> dict: """List rooms hosted by this gateway.""" - try: - from gateway.hosted_rooms import ( - MAX_ROOM_LIST_LIMIT, - default_db_path, - list_rooms, - ) + from gateway.hosted_rooms import MAX_ROOM_LIST_LIMIT, default_db_path, list_rooms - limit = params.get("limit", MAX_ROOM_LIST_LIMIT) - offset = params.get("offset", 0) - rooms = list_rooms( - default_db_path(), - include_disbanded=params.get("include_disbanded") is True, - limit=limit, - offset=offset, - ) - - return _ok( - rid, - { - "rooms": rooms, - "next_offset": offset + limit if len(rooms) == limit else None, - }, - ) - except Exception as exc: - return _err(rid, 5110, str(exc)) + limit = params.get("limit", MAX_ROOM_LIST_LIMIT) + offset = params.get("offset", 0) + rooms = list_rooms( + default_db_path(), + include_disbanded=params.get("include_disbanded") is True, + limit=limit, + offset=offset, + ) + return _ok( + rid, + {"rooms": rooms, "next_offset": offset + limit if len(rooms) == limit else None}, + ) -@method("groups.create") -def _(rid, params: dict) -> dict: +@_room_method( + "groups.create", code=5111, room_code=4110, service_code=4123, service_message=_WORKER_UNAVAILABLE +) +def _(rid, params: dict, service) -> dict: """Create a hosted room idempotently. Required params: ``room_id``, ``name``, and ``members``. Authority is derived from this gateway's stable install identity, never from the client. """ - from gateway.hosted_rooms import HostedRoomError - - try: - service = get_hosted_room_service() - if service is None: - return _err(rid, 4123, _WORKER_UNAVAILABLE) - room = service.create_room( - room_id=params.get("room_id"), - name=params.get("name"), - members=params.get("members"), - ) - return _ok(rid, {"room": room}) - except HostedRoomError as exc: - reason = getattr(exc, "reason", None) - return _err(rid, 4110, str(exc), {"reason": reason} if reason else None) - except Exception as exc: - return _err(rid, 5111, str(exc)) + room = service.create_room( + room_id=params.get("room_id"), + name=params.get("name"), + members=params.get("members"), + ) + return _ok(rid, {"room": room}) -@method("groups.state") +@_room_method("groups.state", code=5115, room_code=4114) def _(rid, params: dict) -> dict: """Return one hosted room's replay cursor and fenced authority state.""" - from gateway.hosted_rooms import HostedRoomError, default_db_path, room_state + from gateway.hosted_rooms import default_db_path, room_state - try: - room = room_state( - default_db_path(), - room_id=params.get("room_id"), - include_disbanded=params.get("include_disbanded") is True, - ) - service = get_hosted_room_service() - return _ok( - rid, - { - "room": room, - **( - {"driver_status": service.status(str(room["room_id"]))} - if service is not None and room.get("disbanded_at") is None - else {} - ), - }, - ) - except HostedRoomError as exc: - reason = getattr(exc, "reason", None) - return _err(rid, 4114, str(exc), {"reason": reason} if reason else None) - except Exception as exc: - return _err(rid, 5115, str(exc)) + room = room_state( + default_db_path(), + room_id=params.get("room_id"), + include_disbanded=params.get("include_disbanded") is True, + ) + service = get_hosted_room_service() + result = {"room": room} + if service is not None and room.get("disbanded_at") is None: + result["driver_status"] = service.status(str(room["room_id"])) + return _ok(rid, result) -@method("groups.send") -def _(rid, params: dict) -> dict: +@_room_method( + "groups.send", code=5112, room_code=4111, service_code=4123, service_message=_WORKER_UNAVAILABLE +) +def _(rid, params: dict, service) -> dict: """Append one typed event to a hosted room idempotently. Required params: ``room_id``, ``event_id``, and object ``payload``. Only inert ``message.user`` events are accepted through this client-facing - method. The actor is server-owned rather than trusted from params. - Admission is durable; no Bot turn is started by this slice. + method; the actor is server-owned rather than trusted from params. """ - from gateway.hosted_rooms import HostedRoomError, user_event_id + from gateway.hosted_rooms import user_event_id - try: - client_event_id = params.get("event_id") - service = get_hosted_room_service() - if service is None: - return _err(rid, 4123, _WORKER_UNAVAILABLE) - event = service.send( - room_id=params.get("room_id"), - event_id=user_event_id(client_event_id), - payload=params.get("payload"), - ) - return _ok( - rid, - { - "event": event, - "client_event_id": client_event_id, - "accepted": True, - "driver_started": True, - }, - ) - except HostedRoomError as exc: - reason = getattr(exc, "reason", None) - return _err(rid, 4111, str(exc), {"reason": reason} if reason else None) - except Exception as exc: - return _err(rid, 5112, str(exc)) + client_event_id = params.get("event_id") + event = service.send( + room_id=params.get("room_id"), + event_id=user_event_id(client_event_id), + payload=params.get("payload"), + ) + return _ok( + rid, + { + "event": event, + "client_event_id": client_event_id, + "accepted": True, + "driver_started": True, + }, + ) -@method("groups.rename") +@_room_method("groups.rename", code=5117, room_code=4117) def _(rid, params: dict) -> dict: """Rename one hosted room atomically with its replay event.""" - from gateway.hosted_rooms import HostedRoomError, default_db_path, rename_room + from gateway.hosted_rooms import default_db_path, rename_room - try: - renamed = rename_room( - default_db_path(), - room_id=params.get("room_id"), - event_id=params.get("event_id"), - name=params.get("name"), - ) - return _ok(rid, {"room": renamed}) - except HostedRoomError as exc: - reason = getattr(exc, "reason", None) - return _err(rid, 4117, str(exc), {"reason": reason} if reason else None) - except Exception as exc: - return _err(rid, 5117, str(exc)) + renamed = rename_room( + default_db_path(), + room_id=params.get("room_id"), + event_id=params.get("event_id"), + name=params.get("name"), + ) + return _ok(rid, {"room": renamed}) -@method("groups.disband") -def _(rid, params: dict) -> dict: +@_room_method( + "groups.disband", code=5114, room_code=4113, service_code=4123, service_message=_WORKER_UNAVAILABLE +) +def _(rid, params: dict, service) -> dict: """Permanently tombstone a hosted room id.""" from gateway.hosted_rooms import ( AuthorityConflictError, - HostedRoomError, RoomHistoryExpiredError, disband_room, local_authority_gateway_id, room_state, ) - try: - service = get_hosted_room_service() - if service is None: - return _err(rid, 4123, _WORKER_UNAVAILABLE) + room_id = str(params.get("room_id") or "") - def disband_with_state(state: dict | None = None) -> dict: - local_gateway_id = local_authority_gateway_id() - if state is not None and ( - str(state["authority_gateway_id"]) != local_gateway_id - ): - raise AuthorityConflictError( - "This Group Chat is managed by another gateway." + def disband_with_state(state: dict | None = None) -> dict: + local_gateway_id = local_authority_gateway_id() + if state is not None and str(state["authority_gateway_id"]) != local_gateway_id: + raise AuthorityConflictError("This Group Chat is managed by another gateway.") + return _ok( + rid, + { + "tombstone": disband_room( + service.db_path, + room_id=params.get("room_id"), + expected_gateway_id=str(local_gateway_id), + expected_epoch=int(state["authority_epoch"] if state is not None else 1), ) - return disband_room( - service.db_path, - room_id=params.get("room_id"), - expected_gateway_id=str( - local_gateway_id - ), - expected_epoch=int( - state["authority_epoch"] if state is not None else 1 - ), - ) - - try: - existing = room_state( - service.db_path, - room_id=params.get("room_id"), - include_disbanded=True, - ) - except RoomHistoryExpiredError: - tombstone = disband_with_state() - return _ok(rid, {"tombstone": tombstone}) - if existing.get("disbanded_at") is not None: - tombstone = disband_with_state(existing) - return _ok(rid, {"tombstone": tombstone}) - service.stop_room( - str(params.get("room_id") or ""), - cancel_id=str(params.get("cancel_id") or "room-disbanded"), - require_acknowledged=True, + }, ) - service.revoke_room_routes(str(params.get("room_id") or "")) - tombstone = disband_with_state(existing) - return _ok(rid, {"tombstone": tombstone}) - except HostedRoomError as exc: - reason = getattr(exc, "reason", None) - return _err(rid, 4113, str(exc), {"reason": reason} if reason else None) - except Exception as exc: - return _err(rid, 5114, str(exc)) + + try: + existing = room_state(service.db_path, room_id=params.get("room_id"), include_disbanded=True) + except RoomHistoryExpiredError: + return disband_with_state() + if existing.get("disbanded_at") is not None: + return disband_with_state(existing) + service.stop_room( + room_id, + cancel_id=str(params.get("cancel_id") or "room-disbanded"), + require_acknowledged=True, + ) + service.revoke_room_routes(room_id) + return disband_with_state(existing) -@method("groups.stop") -def _(rid, params: dict) -> dict: +@_room_method("groups.stop", code=5116, service_code=4115) +def _(rid, params: dict, service) -> dict: """Durably cancel queued or running work for one hosted room.""" - - service = get_hosted_room_service() - if service is None: - return _err(rid, 4115, "hosted room driver is unavailable") - try: - count = service.stop_room( - str(params.get("room_id") or ""), - cancel_id=str(params.get("cancel_id") or "desktop-stop"), - ) - return _ok(rid, {"cancelled": count}) - except Exception as exc: - return _err(rid, 5116, str(exc)) + count = service.stop_room( + str(params.get("room_id") or ""), + cancel_id=str(params.get("cancel_id") or "desktop-stop"), + ) + return _ok(rid, {"cancelled": count}) -@method("groups.approve") -def _(rid, params: dict) -> dict: +@_room_method("groups.approve", code=5119, service_code=4115) +def _(rid, params: dict, service) -> dict: """Resolve one exact approval requested by a local or peer room member.""" - - service = get_hosted_room_service() - if service is None: - return _err(rid, 4115, "hosted room driver is unavailable") - try: - result = service.approve_room_task( - str(params.get("room_id") or ""), - member_id=str(params.get("member_id") or ""), - task_id=str(params.get("task_id") or ""), - execution_generation=int(params.get("execution_generation") or 0), - choice=str(params.get("choice") or ""), - request_id=str(params.get("request_id") or ""), - ) - return _ok(rid, {"approved": True, "result": result}) - except Exception as exc: - return _err(rid, 5119, str(exc)) + result = service.approve_room_task( + str(params.get("room_id") or ""), + member_id=str(params.get("member_id") or ""), + task_id=str(params.get("task_id") or ""), + execution_generation=int(params.get("execution_generation") or 0), + choice=str(params.get("choice") or ""), + request_id=str(params.get("request_id") or ""), + ) + return _ok(rid, {"approved": True, "result": result}) -@method("groups.retry") -def _(rid, params: dict) -> dict: +@_room_method("groups.retry", code=5118, service_code=4115) +def _(rid, params: dict, service) -> dict: """Retry one indeterminate room task after explicit user confirmation.""" - service = get_hosted_room_service() - if service is None: - return _err(rid, 4115, "hosted room driver is unavailable") - try: - task = service.retry_room_task( - str(params.get("room_id") or ""), - task_id=str(params.get("task_id") or ""), - ) - identity = task.get("identity") if isinstance(task, dict) else None - receipt = { - "room_id": str(getattr(identity, "room_id", "") or ""), - "task_id": str(getattr(identity, "task_id", "") or ""), - "thread_id": str(getattr(identity, "thread_id", "") or ""), - "turn_id": str(getattr(identity, "turn_id", "") or ""), - "status": str(task.get("status") or "") if isinstance(task, dict) else "", - "execution_generation": int(task.get("execution_generation") or 0) - if isinstance(task, dict) - else 0, - "cancel_generation": int(task.get("cancel_generation") or 0) - if isinstance(task, dict) - else 0, - } - return _ok(rid, {"retried": True, "task": receipt}) - except Exception as exc: - return _err(rid, 5118, str(exc)) + task = service.retry_room_task( + str(params.get("room_id") or ""), + task_id=str(params.get("task_id") or ""), + ) + if not isinstance(task, dict): + task = {} + identity = task.get("identity") + receipt = { + **{ + field: str(getattr(identity, field, "") or "") + for field in ("room_id", "task_id", "thread_id", "turn_id") + }, + "status": str(task.get("status") or ""), + "execution_generation": int(task.get("execution_generation") or 0), + "cancel_generation": int(task.get("cancel_generation") or 0), + } + return _ok(rid, {"retried": True, "task": receipt}) -@method("groups.log") +@_room_method("groups.log", code=5113, room_code=4112) def _(rid, params: dict) -> dict: """Return a monotonic room-log delta after ``since_seq``.""" - from gateway.hosted_rooms import HostedRoomError, default_db_path, read_events + from gateway.hosted_rooms import default_db_path, read_events - try: - delta = read_events( - default_db_path(), - room_id=params.get("room_id"), - since_seq=params.get("since_seq", 0), - limit=params.get("limit", 100), - include_disbanded=params.get("include_disbanded") is True, - ) - return _ok(rid, delta) - except HostedRoomError as exc: - reason = getattr(exc, "reason", None) - return _err(rid, 4112, str(exc), {"reason": reason} if reason else None) - except Exception as exc: - return _err(rid, 5113, str(exc)) + delta = read_events( + default_db_path(), + room_id=params.get("room_id"), + since_seq=params.get("since_seq", 0), + limit=params.get("limit", 100), + include_disbanded=params.get("include_disbanded") is True, + ) + return _ok(rid, delta) -@method("groups.replicate") +@_room_method("groups.replicate", code=5116, room_code=4116, replica_only=True, with_reason=False) def _(rid, params: dict) -> dict: """Persist one authority-stamped replay page into the local replica store. @@ -778,47 +670,39 @@ def _(rid, params: dict) -> dict: authority gateway; ingest is idempotent and refuses sequence gaps and authority-epoch regressions. """ - from gateway.hosted_room_replicas import ReplicaError, ingest_page + from gateway.hosted_room_replicas import ingest_page from gateway.hosted_rooms import default_db_path - try: - result = ingest_page( - default_db_path(), - room_id=params.get("room_id"), - room_name=params.get("room_name"), - members=params.get("members"), - page=params.get("page"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4116, str(exc)) - except Exception as exc: - return _err(rid, 5116, str(exc)) + result = ingest_page( + default_db_path(), + room_id=params.get("room_id"), + room_name=params.get("room_name"), + members=params.get("members"), + page=params.get("page"), + ) + return _ok(rid, result) -@method("groups.replica_state") +@_room_method( + "groups.replica_state", code=5117, room_code=4117, replica_only=True, with_reason=False +) def _(rid, params: dict) -> dict: """Report the local replica's coverage and authority lineage.""" - from gateway.hosted_room_replicas import ReplicaError, replica_state + from gateway.hosted_room_replicas import replica_state from gateway.hosted_rooms import default_db_path - try: - return _ok(rid, replica_state(default_db_path(), room_id=params.get("room_id"))) - except ReplicaError as exc: - return _err(rid, 4117, str(exc)) - except Exception as exc: - return _err(rid, 5117, str(exc)) + return _ok(rid, replica_state(default_db_path(), room_id=params.get("room_id"))) -@method("groups.promote") +@_room_method("groups.promote", code=5118, room_code=4118, with_reason=False) def _(rid, params: dict) -> dict: """Continue a replicated room on THIS gateway at ``epoch + 1``. Requires ``confirm: true`` — the caller asserts the previous authority can no longer commit (explicit user action; a lease/quorum driver later). """ - from gateway.hosted_room_replicas import ReplicaError, promote_replica - from gateway.hosted_rooms import HostedRoomError, default_db_path + from gateway.hosted_room_replicas import promote_replica + from gateway.hosted_rooms import default_db_path if params.get("confirm") is not True: return _err( @@ -827,39 +711,27 @@ def _(rid, params: dict) -> dict: "promotion requires confirm=true acknowledging the previous " "authority can no longer commit", ) - try: - result = promote_replica( - default_db_path(), - room_id=params.get("room_id"), - reason=params.get("reason", "authority-unreachable"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4118, str(exc)) - except HostedRoomError as exc: - return _err(rid, 4118, str(exc)) - except Exception as exc: - return _err(rid, 5118, str(exc)) + result = promote_replica( + default_db_path(), + room_id=params.get("room_id"), + reason=params.get("reason", "authority-unreachable"), + ) + return _ok(rid, result) -@method("groups.demote") +@_room_method("groups.demote", code=5119, room_code=4119, replica_only=True, with_reason=False) def _(rid, params: dict) -> dict: """Fence this gateway's stale room authority against a proven newer epoch.""" - from gateway.hosted_room_replicas import ReplicaError, demote_room + from gateway.hosted_room_replicas import demote_room from gateway.hosted_rooms import default_db_path - try: - result = demote_room( - default_db_path(), - room_id=params.get("room_id"), - observed_gateway_id=params.get("observed_gateway_id"), - observed_epoch=params.get("observed_epoch"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4119, str(exc)) - except Exception as exc: - return _err(rid, 5119, str(exc)) + result = demote_room( + default_db_path(), + room_id=params.get("room_id"), + observed_gateway_id=params.get("observed_gateway_id"), + observed_epoch=params.get("observed_epoch"), + ) + return _ok(rid, result) def register(server) -> None: diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 3cc757d3b5..35286311fb 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -1,268 +1,263 @@ """Profile JSON-RPC handlers — the ws twin of the dashboard's /api/profiles. -Motivation: desktop plugins reach the backend exclusively through the -generic ws JSON-RPC door (`host.request`). Profile enumeration/creation -previously lived only on the dashboard REST router, which plugins cannot -reach, so anything "one chat per agent profile"-shaped (bot rosters, -profile pickers, team panes) was impossible to build as a plugin. These -handlers delegate to the same `hermes_cli.profiles` primitives the REST -endpoints use. +Desktop plugins reach the backend only through the ws JSON-RPC door, so bot rosters +and profile pickers need profile enumeration/creation/editing here, delegating to +the same `hermes_cli.profiles` primitives the REST endpoints use. -Handlers are rebound onto server.py's globals at install time — see -method_ctx.py. They may reference server.py module globals (`_ok`, -`_err`, `is_truthy_value`, ...) that are not imported here. +Every function here is rebound onto server.py's globals at install time +(method_ctx.bind_module): bodies use server.py globals bare (`_ok`, `_err`, `os`, +`json`, `Path`, `is_truthy_value`, `get_hermes_home`, `*_hermes_home_override`, +`_profile_ui_meta_lock`), and module-level names are published onto server.py, so +they must not collide with its own globals. """ -from .method_ctx import HandlerRegistry +import contextlib + +from .method_ctx import HandlerRegistry, bind_module _registry = HandlerRegistry() method = _registry.method +# ext -> mime; iteration order is the on-disk lookup order for assets. +_ASSET_EXTS = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"} +# session.list's deny-list: sub-agent and kanban dispatcher workers. +_WORKER_SOURCES = frozenset({"kanban", "tool"}) + + +def _lazy(module, name): + """Late-bound attribute lookup (the wrapped modules are heavy / cyclic at import time). + + Uses the ``__import__`` builtin: rebound bodies only see server.py globals, so this + module's own imports (e.g. ``importlib``) are NOT available here. + """ + return getattr(__import__(module, fromlist=[name]), name) + + +def _pin_profile_model(profile_dir, provider, model) -> None: + _lazy("hermes_cli.web_routers.profiles", "_write_profile_model")(profile_dir, provider, model) + + +def _launch_mcp_catalog() -> dict: + mcp = (_lazy("hermes_cli.config", "load_config_readonly")() or {}).get("mcp_servers") + return mcp if isinstance(mcp, dict) else {} + + +def _try(fn, default): + """``fn()`` or ``default`` on any exception — best-effort sections must never fail each other.""" + try: + return fn() + except Exception: + return default + + +def _best_effort(fn) -> bool: + """Run ``fn``; True on success, False on any exception.""" + return _try(lambda: (fn(), True)[1], False) + + +def _read_text_if_file(path) -> str: + return _try(lambda: path.read_text(encoding="utf-8", errors="replace") if path.is_file() else "", "") + + +@contextlib.contextmanager +def _hermes_home_scope(path): + """Scope config/auth resolution to ``path`` for the block.""" + token = set_hermes_home_override(str(path)) + try: + yield + finally: + reset_hermes_home_override(token) + + +def _profile_dir_or_err(rid, name): + """``(profile_dir, None)`` for an existing profile, else ``(None, 4064 error)``.""" + from hermes_cli.profiles import get_profile_dir + profile_dir = Path(get_profile_dir(name)) + if not profile_dir.is_dir(): + return None, _err(rid, 4064, f"profile '{name}' not found") + return profile_dir, None + + +def _resolve_profile(rid, params): + """``(name, profile_dir, err)`` — err is the 4063 (name required) / 4064 (not found) response.""" + name = str(params.get("name") or "").strip() + if not name: + return name, None, _err(rid, 4063, "name required") + profile_dir, err = _profile_dir_or_err(rid, name) + return name, profile_dir, err + + +def _read_profile_yaml(profile_dir) -> dict: + """profile.yaml as a mapping; ``{}`` when missing, unparseable, or not a mapping.""" + import yaml + + meta_path = profile_dir / "profile.yaml" + loaded = (yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}) if meta_path.is_file() else {} + return loaded if isinstance(loaded, dict) else {} + + +def _clean_revisions(raw: dict) -> dict: + """Normalise a ``_ui_meta_revisions`` map: str keys, non-bool ints clamped at 0.""" + return {str(k): max(0, int(v)) for k, v in raw.items() if isinstance(v, int) and not isinstance(v, bool)} + + +def _latest_message_preview(db, session_id): + """Excerpt (≤80 chars) of the NEWEST active user/assistant message, or "". + + Messaging-app semantics for rosters (latest exchange), unlike the first-message + preview session lists use for recognition. Agent-delivery prefixes are kept. + """ + try: + with db._lock: + row = db._conn.execute( + "SELECT content FROM messages" + " WHERE session_id = ? AND role IN ('user', 'assistant')" + " AND active = 1 AND content IS NOT NULL AND TRIM(content) != ''" + " ORDER BY id DESC LIMIT 1", + (session_id,), + ).fetchone() + except Exception: + return "" + if not row: + return "" + text = " ".join(str(row[0] or "").split()).strip() + return text[:80] + "..." if len(text) > 80 else text + + +def _open_profile_session_db_readonly(profile_path): + """Read-only attach for roster previews, or None. + + A writable ``SessionDB()`` waits up to 20s for the write lock; the roster polls + every 5s while the live backend holds it, which stalled the RPC past the desktop timeout. + """ + db_path = Path(profile_path) / "state.db" + if not _try(db_path.exists, False): + return None + return _try(lambda: _lazy("hermes_state", "SessionDB")(db_path=db_path, read_only=True), None) + + +def _resurrect_recoverable_canonical(db, profile_path, session_id): + """Un-archive an accidentally archived canonical row, or False. + + Recoverability is judged on the read-only handle first; the write uses a + short-lived writable handle so the 20s lock patience is never paid on the poll. + """ + try: + row = db.get_session(session_id) + if not row or not row.get("archived"): + return False + tip_id = _try(lambda: db.get_compression_tip(session_id), None) or session_id + tip = (_try(lambda: db.get_session(tip_id), None) or row) if tip_id != session_id else row + from hermes_state import SessionDB, get_shared_session_db + if (tip.get("end_reason") or "") not in SessionDB.RECOVERABLE_END_REASONS: + return False + wdb = get_shared_session_db(Path(profile_path) / "state.db") + try: + return bool(wdb.unarchive_recoverable_session(session_id)) + finally: + _best_effort(lambda: _lazy("hermes_state", "release_or_close")(wdb)) + except Exception: + return False + + +def _canonical_session_row(db, profile_path): + """Summary of the profile's canonical "Bot Chat" registry row, or None. + + Identity is the NAME (UNIQUE(title) ⇒ ≤1 row), so preview and click target agree + without a client pointer. Exact lookup: hidden rows resolve (canonical chats are + always hidden); lineages resolve via ``get_compression_tip``, NOT the resume walker + whose unmarked-child fallback can pick an ordinary child. Worker sources count as + absent. ``id`` is the durable registry row, ``resolved_id`` the live tip. + """ + if db is None: + return None + try: + row = db.get_session_by_title("Bot Chat") + if not row: + return None + session_id = str(row.get("id") or "").strip() + if not session_id or (row.get("source") or "").strip().lower() in _WORKER_SOURCES: + return None + # Archived usually means the user retired it — report absent — but the + # ws-orphan reaper / older cleanup can archive by accident: resurrect those. + if row.get("archived") and not _resurrect_recoverable_canonical(db, profile_path, session_id): + return None + tip = _try(lambda: db.get_compression_tip(session_id), None) or session_id + tip_row = db.get_session(tip) or row + started = row.get("started_at") or 0 + return { + "id": session_id, + "resolved_id": tip, + "root_title": row.get("title") or "", + "title": tip_row.get("title") or "", + "preview": _latest_message_preview(db, tip), + "started_at": tip_row.get("started_at") or started, + "last_active": tip_row.get("last_activity_at") or tip_row.get("started_at") or started, + "message_count": tip_row.get("message_count") or 0, + } + except Exception: + return None + + +def _latest_profile_session_rows(db): + """(newest human-facing session, newest worker session) for a profile. + + The second is the newest DENIED row so rosters can show a profile as working + even though worker sessions never surface in conversation lists (workers + heartbeat ``last_activity_at`` every ≤60s; the client picks a liveness window). + """ + if db is None: + return None, None + try: + human = worker = None + for s in db.list_sessions_rich(source=None, limit=20, order_by_last_active=True, compact_rows=True): + src = (s.get("source") or "").strip().lower() + title = s.get("title") or "" + last_active = s.get("last_active") or s.get("started_at") or 0 + if src in _WORKER_SOURCES: + if worker is None: + worker = {"id": s["id"], "source": src, "title": title, "last_active": last_active} + continue + if human is not None: + continue + human = { + "id": s["id"], + "title": title, + "preview": s.get("preview") or "", + "started_at": s.get("started_at") or 0, + "last_active": last_active, + "message_count": s.get("message_count") or 0, + } + # Rosters want "where the conversation IS": prefer the newest text. + human["preview"] = _latest_message_preview(db, s["id"]) or human["preview"] + if worker is not None: + break + return human, worker + except Exception: + return None, None + + +def _profile_session_fields(row, profile_path): + """Attach last_session / worker_session / canonical_session to a roster row.""" + db = _open_profile_session_db_readonly(profile_path) + try: + row["last_session"], row["worker_session"] = _latest_profile_session_rows(db) + # Resolved server-side on every listing so no client carries a session pointer. + row["canonical_session"] = _canonical_session_row(db, profile_path) + finally: + if db is not None: + _best_effort(db.close) + @method("profiles.list") def _(rid, params: dict) -> dict: """List Hermes profiles (name, path, model, description, skill count). - ``include_sessions`` (default true) additionally reports each profile's - most recent conversation as ``last_session`` so a roster UI can paint - per-agent previews without N follow-up calls. - - NOTE: helpers must be nested — install() rebinds this handler's - __globals__ onto server.py, so module-level names here are invisible. + ``include_sessions`` (default true) adds ``last_session`` / ``worker_session`` + / ``canonical_session`` so a roster paints per-agent previews without N calls. """ - - def _latest_message_preview(db, session_id): - """Short excerpt of the NEWEST user/assistant message in a session. - - Rosters show this under each agent's name — messaging-app semantics - (latest exchange), unlike the shared first-message preview that - session lists use for recognition. Tool rows, inactive rows, and - empty content are skipped; agent-delivery prefixes are kept - (callers style them). Same query shape as - SessionDB.latest_message_row_id. - """ - try: - with db._lock: - row = db._conn.execute( - "SELECT content FROM messages" - " WHERE session_id = ? AND role IN ('user', 'assistant')" - " AND active = 1" - " AND content IS NOT NULL AND TRIM(content) != ''" - " ORDER BY id DESC LIMIT 1", - (session_id,), - ).fetchone() - except Exception: - return "" - if not row: - return "" - text = " ".join(str(row[0] or "").split()).strip() - if len(text) > 80: - return text[:80] + "..." - return text - - def _open_profile_session_db(profile_path): - """Read-only attach for roster previews, or None. - - A writable ``SessionDB()`` waits up to 20s of write-lock patience and - runs schema init. The Bots roster polls ``profiles.list`` every 5s - while a profile's live backend holds the writer — that used to stall - the RPC past the desktop timeout and leave the sidebar on an - infinite spinner. ``read_only=True`` is the cross-profile inspect - path (no write lock, no DDL) SessionDB already documents for this. - """ - try: - from pathlib import Path - - db_path = Path(profile_path) / "state.db" - if not db_path.exists(): - return None - from hermes_state import SessionDB - - return SessionDB(db_path=db_path, read_only=True) - except Exception: - return None - - def _canonical_session_row(db, profile_path): - """Summary of the profile's canonical "Bot Chat" registry row, or None. - - The canonical chat's identity is the NAME: the session titled exactly - "Bot Chat" on this profile (core UNIQUE(title) makes it a registry of - at most one row). Complements ``last_session``: that field answers - "what is the newest conversation", this answers "where is the - forever-chat" — so a roster row's preview and its click target - describe the same session (hermes-agent#88200) with no client-side - pointer involved. - - Exact-lookup semantics, deliberately different from the listing: - hidden rows still resolve (canonical chats are always hidden), - compression lineages resolve to the live tip via - ``get_compression_tip`` (not the generic resume walker, whose - unmarked-child fallback can select an ordinary child). - ``session.resume`` uses that same tip resolver when the target is - titled ``Bot Chat``. Denied internal sources (tool/kanban) count as - absent. The reported ``id`` stays the durable registry row while - ``resolved_id`` names the live tip. Best-effort: any failure - degrades to None rather than failing the whole profiles.list call. - """ - if db is None: - return None - try: - deny = frozenset({"kanban", "tool"}) - row = db.get_session_by_title("Bot Chat") - if not row: - return None - session_id = str(row.get("id") or "").strip() - if not session_id: - return None - if (row.get("source") or "").strip().lower() in deny: - return None - if row.get("archived"): - # An archived canonical row usually means the user deliberately - # retired it — report absent. But the ws-orphan reaper / older - # agent cleanup can archive it by accident (#92687): resurrect - # those. Judge recoverability READ-ONLY first so the writable - # open (20s write-lock patience, the very stall this refactor - # removes from the 5s poll) is paid only in the rare - # accidental-archive case, then run the real predicate through - # unarchive_recoverable_session on a short-lived writable handle. - if not _resurrect_recoverable_canonical(db, profile_path, session_id): - return None - try: - # Canonical Bot Chat identity may advance only across a proven - # compression edge. The generic resume resolver also carries - # a legacy unmarked-child fallback, which is intentionally too - # broad for this exact-title registry lookup. - tip = db.get_compression_tip(session_id) or session_id - except Exception: - tip = session_id - tip_row = db.get_session(tip) or row - preview = "" - try: - preview = _latest_message_preview(db, tip) - except Exception: - pass - return { - "id": session_id, - "resolved_id": tip, - "root_title": row.get("title") or "", - "title": tip_row.get("title") or "", - "preview": preview, - "started_at": tip_row.get("started_at") or row.get("started_at") or 0, - "last_active": ( - tip_row.get("last_activity_at") - or tip_row.get("started_at") - or row.get("started_at") - or 0 - ), - "message_count": tip_row.get("message_count") or 0, - } - except Exception: - return None - - def _resurrect_recoverable_canonical(db, profile_path, session_id): - """Un-archive an accidentally archived canonical row (#92687), or False. - - The roster's inspect connection is READ-ONLY (the whole point of - _open_profile_session_db), so the resurrect write happens on a - short-lived writable SessionDB opened only when the read-only - recoverability pre-check passes. The 20s write-lock patience is thus - paid only when there is a real resurrect to perform — never on the - 5s-poll fast path. - """ - try: - row = db.get_session(session_id) - if not row or not row.get("archived"): - return False - tip = row - try: - tip_id = db.get_compression_tip(session_id) or session_id - if tip_id != session_id: - tip = db.get_session(tip_id) or row - except Exception: - pass - from hermes_state import SessionDB - - if (tip.get("end_reason") or "") not in SessionDB.RECOVERABLE_END_REASONS: - return False - - from pathlib import Path - - from hermes_state import get_shared_session_db - wdb = get_shared_session_db(Path(profile_path) / "state.db") - try: - return bool(wdb.unarchive_recoverable_session(session_id)) - finally: - try: - from hermes_state import release_or_close - release_or_close(wdb) - except Exception: - pass - except Exception: - return False - - def _latest_profile_session_rows(db): - """(newest human-facing session, newest worker session) for a profile. - - First element mirrors session.list's deny-list (drops ``tool`` - sub-agent rows and ``kanban`` dispatcher workers). Second element is - the newest DENIED row — the freshest kanban/tool worker — so roster - UIs can show that a profile is actively working even though worker - sessions never surface in conversation lists (hermes-agent#90268). - Workers heartbeat ``last_activity_at`` every ≤60s while running - (#72016), so a live worker's ``last_active`` stays fresh and the - client can apply its own liveness window. Best-effort: any failure - (missing state.db, locked db, older schema) degrades to (None, None) - rather than failing the whole profiles.list call. - """ - if db is None: - return None, None - try: - deny = frozenset({"kanban", "tool"}) - human = None - worker = None - for s in db.list_sessions_rich( - source=None, limit=20, order_by_last_active=True, compact_rows=True - ): - src = (s.get("source") or "").strip().lower() - if src in deny: - if worker is None: - worker = { - "id": s["id"], - "source": src, - "title": s.get("title") or "", - "last_active": s.get("last_active") or s.get("started_at") or 0, - } - continue - if human is not None: - continue - row = { - "id": s["id"], - "title": s.get("title") or "", - "preview": s.get("preview") or "", - "started_at": s.get("started_at") or 0, - "last_active": s.get("last_active") or s.get("started_at") or 0, - "message_count": s.get("message_count") or 0, - } - # Roster surfaces want "where the conversation IS", not - # where it began: override the shared first-message - # preview with the newest user/assistant text. Best- - # effort — any failure keeps the first-message preview. - try: - latest = _latest_message_preview(db, s["id"]) - if latest: - row["preview"] = latest - except Exception: - pass - human = row - if worker is not None: - break - return human, worker - except Exception: - return None, None - try: from hermes_cli.profiles import list_profiles - include_sessions = is_truthy_value(params.get("include_sessions", True)) out = [] for p in list_profiles(): @@ -277,114 +272,134 @@ def _(rid, params: dict) -> dict: "skill_count": getattr(p, "skill_count", 0) or 0, } if include_sessions: - db = _open_profile_session_db(p.path) - try: - last_row, worker_row = _latest_profile_session_rows(db) - row["last_session"] = last_row - # Freshest kanban/tool worker (or None) — lets rosters count - # a profile as active while its worker runs (#90268). Older - # clients ignore the extra field. - row["worker_session"] = worker_row - # The profile's canonical "Bot Chat" registry row (or None) — - # identity is the NAME, resolved server-side on every listing - # so no client ever needs to carry a session pointer. - row["canonical_session"] = _canonical_session_row(db, p.path) - finally: - if db is not None: - try: - db.close() - except Exception: - pass - - # Client-agnostic UI metadata (avatars, accent colors, pinned - # order, …) — stored server-side in profile.yaml so every - # machine connecting to this gateway paints the same roster. - try: - import yaml as _yaml - from pathlib import Path as _Path - - meta_path = _Path(str(p.path)) / "profile.yaml" - # Presence of this field feature-detects gateway-owned CAS, - # including a brand-new profile whose revision map is empty. - row["ui_meta_revisions"] = {} - if meta_path.is_file(): - with open(meta_path, "r", encoding="utf-8") as f: - raw_meta = _yaml.safe_load(f) or {} - ui_meta = raw_meta.get("ui_meta") - if isinstance(ui_meta, dict) and ui_meta: - row["ui_meta"] = ui_meta - revisions = raw_meta.get("_ui_meta_revisions") - if isinstance(revisions, dict) and revisions: - row["ui_meta_revisions"] = { - str(key): max(0, int(value)) - for key, value in revisions.items() - if isinstance(value, int) and not isinstance(value, bool) - } - except Exception: - pass - - # Cheap existence flag so roster UIs know to profiles.get_asset - # without a probe call per profile per paint. - try: - from pathlib import Path as _Path - - assets = _Path(str(p.path)) / "assets" - row["has_avatar"] = any( - (assets / f"avatar.{ext}").is_file() for ext in ("png", "jpg", "webp") - ) - except Exception: - row["has_avatar"] = False + _profile_session_fields(row, p.path) + # Client-agnostic UI metadata lives in profile.yaml so every client paints + # the same roster. ``ui_meta_revisions`` is always present: it + # feature-detects gateway-owned CAS even for a brand-new profile. + profile_dir = Path(str(p.path)) + row["ui_meta_revisions"] = {} + raw_meta = _try(lambda: _read_profile_yaml(profile_dir), {}) + ui_meta = raw_meta.get("ui_meta") + if isinstance(ui_meta, dict) and ui_meta: + row["ui_meta"] = ui_meta + revisions = raw_meta.get("_ui_meta_revisions") + if isinstance(revisions, dict) and revisions: + row["ui_meta_revisions"] = _try(lambda: _clean_revisions(revisions), {}) + # Cheap existence flag so rosters skip a get_asset probe per paint. + row["has_avatar"] = _try(lambda: any((profile_dir / "assets" / f"avatar.{e}").is_file() for e in _ASSET_EXTS), False) out.append(row) - # Capability flag: this backend's prompt builder injects the Bot Mode - # teammate-messaging protocol (tools/bot_mode_probe.py) into every - # session of Bot-Mode-managed installs. Clients that would otherwise - # append the protocol to SOUL.md (the desktop's hermes-bots plugin) - # must skip their SOUL writes when this is present. + # Capability flag: this backend injects the Bot Mode teammate-messaging + # protocol into every session, so clients must not append it to SOUL.md. return _ok(rid, {"profiles": out, "bot_mode_protocol": True}) except Exception as e: return _err(rid, 5061, str(e)) +def _has_real_env_content(env_path) -> bool: + """True when .env has any non-comment, non-blank line.""" + lines = env_path.read_text(encoding="utf-8", errors="replace").splitlines() + return any(s and not s.startswith("#") for s in (line.strip() for line in lines)) + + +def _copy_secret_file(src, dst) -> None: + import shutil + shutil.copy2(src, dst) + with contextlib.suppress(OSError): + os.chmod(str(dst), 0o600) + + +def _mirror_env(path, launch_home) -> bool: + """Copy the launch .env only over the seeded comment-only stub (never a clone's secrets).""" + src, dst = launch_home / ".env", path / ".env" + if not (src.is_file() and _has_real_env_content(src) and not _try(lambda: _has_real_env_content(dst), False)): + return False + _copy_secret_file(src, dst) + return True + + +def _mirror_auth(path, launch_home) -> bool: + """Copy the launch auth.json when absent, dropping single-use OAuth grants. + + Skipped under ``share_auth`` so the profile reads token state via the global-root + fallback (refreshes write through): a copy forks token state and the first refresh + in either store strands the other. Static .env keys have no refresh semantics. + """ + src, dst = launch_home / "auth.json", path / "auth.json" + if not (src.is_file() and not dst.exists()): + return False + _copy_secret_file(src, dst) + # Never fork single-use OAuth grants (Anthropic / Codex / xAI): the first profile + # to refresh strands every sibling. API keys stay; OAuth rows are dropped and + # read from the root grant via the pool fallback. + _best_effort(lambda: _lazy("hermes_cli.auth", "strip_cloned_single_use_oauth_grants")(path)) + return True + + +def _mirror_voice_sections(path) -> bool: + """Copy voice config (stt/tts/voice) from the launch profile; True if written. + + Dictation/TTS resolve ``stt`` inside the TARGET profile's home, and a fresh + profile has only a ``model`` section, so voice fell back to defaults. Goes + through the canonical loaders under the home override (config-read-guard). + """ + try: + from hermes_cli.config import load_config_readonly, read_user_config_raw, save_config + src_cfg = load_config_readonly() or {} + sections = {k: src_cfg[k] for k in ("stt", "tts", "voice") if src_cfg.get(k)} + if not sections: + return False + with _hermes_home_scope(path): + # Round-trip the RAW file: load_config() merges DEFAULT_CONFIG, making every + # section look present (no-op mirror) and save_config would then persist + # the whole default tree into the fresh profile. + dst_cfg = read_user_config_raw() or {} + missing = {k: v for k, v in sections.items() if k not in dst_cfg} + if missing: + dst_cfg.update(missing) + save_config(dst_cfg) + return bool(missing) + except Exception: + return False + + +def _inherit_launch_model(path) -> bool: + """Inherit the launch profile's model.provider/default when the new profile has none. + + Gate on the MODEL SECTION being absent, not on config.yaml existing: voice + mirroring legitimately creates the file first, and a file-existence gate + silently skipped inheritance for every non-clone bot. Clones keep theirs. + """ + from hermes_cli.config import load_config_readonly, read_user_config_raw + + with _hermes_home_scope(path): + dst_model = (read_user_config_raw() or {}).get("model") or {} + if dst_model.get("provider") and dst_model.get("default"): + return False + model_cfg = (load_config_readonly() or {}).get("model") or {} + provider, model = str(model_cfg.get("provider") or ""), str(model_cfg.get("default") or "") + if not (provider and model): + return False + _pin_profile_model(path, provider, model) + return True + + @method("profiles.create") def _(rid, params: dict) -> dict: """Create a profile — the ws twin of POST /api/profiles. - Params: ``name`` (required, lowercase slug), ``description``, - ``clone_from`` (source profile; omitted = fresh profile with bundled - skills), ``clone_all``, ``no_skills``, ``soul`` (SOUL.md content), - ``model`` + ``provider`` (optional model pin, best-effort), and - ``mirror_credentials`` (default true) — copy the launch profile's - ``.env`` and ``auth.json`` into the new profile, and inherit its - model.provider/model.default when no explicit pin is given. - - Credential mirroring exists because ``create_profile()`` deliberately - seeds a comment-only ``.env`` and never copies ``auth.json`` (OAuth - tokens / credential pools), so a profile created headlessly from a - plugin was born with NO inference provider — the first message failed - with "No inference provider configured" and there is no interactive - ``hermes setup`` in that flow to recover. A profile spawned as an - always-available teammate must be able to think out of the box; callers - that want an isolated/credential-free profile pass - ``mirror_credentials: false``. + Params: ``name`` (lowercase slug), ``description``, ``clone_from`` (omitted = + fresh profile with bundled skills), ``clone_all``, ``no_skills``, ``soul``, + ``model`` + ``provider`` (optional pin), ``share_auth``, ``mirror_credentials`` + (default true: copy the launch .env, auth.json and voice sections; inherit its + model when unpinned). Mirroring exists because ``create_profile()`` seeds a + comment-only .env and never copies auth.json, so a headlessly created profile had + NO inference provider and no interactive ``hermes setup`` to recover. """ - - def _has_real_env_content(env_path) -> bool: - """True when .env has any non-comment, non-blank line.""" - try: - for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): - stripped = line.strip() - if stripped and not stripped.startswith("#"): - return True - except Exception: - pass - return False - name = str(params.get("name") or "").strip() if not name: return _err(rid, 4061, "name required") try: from hermes_cli import profiles as profiles_mod - clone_from = str(params.get("clone_from") or "").strip() or None clone_all = is_truthy_value(params.get("clone_all", False)) path = profiles_mod.create_profile( @@ -400,196 +415,89 @@ def _(rid, params: dict) -> dict: except Exception as e: return _err(rid, 5062, str(e)) - # Mirror the CLI/REST create flow: fresh profiles get the bundled - # skills; safe alias wrapper. Both best-effort. - try: - if not clone_from: - profiles_mod.seed_profile_skills(path, quiet=True) - except Exception: - pass - try: - if not profiles_mod.check_alias_collision(name): - profiles_mod.create_wrapper_script(name) - except Exception: - pass + # Mirror the CLI/REST create flow: bundled skills for fresh profiles, then the + # alias wrapper. Both best-effort. + if not clone_from: + _best_effort(lambda: profiles_mod.seed_profile_skills(path, quiet=True)) + _best_effort(lambda: profiles_mod.check_alias_collision(name) or profiles_mod.create_wrapper_script(name)) soul = params.get("soul") soul_written = False if isinstance(soul, str) and soul.strip(): - try: - (path / "SOUL.md").write_text(soul, encoding="utf-8") - soul_written = True - except Exception: - pass + soul_written = _best_effort(lambda: (path / "SOUL.md").write_text(soul, encoding="utf-8")) - # Credential + provider mirroring (default ON): a headless-created - # profile must be able to run a first turn. Copy the launch profile's - # .env (only over the seeded comment-only stub — never clobber real - # secrets a clone brought along) and auth.json (only when absent), then - # inherit model.provider/model.default unless the caller pinned a model. - # - # ``share_auth`` (default false): SKIP the auth.json copy so the new - # profile reads OAuth/token state through the global-root fallback - # instead (hermes_cli.auth: profile reads fall back to the global - # store, and token refreshes write THROUGH to it). A copy forks token - # state — the first refresh in either store invalidates the other - # for single-use refresh tokens. Sharing keeps one live token pool - # for the main profile and every bot. Static .env keys still copy - # (no refresh semantics, so copying is safe). mirrored = {"env": False, "auth": False, "model_inherited": False, "voice": False} share_auth = is_truthy_value(params.get("share_auth", False)) if share_auth: mirrored["auth"] = "shared" - if is_truthy_value(params.get("mirror_credentials", True)): - import shutil - - from hermes_constants import get_hermes_home - + mirror = is_truthy_value(params.get("mirror_credentials", True)) + if mirror: launch_home = get_hermes_home() - try: - src_env = launch_home / ".env" - dst_env = path / ".env" - if src_env.is_file() and _has_real_env_content(src_env) and not _has_real_env_content(dst_env): - shutil.copy2(src_env, dst_env) - try: - os.chmod(str(dst_env), 0o600) - except OSError: - pass - mirrored["env"] = True - except Exception: - pass - try: - src_auth = launch_home / "auth.json" - dst_auth = path / "auth.json" - if not share_auth and src_auth.is_file() and not dst_auth.exists(): - shutil.copy2(src_auth, dst_auth) - try: - os.chmod(str(dst_auth), 0o600) - except OSError: - pass - # Mirroring must not fork single-use OAuth grants (Anthropic / - # Codex / xAI): the first profile to refresh strands every - # sibling (#100339). API keys stay; OAuth rows are dropped - # and read from the root grant via the pool fallback. - try: - from hermes_cli.auth import strip_cloned_single_use_oauth_grants - strip_cloned_single_use_oauth_grants(path) - except Exception: - pass - mirrored["auth"] = True - except Exception: - pass + mirrored["env"] = _try(lambda: _mirror_env(path, launch_home), False) + if not share_auth: + mirrored["auth"] = _try(lambda: _mirror_auth(path, launch_home), False) + mirrored["voice"] = _mirror_voice_sections(path) model = str(params.get("model") or "").strip() provider = str(params.get("provider") or "").strip() model_set = False - - def _mirror_voice_sections() -> bool: - """Copy voice config (stt/tts/voice) from the launch profile. - - Desktop dictation and TTS are profile-scoped: /api/audio/transcribe - resolves the ``stt`` section inside the TARGET profile's home. A - freshly created profile has only a ``model`` section, so voice fell - back to defaults (local whisper, often not installed) and dictation - "didn't work in bot mode" while working on the primary profile. - - Reads/writes go through the canonical loaders scoped to the target - profile via the context-local HERMES_HOME override — the same - mechanism as ``_write_profile_model`` (config-read-guard: no raw - yaml on config.yaml). - """ - try: - from hermes_cli.config import ( - load_config_readonly, - read_user_config_raw, - save_config, - ) - from hermes_constants import ( - reset_hermes_home_override, - set_hermes_home_override, - ) - - src_cfg = load_config_readonly() or {} - sections = { - k: src_cfg[k] for k in ("stt", "tts", "voice") if src_cfg.get(k) - } - if not sections: - return False - - token = set_hermes_home_override(str(path)) - try: - # Write-back round-trip on the raw file: load_config() would - # merge DEFAULT_CONFIG, making every section look present and - # the mirror a no-op (and save_config would then persist the - # entire default tree into the fresh profile). - dst_cfg = read_user_config_raw() or {} - changed = False - for key, value in sections.items(): - if key not in dst_cfg: - dst_cfg[key] = value - changed = True - if changed: - save_config(dst_cfg) - finally: - reset_hermes_home_override(token) - return changed - except Exception: - return False - - if is_truthy_value(params.get("mirror_credentials", True)): - mirrored["voice"] = _mirror_voice_sections() - if model and provider: - try: - from hermes_cli.web_routers.profiles import _write_profile_model - - _write_profile_model(path, provider, model) - model_set = True - except Exception: - pass - elif is_truthy_value(params.get("mirror_credentials", True)): - # No explicit pin: inherit the launch profile's provider+model so the - # first turn resolves. Gate on the MODEL SECTION being absent, not on - # config.yaml existing — earlier mirroring steps (voice sections, - # #85755) legitimately create the file first, and a file-existence - # gate silently skipped inheritance for every non-clone bot - # ("No inference provider configured" on first message, tester - # report). Clones bring their own model section and stay untouched. - try: - from hermes_cli.config import load_config_readonly, read_user_config_raw - from hermes_cli.web_routers.profiles import _write_profile_model - from hermes_constants import ( - reset_hermes_home_override, - set_hermes_home_override, - ) - - token = set_hermes_home_override(str(path)) - try: - dst_model = (read_user_config_raw() or {}).get("model") or {} - finally: - reset_hermes_home_override(token) - - if not (dst_model.get("provider") and dst_model.get("default")): - cfg = load_config_readonly() or {} - model_cfg = cfg.get("model") or {} - inherited_provider = str(model_cfg.get("provider") or "") - inherited_model = str(model_cfg.get("default") or "") - if inherited_provider and inherited_model: - _write_profile_model(path, inherited_provider, inherited_model) - mirrored["model_inherited"] = True - except Exception: - pass + model_set = _best_effort(lambda: _pin_profile_model(path, provider, model)) + elif mirror: + mirrored["model_inherited"] = _try(lambda: _inherit_launch_model(path), False) return _ok( rid, - { - "ok": True, - "name": name, - "path": str(path), - "soul_written": soul_written, - "model_set": model_set, - "mirrored": mirrored, - }, + {"ok": True, "name": name, "path": str(path), "soul_written": soul_written, "model_set": model_set, "mirrored": mirrored}, + ) + + +def _describe_toolsets(cfg): + """``(toolsets, pinned_set)`` as the `hermes tools` checklist presents them. + + Configurable universe minus platform-restricted, enablement resolved as the runtime + does. The raw registry leaks internal platform composites and reports everything + "enabled" when the profile has no pin. + """ + from hermes_cli.tools_config import _get_effective_configurable_toolsets, _get_platform_tools, _toolset_allowed_for_platform + from toolsets import resolve_toolset + pinned = (cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {}).get("enabled_toolsets") + pinned_set = _clean_names(pinned) if isinstance(pinned, list) else None + platform_enabled = _try(lambda: set(_get_platform_tools(cfg, "cli", include_default_mcp_servers=False)), set()) + default_off = _try(lambda: _lazy("hermes_cli.tools_config", "_DEFAULT_OFF_TOOLSETS"), set()) + toolsets_out = [] + for ts_name, ts_label, ts_desc in _get_effective_configurable_toolsets(): + if not _toolset_allowed_for_platform(ts_name, "cli"): + continue + enabled = ts_name in pinned_set if pinned_set is not None else ts_name in platform_enabled + # Default-off integrations (a2a, spotify, ...) and the equally opt-in yuanbao + # are noise in a per-profile editor unless already enabled. + if (ts_name in default_off or ts_name == "yuanbao") and not enabled: + continue + tool_count = _try(lambda: len(set(resolve_toolset(ts_name))), 0) + toolsets_out.append( + {"name": ts_name, "label": ts_label, "description": ts_desc or "", "tool_count": tool_count, "enabled": enabled} + ) + return toolsets_out, pinned_set + + +def _describe_mcp_servers(cfg): + """``[{name, enabled, transport}]`` for the profile's ``mcp_servers`` (best-effort).""" + mcp_cfg = cfg.get("mcp_servers") + if not isinstance(mcp_cfg, dict): + return [] + return _try( + lambda: [ + { + "name": str(srv_name), + "enabled": not is_truthy_value(entry.get("disabled", False)), + "transport": str(entry.get("transport") or "http") if entry.get("url") else "stdio", + } + for srv_name in sorted(mcp_cfg.keys()) + for entry in (mcp_cfg[srv_name],) + if isinstance(entry, dict) + ], + [], ) @@ -597,438 +505,218 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Full configuration snapshot of one profile, for an editor UI. - Params: ``name`` (required). Result: - ``{name, description, soul, model: {provider, default}, skills: - [{name, enabled}], toolsets: [{name, description, tool_count, enabled}]}`` - - Skill enablement mirrors the disabled-list model (installed = enabled - unless in ``skills.disabled``). Toolset enablement reports the profile's - ``tools.enabled_toolsets`` pin, or every toolset enabled when unpinned. - All reads are scoped to the profile via the HERMES_HOME override. + Result: ``{name, description, soul, model: {provider, default}, skills: + [{name, enabled}], toolsets: [...], toolsets_pinned, mcp_servers}``. Skill + enablement mirrors the disabled-list model (installed = enabled unless in + ``skills.disabled``). All reads are scoped to the profile via the home override. """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") try: - from pathlib import Path - - from hermes_cli.profiles import get_profile_dir - from hermes_constants import reset_hermes_home_override, set_hermes_home_override - - profile_dir = Path(get_profile_dir(name)) - if not profile_dir.is_dir(): - return _err(rid, 4064, f"profile '{name}' not found") - - token = set_hermes_home_override(str(profile_dir)) - try: + name, profile_dir, err = _resolve_profile(rid, params) + if err is not None: + return err + with _hermes_home_scope(profile_dir): from hermes_cli.config import load_config from hermes_cli.skills_config import get_disabled_skills - cfg = load_config() or {} disabled = {s.lower() for s in get_disabled_skills(cfg)} - - installed = [] skills_root = profile_dir / "skills" - if skills_root.is_dir(): - for md in sorted(skills_root.rglob("SKILL.md")): - skill_name = md.parent.name - installed.append( - {"name": skill_name, "enabled": skill_name.lower() not in disabled} - ) - - # Toolsets: the same filtered universe the `hermes tools` - # checklist offers — configurable toolsets (built-in + plugin), - # minus platform-restricted ones that don't apply here — with - # enablement resolved the way the runtime actually resolves it. - # The raw registry (get_all_toolsets) leaks internal platform - # composites (hermes-discord, feishu_drive, ...) and reports - # everything "enabled" whenever the profile has no pin, which a - # capabilities UI then faithfully mis-renders (tester report). - from hermes_cli.tools_config import ( - _get_effective_configurable_toolsets, - _get_platform_tools, - _toolset_allowed_for_platform, - ) - from toolsets import resolve_toolset - - tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {} - pinned = tools_cfg.get("enabled_toolsets") - pinned_set = ( - {str(t).strip() for t in pinned if str(t).strip()} - if isinstance(pinned, list) - else None - ) - try: - platform_enabled = set( - _get_platform_tools(cfg, "cli", include_default_mcp_servers=False) - ) - except Exception: - platform_enabled = set() - try: - from hermes_cli.tools_config import _DEFAULT_OFF_TOOLSETS - except Exception: - _DEFAULT_OFF_TOOLSETS = set() - toolsets_out = [] - for ts_name, ts_label, ts_desc in _get_effective_configurable_toolsets(): - if not _toolset_allowed_for_platform(ts_name, "cli"): - continue - enabled = ( - ts_name in pinned_set - if pinned_set is not None - else ts_name in platform_enabled - ) - # Default-off integrations (a2a, yuanbao, spotify, ...) are - # opt-ins; when the profile hasn't opted in they're noise in - # a per-profile editor — `hermes tools` / Settings is where - # you turn them on globally first. Enabled ones still show. - # yuanbao rides the same rule: a region-specific integration - # that isn't in _DEFAULT_OFF_TOOLSETS but is equally opt-in. - if (ts_name in _DEFAULT_OFF_TOOLSETS or ts_name == "yuanbao") and not enabled: - continue - try: - tool_count = len(set(resolve_toolset(ts_name))) - except Exception: - tool_count = 0 - toolsets_out.append( - { - "name": ts_name, - "label": ts_label, - "description": ts_desc or "", - "tool_count": tool_count, - "enabled": enabled, - } - ) - - soul_path = profile_dir / "SOUL.md" - soul = "" - try: - if soul_path.is_file(): - soul = soul_path.read_text(encoding="utf-8", errors="replace") - except Exception: - pass - - # MCP servers configured for this profile (config.yaml - # mcp_servers). Report name + enabled + a transport hint so a - # capabilities UI can list and toggle them without parsing the - # raw config shape. - mcp_out = [] - try: - mcp_cfg = cfg.get("mcp_servers") - if isinstance(mcp_cfg, dict): - for srv_name in sorted(mcp_cfg.keys()): - entry = mcp_cfg.get(srv_name) - if not isinstance(entry, dict): - continue - transport = "stdio" - if entry.get("url"): - transport = str(entry.get("transport") or "http") - mcp_out.append( - { - "name": str(srv_name), - "enabled": not is_truthy_value(entry.get("disabled", False)), - "transport": transport, - } - ) - except Exception: - pass - + installed = [ + {"name": md.parent.name, "enabled": md.parent.name.lower() not in disabled} + for md in (sorted(skills_root.rglob("SKILL.md")) if skills_root.is_dir() else ()) + ] + toolsets_out, pinned_set = _describe_toolsets(cfg) + soul = _read_text_if_file(profile_dir / "SOUL.md") + mcp_out = _describe_mcp_servers(cfg) model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {} - - description = "" - try: - from hermes_cli.profiles import read_profile_meta - - description = str(read_profile_meta(profile_dir).get("description") or "") - except Exception: - pass - - return _ok( - rid, - { - "name": name, - "description": description, - "soul": soul, - "model": { - "provider": str(model_cfg.get("provider") or ""), - "default": str(model_cfg.get("default") or ""), - }, - "skills": installed, - "toolsets": toolsets_out, - "toolsets_pinned": pinned_set is not None, - "mcp_servers": mcp_out, - }, - ) - finally: - reset_hermes_home_override(token) + meta = _try(lambda: _lazy("hermes_cli.profiles", "read_profile_meta")(profile_dir), {}) + result = { + "name": name, + "description": str(meta.get("description") or ""), + "soul": soul, + "model": {"provider": str(model_cfg.get("provider") or ""), "default": str(model_cfg.get("default") or "")}, + "skills": installed, + "toolsets": toolsets_out, + "toolsets_pinned": pinned_set is not None, + "mcp_servers": mcp_out, + } + return _ok(rid, result) except Exception as e: return _err(rid, 5063, str(e)) +def _configure_ui_meta(profile_dir, params, applied) -> None: + """Merge ``params["ui_meta"]`` key-wise into profile.yaml (None deletes a key). + + Size-capped (64KB) because it rides profiles.list on every roster paint. + ``ui_meta_expected_revisions`` are per-key CAS preconditions; any mismatch rejects + the whole write. Revisions survive deletion so a stale client cannot recreate a + removed key by presenting the initial revision. + """ + try: + incoming = params["ui_meta"] + if len(json.dumps(incoming)) > 65536: + applied["ui_meta"] = False + return + expected = params.get("ui_meta_expected_revisions") + if expected is not None and not isinstance(expected, dict): + raise ValueError("ui_meta_expected_revisions must be an object") + with _profile_ui_meta_lock: + existing = _try(lambda: _read_profile_yaml(profile_dir), {}) + raw_revisions = existing.get("_ui_meta_revisions") + revisions = _clean_revisions(raw_revisions if isinstance(raw_revisions, dict) else {}) + conflicts = {} + for key in incoming if isinstance(expected, dict) else (): + wanted, actual = expected.get(key), revisions.get(key, 0) + if not isinstance(wanted, int) or isinstance(wanted, bool) or wanted < 0 or wanted != actual: + conflicts[key] = {"expected": wanted, "actual": actual} + if conflicts: + applied["ui_meta"] = False + applied["ui_meta_conflicts"] = conflicts + applied["ui_meta_revisions"] = {key: revisions.get(key, 0) for key in incoming} + return + current = existing.get("ui_meta") + current = current if isinstance(current, dict) else {} + for key, value in incoming.items(): + if value is None: + current.pop(key, None) + else: + current[key] = value + revisions[key] = revisions.get(key, 0) + 1 + if current: + existing["ui_meta"] = current + else: + existing.pop("ui_meta", None) + existing["_ui_meta_revisions"] = revisions + from utils import atomic_yaml_write + atomic_yaml_write(profile_dir / "profile.yaml", existing, sort_keys=False) + applied["ui_meta"] = True + applied["ui_meta_revisions"] = {key: revisions[key] for key in incoming} + except Exception: + applied["ui_meta"] = False + + +def _configure_model(profile_dir, params, applied): + """Apply a ``model`` + ``provider`` pin; returns a confirm message instead of writing. + + Same handshake as ``config.set model``: without ``confirm_expensive_model`` a + guarded (data-policy / expensive) pick answers ``confirm_required`` and writes + NOTHING; the client resends with the flag once confirmed. A misbehaving guard + must never break the save (treated as "no warning"), matching ``_apply_model_switch``. + """ + model = str(params.get("model") or "").strip() + provider = str(params.get("provider") or "").strip() + confirm_message = None + if not (model and provider): + return None + if not is_truthy_value(params.get("confirm_expensive_model", False)): + confirm_message = _try( + lambda: getattr( + _lazy("hermes_cli.model_selection_guards", "combined_selection_warning")(model, provider=provider or None), + "message", + None, + ), + None, + ) + if confirm_message is None: + applied["model"] = _best_effort(lambda: _pin_profile_model(profile_dir, provider, model)) + return confirm_message + + +def _configure_cfg_sections(profile_dir, params, applied) -> None: + """Apply ``disabled_skills`` / ``enabled_toolsets`` / ``enabled_mcp_servers`` (replace semantics). + + An empty ``enabled_toolsets`` clears the pin. ``enabled_mcp_servers`` toggles the + ``disabled`` flag; enabling a server the profile doesn't define copies its + definition from the LAUNCH profile's catalog — unknown names are skipped, never + invented. Server defs are config, not secrets; credentials stay in .env/auth. + """ + want_mcp = isinstance(params.get("enabled_mcp_servers"), list) + # Launch profile's MCP catalog, read BEFORE the home override flips config + # resolution to the target profile. + launch_mcp = _try(_launch_mcp_catalog, {}) if want_mcp else {} + + with _hermes_home_scope(profile_dir): + from hermes_cli.config import load_config, save_config + cfg = load_config() or {} + if isinstance(params.get("disabled_skills"), list): + try: + from hermes_cli.skills_config import save_disabled_skills + save_disabled_skills(cfg, _clean_names(params["disabled_skills"])) + applied["skills"] = True + cfg = load_config() or {} + except Exception: + applied["skills"] = False + if isinstance(params.get("enabled_toolsets"), list): + applied["toolsets"] = _best_effort(lambda: _save_toolset_pin(cfg, params["enabled_toolsets"], save_config)) + if want_mcp: + applied["mcp_servers"] = _best_effort( + lambda: _save_mcp_toggles(load_config() or {}, params["enabled_mcp_servers"], launch_mcp, save_config) + ) + + +def _clean_names(values) -> set: + return {str(v).strip() for v in values if str(v).strip()} + + +def _save_toolset_pin(cfg, enabled, save_config) -> None: + wanted = sorted(_clean_names(enabled)) + tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {} + if wanted: + tools_cfg["enabled_toolsets"] = wanted + else: + tools_cfg.pop("enabled_toolsets", None) + cfg["tools"] = tools_cfg + save_config(cfg) + + +def _save_mcp_toggles(cfg, enabled, launch_mcp, save_config) -> None: + wanted = _clean_names(enabled) + mcp_cfg = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {} + for srv in wanted: + if srv in mcp_cfg and isinstance(mcp_cfg[srv], dict): + mcp_cfg[srv].pop("disabled", None) + elif srv in launch_mcp and isinstance(launch_mcp[srv], dict): + mcp_cfg[srv] = dict(launch_mcp[srv]) + mcp_cfg[srv].pop("disabled", None) + for srv, entry in mcp_cfg.items(): + if srv not in wanted and isinstance(entry, dict): + entry["disabled"] = True + if mcp_cfg: + cfg["mcp_servers"] = mcp_cfg + save_config(cfg) + + @method("profiles.configure") def _(rid, params: dict) -> dict: """Apply configuration changes to a profile (editor Save). - Params: ``name`` (required) plus any of: - ``description`` (str), ``soul`` (str, full SOUL.md replacement), - ``model`` + ``provider`` (both required together), - ``disabled_skills`` (list[str], replace semantics), - ``enabled_toolsets`` (list[str], replace semantics; empty list clears - the pin so every toolset is enabled again), and - ``ui_meta_expected_revisions`` (dict[str, int], optional compare-and-swap - preconditions for keys supplied in ``ui_meta``). - - Each section is applied independently and best-effort; the result - reports per-section success so a UI can surface partial failures. + Params: ``name`` plus any of ``ui_meta`` (+ ``ui_meta_expected_revisions``), ``soul``, + ``description``, ``model`` + ``provider`` (+ ``confirm_expensive_model``), + ``disabled_skills``, ``enabled_toolsets``, ``enabled_mcp_servers``. Sections are + independent and best-effort; ``applied`` reports per-section success. """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") try: - from pathlib import Path - - from hermes_cli.profiles import get_profile_dir - from hermes_constants import reset_hermes_home_override, set_hermes_home_override - - profile_dir = Path(get_profile_dir(name)) - if not profile_dir.is_dir(): - return _err(rid, 4064, f"profile '{name}' not found") - + _name, profile_dir, err = _resolve_profile(rid, params) + if err is not None: + return err applied = {} - if isinstance(params.get("ui_meta"), dict): - # Client-agnostic UI metadata (avatar/pet/etc.), merged key-wise - # into profile.yaml's ui_meta block. A key set to None deletes it. - # Size-capped: this rides profiles.list on every roster paint, so - # large blobs (e.g. raw base64 images) are rejected — persist big - # assets elsewhere and store a reference. - try: - import json as _json - - incoming = params["ui_meta"] - if len(_json.dumps(incoming)) > 65536: - applied["ui_meta"] = False - else: - import yaml as _yaml - - expected = params.get("ui_meta_expected_revisions") - if expected is not None and not isinstance(expected, dict): - raise ValueError("ui_meta_expected_revisions must be an object") - - meta_path = profile_dir / "profile.yaml" - with _profile_ui_meta_lock: - existing = {} - if meta_path.is_file(): - try: - with open(meta_path, "r", encoding="utf-8") as f: - loaded = _yaml.safe_load(f) or {} - if isinstance(loaded, dict): - existing = loaded - except Exception: - existing = {} - - raw_revisions = existing.get("_ui_meta_revisions") - revisions = dict(raw_revisions) if isinstance(raw_revisions, dict) else {} - revisions = { - str(key): max(0, int(value)) - for key, value in revisions.items() - if isinstance(value, int) and not isinstance(value, bool) - } - conflicts = {} - if isinstance(expected, dict): - for key in incoming: - wanted = expected.get(key) - actual = revisions.get(key, 0) - if ( - not isinstance(wanted, int) - or isinstance(wanted, bool) - or wanted < 0 - or wanted != actual - ): - conflicts[key] = {"expected": wanted, "actual": actual} - - if conflicts: - applied["ui_meta"] = False - applied["ui_meta_conflicts"] = conflicts - applied["ui_meta_revisions"] = { - key: revisions.get(key, 0) for key in incoming - } - else: - current = existing.get("ui_meta") - if not isinstance(current, dict): - current = {} - for key, value in incoming.items(): - if value is None: - current.pop(key, None) - else: - current[key] = value - revisions[key] = revisions.get(key, 0) + 1 - if current: - existing["ui_meta"] = current - else: - existing.pop("ui_meta", None) - # Revisions intentionally survive deletion: a - # stale client must not recreate a removed key by - # presenting the initial revision again. - existing["_ui_meta_revisions"] = revisions - from utils import atomic_yaml_write - - atomic_yaml_write(meta_path, existing, sort_keys=False) - applied["ui_meta"] = True - applied["ui_meta_revisions"] = { - key: revisions[key] for key in incoming - } - except Exception: - applied["ui_meta"] = False - + _configure_ui_meta(profile_dir, params, applied) if isinstance(params.get("soul"), str): - try: - (profile_dir / "SOUL.md").write_text(params["soul"], encoding="utf-8") - applied["soul"] = True - except Exception: - applied["soul"] = False - + applied["soul"] = _best_effort(lambda: (profile_dir / "SOUL.md").write_text(params["soul"], encoding="utf-8")) if isinstance(params.get("description"), str): - try: - from hermes_cli.profiles import write_profile_meta - - write_profile_meta( - profile_dir, - description=params["description"].strip(), - description_auto=False, + applied["description"] = _best_effort( + lambda: _lazy("hermes_cli.profiles", "write_profile_meta")( + profile_dir, description=params["description"].strip(), description_auto=False ) - applied["description"] = True - except Exception: - applied["description"] = False - - model = str(params.get("model") or "").strip() - provider = str(params.get("provider") or "").strip() - confirm_message = None - if model and provider: - # #95293 remainder: this is the Bots editor's model-switch path, - # and it used to write guarded (data-policy / expensive) models - # silently — the ONE surface that bypassed the selection guard - # every other switch path enforces. Same handshake contract as - # ``config.set model``: without ``confirm_expensive_model`` a - # guarded pick answers ``confirm_required`` + ``confirm_message`` - # and writes NOTHING; the client resends with - # ``confirm_expensive_model: true`` once the user confirms. A - # misbehaving guard must never break the save (treated as "no - # warning"), matching ``_apply_model_switch``. - if not is_truthy_value(params.get("confirm_expensive_model", False)): - try: - from hermes_cli.model_selection_guards import combined_selection_warning - - warning = combined_selection_warning(model, provider=provider or None) - confirm_message = warning.message if warning is not None else None - except Exception: - confirm_message = None - if confirm_message is None: - try: - from hermes_cli.web_routers.profiles import _write_profile_model - - _write_profile_model(profile_dir, provider, model) - applied["model"] = True - except Exception: - applied["model"] = False - - needs_cfg = ( - isinstance(params.get("disabled_skills"), list) - or isinstance(params.get("enabled_toolsets"), list) - or isinstance(params.get("enabled_mcp_servers"), list) - ) - if needs_cfg: - # Launch profile's MCP catalog, read BEFORE the home override - # flips config resolution to the target profile. - launch_mcp = {} - if isinstance(params.get("enabled_mcp_servers"), list): - try: - from hermes_cli.config import load_config_readonly - - launch_cfg = load_config_readonly() or {} - if isinstance(launch_cfg.get("mcp_servers"), dict): - launch_mcp = launch_cfg["mcp_servers"] - except Exception: - launch_mcp = {} - - token = set_hermes_home_override(str(profile_dir)) - try: - from hermes_cli.config import load_config, save_config - - cfg = load_config() or {} - - if isinstance(params.get("disabled_skills"), list): - try: - from hermes_cli.skills_config import save_disabled_skills - - wanted = { - str(s).strip() - for s in params["disabled_skills"] - if str(s).strip() - } - save_disabled_skills(cfg, wanted) - applied["skills"] = True - cfg = load_config() or {} - except Exception: - applied["skills"] = False - - if isinstance(params.get("enabled_toolsets"), list): - try: - wanted = [str(t).strip() for t in params["enabled_toolsets"] if str(t).strip()] - tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {} - if wanted: - tools_cfg["enabled_toolsets"] = sorted(set(wanted)) - else: - tools_cfg.pop("enabled_toolsets", None) - cfg["tools"] = tools_cfg - save_config(cfg) - applied["toolsets"] = True - except Exception: - applied["toolsets"] = False - - # ``enabled_mcp_servers`` (list[str], replace semantics): - # toggle the profile's mcp_servers entries via the standard - # ``disabled`` flag. Enabling a server the profile doesn't - # define copies its definition from the LAUNCH profile's - # config (capabilities UIs offer the main profile's catalog); - # unknown names are skipped, never invented. Server defs are - # config, not secrets — credentials stay in .env/auth. - if isinstance(params.get("enabled_mcp_servers"), list): - try: - wanted = { - str(s).strip() - for s in params["enabled_mcp_servers"] - if str(s).strip() - } - cfg = load_config() or {} - mcp_cfg = ( - cfg.get("mcp_servers") - if isinstance(cfg.get("mcp_servers"), dict) - else {} - ) - - for srv in wanted: - if srv in mcp_cfg and isinstance(mcp_cfg[srv], dict): - mcp_cfg[srv].pop("disabled", None) - elif srv in launch_mcp and isinstance(launch_mcp[srv], dict): - mcp_cfg[srv] = dict(launch_mcp[srv]) - mcp_cfg[srv].pop("disabled", None) - for srv, entry in mcp_cfg.items(): - if srv not in wanted and isinstance(entry, dict): - entry["disabled"] = True - - if mcp_cfg: - cfg["mcp_servers"] = mcp_cfg - save_config(cfg) - applied["mcp_servers"] = True - except Exception: - applied["mcp_servers"] = False - finally: - reset_hermes_home_override(token) + ) + confirm_message = _configure_model(profile_dir, params, applied) + if any(isinstance(params.get(k), list) for k in ("disabled_skills", "enabled_toolsets", "enabled_mcp_servers")): + _configure_cfg_sections(profile_dir, params, applied) result = {"ok": all(applied.values()) if applied else True, "applied": applied} if confirm_message is not None: - # Model write pending user confirmation — same shape config.set - # returns, so clients reuse one confirm handler for both surfaces. + # Same shape config.set returns, so clients reuse one confirm handler. result["confirm_required"] = True result["confirm_message"] = confirm_message return _ok(rid, result) @@ -1036,84 +724,61 @@ def _(rid, params: dict) -> dict: return _err(rid, 5064, str(e)) +def _sniff_asset_ext(blob): + """Extension for a PNG/JPEG/WebP blob by magic bytes (never trust declared mime), or None.""" + if blob[:8] == b"\x89PNG\r\n\x1a\n": + return "png" + if blob[:3] == b"\xff\xd8\xff": + return "jpg" + return "webp" if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP" else None + + +def _unlink_asset_files(assets_dir, asset) -> int: + """Delete every ``.`` in ``assets_dir``; returns how many existed.""" + present = [t for t in (assets_dir / f"{asset}.{ext}" for ext in _ASSET_EXTS) if t.is_file()] + for target in present: + target.unlink() + return len(present) + + @method("profiles.set_asset") def _(rid, params: dict) -> dict: - """Store a small binary asset (e.g. avatar image) in a profile's dir. + """Store a small binary asset (avatar image) as ``assets/.``, atomically. - Params: ``name`` (profile), ``asset`` (currently only ``"avatar"``), - ``data`` (data URL or raw base64; PNG/JPEG/WebP; decoded size capped at - 2MB), or ``clear: true`` to delete. Written atomically as - ``assets/.`` inside the profile directory — server-side, so - every client machine sees the same image via ``profiles.get_asset``. - - Result: ``{ok, asset, size}`` (``size`` 0 on clear). + Params: ``name``, ``asset`` (only ``"avatar"``), ``data`` (data URL or raw base64; + PNG/JPEG/WebP; decoded ≤2MB), or ``clear: true``. Result: ``{ok, asset, size}``. """ - name = str(params.get("name") or "").strip() asset = str(params.get("asset") or "avatar").strip().lower() - if not name: + if not str(params.get("name") or "").strip(): return _err(rid, 4063, "name required") - if asset not in {"avatar"}: + if asset != "avatar": return _err(rid, 4066, f"unknown asset '{asset}' (supported: avatar)") try: import base64 - import re as _re - from pathlib import Path as _Path - - from hermes_cli.profiles import get_profile_dir - - profile_dir = _Path(get_profile_dir(name)) - if not profile_dir.is_dir(): - return _err(rid, 4064, f"profile '{name}' not found") - + import re + _name, profile_dir, err = _resolve_profile(rid, params) + if err is not None: + return err assets_dir = profile_dir / "assets" - exts = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"} - if is_truthy_value(params.get("clear", False)): - removed = 0 - for ext in exts.values(): - target = assets_dir / f"{asset}.{ext}" - if target.is_file(): - target.unlink() - removed += 1 + removed = _unlink_asset_files(assets_dir, asset) return _ok(rid, {"ok": True, "asset": asset, "size": 0, "removed": removed}) - data = str(params.get("data") or "") if not data: return _err(rid, 4067, "data required (data URL or base64)") - - mime = "image/png" - match = _re.match(r"^data:(image/(?:png|jpeg|webp));base64,(.*)$", data, _re.DOTALL) - if match: - mime, payload = match.group(1), match.group(2) - else: - payload = data - + match = re.match(r"^data:(image/(?:png|jpeg|webp));base64,(.*)$", data, re.DOTALL) try: - blob = base64.b64decode(payload, validate=True) + blob = base64.b64decode(match.group(2) if match else data, validate=True) except Exception: return _err(rid, 4068, "data is not valid base64") - if len(blob) > 2_000_000: return _err(rid, 4069, f"asset too large ({len(blob)} bytes; max 2MB)") - - # Magic-byte check — don't trust the declared mime. - if blob[:8] == b"\x89PNG\r\n\x1a\n": - mime = "image/png" - elif blob[:3] == b"\xff\xd8\xff": - mime = "image/jpeg" - elif blob[:4] == b"RIFF" and blob[8:12] == b"WEBP": - mime = "image/webp" - else: + ext = _sniff_asset_ext(blob) + if ext is None: return _err(rid, 4070, "unsupported image format (PNG/JPEG/WebP only)") - assets_dir.mkdir(parents=True, exist_ok=True) - # One canonical file per asset: clear other extensions first. - for ext in exts.values(): - stale = assets_dir / f"{asset}.{ext}" - if stale.is_file(): - stale.unlink() - - target = assets_dir / f"{asset}.{exts[mime]}" + _unlink_asset_files(assets_dir, asset) # one canonical file per asset + target = assets_dir / f"{asset}.{ext}" tmp = target.with_suffix(target.suffix + ".tmp") tmp.write_bytes(blob) tmp.replace(target) @@ -1124,44 +789,26 @@ def _(rid, params: dict) -> dict: @method("profiles.get_asset") def _(rid, params: dict) -> dict: - """Fetch a profile asset as a data URL. + """Fetch a profile asset as a data URL: ``{found, data?, mime?, size?}``. - Params: ``name`` (profile), ``asset`` (default ``"avatar"``). - Result: ``{found, data?, mime?, size?}`` — ``found: false`` (not an - error) when the asset doesn't exist, so roster UIs can probe cheaply. + ``found: false`` (not an error) when absent, so rosters can probe cheaply. """ - name = str(params.get("name") or "").strip() asset = str(params.get("asset") or "avatar").strip().lower() - if not name: - return _err(rid, 4063, "name required") try: import base64 - from pathlib import Path as _Path - - from hermes_cli.profiles import get_profile_dir - - profile_dir = _Path(get_profile_dir(name)) - if not profile_dir.is_dir(): - return _err(rid, 4064, f"profile '{name}' not found") - - mimes = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"} - for ext, mime in mimes.items(): + _name, profile_dir, err = _resolve_profile(rid, params) + if err is not None: + return err + for ext, mime in _ASSET_EXTS.items(): target = profile_dir / "assets" / f"{asset}.{ext}" if target.is_file(): blob = target.read_bytes() - return _ok( - rid, - { - "found": True, - "mime": mime, - "size": len(blob), - "data": f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}", - }, - ) + data = f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}" + return _ok(rid, {"found": True, "mime": mime, "size": len(blob), "data": data}) return _ok(rid, {"found": False}) except Exception as e: return _err(rid, 5066, str(e)) def register(server) -> None: - _registry.install(server) + bind_module(globals(), server) diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 2b1d0d10fe..497216d188 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -1,16 +1,265 @@ -"""Session / delegation / spawn-tree / billing / pet JSON-RPC handlers (moved verbatim from server.py). +"""Session / delegation / spawn-tree / billing / pet JSON-RPC handlers. -Handler bodies are byte-identical to their pre-split server.py form; they -are rebound onto server.py's globals at install time — see method_ctx.py. +Handler bodies are rebound onto server.py's globals at install time (see +method_ctx.py), so they reference server helpers (``_sessions``, ``_ok``, +``_err``, ...) bare. Module-level helpers defined here are published onto +server.py by :func:`register` the same way, so handlers and helpers share one +namespace (and tests that monkeypatch ``server.X`` still intercept). """ -from .method_ctx import HandlerRegistry +import contextlib +from dataclasses import dataclass + +from .method_ctx import HandlerRegistry, bind_module _registry = HandlerRegistry() method = _registry.method _profile_scoped = _registry.profile_scoped +# ── shared handler plumbing ────────────────────────────────────────── + + +def _with_session(fn): + """Resolve ``params.session_id`` via ``_sess_nowait`` and pass the record as a 3rd arg.""" + + def handler(rid, params: dict) -> dict: + session, err = _sess_nowait(params, rid) + if err: + return err + return fn(rid, params, session) + + return handler + + +def _with_live_session(fn): + """Like :func:`_with_session` but via ``_sess`` (waits for the agent build).""" + + def handler(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + return fn(rid, params, session) + + return handler + + +def _new_runtime_ids(params: dict) -> tuple[str, str]: + """Fresh runtime sid + resolved DB ``source`` for a session minted from ``params``.""" + return ( + uuid.uuid4().hex[:8], + _resolve_session_source(str(params.get("source") or "").strip() or None), + ) + + +@contextlib.contextmanager +def _profile_build_scope(profile_home): + """Bind HERMES_HOME + the profile's secret scope while building/initializing an agent. + + The home override alone only moves config/skills/memory; credentials resolve + through get_secret(), which without a scope falls through to the LAUNCH + profile's .env — so both are installed together. No-op for the launch profile. + """ + if not profile_home: + yield + return + home_token = set_hermes_home_override(str(profile_home)) + secret_token = set_secret_scope(build_profile_secret_scope(Path(str(profile_home)))) + try: + yield + finally: + reset_hermes_home_override(home_token) + reset_secret_scope(secret_token) + + +def _branch_title(db, parent_key: str) -> str: + """Next title in the parent's lineage (mirrors the TUI /branch naming).""" + current = db.get_session_title(parent_key) or "branch" + if hasattr(db, "get_next_title_in_lineage"): + return db.get_next_title_in_lineage(current) + return f"{current} (branch)" + + +def _cwd_info(session: dict, cwd: str, branch=None) -> dict: + """session.info after a cwd change: the full agent view, or the lazy shape.""" + agent = session.get("agent") + if agent is not None: + return _session_info(agent, session) + return { + "cwd": cwd, + "branch": _git_branch_for_cwd(cwd) if branch is None else branch, + "project": _project_info_for_cwd(cwd), + "lazy": True, + } + + +def _session_row_summary(row: dict, *, tip_row: dict | None = None, resolved_id=None) -> dict: + """Compact session.list row; ``tip_row``/``resolved_id`` come from the compression tip.""" + tip_row = tip_row or row + out = {"id": row["id"]} + if resolved_id is not None: + out["resolved_id"] = resolved_id + out.update( + { + "title": row.get("title") or "", + "preview": tip_row.get("preview") or "", + "started_at": row.get("started_at") or 0, + "message_count": tip_row.get("message_count") or 0, + "source": row.get("source") or "", + } + ) + return out + + +# Sources hidden from human-facing listings: ``tool`` sub-agent runs and +# ``kanban`` dispatcher workers. A deny-list (not an allow-list) so new +# platforms / custom HERMES_SESSION_SOURCE values surface automatically. +_LISTING_DENY_SOURCES = frozenset({"kanban", "tool"}) + + +def _denied_source(row: dict) -> bool: + return (row.get("source") or "").strip().lower() in _LISTING_DENY_SOURCES + + +def _pet_display_cfg() -> dict: + """``display.pet`` config block, ``{}`` when config is unreadable.""" + try: + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + return display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} + except Exception: + return {} + + +def _pet_guard(name: str, *, fail_open=None): + """Wrap a pet handler so any exception is logged at debug and never breaks the surface. + + ``fail_open`` is the result payload to return (``pet.info`` style); without it + the caller gets ``_err(5031, " failed: ...")``. + """ + + def deco(fn): + def handler(rid, params: dict) -> dict: + try: + return fn(rid, params) + except Exception as exc: # noqa: BLE001 - cosmetic surface + logger.debug("%s failed: %s", name, exc) + if fail_open is not None: + return _ok(rid, fail_open(params) if callable(fail_open) else dict(fail_open)) + return _err(rid, 5031, f"{name} failed: {exc}") + + return handler + + return deco + + +def _billing_call(rid, fn, extra: dict | None = None) -> dict: + """Run a portal call; typed BillingError → serialized envelope, anything else → generic. + + ``extra`` is appended to both ERROR envelopes (e.g. the idempotency key the + TUI reuses on retry); the success payload is whatever ``fn`` returns. + """ + from hermes_cli.nous_billing import BillingError + + try: + return _ok(rid, fn()) + except BillingError as exc: + return _ok(rid, {**_serialize_billing_error(exc), **(extra or {})}) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc), **(extra or {})}) + + +def _billing_invalid(rid, message: str, error: str = "invalid_request") -> dict: + return _ok(rid, {"ok": False, "error": error, "message": message}) + + +# ── session.create / list / most_recent / facts ────────────────────── + + +def _create_branch_rows( + db, new_key: str, parent_key: str, title: str, history: list, *, source, cwd, profile_name, copy_fields=() +) -> None: + """Create a branch child row + copy the parent transcript in bounded-chunk transactions. + + ``_branched_from`` is the stable marker that keeps the branch visible in + list_sessions_rich(): the TUI branch leaves the parent live (no + end_reason='branched'), so the legacy end_reason heuristic never matches it. + ``profile_name`` is stamped explicitly (not just parent-backfill) — NULL rows + drop out of profile-keyed sidebar matching and deep-link resolution. + """ + db.create_session( + new_key, + source=source, + model=_resolve_model(), + model_config={"_branched_from": parent_key}, + parent_session_id=parent_key, + cwd=cwd, + profile_name=profile_name, + ) + db.append_messages_batch( + new_key, + [ + { + "role": msg.get("role", "user"), + "content": msg.get("content"), + **{field: msg.get(field) for field in copy_fields}, + } + for msg in history + ], + chunk_rows=500, + ) + db.set_session_title(new_key, title) + + +def _seed_branch_row(sid: str, key: str, parent_session_id: str, history: list, source: str, profile_home) -> None: + """Persist a seeded desktop branch child up front (the one session.create exception to lazy rows). + + A branch carries parent_session_id AND a seeded transcript — explicit intent, not + an abandoned draft. The renderer's post-create resume re-fetches the child via REST + + defer_history hydration, both of which read the DB, so an unpersisted child 404s + and the fail-latch spins forever. Best-effort: on failure the lazy first-prompt + path stays as the fallback, exactly as for plain drafts. + """ + try: + with _session_db(_sessions[sid]) as db: + if db is None: + return + branch_title = _branch_title(db, parent_session_id) + try: + _create_branch_rows( + db, + key, + parent_session_id, + branch_title, + history, + source=source, + cwd=_sessions[sid]["cwd"], + profile_name=(Path(profile_home).name if profile_home else None), + ) + except Exception as exc: + # Compensation: if the transcript copy / title write failed AFTER the + # row committed, a durable-but-empty row would defeat the INSERT OR + # IGNORE first-prompt seed. Roll back just this child so it can retry. + from hermes_state import is_disk_full_error + + if is_disk_full_error(exc): + raise + try: + db.delete_session(key) + except Exception: + logger.debug("branch seed compensation delete failed for %s", key, exc_info=True) + raise + _sessions[sid]["pending_title"] = None + except Exception: + logger.warning( + "seeded-branch persistence failed for %s; falling back to lazy row creation", + key, + exc_info=True, + ) + + @method("session.create") def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] @@ -18,14 +267,11 @@ def _(rid, params: dict) -> dict: cols = int(params.get("cols", 80)) history = _coerce_seed_history(params.get("messages")) title = str(params.get("title") or "").strip() - # When set, this is a branch: the new chat copies an existing conversation's - # history and links back to it so list_sessions_rich keeps it visible and the - # sidebar can nest it under its parent. Mirrors the TUI /branch marker. + # A branch: copies an existing conversation and links back so list_sessions_rich + # keeps it visible and the sidebar nests it (mirrors the TUI /branch marker). parent_session_id = str(params.get("parent_session_id") or "").strip() or None - # Did the client pick a workspace, or are we falling back to the gateway's - # launch directory? Only an explicit choice is persisted as the session's - # workspace (see _ensure_session_db_row); otherwise it lands in "No - # workspace" instead of whatever folder the desktop launched in. + # Only an explicitly chosen (existing) workspace is persisted as the session's + # cwd (_ensure_session_db_row); the gateway launch dir fallback lands in "No workspace". raw_cwd = str(params.get("cwd") or "").strip() try: explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd))) @@ -35,18 +281,13 @@ def _(rid, params: dict) -> dict: source = _resolve_session_source(str(params.get("source") or "").strip() or None) _enable_gateway_prompts() - # ``profile`` (app-global remote mode): a new chat started under a non-launch - # profile must build its agent + persist against THAT profile's home/state.db, - # not the dashboard's launch profile. Stored on the session so _start_agent_build - # and each turn re-bind HERMES_HOME. None/own profile → launch (unchanged). + # ``profile`` (app-global remote mode): build + persist against THAT profile's + # home/state.db. Stored on the session so the build and every turn re-bind HERMES_HOME. profile = (params.get("profile") or "").strip() or None profile_home = _profile_home(profile) - # The desktop composer owns its model/effort/fast as plain UI state and ships - # it on every session.create. Honor each as a PER-SESSION override (built into - # the agent below) — never a global config write, so picking a model/effort - # for a new chat can't mutate the profile default. provider is optional - # (resolved at build). + # Composer model/effort/fast are PER-SESSION overrides, never a global config + # write. provider is optional (resolved at build). create_model = str(params.get("model") or "").strip() session_model_override = ( {"model": create_model, "provider": str(params.get("provider") or "").strip() or None} @@ -61,27 +302,21 @@ def _(rid, params: dict) -> dict: create_reasoning_override = parse_reasoning_effort(effort) except Exception: create_reasoning_override = None - # Presence is part of the contract: omitted means inherit the profile, - # true pins priority, and false pins normal. Empty string is the internal - # explicit-normal sentinel because _make_agent uses None for inheritance. + # ``fast`` presence is the contract: omitted inherits the profile, true pins + # priority, false pins normal ("" — _make_agent uses None for inheritance). create_service_tier_override = None if "fast" in params: - create_service_tier_override = ( - "priority" if is_truthy_value(params.get("fast")) else "" - ) + create_service_tier_override = "priority" if is_truthy_value(params.get("fast")) else "" - ready = threading.Event() now = time.time() - lease = None # claimed lazily on the first turn (_ensure_active_session_slot) - with _sessions_lock: _sessions[sid] = { "agent": None, "agent_error": None, - "agent_ready": ready, + "agent_ready": threading.Event(), "attached_images": [], "close_on_disconnect": is_truthy_value(params.get("close_on_disconnect", False)), - "active_session_lease": lease, + "active_session_lease": None, # claimed lazily on the first turn (_ensure_active_session_slot) "cols": cols, "created_at": now, "edit_snapshots": {}, @@ -113,93 +348,15 @@ def _(rid, params: dict) -> dict: } _register_session_cwd(_sessions[sid]) - # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop - # launch (and every "New agent" / draft) opens a session here just to paint - # the composer, so eagerly creating a row left an "Untitled" empty session - # behind for every launch the user never typed into. The row is now created - # lazily on the first prompt (see _ensure_session_db_row + prompt.submit), - # and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too. - # - # EXCEPTION — seeded branch children (#93959): a desktop branch carries - # parent_session_id AND a seeded transcript, which is explicit user intent, - # not an abandoned draft. The row MUST exist immediately: the renderer's - # post-create resume re-fetches the child through REST + defer_history - # hydration, both of which read the DB — an unpersisted child 404s, the - # fail-latch then refuses to bind a "transcript-less" session, and the user - # sees an infinite spinner whose optimistic row vanishes on restart. - # Persisting up front also means a restart keeps the branch (both reports - # lost it) and the title lands in the parent's lineage instead of falling - # back to a message-preview name. Title mirrors the TUI /branch naming. + # No DB row here: every launch/draft opens a session just to paint the + # composer, and eager rows left "Untitled" litter. The row is created lazily + # on the first prompt (_ensure_session_db_row + prompt.submit) — except for + # seeded branch children, which must exist immediately. if parent_session_id and history: - try: - with _session_db(_sessions[sid]) as db: - if db is not None: - parent_key = parent_session_id - current = db.get_session_title(parent_key) or "branch" - branch_title = ( - db.get_next_title_in_lineage(current) - if hasattr(db, "get_next_title_in_lineage") - else f"{current} (branch)" - ) - db.create_session( - key, - source=source, - model=_resolve_model(), - model_config={"_branched_from": parent_key}, - parent_session_id=parent_key, - cwd=_sessions[sid]["cwd"], - profile_name=( - Path(profile_home).name if profile_home else None - ), - ) - # Compensation guard (#93959 review): if the transcript - # copy or title write fails AFTER the row committed, the - # durable-but-empty row would defeat the lazy first-prompt - # fallback (_ensure_session_db_row is INSERT OR IGNORE — - # the row exists, so the seed never lands and the renderer - # fail-latches on a "transcript-less" session again). - # Roll back just this child so the seed path can retry - # cleanly on first submit. - try: - db.append_messages_batch( - key, - [ - {"role": m.get("role", "user"), "content": m.get("content")} - for m in history - ], - chunk_rows=500, - ) - db.set_session_title(key, branch_title) - except Exception as exc: - from hermes_state import is_disk_full_error + _seed_branch_row(sid, key, parent_session_id, history, source, profile_home) - if is_disk_full_error(exc): - raise - try: - db.delete_session(key) - except Exception: - logger.debug( - "branch seed compensation delete failed for %s", - key, - exc_info=True, - ) - raise - _sessions[sid]["pending_title"] = None - except Exception: - # Persistence is best-effort here: a failed write must not break - # session.create itself — the lazy first-prompt path remains as the - # fallback, exactly as for plain drafts. - logger.warning( - "seeded-branch persistence failed for %s; falling back to " - "lazy row creation", - key, - exc_info=True, - ) - - # Return the lightweight session immediately so Ink can paint the composer - # + skeleton panel, then build the real AIAgent just after this response is - # flushed. This keeps startup responsive while still hydrating tools/skills - # without requiring the user to submit a first prompt. + # Return immediately so Ink can paint; the real AIAgent builds right after + # this response is flushed (no first prompt needed to hydrate tools/skills). _schedule_agent_build(sid) _schedule_session_cap_enforcement() # trim detached idle sessions over the cap @@ -211,14 +368,10 @@ def _(rid, params: dict) -> dict: "message_count": len(history), "messages": _history_to_messages(history), "info": { - # Reflect the per-session model override (desktop composer pick) - # in the immediate response so the client doesn't briefly clobber - # its sticky pick with the global default before the deferred - # build's session.info lands. + # Reflect the per-session model override immediately so the client + # doesn't briefly clobber its sticky pick with the global default. "model": ( - session_model_override.get("model") - if session_model_override - else _resolve_model() + session_model_override.get("model") if session_model_override else _resolve_model() ), **( {"provider": session_model_override["provider"]} @@ -238,96 +391,55 @@ def _(rid, params: dict) -> dict: ) +def _session_list_by_title(rid, db, title_lookup: str) -> dict: + """EXACT-title registry lookup (not a listing) for callers that treat a title as identity. + + Hidden rows resolve (canonical chats are born hidden); archived rows and + deny-listed sources do not; compression lineages resolve to the live tip + (``resolved_id``), mirroring profiles.list's canonical_session resolver. + """ + row = db.get_session_by_title(title_lookup) + if row and row.get("archived"): + from tools.bot_mode_probe import BOT_CHAT_TITLE + + # The canonical Bot Chat is identity-scoped: an archive stamped by the + # ws-orphan reaper / agent_close is an accident, and hiding it makes the + # desktop mint transient replacements forever. Resurrect recoverable + # reasons only; deliberate archives still hide. Re-fetch by ID — title + # has no DB-level UNIQUE, so a title re-query could grab a duplicate. + if title_lookup == BOT_CHAT_TITLE and db.unarchive_recoverable_session(row["id"]): + row = db.get_session(row["id"]) + if not row or row.get("archived") or _denied_source(row): + return _ok(rid, {"sessions": []}) + try: + # Only a real compression continuation: the generic resume resolver's + # legacy unmarked-child fallback could redirect the canonical Bot Chat + # to an unrelated normal child. + tip = db.get_compression_tip(row["id"]) or row["id"] + except Exception: + tip = row["id"] + tip_row = (db.get_session(tip) or row) if tip != row["id"] else row + return _ok(rid, {"sessions": [_session_row_summary(row, tip_row=tip_row, resolved_id=tip)]}) + + @method("session.list") def _(rid, params: dict) -> dict: with _profile_db(params) as db: if db is None: return _db_unavailable_error(rid, code=5006) try: - # Resume picker should surface human conversation sessions from every - # user-facing surface — CLI, TUI, all gateway platforms (including new - # ones not enumerated here), ACP adapter clients, webhook sessions, - # custom `HERMES_SESSION_SOURCE` values, and older installs with - # different source labels. We deny-list only the noisy internal - # sources (``tool`` sub-agent runs and ``kanban`` dispatcher - # workers) rather than allow-listing a fixed set of platform names - # that goes stale whenever a new platform is added or a user names - # their own source. - deny = frozenset({"kanban", "tool"}) - - # ``title``: EXACT-title registry lookup, not a listing. The core - # UNIQUE title index means at most one session per db carries a - # given exact title, so callers that treat a title as an identity - # key (Bot Mode's canonical "Bot Chat" — Profile → Named Session) - # get a window-free O(1) answer instead of scanning a recency - # window that a busy profile can push the row out of. Hidden rows - # resolve (canonical chats are born hidden); archived rows and - # deny-listed sources do not; compression lineages resolve to the - # live tip (``resolved_id``), mirroring profiles.list's - # canonical_session resolver. Older clients never send this param; - # newer clients falling back to older gateways just get the normal - # windowed listing back (the param is ignored) and scan it. + # Older clients never send ``title``; newer clients on older gateways + # just get the windowed listing back and scan it. title_lookup = str(params.get("title") or "").strip() if title_lookup: - row = db.get_session_by_title(title_lookup) - if row and row.get("archived"): - from tools.bot_mode_probe import BOT_CHAT_TITLE - - if title_lookup == BOT_CHAT_TITLE: - # The canonical Bot Chat is identity-scoped: an archive - # stamped by the ws-orphan reaper or older agent cleanup - # (ws_orphan_reap / agent_close) is an accident, not user - # intent, and hiding the row here makes the desktop mint - # transient replacements forever (#92687). Resurrect it — - # same recoverable-reason set as stale-route recovery. - # Deliberate archives (no/explicit end_reason) still hide. - # Re-fetch by ID: title has no DB-level UNIQUE, so a - # title re-query could grab a different (still-archived) - # duplicate row. - if db.unarchive_recoverable_session(row["id"]): - row = db.get_session(row["id"]) - if ( - not row - or row.get("archived") - or (row.get("source") or "").strip().lower() in deny - ): - return _ok(rid, {"sessions": []}) - try: - # A named-session registry lookup must resolve only a real - # compression continuation. The generic resume resolver - # retains a legacy unmarked-child fallback for historical - # sessions; using it here can redirect the canonical Bot - # Chat to an unrelated normal child. - tip = db.get_compression_tip(row["id"]) or row["id"] - except Exception: - tip = row["id"] - tip_row = (db.get_session(tip) or row) if tip != row["id"] else row - return _ok( - rid, - { - "sessions": [ - { - "id": row["id"], - "resolved_id": tip, - "title": row.get("title") or "", - "preview": tip_row.get("preview") or "", - "started_at": row.get("started_at") or 0, - "message_count": tip_row.get("message_count") or 0, - "source": row.get("source") or "", - } - ] - }, - ) + return _session_list_by_title(rid, db, title_lookup) limit = int(params.get("limit", 200) or 200) - # ``include_hidden``: surfaces that OWN hidden sessions (the Bots - # pane's per-profile browser, plugin session pickers) need to list - # them; the flag stays off for the resume picker and every other - # global caller so `hidden` keeps meaning "not in shared lists". + # ``include_hidden``: only for surfaces that OWN hidden sessions (Bots + # pane, plugin pickers); off for the resume picker and every global caller. include_hidden = is_truthy_value(params.get("include_hidden", False)) - # Over-fetch modestly so per-source filtering doesn't leave us - # short; the compression-tip projection in ``list_sessions_rich`` - # can also merge rows. + # Over-fetch so per-source filtering (and tip projection merging in + # list_sessions_rich) doesn't leave us short. fetch_limit = max(limit * 2, 200) rows = [ s @@ -338,61 +450,32 @@ def _(rid, params: dict) -> dict: compact_rows=True, include_hidden=include_hidden, ) - if (s.get("source") or "").strip().lower() not in deny + if not _denied_source(s) ][:limit] - return _ok( - rid, - { - "sessions": [ - { - "id": s["id"], - "title": s.get("title") or "", - "preview": s.get("preview") or "", - "started_at": s.get("started_at") or 0, - "message_count": s.get("message_count") or 0, - "source": s.get("source") or "", - } - for s in rows - ] - }, - ) + return _ok(rid, {"sessions": [_session_row_summary(s) for s in rows]}) except Exception as e: return _err(rid, 5006, str(e)) @method("session.most_recent") def _(rid, params: dict) -> dict: - """Return the most recent human-facing session id, or ``None``. + """Most recent human-facing session id, or ``None`` (same deny-list as session.list). - Mirrors ``session.list``'s deny-list behaviour (drops ``tool`` - sub-agent rows and ``kanban`` worker rows). Used by TUI auto-resume when - ``display.tui_auto_resume_recent`` is on; the field is also handy - for any CLI tooling that wants "latest session" without paginating - the full list. - - Contract: a ``{"session_id": null}`` result means "no eligible - session found right now". Errors are also folded into that - null-result shape (and logged) so callers don't have to special- - case JSON-RPC error envelopes for what is a normal "no answer". - - Honors ``params.profile`` so app-global remote mode lists from the - focused profile's ``state.db`` (mirrors ``session.resume``). + ``{"session_id": null}`` means "no eligible session right now"; errors fold + into that shape (and log) so callers never special-case error envelopes. + Honors ``params.profile`` (mirrors ``session.resume``). """ with _profile_db(params) as db: if db is None: return _ok(rid, {"session_id": None}) try: - deny = frozenset({"kanban", "tool"}) - # Over-fetch by a generous bounded amount so heavy sub-agent - # users (lots of recent ``tool`` rows) don't get a false - # "no eligible session" answer. ``session.list`` uses a - # similar over-fetch strategy. + # Generous over-fetch so heavy sub-agent users (many ``tool`` rows) + # don't get a false "no eligible session". rows = db.list_sessions_rich( source=None, limit=200, order_by_last_active=True, compact_rows=True ) for row in rows: - src = (row.get("source") or "").strip().lower() - if src in deny: + if _denied_source(row): continue return _ok( rid, @@ -411,12 +494,10 @@ def _(rid, params: dict) -> dict: @method("project.facts") def _(rid, params: dict) -> dict: - """Structured project facts for a cwd — manifests, package manager, the - exact verify commands, and context files. + """Project facts for a cwd (manifests, package manager, verify commands, context files). - The same detection the coding-context posture (#43316) bakes into the system - prompt, exposed so UIs (the desktop verify surface) consume it instead of - re-sniffing. ``{"facts": null}`` means the cwd isn't a code workspace. + Same detection the coding-context posture bakes into the system prompt, + exposed so UIs consume it instead of re-sniffing. ``{"facts": null}`` = not a code workspace. """ try: from agent.coding_context import project_facts_for @@ -432,8 +513,8 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Best known coding verification evidence for a cwd/session. - Read-only consumer of the core ledger. It never runs checks and never - upgrades targeted evidence into a repository-wide guarantee. + Read-only consumer of the core ledger: never runs checks, never upgrades + targeted evidence into a repository-wide guarantee. """ try: from agent.verification_evidence import verification_status @@ -452,6 +533,560 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"verification": {"status": "unknown", "evidence": None}}) +# ── session.resume ─────────────────────────────────────────────────── + + +@dataclass +class _Resume: + """Per-call state for ``session.resume`` shared by the path helpers below. + + ``owns_db`` tracks the DEDICATED profile-scoped handle: it is ours to close + (the handler's ``finally``) until a path hands it to the hydration worker or + the agent (``_init_session``), which flips it False. + """ + + rid: object + params: dict + target: str + cols: int + profile: str | None + profile_home: object + lazy: bool + defer_history: bool + omit_messages: bool + eager_build: bool + db: object = None + owns_db: bool = False + found: dict | None = None + profile_resume_cwd: str = "" + + def record(self, source: str, history: list, **extra) -> dict: + """``_deferred_session_record`` with this resume's common fields; the active-session + lease is always claimed lazily on the first turn (_ensure_active_session_slot).""" + return _deferred_session_record( + self.target, + cols=self.cols, + cwd=self.profile_resume_cwd or _default_session_cwd(), + history=history, + lease=None, + source=source, + close_on_disconnect=is_truthy_value(self.params.get("close_on_disconnect", False)), + profile_home=self.profile_home, + explicit_cwd=bool(self.profile_resume_cwd), + **extra, + ) + + def resume_failed(self, exc) -> dict: + return _err(self.rid, 5000, f"resume failed: {exc}") + + +def _find_live_unpersisted(needle: str, home) -> str: + """Runtime sid of a live, not-yet-persisted session matched by stored key or pending title.""" + want_home = str(home) if home is not None else None + for live_sid, record in list(_sessions.items()): + if not isinstance(record, dict): + continue + if (record.get("profile_home") or None) != want_home: + continue + if str(record.get("session_key") or "") == needle or (record.get("pending_title") or "") == needle: + return live_sid + return "" + + +def _resume_live_unpersisted(ctx: _Resume, live_sid: str, live: dict) -> dict: + """Reattach a LIVE lazy session (no state.db row yet — every fresh Bot Chat). + + session.create persists no row until the first prompt, so a resume by stored + key / pending title for a never-messaged session lands here; a hard 404 killed + messaging for exactly the bots that had never spoken. A WS drop may have + sentinel-parked the record, so rebind the transport and cancel the armed + orphan-reap Timer or it fires against a client that is attached right now. + """ + if ctx.owns_db: + with contextlib.suppress(Exception): + from hermes_state import release_or_close + + release_or_close(ctx.db) + live["last_active"] = time.time() + transport = current_transport() + if transport is not None: + with live.setdefault("history_lock", threading.Lock()): + live["transport"] = transport + live.setdefault("viewers", {})[transport] = time.time() + _cancel_ws_orphan_reap(live_sid) + history = live.get("history") or [] + return _ok( + ctx.rid, + _attach_todo_state( + { + "session_id": live_sid, + "stored_session_id": str(live.get("session_key") or ""), + "message_count": len(history), + "messages": [] if ctx.omit_messages else _history_to_messages(history), + "info": {"model": _resolve_model(), "lazy": True, "profile_name": ctx.profile or ""}, + }, + live, + ), + ) + + +def _resume_adopt_stranded(ctx: _Resume) -> None: + """Adopt a lineage stranded in the DEFAULT store into this profile's db (profile-scoped only). + + Before session RPCs routed by their TARGET session, a profile bot's turns ran + on the focused tile's backend, so its canonical session accumulated in the + default profile's state.db; without adoption that chat 4001s forever. + Exact-id match ONLY: title lookup has no archived filter and bot titles + collide by design, so a title-matched donor could retire an UNRELATED + conversation. Never re-adopt an already-retired donor (two "canonical" clones). + """ + try: + default_db = _get_db() + donor_row = default_db.get_session(ctx.target) if default_db is not None else None + if donor_row and donor_row.get("archived"): + donor_row = None + if donor_row: + adoption = ctx.db.adopt_session_lineage_from(default_db, donor_row["id"]) + if adoption.get("adopted"): + logger.info( + "adopted stranded session %s (lineage of %s segment(s)) from default store into profile %s", + donor_row["id"], + len(adoption.get("imported_ids") or []) + len(adoption.get("skipped_ids") or []), + ctx.profile or "?", + ) + ctx.found = ctx.db.get_session(donor_row["id"]) + if ctx.found: + ctx.target = ctx.found["id"] + except Exception: + logger.exception("stranded-session adoption failed for %s", ctx.target) + + +def _resume_locate(ctx: _Resume) -> dict | None: + """Resolve ``ctx.target`` to a stored row (``ctx.found``); a dict is an early response.""" + db = ctx.db + ctx.found = db.get_session(ctx.target) + if ctx.found: + return None + ctx.found = db.get_session_by_title(ctx.target) + if ctx.found: + ctx.target = ctx.found["id"] + return None + if ctx.lazy and _child_run_active(ctx.target): + # Race: a watch window opened on a freshly-spawned subagent. The child + # relays `subagent.start` BEFORE its first run_conversation() flushes the + # DB row, so the row is momentarily missing (reliably on WSL2). The child + # is provably live, so proceed lazily with empty history — the live + # mirror streams the turn and the row exists by upgrade time. + ctx.found = {} + return None + live_sid = _find_live_unpersisted(ctx.target, ctx.profile_home) + live = _sessions.get(live_sid) if live_sid else None + if live is not None: + return _resume_live_unpersisted(ctx, live_sid, live) + if ctx.owns_db: + _resume_adopt_stranded(ctx) + if not ctx.found: + return _err(ctx.rid, 4007, "session not found") + return None + + +def _resume_follow_tip(ctx: _Resume) -> None: + """Rebind a rotated-out parent id to its compression-continuation tip. + + Auto-compression ends the session and forks a child; resuming the original + id would reload the parent transcript and lose the post-compression reply. + Resolving here also re-anchors the live fast path so a rotated live session + is reused (by its new key) instead of rebuilding a duplicate on the stale + parent. Skipped for lazy watch windows (they attach to the exact child). + Bot Chat stays on a proven compression edge so an unmarked side chat cannot + steal the open; other sessions keep the legacy unmarked-child walker. + """ + if not ctx.found or ctx.lazy: + return + try: + from tools.bot_mode_probe import BOT_CHAT_TITLE + + if (ctx.found.get("title") or "").strip() == BOT_CHAT_TITLE: + tip = ctx.db.get_compression_tip(ctx.target) or ctx.target + else: + tip = ctx.db.resolve_resume_session_id(ctx.target) + except Exception: + tip = ctx.target + if tip and tip != ctx.target: + ctx.target = tip + ctx.found = ctx.db.get_session(tip) or ctx.found + + +def _resume_guard(ctx: _Resume) -> dict | None: + """Refuse a runaway transcript before any history read (sessions.max_resume_messages). + + Only the non-deferred, non-omitted resume reads the whole lineage; the + deferred Desktop resume, omit_messages resume and lazy watch load the TIP + segment only, so they are guarded tip-only (a full-lineage count rejected + exactly the well-compressed conversations compaction produces). Metadata + fallback keeps lightweight adaptor DBs compatible. Fails OPEN on guard errors + — only a genuine over-limit blocks. + """ + from hermes_state import SessionResumeTooLargeError, resolved_max_resume_messages + + guard_tip_only = ctx.lazy or ctx.omit_messages or (ctx.defer_history and not ctx.eager_build) + safety_check = getattr(ctx.db, "assert_resume_safe", None) + try: + if callable(safety_check): + if guard_tip_only: + safety_check(ctx.target, tip_only=True) + else: + safety_check(ctx.target) + else: + resume_limit = resolved_max_resume_messages() + stored_message_count = int(ctx.found.get("message_count") or 0) + if resume_limit and stored_message_count > resume_limit: + raise SessionResumeTooLargeError(stored_message_count, resume_limit) + except SessionResumeTooLargeError as exc: + return _err(ctx.rid, 4130, str(exc)) + except Exception as exc: + logger.warning( + "resume safety check failed for %s (proceeding without guard): %s", ctx.target, exc + ) + return None + + +def _resume_reuse_live(ctx: _Resume, sid: str, session: dict) -> dict: + """Reattach an already-live session under the resume lock. + + Holding the lock across the client-gone check, transport rebind and reap + cancel makes grace expiry atomic across every reuse path (slow-path claim + races discover a winner after releasing their own lock). + """ + with _session_resume_lock: + if _sessions.get(sid) is not session: + return _err(ctx.rid, 4007, "session no longer live; retry resume") + if session.get("_client_gone_interrupt_requested"): + return _err(ctx.rid, 4009, "session disconnect interrupt settling") + # Cancel unconditionally (the payload's rebind only cancels when a + # transport is passed) so the fast path can never race the reap Timer. + _cancel_ws_orphan_reap(sid) + payload = _live_session_payload( + sid, + session, + cols=ctx.cols, + touch=True, + transport=current_transport() or _stdio_transport, + omit_messages=ctx.omit_messages, + ) + payload["resumed"] = ctx.target + if ctx.defer_history: + payload["messages"] = [] + payload["message_count"] = int(session.get("resume_message_count") or payload["message_count"]) + payload["hydrating"] = bool(session.get("resume_hydrating")) + # A lazy watch session never owns a run loop (running always False) — + # overlay the child-run registry so a reconnecting window stays busy. + if session.get("agent") is None and _child_run_active(ctx.target): + payload["running"] = True + payload["status"] = "streaming" + return _ok(ctx.rid, payload) + + +def _resume_info(ctx: _Resume, cwd: str, overrides: dict | None = None) -> dict: + overrides = overrides or {} + model_override = overrides.get("model_override") or {} + return _lazy_resume_info( + cwd, + model=model_override.get("model") or "", + provider=overrides.get("provider_override") or "", + profile=ctx.profile, + ) + + +def _resume_response( + ctx: _Resume, + sid: str, + record: dict, + *, + messages: list, + message_count: int, + info: dict, + running: bool, + status: str, + hydrating: bool | None = None, + started_at=None, + auto_continue=None, +) -> dict: + payload = { + "session_id": sid, + "resumed": ctx.target, + "message_count": message_count, + "messages": messages, + } + if hydrating is None: + payload["messages_omitted"] = ctx.omit_messages + else: + payload["hydrating"] = hydrating + payload.update( + { + "info": info, + "inflight": None, + "running": running, + "session_key": ctx.target, + "started_at": record["created_at"] if started_at is None else started_at, + "status": status, + } + ) + if auto_continue is not None: + payload["auto_continue"] = auto_continue + return _ok(ctx.rid, _attach_todo_state(payload, record)) + + +def _resume_read_history(ctx: _Resume): + """One lineage SELECT feeds both projections: model-fed copy alternation-repaired + for live replay, display copy verbatim (inspection/export shows what is stored).""" + ctx.db.reopen_session(ctx.target) + if ctx.omit_messages: + raw = ctx.db.get_messages_as_conversation(ctx.target, repair_alternation=True, include_row_ids=True) + return raw, [] + return ctx.db.get_resume_conversations(ctx.target) + + +def _resume_lazy(ctx: _Resume) -> dict: + """Lazy/watch resume: register the live session WITHOUT building an agent. + + Used by the desktop's subagent windows — the child runs inside the parent's + turn, so the window only needs stored history plus a transport for the + child-mirror's live events. A later prompt.submit upgrades it via + _start_agent_build (resume_session_id keeps it on the stored conversation). + """ + sid, source = _new_runtime_ids(ctx.params) + try: + ctx.db.reopen_session(ctx.target) + # The child's OWN conversation only (include_ancestors would prepend the + # parent's transcript). repair_alternation heals a durable ``user;user`` + # once here instead of re-firing the pre-request repair every turn. + history = ctx.db.get_messages_as_conversation( + ctx.target, repair_alternation=True, include_row_ids=True + ) + except Exception as e: + return ctx.resume_failed(e) + record = ctx.record(source, history, lazy=True, todo_state=_todo_state_from_history(history)) + if (live := _claim_or_reuse_live(sid, ctx.target, record, None)) is not None: + return _resume_reuse_live(ctx, *live) + # A delegated child mid-run emits no session events of its own — report + # liveness from the relay registry so the window shows a busy turn. + child_running = _child_run_active(ctx.target) + # Display uses the VERBATIM projection (child-only, matching the repaired + # read) so model-invisible rows survive in the watch window as on the eager + # + REST paths; the repaired ``history`` still feeds live replay. + try: + display_history = ctx.db.get_messages_as_conversation( + ctx.target, repair_alternation=False, include_row_ids=True + ) + except Exception: + logger.debug("child-watch display projection read failed", exc_info=True) + display_history = history + messages = [] if ctx.omit_messages else _history_to_messages(display_history) + return _resume_response( + ctx, + sid, + record, + messages=messages, + message_count=len(display_history) if ctx.omit_messages else len(messages), + info=_lazy_resume_info(record["cwd"], profile=ctx.profile), + running=child_running, + status="streaming" if child_running else "idle", + ) + + +def _resume_deferred(ctx: _Resume) -> dict: + """Bounded acknowledgement; the transcript hydrates in the background and the + display copy pages over REST. defer_history SUPERSEDES omit_messages: the + response never carries a transcript and the ONE history read happens in the + hydration worker, so it is never loaded twice for one resume.""" + sid, source = _new_runtime_ids(ctx.params) + _enable_gateway_prompts() + overrides = _stored_session_runtime_overrides(ctx.found) or {} + record = ctx.record( + source, + [], + model_override=overrides.get("model_override"), + resume_runtime_overrides=overrides or None, + ) + record["resume_history_ready"] = threading.Event() + record["resume_hydrating"] = True + record["resume_message_count"] = int(ctx.found.get("message_count") or 0) + if (live := _claim_or_reuse_live(sid, ctx.target, record, None)) is not None: + return _resume_reuse_live(ctx, *live) + + _schedule_resume_hydration(sid, ctx.target, ctx.db, close_db=ctx.owns_db) + # The hydration worker now owns a profile-scoped handle and closes it after + # the read. The shared launch DB is process-owned. + ctx.owns_db = False + _schedule_session_cap_enforcement() + return _resume_response( + ctx, + sid, + record, + messages=[], + message_count=record["resume_message_count"], + info=_resume_info(ctx, record["cwd"], overrides), + running=False, + status="resuming", + hydrating=True, + ) + + +def _resume_cold(ctx: _Resume) -> dict: + """Cold resume default: register the session and read its transcript, but build + the agent OFF the response path — _make_agent can block for seconds and every + resume caller awaits this RPC before it paints. Pre-warms on a short timer + (session.create's deferred-build contract); _sess() builds on demand if the + first prompt beats it. Unlike the lazy branch this restores the full ancestor + history and persisted runtime identity, and is a real (upgradable) session.""" + sid, source = _new_runtime_ids(ctx.params) + _enable_gateway_prompts() + try: + raw_history, display_history = _resume_read_history(ctx) + except Exception as e: + return ctx.resume_failed(e) + # Display keeps the full transcript; the model-fed history drops a dangling + # tool-call tail so a session killed mid-loop does not replay it forever. + prefix = [] if ctx.omit_messages else ctx.db.get_ancestor_display_prefix(ctx.target) + history = sanitize_replay_history(raw_history) + # Restore model/provider/reasoning/tier so the deferred build matches the + # eager path — without them the build drops the provider. + overrides = _stored_session_runtime_overrides(ctx.found) or {} + record = ctx.record( + source, + history, + display_history_prefix=prefix, + model_override=overrides.get("model_override"), + resume_runtime_overrides=overrides or None, + todo_state=_todo_state_from_history(history), + ) + if (live := _claim_or_reuse_live(sid, ctx.target, record, None)) is not None: + return _resume_reuse_live(ctx, *live) + + _schedule_agent_build(sid) + _schedule_session_cap_enforcement() # trim detached idle sessions over the cap + auto_continue = _maybe_schedule_auto_continue(sid, record, ctx.target) + + messages = [] if ctx.omit_messages else _history_to_messages(display_history) + return _resume_response( + ctx, + sid, + record, + messages=messages, + message_count=len(raw_history) if ctx.omit_messages else len(messages), + info=_resume_info(ctx, record["cwd"], overrides), + running=False, + status="idle", + auto_continue=auto_continue, + ) + + +def _resume_eager(ctx: _Resume) -> dict: + """Synchronous build (``eager_build: true``, e.g. build-race tests). + + The agent is built OUTSIDE _session_resume_lock (it can block for seconds and + would stall session.close on the dispatch thread), then double-checked: if a + concurrent resume won meanwhile, discard our agent and reuse theirs. + """ + sid, source = _new_runtime_ids(ctx.params) + _enable_gateway_prompts() + with _profile_build_scope(ctx.profile_home): + try: + raw_history, display_history = _resume_read_history(ctx) + display_history_prefix = [] if ctx.omit_messages else ctx.db.get_ancestor_display_prefix(ctx.target) + history = sanitize_replay_history(raw_history) + messages = [] if ctx.omit_messages else _history_to_messages(display_history) + tokens = _set_session_context(ctx.target) + try: + # The profile's db so turns persist to the right state.db; runtime + # identity from the stored row so switching chats does not inherit + # whatever global model another chat last selected. + stored_runtime_overrides = _stored_session_runtime_overrides(ctx.found) + agent = _make_agent( + sid, + ctx.target, + session_id=ctx.target, + session_db=ctx.db, + platform_override=source, + context_cwd_is_launch_artifact=( + source in _LAUNCH_CWD_NOT_A_WORKSPACE and not ctx.profile_resume_cwd + ), + **stored_runtime_overrides, + ) + finally: + _clear_session_context(tokens) + except Exception as e: + return ctx.resume_failed(e) + + with _session_resume_lock: + live = _find_live_session_by_key(ctx.target, ctx.profile_home) + if live is not None: + try: + if hasattr(agent, "close"): + agent.close() + except Exception: + pass + return _resume_reuse_live(ctx, *live) + try: + with _profile_build_scope(ctx.profile_home): + _init_session( + sid, + ctx.target, + agent, + history, + cols=ctx.cols, + cwd=ctx.profile_resume_cwd, + session_db=ctx.db, + source=source, + explicit_cwd=bool(ctx.profile_resume_cwd), + ) + # Ownership TRANSFER: the registered agent holds this handle for + # its life and AIAgent.close() releases it at teardown + # (_init_session never closes a caller-supplied db). The drop is + # UNCONDITIONAL — past this line the session is registered against + # the handle, so the finally must not close it even if the transfer + # was refused (a leak is survivable; "Cannot operate on a closed + # database" on every later turn is not). The transfer is gated on + # owns_db: the SHARED launch handle must never move onto one + # session, or session.close tears down the process-wide database. + if ctx.owns_db: + _transfer_db_to_agent(agent, ctx.db) + ctx.owns_db = False + if sid in _sessions: + if stored_runtime_overrides.get("model_override") is not None: + _sessions[sid]["model_override"] = stored_runtime_overrides["model_override"] + _sessions[sid]["display_history_prefix"] = display_history_prefix + # Each turn re-binds HERMES_HOME (mid-turn home reads — memory, + # skills — must resolve to the resumed profile too). + if ctx.profile_home is not None: + _sessions[sid]["profile_home"] = str(ctx.profile_home) + _sessions[sid]["active_session_lease"] = None # claimed lazily on the first turn + except Exception as e: + # _init_session registers _sessions[sid] BEFORE its first read through + # this handle ("database is locked" is the realistic trigger). Left in + # place, the live fast path would serve that dead session forever; + # owns_db still True means the registration is ours to undo. + if ctx.owns_db: + with _sessions_lock: + _sessions.pop(sid, None) + return ctx.resume_failed(e) + session = _sessions.get(sid) or {} + auto_continue = _maybe_schedule_auto_continue(sid, session, ctx.target) if session else None + return _resume_response( + ctx, + sid, + session, + messages=messages, + message_count=len(raw_history) if ctx.omit_messages else len(messages), + info=_session_info(agent, session), + running=False, + status="idle", + started_at=float(session.get("created_at") or time.time()), + auto_continue=auto_continue, + ) + + @method("session.resume") def _(rid, params: dict) -> dict: target = params.get("session_id", "") @@ -461,761 +1096,71 @@ def _(rid, params: dict) -> dict: cols = int(params.get("cols", 80)) except (TypeError, ValueError): cols = 80 - # ``profile`` (app-global remote mode): resume a session that lives in another - # local profile's state.db. None/own profile → the launch profile (unchanged). + # ``profile`` (app-global remote mode): resume from another local profile's state.db. profile = (params.get("profile") or "").strip() or None - profile_home = _profile_home(profile) - defer_history = is_truthy_value(params.get("defer_history", False)) - # Desktop hydrates persisted transcripts through the authenticated REST - # route in parallel. Suppress the duplicate WebSocket transcript only when - # the caller explicitly requests it; other clients keep upstream behavior. - omit_messages = is_truthy_value(params.get("omit_messages", False)) - - # In a profile scope this opens a DEDICATED handle we own until the agent - # takes it (see the ownership transfer at _init_session below); every path - # that returns before that transfer must close it. Otherwise reuse the - # shared launch db, which outlives the RPC and is never closed here. - owns_db = False - if profile_home is not None: + ctx = _Resume( + rid=rid, + params=params, + target=target, + cols=cols, + profile=profile, + profile_home=_profile_home(profile), + lazy=is_truthy_value(params.get("lazy", False)), + defer_history=is_truthy_value(params.get("defer_history", False)), + # Desktop hydrates transcripts over REST in parallel; suppress the + # duplicate WebSocket copy only when explicitly asked. + omit_messages=is_truthy_value(params.get("omit_messages", False)), + eager_build=is_truthy_value(params.get("eager_build", False)), + ) + # Profile scope opens a DEDICATED handle we own until the agent takes it; + # otherwise the shared launch db, which outlives the RPC and is never closed here. + if ctx.profile_home is not None: from hermes_state import get_shared_session_db - db = get_shared_session_db(profile_home / "state.db") - owns_db = True + ctx.db = get_shared_session_db(ctx.profile_home / "state.db") + ctx.owns_db = True else: - db = _get_db() + ctx.db = _get_db() try: - if db is None: + if ctx.db is None: return _db_unavailable_error(rid, code=5000) - - found = db.get_session(target) - if not found: - found = db.get_session_by_title(target) - if found: - target = found["id"] - elif is_truthy_value(params.get("lazy", False)) and _child_run_active(target): - # Race: a watch window opened on a freshly-spawned subagent. The - # child relays `subagent.start` (which carries child_session_id and - # triggers the window) BEFORE its first run_conversation() flushes - # the DB row via _ensure_db_session, so db.get_session(target) is - # momentarily empty. On slower hosts (notably WSL2, where SQLite + - # process scheduling widen the gap) the window's resume consistently - # lands inside this window and used to hard-fail "session not found" - # — the frontend then 404'd on the REST messages fallback and the - # window spun forever. The child is provably live (_child_run_active), - # so proceed into the lazy branch with empty history; the live mirror - # streams the whole turn anyway and the row exists by upgrade time. - found = {} - else: - # LIVE lazy session: session.create intentionally persists no - # state.db row until the first prompt (no "Untitled" litter), - # so a resume by the stored key or pending title lands here for - # every never-messaged session. Bot Mode hits it on every fresh - # non-default bot — the canonical Bot Chat is created lazily on - # the profile, the open/send then resumes it, and this hard 404 - # ("session not found") killed messaging for exactly the bots - # that had never spoken. Match the in-memory registry by stored - # key or pending title, scoped to the SAME profile home this - # resume targets, and hand the caller the live record. - # (Nested per method_ctx rebinding — module helpers are - # invisible from installed handlers.) - def _find_live_unpersisted(needle: str, home) -> str: - want_home = str(home) if home is not None else None - for live_sid, record in list(_sessions.items()): - if not isinstance(record, dict): - continue - if (record.get("profile_home") or None) != want_home: - continue - if ( - str(record.get("session_key") or "") == needle - or (record.get("pending_title") or "") == needle - ): - return live_sid - return "" - - live_sid = _find_live_unpersisted(target, profile_home) - live = _sessions.get(live_sid) if live_sid else None - if live is not None: - if owns_db: - with contextlib.suppress(Exception): - from hermes_state import release_or_close - release_or_close(db) - live["last_active"] = time.time() - # This resume reattaches the live record. A lazy session - # (no state.db row yet — every fresh Bot Chat) that was - # sentinel-parked by a WS drop MUST be rebound here, or it - # keeps the drop sentinel and the armed orphan-reap Timer - # fires against a client that is attached right now — the - # unpersisted sibling of the storm-killer paths (#91276). - transport = current_transport() - if transport is not None: - with live.setdefault("history_lock", threading.Lock()): - live["transport"] = transport - live.setdefault("viewers", {})[transport] = time.time() - _cancel_ws_orphan_reap(live_sid) - history = live.get("history") or [] - return _ok( - rid, - _attach_todo_state( - { - "session_id": live_sid, - "stored_session_id": str(live.get("session_key") or ""), - "message_count": len(history), - "messages": [] if omit_messages else _history_to_messages(history), - "info": { - "model": _resolve_model(), - "lazy": True, - "profile_name": profile or "", - }, - }, - live, - ), - ) - - # Stranded-session adoption (#93296 follow-up): before session - # RPCs routed by their TARGET session, a profile bot's turns - # executed on the focused tile's backend — usually default — - # so its canonical session accumulated in the DEFAULT - # profile's state.db. Now that routing is correct, this - # profile-scoped resume is the first place the fix and the - # stranded data collide: the id exists in the default store - # but not here, and without adoption the same chat 4001s - # forever (the fix made it unreachable instead of misrouted). - # Adopt the full lineage from the default store into this - # profile's db, then retry the lookup. Only profile-scoped - # resumes reach here (owns_db); unknown ids in the default - # store still 4007 exactly as before. - if owns_db: - try: - default_db = _get_db() - # Exact-id match ONLY. Title lookup (get_session_by_title) - # has no archived filter, no ordering, and bot titles - # collide by design ("Bot Chat") — a title-matched donor - # could adopt and non-recoverably retire an UNRELATED - # default-profile conversation. The stranded-session - # repro always has the exact id (the desktop routes by - # id), so nothing real is lost. - donor_row = ( - default_db.get_session(target) - if default_db is not None - else None - ) - # Never re-adopt an already-retired donor: a second - # profile resuming the same id would otherwise clone - # the conversation into two "canonical" stores. - if donor_row and donor_row.get("archived"): - donor_row = None - if donor_row: - adoption = db.adopt_session_lineage_from( - default_db, donor_row["id"] - ) - if adoption.get("adopted"): - logger.info( - "adopted stranded session %s (lineage of %s " - "segment(s)) from default store into profile %s", - donor_row["id"], - len(adoption.get("imported_ids") or []) - + len(adoption.get("skipped_ids") or []), - profile or "?", - ) - found = db.get_session(donor_row["id"]) - if found: - target = found["id"] - except Exception: - logger.exception( - "stranded-session adoption failed for %s", target - ) - if not found: - return _err(rid, 4007, "session not found") - - # Follow the compression-continuation chain to the live tip so a resume on - # a rotated-out parent id binds to the descendant that actually holds the - # post-compression turns. Auto-compression ends the session and forks a - # continuation child; without this, resuming the original id (the desktop's - # routed id when the chat was opened before it rotated) reloads the parent - # transcript and the response generated after compression is missing — the - # "I came back and the reply isn't there" bug on large sessions. Resolving - # here also re-anchors the fast path below so a still-live rotated session - # is reused (by its new key) instead of rebuilding a duplicate agent on the - # stale parent. Skipped for lazy watch windows, which intentionally attach - # to the exact child branch they were opened on. Bot Chat is a named - # registry row — stay on a proven compression edge so an unmarked - # side chat cannot steal the open (the title-lookup / profiles.list - # contract). Other sessions keep the legacy unmarked-child walker. - if found and not is_truthy_value(params.get("lazy", False)): - try: - from tools.bot_mode_probe import BOT_CHAT_TITLE - - if (found.get("title") or "").strip() == BOT_CHAT_TITLE: - tip = db.get_compression_tip(target) or target - else: - tip = db.resolve_resume_session_id(target) - except Exception: - tip = target - if tip and tip != target: - target = tip - found = db.get_session(target) or found - - # Todo snapshots are derived from each path's already-loaded history - # (see _todo_state_from_history) — no extra transcript read here. - - # Every interactive resume path materializes the model history, even when - # omit_messages suppresses the response copy. Count what THIS path will - # actually load before any reopen/history read so a runaway transcript - # cannot exhaust the dashboard. Only the non-deferred, non-omitted - # resume reads the whole compression lineage (ancestors → tip) into - # memory; the deferred Desktop resume (display transcript paged over - # REST), the omit_messages resume, and the lazy watch resume all load - # the TIP segment only — guarding those against the full-lineage count - # rejected exactly the well-compressed conversations compaction is - # meant to produce (85 segments / ~29k lineage rows / ~700-row tip → - # 4130 and a Bot Chat stuck on "Waking up…"). The metadata fallback - # keeps lightweight test/adaptor DBs that predate the shared SessionDB - # guard compatible. The limit resolves from config - # (sessions.max_resume_messages, 0 disables). - from hermes_state import ( - SessionResumeTooLargeError, - resolved_max_resume_messages, + if (resp := _resume_locate(ctx)) is not None: + return resp + _resume_follow_tip(ctx) + if (resp := _resume_guard(ctx)) is not None: + return resp + ctx.profile_resume_cwd = str(ctx.found.get("cwd") or "").strip() or _profile_configured_cwd( + ctx.profile_home ) - - eager_build = is_truthy_value(params.get("eager_build", False)) - guard_tip_only = ( - is_truthy_value(params.get("lazy", False)) - or omit_messages - or (defer_history and not eager_build) - ) - safety_check = getattr(db, "assert_resume_safe", None) - try: - if callable(safety_check): - if guard_tip_only: - safety_check(target, tip_only=True) - else: - safety_check(target) - else: - resume_limit = resolved_max_resume_messages() - stored_message_count = int(found.get("message_count") or 0) - if resume_limit and stored_message_count > resume_limit: - raise SessionResumeTooLargeError(stored_message_count, resume_limit) - except SessionResumeTooLargeError as exc: - return _err(rid, 4130, str(exc)) - except Exception as exc: - # Fail OPEN: a transient guard failure (locked DB, schema skew on - # an adaptor store) must not turn the safety check into a new way - # to lose access to a session. Only a genuine over-limit blocks. - logger.warning( - "resume safety check failed for %s (proceeding without guard): %s", - target, exc, - ) - - profile_resume_cwd = str(found.get("cwd") or "").strip() or _profile_configured_cwd( - profile_home - ) - - def _reuse_live_payload(sid: str, session: dict) -> dict: - payload = _live_session_payload( - sid, - session, - cols=cols, - touch=True, - transport=current_transport() or _stdio_transport, - omit_messages=omit_messages, - ) - payload["resumed"] = target - if defer_history: - payload["messages"] = [] - payload["message_count"] = int( - session.get("resume_message_count") or payload["message_count"] - ) - payload["hydrating"] = bool(session.get("resume_hydrating")) - # A lazy watch session never owns a run loop, so its payload's running - # flag is always False — overlay the child-run registry so a reconnecting - # watch window keeps its busy indicator while the child is still mid-run. - if session.get("agent") is None and _child_run_active(target): - payload["running"] = True - payload["status"] = "streaming" - return payload - - def _reuse_live_response(sid: str, session: dict) -> dict: - # The helper owns the resume lock because slow-path claim races can - # discover a live winner and return it after releasing their own lock. - # Keeping the client-gone check and transport rebind in one critical - # section makes grace expiry atomic across every reuse path. - with _session_resume_lock: - if _sessions.get(sid) is not session: - return _err(rid, 4007, "session no longer live; retry resume") - if session.get("_client_gone_interrupt_requested"): - return _err(rid, 4009, "session disconnect interrupt settling") - # This resume reattaches the live record: cancel any pending - # ws-orphan reap timer armed while the client was detached - # (storm killer — _live_session_payload's rebind also cancels, - # but only when a transport is passed; cancel unconditionally - # here so the fast path can never race the reap Timer). - _cancel_ws_orphan_reap(sid) - return _ok(rid, _reuse_live_payload(sid, session)) - - # Fast path: if the session is already live IN THIS PROFILE, reuse it - # under the lock. Never another profile's runtime of the same stored id - # — that ran profile B's turn on profile A's agent/memory (#100029). + # Fast path: reuse a session already live IN THIS PROFILE — never another + # profile's runtime of the same stored id. with _session_resume_lock: - live = _find_live_session_by_key(target, profile_home) + live = _find_live_session_by_key(ctx.target, ctx.profile_home) if live is not None: - return _reuse_live_response(*live) - - # Lazy/watch resume: register the live session WITHOUT building an agent. - # Used by the desktop's subagent windows — the child runs inside the - # parent's turn, so its window only needs the stored history plus a - # transport for the child-mirror's live events. Skipping _make_agent here - # is what keeps the window cheap while the backend is busy running the - # delegation. A later prompt.submit upgrades it via _start_agent_build - # (resume_session_id keeps the upgrade on the stored conversation). - if is_truthy_value(params.get("lazy", False)): - sid = uuid.uuid4().hex[:8] - source = _resolve_session_source(str(params.get("source") or "").strip() or None) - lease = None # claimed lazily on the first turn (_ensure_active_session_slot) - try: - db.reopen_session(target) - # The child's OWN conversation only — include_ancestors would prepend - # the parent's transcript onto the subagent's branch. - # repair_alternation: this resume feeds LIVE REPLAY (the loaded - # history becomes the resumed session record's working conversation), - # so heal a durable ``user;user`` violation once here instead of - # re-firing the pre-request repair on every subsequent turn. - history = db.get_messages_as_conversation( - target, repair_alternation=True, include_row_ids=True - ) - except Exception as e: - if lease is not None: - lease.release() - return _err(rid, 5000, f"resume failed: {e}") - cwd = profile_resume_cwd or _default_session_cwd() - record = _deferred_session_record( - target, - cols=cols, - cwd=cwd, - history=history, - lease=lease, - source=source, - close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)), - profile_home=profile_home, - lazy=True, - todo_state=_todo_state_from_history(history), - explicit_cwd=bool(profile_resume_cwd), - ) - if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None: - return _reuse_live_response(*live) - # A delegated child mid-run emits no session events of its own — report - # its liveness from the relay registry so the window shows a busy turn. - child_running = _child_run_active(target) - # User-visible messages use the VERBATIM display projection (child-only, - # no ancestors — matching the repaired read above), so model-invisible - # rows persisted by #65919 (verification candidates collapsed by - # repair_message_sequence) survive in the watch window just as they do - # on the eager resume + REST paths. The repaired ``history`` above still - # feeds live replay. Fall back to it if the display read fails. - try: - display_history = db.get_messages_as_conversation( - target, repair_alternation=False, include_row_ids=True - ) - except Exception: - logger.debug("child-watch display projection read failed", exc_info=True) - display_history = history - messages = [] if omit_messages else _history_to_messages(display_history) - return _ok( - rid, - _attach_todo_state( - { - "session_id": sid, - "resumed": target, - "message_count": len(display_history) if omit_messages else len(messages), - "messages": messages, - "messages_omitted": omit_messages, - "info": _lazy_resume_info(cwd, profile=profile), - "inflight": None, - "running": child_running, - "session_key": target, - "started_at": record["created_at"], - "status": "streaming" if child_running else "idle", - }, - record, - ), - ) - - # Desktop can ask for a bounded acknowledgement and hydrate the display - # transcript through the paginated REST endpoint. Register the runtime now, - # then load model history and initialize optional providers in background. - # Repeated requests reuse the record through the live fast path above. - # - # Precedence vs omit_messages: defer_history SUPERSEDES omit_messages. - # Desktop sends both flags on a cold resume; when defer_history is set the - # response never carries a transcript (messages is always []) and the ONE - # history read happens in the background hydration worker — the synchronous - # omit_messages read below (cold resume default) is skipped entirely, so - # the transcript is never loaded twice for one resume. omit_messages only - # governs the response shape of the non-deferred paths. - if defer_history and not is_truthy_value(params.get("eager_build", False)): - sid = uuid.uuid4().hex[:8] - source = _resolve_session_source(str(params.get("source") or "").strip() or None) - lease = None # claimed lazily on the first turn (_ensure_active_session_slot) - _enable_gateway_prompts() - overrides = _stored_session_runtime_overrides(found) or {} - model_override = overrides.get("model_override") or {} - cwd = profile_resume_cwd or _default_session_cwd() - record = _deferred_session_record( - target, - cols=cols, - cwd=cwd, - history=[], - lease=lease, - source=source, - close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)), - profile_home=profile_home, - model_override=overrides.get("model_override"), - resume_runtime_overrides=overrides or None, - explicit_cwd=bool(profile_resume_cwd), - ) - record["resume_history_ready"] = threading.Event() - record["resume_hydrating"] = True - record["resume_message_count"] = int(found.get("message_count") or 0) - if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None: - return _reuse_live_response(*live) - - _schedule_resume_hydration(sid, target, db, close_db=owns_db) - # The hydration worker now owns a profile-scoped handle and closes it - # after the transcript read. The shared launch DB is process-owned. - if owns_db: - owns_db = False - _schedule_session_cap_enforcement() - return _ok( - rid, - _attach_todo_state( - { - "session_id": sid, - "resumed": target, - "message_count": record["resume_message_count"], - "messages": [], - "hydrating": True, - "info": _lazy_resume_info( - cwd, - model=model_override.get("model") or "", - provider=overrides.get("provider_override") or "", - profile=profile, - ), - "inflight": None, - "running": False, - "session_key": target, - "started_at": record["created_at"], - "status": "resuming", - }, - record, - ), - ) - - # Cold resume default: register the live session and read its stored - # transcript, but build the agent OFF the response path. _make_agent can - # block for seconds (MCP discovery, prompt/skill build, AIAgent - # construction), and every resume caller (desktop + Ink TUI) awaits this RPC - # before it paints — so building eagerly is the bulk of the multi-second - # "switching sessions is frozen" latency. Return the full display transcript - # immediately and pre-warm the agent on a short timer (the same deferred- - # build contract session.create uses); _sess() also builds on demand if the - # first prompt beats the timer. A caller that needs the agent built - # synchronously (e.g. tests of the build race) passes ``eager_build: true`` - # to fall through to the eager path below. Distinct from the lazy/watch - # branch above: a normal resume restores the full ancestor history and the - # session's persisted runtime identity, and is a real (upgradable) session. - if not is_truthy_value(params.get("eager_build", False)): - sid = uuid.uuid4().hex[:8] - source = _resolve_session_source(str(params.get("source") or "").strip() or None) - lease = None # claimed lazily on the first turn (_ensure_active_session_slot) - # Interactive resume routes approvals/clarify through gateway prompts; - # the deferred build wires the remaining per-session callbacks. - _enable_gateway_prompts() - try: - db.reopen_session(target) - # One lineage SELECT feeds both projections (#67142-adjacent perf, - # from the desktop audit): the model-fed copy is alternation-repaired - # (raw_history → sanitize_replay_history → the resumed session's - # working conversation) and the display copy stays verbatim — - # inspection/export must show what is actually stored. - if omit_messages: - raw_history = db.get_messages_as_conversation( - target, repair_alternation=True, include_row_ids=True - ) - display_history = [] - else: - raw_history, display_history = db.get_resume_conversations(target) - except Exception as e: - if lease is not None: - lease.release() - return _err(rid, 5000, f"resume failed: {e}") - # Display keeps the full transcript; the model-fed history drops a - # dangling/interrupted tool-call tail so a session killed mid-loop does - # not replay the unanswered call forever (#29086). - prefix = [] if omit_messages else db.get_ancestor_display_prefix(target) - history = sanitize_replay_history(raw_history) - # Restore the model/provider/reasoning/tier this chat last used so the - # deferred build (and the info below) match the eager path — without them - # the build drops the provider ("No LLM provider configured"). - overrides = _stored_session_runtime_overrides(found) or {} - model_override = overrides.get("model_override") or {} - cwd = profile_resume_cwd or _default_session_cwd() - record = _deferred_session_record( - target, - cols=cols, - cwd=cwd, - history=history, - lease=lease, - source=source, - close_on_disconnect=is_truthy_value(params.get("close_on_disconnect", False)), - display_history_prefix=prefix, - profile_home=profile_home, - model_override=overrides.get("model_override"), - resume_runtime_overrides=overrides or None, - todo_state=_todo_state_from_history(history), - explicit_cwd=bool(profile_resume_cwd), - ) - if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None: - return _reuse_live_response(*live) - - _schedule_agent_build(sid) - _schedule_session_cap_enforcement() # trim detached idle sessions over the cap - auto_continue = _maybe_schedule_auto_continue(sid, record, target) - - messages = [] if omit_messages else _history_to_messages(display_history) - payload = { - "session_id": sid, - "resumed": target, - "message_count": len(raw_history) if omit_messages else len(messages), - "messages": messages, - "messages_omitted": omit_messages, - "info": _lazy_resume_info( - cwd, - model=model_override.get("model") or "", - provider=overrides.get("provider_override") or "", - profile=profile, - ), - "inflight": None, - "running": False, - "session_key": target, - "started_at": record["created_at"], - "status": "idle", - } - if auto_continue is not None: - payload["auto_continue"] = auto_continue - return _ok(rid, _attach_todo_state(payload, record)) - - # Build the agent OUTSIDE the lock — _make_agent can block for seconds - # (MCP discovery, prompt/skill build, AIAgent construction). Holding - # _session_resume_lock across it would stall session.close on the main - # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs. - sid = uuid.uuid4().hex[:8] - source = _resolve_session_source(str(params.get("source") or "").strip() or None) - lease = None # claimed lazily on the first turn (_ensure_active_session_slot) - _enable_gateway_prompts() - home_token = ( - set_hermes_home_override(str(profile_home)) if profile_home is not None else None - ) - secret_token = ( - set_secret_scope(build_profile_secret_scope(Path(str(profile_home)))) - if profile_home is not None - else None - ) - try: - db.reopen_session(target) - # One lineage SELECT feeds both projections (see the interactive resume - # above): the model-fed copy is alternation-repaired for LIVE REPLAY, the - # display copy stays verbatim. - if omit_messages: - raw_history = db.get_messages_as_conversation( - target, repair_alternation=True, include_row_ids=True - ) - display_history = [] - else: - raw_history, display_history = db.get_resume_conversations(target) - # The display transcript keeps every row so the user still sees their - # full history. The model-fed history is sanitized: a session whose - # last turn died mid-tool-loop persists a dangling assistant(tool_calls) - # (or interrupted assistant→tool) tail; replaying it makes the model - # re-issue the unanswered call forever — the permanent-"thinking" stuck - # session in #29086. The messaging gateway already strips this; this is - # the WebUI/TUI resume path picking up the same cleanup. - display_history_prefix = ( - [] if omit_messages else db.get_ancestor_display_prefix(target) - ) - history = sanitize_replay_history(raw_history) - messages = [] if omit_messages else _history_to_messages(display_history) - tokens = _set_session_context(target) - try: - # Pass the profile's db so the agent persists turns to the right - # state.db; home override is active here so config/skills/model - # resolve to the profile too. Runtime identity is restored from the - # stored session row so switching chats does not inherit whatever - # global model another chat last selected. - stored_runtime_overrides = _stored_session_runtime_overrides(found) - agent = _make_agent( - sid, - target, - session_id=target, - session_db=db, - platform_override=source, - context_cwd_is_launch_artifact=( - source in _LAUNCH_CWD_NOT_A_WORKSPACE - and not profile_resume_cwd - ), - **stored_runtime_overrides, - ) - finally: - _clear_session_context(tokens) - except Exception as e: - if lease is not None: - lease.release() - return _err(rid, 5000, f"resume failed: {e}") - finally: - if home_token is not None: - reset_hermes_home_override(home_token) - if secret_token is not None: - reset_secret_scope(secret_token) - - # Double-checked locking: another concurrent resume may have created the - # live session while we were building. Re-check under the lock; if it won, - # discard our just-built agent and reuse theirs (no worker/poller wired yet). - with _session_resume_lock: - live = _find_live_session_by_key(target, profile_home) - if live is not None: - try: - if hasattr(agent, "close"): - agent.close() - except Exception: - pass - if lease is not None: - lease.release() - return _reuse_live_response(*live) - try: - init_home_token = ( - set_hermes_home_override(str(profile_home)) - if profile_home is not None - else None - ) - init_secret_token = ( - set_secret_scope(build_profile_secret_scope(Path(str(profile_home)))) - if profile_home is not None - else None - ) - try: - _init_session( - sid, - target, - agent, - history, - cols=cols, - cwd=profile_resume_cwd, - session_db=db, - source=source, - explicit_cwd=bool(profile_resume_cwd), - ) - # Ownership TRANSFER — the registered session's agent now - # holds this handle for its whole life, and _init_session - # never closes a caller-supplied session_db (its - # _init_owns_db stays False). Closing it in the finally - # below would fault every later turn on this session with - # "Cannot operate on a closed database". - # - # Ownership moves ONTO the agent rather than just being - # dropped: AIAgent.close() (reached from _teardown_session - # on session.close and the orphaned-session reaper) closes - # a handle it owns, so the dedicated fds and the token - # writer are released at teardown instead of living as - # long as the gateway process. - # - # The drop is UNCONDITIONAL and the transfer is best-effort - # on top of it, deliberately. Past this line the session is - # registered and holding this handle, so the finally must - # not close it even if the transfer was refused — a refusal - # leaves the old leak, which is survivable; closing under a - # live session is the permanent "Cannot operate on a closed - # database" break this patch exists to avoid. - # - # The transfer itself is gated on owns_db: with no - # non-launch profile selected this path resolved db to the - # SHARED launch handle (_get_db()), and transferring it - # made session.close() tear down the process-wide - # database under every unrelated session (#91610). - if owns_db: - _transfer_db_to_agent(agent, db) - owns_db = False - finally: - if init_home_token is not None: - reset_hermes_home_override(init_home_token) - if init_secret_token is not None: - reset_secret_scope(init_secret_token) - if sid in _sessions: - if stored_runtime_overrides.get("model_override") is not None: - _sessions[sid]["model_override"] = stored_runtime_overrides[ - "model_override" - ] - _sessions[sid]["display_history_prefix"] = display_history_prefix - # Remember the profile home so each turn re-binds HERMES_HOME (the - # agent persists to its own db, but mid-turn home reads — memory, - # skills — must resolve to the resumed profile too). - if profile_home is not None: - _sessions[sid]["profile_home"] = str(profile_home) - _sessions[sid]["active_session_lease"] = lease - except Exception as e: - # _init_session registers _sessions[sid] BEFORE its first read - # through this handle. If it raised in between — "database is - # locked" is the realistic trigger — the half-built session is - # still registered while the finally below closes the handle it - # holds, and the live-session fast path above would then serve - # that dead session on every later resume of this id - # ("'NoneType' object has no attribute 'execute'", permanently). - # owns_db still True means ownership never transferred, so the - # registration is ours to undo. - if owns_db: - with _sessions_lock: - _sessions.pop(sid, None) - if lease is not None: - lease.release() - return _err(rid, 5000, f"resume failed: {e}") - session = _sessions.get(sid) or {} + return _resume_reuse_live(ctx, *live) + if ctx.lazy: + return _resume_lazy(ctx) + if ctx.defer_history and not ctx.eager_build: + return _resume_deferred(ctx) + if not ctx.eager_build: + return _resume_cold(ctx) + return _resume_eager(ctx) finally: - # Every return that does NOT reach the transfer above abandons this - # handle — session-not-found, both "resume failed" paths, the live-session - # fast path (the hot one: reconnects re-resume live chats through it), the - # deferred cold/lazy returns, and the double-checked-locking discard. - # Dropping it merely relied on refcounting to release the sqlite fds; that - # stops being true the moment anything pins the instance — SessionDB pins - # ITSELF once its background token writer starts, via - # atexit.register(_drain_token_queue_at_exit) (hermes_state.py), which only - # close() unregisters. A pinned handle keeps its db/-wal/-shm fds and its - # writer thread for the life of the process. - if owns_db and db is not None: + # Every return that does not transfer the handle abandons it. Refcounting + # alone does not release the sqlite fds: SessionDB pins ITSELF once its + # background token writer starts (atexit.register in hermes_state), which + # only close() unregisters. + if ctx.owns_db and ctx.db is not None: with contextlib.suppress(Exception): - db.close() - auto_continue = ( - _maybe_schedule_auto_continue(sid, session, target) if session else None - ) - payload = { - "session_id": sid, - "resumed": target, - "message_count": len(raw_history) if omit_messages else len(messages), - "messages": messages, - "messages_omitted": omit_messages, - "info": _session_info(agent, session), - "inflight": None, - "running": False, - "session_key": target, - "started_at": float(session.get("created_at") or time.time()), - "status": "idle", - } - if auto_continue is not None: - payload["auto_continue"] = auto_continue - return _ok(rid, _attach_todo_state(payload, session)) + ctx.db.close() + + +# ── cwd / workspace / live-session bookkeeping ─────────────────────── @method("session.cwd.set") -def _(rid, params: dict) -> dict: - session, err = _sess_nowait(params, rid) - if err: - return err +@_with_session +def _(rid, params: dict, session: dict) -> dict: if session.get("running"): return _err(rid, 4009, "session busy") raw = str(params.get("cwd", "") or "").strip() @@ -1225,34 +1170,20 @@ def _(rid, params: dict) -> dict: cwd = _set_session_cwd(session, raw) except ValueError as e: return _err(rid, 4017, str(e)) - agent = session.get("agent") - info = _session_info(agent, session) if agent is not None else { - "cwd": cwd, - "branch": _git_branch_for_cwd(cwd), - "project": _project_info_for_cwd(cwd), - "lazy": True, - } + info = _cwd_info(session, cwd) _emit("session.info", params.get("session_id", ""), info) return _ok(rid, info) @method("session.workspace.move") def _(rid, params: dict) -> dict: - """Re-home a STORED session's workspace into another folder/project. + """Re-home a STORED session's workspace (by ``session_key``) into another folder/project. - Unlike ``session.cwd.set`` (which acts on a live runtime session by its UI - id), this targets a persisted row by ``session_key`` so the desktop can fix - a session that was created in the wrong directory — no live agent required. - The git branch/root columns are REPLACED (not merely enriched), because the - whole point of the move is to change which project claims the session; a - stale ``git_repo_root`` would keep it grouped under the project it left. - - A live agent bound to the row follows through the runtime path too, so its - terminal/file tools re-anchor immediately. An explicit move wins even - mid-turn: refusing a running session made the desktop's "Move to project" - claim success in the UI while ``state.db`` kept the old cwd — two sources - of truth disagreeing (#86626). In-flight tool calls keep the cwd they were - launched with; the NEXT tool call uses the new workspace. + Unlike ``session.cwd.set`` no live agent is required. The git branch/root + columns are REPLACED, not enriched — a stale ``git_repo_root`` would keep the + session grouped under the project it left. A live agent bound to the row + follows too, even mid-turn (refusing made the UI claim success while state.db + kept the old cwd); in-flight tool calls keep their cwd, the NEXT one moves. """ target = str(params.get("session_key") or "").strip() if not target: @@ -1266,8 +1197,7 @@ def _(rid, params: dict) -> dict: if not os.path.isdir(resolved): return _err(rid, 4017, f"working directory does not exist: {raw}") - # Snapshot under the lock — concurrent RPCs mutate _sessions (same pattern - # as _cwd_for_session_key). + # Snapshot under the lock — concurrent RPCs mutate _sessions. live = None live_sid = "" with _sessions_lock: @@ -1281,16 +1211,14 @@ def _(rid, params: dict) -> dict: with _profile_db(params) as db: if db is None: return _db_unavailable_error(rid, code=5007) - # A brand-new draft has no persisted row yet; the live re-home below - # still applies and the row inherits the cwd when it is first written. + # A brand-new draft has no row yet; the live re-home still applies and + # the row inherits the cwd when first written. row_exists = bool(db.get_session(target)) if not row_exists and live is None: return _err(rid, 4007, "session not found") if row_exists: try: - db.update_session_cwd( - target, resolved, branch, root, replace_git_meta=True - ) + db.update_session_cwd(target, resolved, branch, root, replace_git_meta=True) except Exception as e: return _err(rid, 5007, f"move failed: {e}") @@ -1299,26 +1227,15 @@ def _(rid, params: dict) -> dict: _set_session_cwd(live, resolved) except ValueError as e: return _err(rid, 4017, str(e)) - agent = live.get("agent") - info = _session_info(agent, live) if agent is not None else { - "cwd": resolved, - "branch": branch, - "project": _project_info_for_cwd(resolved), - "lazy": True, - } - _emit("session.info", live_sid, info) + _emit("session.info", live_sid, _cwd_info(live, resolved, branch=branch)) return _ok(rid, {"cwd": resolved, "branch": branch, "git_repo_root": root}) @method("session.active_list") def _(rid, params: dict) -> dict: - """Return live TUI sessions in this gateway process. - - Unlike ``session.list`` this is not a historical DB browser: it reports only - sessions with in-memory agents/workers that the current TUI can switch to - without closing siblings. - """ + """Live TUI sessions in this process (not a DB browser): only sessions with + in-memory agents/workers the current TUI can switch to without closing siblings.""" current = str(params.get("current_session_id") or "") try: with _sessions_lock: @@ -1326,21 +1243,12 @@ def _(rid, params: dict) -> dict: except Exception as e: return _err(rid, 5036, f"could not enumerate active sessions: {e}") - # Liveness filter (#38950): a session whose teardown has begun (``_finalized``) - # is dead — its agent/worker are being released and it is no longer - # attachable — but it can briefly remain in ``_sessions`` until the reaper - # pops it (the WS grace-reap and idle reaper both set ``_finalized`` inside - # ``_teardown_session`` before the pop). Counting these inflated the footer's - # "N sessions" count, which only ever went up until a gateway restart. Drop - # them here so the count reflects genuinely attachable sessions. We do NOT - # filter on ``transport is _detached_ws_transport`` (the WS-detached drop - # sentinel): a detached session is still attachable via a quick reconnect / - # session.resume until the grace-reap finalizes it, and a standalone - # ``hermes --tui`` session legitimately rides the real stdio transport and - # must stay visible. - # Keep the natural creation/insertion order from ``_sessions``. The - # frontend marks the focused session with ``current``; it should not jump to - # the top just because the user switched to it. + # ``_finalized`` sessions are dead (teardown begun) but may linger in + # ``_sessions`` until the reaper pops them; counting them inflated the footer + # forever. Do NOT filter on the WS-detached sentinel: a detached session is + # still attachable via reconnect until grace-reap finalizes it, and a + # standalone ``hermes --tui`` rides the real stdio transport. Keep insertion + # order — the focused session must not jump to the top. rows = [ _session_live_item(sid, session, current) for sid, session in snapshot @@ -1351,17 +1259,12 @@ def _(rid, params: dict) -> dict: @method("session.activate") def _(rid, params: dict) -> dict: - """Attach the frontend to an already-live TUI session. - - This intentionally does not close the previously focused session; it merely - returns enough state for Ink to redraw around another live session id. - """ + """Attach the frontend to an already-live TUI session (does not close the + previously focused one — just enough state for Ink to redraw).""" sid = str(params.get("session_id") or "") session, err = _sess_nowait({"session_id": sid}, rid) if err: return err - assert session is not None - return _ok( rid, _live_session_payload( @@ -1376,28 +1279,17 @@ def _(rid, params: dict) -> dict: @method("session.delete") def _(rid, params: dict) -> dict: - """Delete a stored session and its on-disk transcript files. + """Delete a stored session and its transcript files (TUI resume picker ``d``). - Used by the TUI resume picker (``d`` key) so users can prune old - sessions without dropping to the CLI. Refuses to delete a session - that is currently active in this gateway process — those rows are - still being written to and removing them out from under the live - agent corrupts message ordering and trips FK constraints when the - next message append flushes. - - Honors ``params.profile`` so app-global remote mode deletes from the - focused profile's ``state.db`` + sessions dir (mirrors ``session.resume``). + Refuses sessions live in this process — removing rows under a live agent + corrupts message ordering and trips FK constraints on the next flush. + Honors ``params.profile`` (mirrors ``session.resume``). """ target = params.get("session_id", "") if not target: return _err(rid, 4006, "session_id required") - # Block deletion of any session currently bound to a live TUI session - # in this process. The picker hides the active session anyway, but a - # racing caller could still target it. Snapshot via ``list(...)`` - # because ``_sessions`` is mutated by concurrent RPCs on the thread - # pool — iterating the dict directly can raise ``RuntimeError: - # dictionary changed size during iteration``. If even the snapshot - # raises, fail closed (refuse the delete) rather than fail open. + # Snapshot via list(): _sessions is mutated by concurrent RPCs. If even the + # snapshot raises, fail CLOSED (refuse the delete). try: with _sessions_lock: snapshot = list(_sessions.values()) @@ -1406,15 +1298,11 @@ def _(rid, params: dict) -> dict: active = {s.get("session_key") for s in snapshot if s.get("session_key")} if target in active: return _err(rid, 4023, "cannot delete an active session") - profile = (params.get("profile") or "").strip() or None - profile_home = _profile_home(profile) + profile_home = _profile_home((params.get("profile") or "").strip() or None) with _profile_db(params) as db: if db is None: return _db_unavailable_error(rid, code=5036) - if profile_home is not None: - sessions_dir = Path(profile_home) / "sessions" - else: - sessions_dir = get_hermes_home() / "sessions" + sessions_dir = (Path(profile_home) if profile_home is not None else get_hermes_home()) / "sessions" try: deleted = db.delete_session(target, sessions_dir=sessions_dir) except Exception as e: @@ -1424,84 +1312,70 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"deleted": target}) +def _title_read(rid, params: dict, session: dict, db) -> dict: + """``session.title`` without ``title``: read it, applying a queued pending_title if possible.""" + key = session["session_key"] + fallback = session.get("pending_title") or "" + try: + resolved_title = db.get_session_title(key) or "" + if fallback: + if db.set_session_title(key, fallback): + session["pending_title"] = None + resolved_title = fallback + else: + existing_row = db.get_session(key) + existing_title = ((existing_row or {}).get("title") or "").strip() + if existing_title == fallback: + session["pending_title"] = None + resolved_title = fallback + elif not resolved_title: + resolved_title = fallback + elif resolved_title: + session["pending_title"] = None + except Exception: + resolved_title = fallback + _emit_session_info_for_session(params.get("session_id", ""), session) + return _ok(rid, {"title": resolved_title, "session_key": key}) + + @method("session.title") -def _(rid, params: dict) -> dict: - session, err = _sess_nowait(params, rid) - if err: - return err +@_with_session +def _(rid, params: dict, session: dict) -> dict: with _session_db(session) as db: if db is None: return _db_unavailable_error(rid, code=5007) - key = session["session_key"] if "title" not in params: - fallback = session.get("pending_title") or "" - try: - resolved_title = db.get_session_title(key) or "" - if fallback: - if db.set_session_title(key, fallback): - session["pending_title"] = None - resolved_title = fallback - else: - existing_row = db.get_session(key) - existing_title = ((existing_row or {}).get("title") or "").strip() - if existing_title == fallback: - session["pending_title"] = None - resolved_title = fallback - elif not resolved_title: - resolved_title = fallback - elif resolved_title: - session["pending_title"] = None - except Exception: - resolved_title = fallback - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok( - rid, - { - "title": resolved_title, - "session_key": key, - }, - ) + return _title_read(rid, params, session, db) + key = session["session_key"] title = (params.get("title", "") or "").strip() if not title: return _err(rid, 4021, "title required") + sid = params.get("session_id", "") + + def _done(pending: bool, value: str) -> dict: + session["pending_title"] = value if pending else None + _emit_session_info_for_session(sid, session) + return _ok(rid, {"pending": pending, "title": value}) + try: if db.set_session_title(key, title): - session["pending_title"] = None - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok(rid, {"pending": False, "title": title}) + return _done(False, title) # rowcount == 0 can mean "same value" as well as "missing row". existing_row = db.get_session(key) if existing_row: - session["pending_title"] = None - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok( - rid, - { - "pending": False, - "title": (existing_row.get("title") or title), - }, - ) - # No row yet (the DB write is deferred to the first prompt so empty - # drafts don't litter the sidebar). An explicit /title is clear user - # intent, not an abandoned draft — so persist the row NOW and set the - # title, mirroring the messaging gateway's _handle_title_command. The - # old behavior only queued pending_title and relied on the post-turn - # apply block; if that turn never landed under this session_key the - # title was silently lost and the sidebar fell back to the message - # preview. Creating the row up front removes that race entirely. The - # min-messages sidebar filter keeps a titled 0-message row hidden, so - # a /title'd-but-never-used draft still doesn't clutter the list. + return _done(False, existing_row.get("title") or title) + # No row yet (deferred to the first prompt). An explicit /title is + # clear intent, so persist the row NOW (mirrors the messaging + # gateway's _handle_title_command) instead of queuing pending_title + # and hoping the post-turn apply block lands under this key. The + # min-messages sidebar filter keeps a titled 0-message row hidden. _ensure_session_db_row(session) with _session_db(session) as scoped_db: if scoped_db is not None and scoped_db.set_session_title(key, title): - session["pending_title"] = None - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok(rid, {"pending": False, "title": title}) - # Row creation didn't take (DB unavailable, or a concurrent writer) — - # fall back to queuing so the post-turn apply block can still recover. - session["pending_title"] = title - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok(rid, {"pending": True, "title": title}) + return _done(False, title) + # Row creation didn't take (DB unavailable / concurrent writer) — + # queue so the post-turn apply block can still recover. + return _done(True, title) except ValueError as e: return _err(rid, 4022, str(e)) except Exception as e: @@ -1510,21 +1384,13 @@ def _(rid, params: dict) -> dict: @method("session.set_hidden") def _(rid, params: dict) -> dict: - """Set/clear the generic ``hidden`` flag on a session (and its lineage). + """Set/clear the ``hidden`` flag on a session (and its compression lineage). - Mirrors the durable ``pinned``/``archived`` setters: a hidden session is - dropped from the default global Sessions list (``list_sessions_rich`` - without ``include_hidden``) but stays fully resumable by the surface that - owns it — for plugins that manage their own sessions and don't want them - cluttering the shared recents list. Flips the whole compression chain as a - unit in the DB layer. - - Resolution is two-tier: a LIVE runtime session id first (which also - covers the not-yet-persisted draft via the ``pending_hidden`` deferral), - then a durable stored id/key against the target profile's state.db — - plugins reconciling sessions they own (e.g. Bot Mode's hide sweep) hold - stored ids for chats that aren't live right now, and the live-only - lookup silently failed those with 4001. + A hidden session is dropped from the default Sessions list but stays fully + resumable by the surface that owns it. Two-tier resolution: a LIVE runtime + id first (covers the not-yet-persisted draft via ``pending_hidden``), then a + durable stored id/key in the target profile's state.db — plugins reconciling + sessions they own hold stored ids for chats that aren't live right now. """ hidden = is_truthy_value(params.get("hidden", True)) session, err = _sess_nowait(params, rid) @@ -1534,18 +1400,14 @@ def _(rid, params: dict) -> dict: return _db_unavailable_error(rid, code=5007) key = session["session_key"] try: - changed = db.set_session_hidden(key, hidden) - if not changed: - # No row yet (write deferred to the first prompt): remember the - # intent so _ensure_session_db_row is born hidden, mirroring the - # pending_title deferral. + if not db.set_session_hidden(key, hidden): + # No row yet: remember the intent so _ensure_session_db_row + # is born hidden (mirrors the pending_title deferral). session["pending_hidden"] = hidden return _ok(rid, {"hidden": hidden, "session_key": key}) except Exception as e: return _err(rid, 5007, str(e)) - # Durable fallback: a stored session id (or key) in the requested - # profile's db. ``resolve_session_id`` follows key/title aliases the - # same way the REST pin/archive path does. + # ``resolve_session_id`` follows key/title aliases like the REST pin/archive path. target = str(params.get("session_id") or "").strip() with _profile_db(params) as db: if db is None: @@ -1561,21 +1423,15 @@ def _(rid, params: dict) -> dict: @method("message.react") -def _(rid, params: dict) -> dict: +@_with_session +def _(rid, params: dict, session: dict) -> dict: """Set or clear one author's emoji reaction on a persisted message. - iOS Tapback semantics, enforced in the DB layer: one reaction per author - per message, re-sending the same emoji retracts it. ``emoji: null`` clears - unconditionally. ``row_id`` is the durable ``messages.id`` forwarded by - ``_history_to_messages`` — the renderer's own message ids are ephemeral. + iOS Tapback semantics enforced in the DB layer: one reaction per author per + message, re-sending the same emoji retracts it, ``emoji: null`` clears. + ``row_id`` is the durable ``messages.id``; a live message that hasn't + round-tripped through a resume can instead name ``newest_role``. """ - session, err = _sess_nowait(params, rid) - if err: - return err - - # A live message hasn't round-tripped through a resume, so the desktop has - # no durable row id for it yet. It can instead name the ROLE whose newest - # row it means — which is the message the user just reacted to. newest_role = str(params.get("newest_role") or "").strip() row_id = params.get("row_id") if row_id is None and newest_role not in {"user", "assistant"}: @@ -1596,33 +1452,25 @@ def _(rid, params: dict) -> dict: return _db_unavailable_error(rid, code=5007) try: if row_id is None: - row_id = db.latest_message_row_id( - session["session_key"], role=newest_role - ) + row_id = db.latest_message_row_id(session["session_key"], role=newest_role) if row_id is None: return _err(rid, 4040, "no message to react to yet") - reactions = db.set_message_reaction( - session["session_key"], int(row_id), emoji, author=author - ) + reactions = db.set_message_reaction(session["session_key"], int(row_id), emoji, author=author) except Exception as e: return _err(rid, 5007, str(e)) if reactions is None: return _err(rid, 4040, "message not found in this session") - return _ok(rid, {"row_id": int(row_id), "reactions": reactions}) @method("llm.oneshot") def _(rid, params: dict) -> dict: - """Run a single stateless LLM request outside any conversation. + """Single stateless LLM request outside any conversation (e.g. a commit message). - Generic helper for small generative chores (e.g. a commit message from a - diff). Accepts either a named ``template`` + ``variables`` or an explicit - ``instructions`` / ``input`` pair. When ``session_id`` resolves to a live - session the call inherits that agent's model; otherwise it uses the - configured auxiliary ``task`` backend. Never mutates session history, so - prompt caching is untouched. + Accepts a named ``template`` + ``variables`` or ``instructions``/``input``. + A live ``session_id`` lends its agent's model; otherwise the auxiliary + ``task`` backend. Never mutates session history (prompt cache untouched). """ template = (params.get("template") or "").strip() or None instructions = params.get("instructions") or "" @@ -1644,7 +1492,6 @@ def _(rid, params: dict) -> dict: if not template and not str(instructions).strip() and not str(user_input).strip(): return _err(rid, 4030, "llm.oneshot requires a template or instructions/input") - # Optional: inherit the live session's model (no error if absent). session = _sessions.get(params.get("session_id") or "") main_runtime = _main_runtime_from_agent(session.get("agent")) if session else None @@ -1672,34 +1519,30 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"text": text}) -@method("handoff.request") -def _(rid, params: dict) -> dict: - """Queue a handoff of this session to a messaging platform. +# ── handoff ────────────────────────────────────────────────────────── - Desktop parity with the CLI ``/handoff`` command: we only write - ``handoff_state='pending'`` onto the persisted session row. The actual - transfer is performed by the separate ``hermes gateway`` process, whose - ``_handoff_watcher`` claims the row, re-binds the session to the platform's - home channel, and forges a synthetic turn. The desktop then polls - ``handoff.state`` for the terminal result. + +@method("handoff.request") +@_with_session +def _(rid, params: dict, session: dict) -> dict: + """Queue a handoff of this session to a messaging platform (desktop parity with /handoff). + + Only writes ``handoff_state='pending'`` on the persisted row; the separate + ``hermes gateway`` process's ``_handoff_watcher`` claims it, re-binds the + session to the platform's home channel and forges a synthetic turn. The + desktop then polls ``handoff.state``. """ - session, err = _sess_nowait(params, rid) - if err: - return err if session.get("running"): return _err( - rid, - 4009, - "session busy — wait for the current turn to finish, then retry the handoff", + rid, 4009, "session busy — wait for the current turn to finish, then retry the handoff" ) platform_name = (params.get("platform", "") or "").strip().lower() if not platform_name: return _err(rid, 4023, "platform required") - # Validate against the live gateway config — an unconfigured platform or a - # missing home channel would leave the handoff pending forever, so reject - # up front with a clear, actionable message (mirrors cli.py). + # Validate against the live gateway config up front: an unconfigured platform + # or missing home channel would leave the handoff pending forever. try: from gateway.config import Platform, load_gateway_config except Exception as e: # pragma: no cover — gateway pkg always ships @@ -1715,11 +1558,7 @@ def _(rid, params: dict) -> dict: return _err(rid, 5021, f"could not load gateway config: {e}") pcfg = gw_config.platforms.get(platform) if not pcfg or not pcfg.enabled: - return _err( - rid, - 4025, - f"platform '{platform_name}' is not configured/enabled in the gateway", - ) + return _err(rid, 4025, f"platform '{platform_name}' is not configured/enabled in the gateway") home = gw_config.get_home_channel(platform) if not home or not home.chat_id: return _err( @@ -1729,8 +1568,8 @@ def _(rid, params: dict) -> dict: "/sethome on the destination chat first", ) - # The watcher transfers a persisted DB row, so make sure one exists even - # for a brand-new empty chat (mirrors the CLI's set_session_title stub). + # The watcher transfers a persisted row, so make sure one exists even for a + # brand-new empty chat (mirrors the CLI's set_session_title stub). _ensure_session_db_row(session) with _session_db(session) as db: @@ -1746,32 +1585,17 @@ def _(rid, params: dict) -> dict: if not ok: return _err( - rid, - 4027, - "session is already in flight for handoff — wait for it to settle, then retry", + rid, 4027, "session is already in flight for handoff — wait for it to settle, then retry" ) return _ok( - rid, - { - "queued": True, - "session_key": key, - "platform": platform_name, - "home_name": home.name, - }, + rid, {"queued": True, "session_key": key, "platform": platform_name, "home_name": home.name} ) @method("handoff.state") -def _(rid, params: dict) -> dict: - """Poll the handoff state for a session. - - Returns ``{state, platform, error}`` where ``state`` is one of - ``pending|running|completed|failed`` (or empty when no handoff record - exists). Desktop polls this after ``handoff.request``. - """ - session, err = _sess_nowait(params, rid) - if err: - return err +@_with_session +def _(rid, params: dict, session: dict) -> dict: + """Poll ``{state, platform, error}``; ``state`` is pending|running|completed|failed or empty.""" with _session_db(session) as db: if db is None: return _db_unavailable_error(rid, code=5007) @@ -1790,17 +1614,15 @@ def _(rid, params: dict) -> dict: @method("handoff.fail") def _(rid, params: dict) -> dict: - """Mark a not-yet-claimed handoff as failed so the user can retry. + """Mark a not-yet-claimed handoff failed so the user can retry (desktop poll timeout). - Desktop calls this when its bounded poll times out. Only PENDING rows are - changed (compare-and-swap in ``fail_handoff``): once the gateway watcher - has claimed the row (``running``) it owns the terminal state — failing it - from the waiter races the in-flight dispatch, which later overwrites - ``failed`` → ``completed`` after the user was already told it failed - (split-brain; the delivery actually happened). For a ``running`` row the - caller gets ``{"failed": False, "state": "running"}`` and should surface - "still transferring" instead. + Only PENDING rows change (compare-and-swap in ``fail_handoff``): once the + watcher has claimed the row (``running``) it owns the terminal state — failing + it from the waiter races the dispatch and later flips failed→completed after + the user was told it failed. A ``running`` row yields + ``{"failed": False, "state": "running"}`` ("still transferring"). """ + # Undecorated on purpose: tests rebind this handler's __code__ directly. session, err = _sess_nowait(params, rid) if err: return err @@ -1812,8 +1634,7 @@ def _(rid, params: dict) -> dict: try: failed = db.fail_handoff(key, reason, only_states=("pending",)) except TypeError: - # Older SessionDB without only_states: preserve prior behavior - # minus the running-row stomp (fail only when still pending). + # Older SessionDB without only_states: fail only when still pending. record = db.get_handoff_state(key) or {} failed = (record.get("state") or "") == "pending" if failed: @@ -1825,19 +1646,18 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"failed": False, "state": record.get("state") or ""}) +# ── usage ──────────────────────────────────────────────────────────── + + @method("session.usage") -def _(rid, params: dict) -> dict: - session, err = _sess_nowait(params, rid) - if err: - return err +@_with_session +def _(rid, params: dict, session: dict) -> dict: agent = session.get("agent") usage: dict = _session_usage_snapshot(session) if agent is None and not usage: usage = {"calls": 0, "input": 0, "output": 0, "total": 0} - # Nous credits block — agent-independent (a portal fetch), so it shows even - # with zero API calls or on a resumed session. The TUI /usage panel renders - # these lines regardless of `calls`. Fail-open: [] when not logged into Nous - # or on any portal hiccup. + # Nous credits are agent-independent (portal fetch) so they show even with + # zero API calls. Fail-open: absent when not logged in / portal hiccup. try: from agent.account_usage import nous_credits_lines @@ -1850,10 +1670,8 @@ def _(rid, params: dict) -> dict: @method("session.context_breakdown") -def _(rid, params: dict) -> dict: - session, err = _sess_nowait(params, rid) - if err: - return err +@_with_session +def _(rid, params: dict, session: dict) -> dict: agent = session.get("agent") if agent is None: usage = _session_usage_snapshot(session) or _get_usage(None) @@ -1879,452 +1697,323 @@ def _(rid, params: dict) -> dict: return _ok(rid, payload) +# ── pet ────────────────────────────────────────────────────────────── + +_PET_OFF = {"enabled": False} + + @method("pet.info") @_profile_scoped +@_pet_guard("pet.info", fail_open=_PET_OFF) def _(rid, params: dict) -> dict: - """Return the active petdex pet for surfaces that render sprites. + """Active petdex pet for sprite-rendering surfaces (desktop canvas + TUI half-block). - Shared by the desktop (canvas) and the TUI (half-block). Carries the - spritesheet bytes (base64) plus the engine's frame geometry + state-row - taxonomy so the renderer is a thin, framework-native consumer. The - activity→state decision is mirrored from ``agent.pet.state`` client-side. - - Agent-independent (reads config + disk), so it works on any session and - before the agent finishes building. Fail-open: returns ``enabled=False`` - on any error rather than erroring the surface. + Carries the spritesheet (base64) plus frame geometry + state-row taxonomy so + the renderer is a thin consumer. Agent-independent; fail-open ``enabled=False``. """ - try: - enabled, pet, scale = _pet_active_selection() - - if not enabled or pet is None or not pet.exists: - return _ok(rid, {"enabled": False}) - - payload = {"enabled": True, **_pet_sprite_payload(pet, scale=scale)} - - # Send-once semantics for the multi-MB spritesheet (#54730): a caller - # that already holds the sheet passes the revision it has, and an - # unchanged sheet comes back as metadata only (spritesheetUnchanged). - known_revision = str(params.get("knownRevision", "") or "") - if known_revision and known_revision == payload.get("spritesheetRevision"): - payload.pop("spritesheetBase64", None) - payload["spritesheetUnchanged"] = True - - return _ok(rid, payload) - except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface - logger.debug("pet.info failed: %s", exc) + enabled, pet, scale = _pet_active_selection() + if not enabled or pet is None or not pet.exists: return _ok(rid, {"enabled": False}) + payload = {"enabled": True, **_pet_sprite_payload(pet, scale=scale)} + # Send-once for the multi-MB sheet: a caller holding revision R gets + # metadata only (spritesheetUnchanged) when the sheet hasn't changed. + known_revision = str(params.get("knownRevision", "") or "") + if known_revision and known_revision == payload.get("spritesheetRevision"): + payload.pop("spritesheetBase64", None) + payload["spritesheetUnchanged"] = True + return _ok(rid, payload) @method("pet.info.meta") @_profile_scoped +@_pet_guard("pet.info.meta", fail_open=_PET_OFF) def _(rid, params: dict) -> dict: """Cheap active-pet metadata used to avoid full payload refreshes.""" - try: - enabled, pet, scale = _pet_active_selection() - if not enabled or pet is None or not pet.exists: - return _ok(rid, {"enabled": False}) - return _ok( - rid, - { - "enabled": True, - "slug": pet.slug, - "displayName": pet.display_name, - "scale": scale, - "spritesheetRevision": _pet_sheet_revision(pet.spritesheet), - }, - ) - except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface - logger.debug("pet.info.meta failed: %s", exc) + enabled, pet, scale = _pet_active_selection() + if not enabled or pet is None or not pet.exists: return _ok(rid, {"enabled": False}) + return _ok( + rid, + { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "scale": scale, + "spritesheetRevision": _pet_sheet_revision(pet.spritesheet), + }, + ) @method("pet.cells") @_profile_scoped +@_pet_guard("pet.cells", fail_open=_PET_OFF) def _(rid, params: dict) -> dict: - """Return half-block cell frames for one pet state (TUI renderer). + """Half-block cell frames for one pet state (TUI renderer). - The TUI can't draw a canvas, so the engine downsamples the spritesheet to - a grid of half-block cells and the Ink side paints them with native color - props. Each cell is ``[tr,tg,tb,ta, br,bg,bb,ba]`` (top + bottom pixel). - - Params: ``state`` (idle/run/review/failed/wave/jump), ``cols`` (width). - Fail-open: ``enabled=False`` on any problem. + Each cell is ``[tr,tg,tb,ta, br,bg,bb,ba]`` (top + bottom pixel). + Params: ``state`` (idle/run/review/failed/wave/jump), ``cols``, ``graphics``. """ - try: - from agent.pet import constants, render, store - from agent.pet.render import PetRenderer + from agent.pet import constants, render, store + from agent.pet.render import PetRenderer - try: - from hermes_cli.config import load_config - - cfg = load_config() - display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} - pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} - except Exception: - pet_cfg = {} - - if not is_truthy_value(pet_cfg.get("enabled"), default=False): - return _ok(rid, {"enabled": False}) - - pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or "")) - if pet is None or not pet.exists: - return _ok(rid, {"enabled": False}) - - state = str(params.get("state") or constants.PetState.IDLE.value) - scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) - cols = int(params.get("cols") or 0) or constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0)) - - # Graphics path: when the TUI is attached to a real TTY (``graphics``) - # and the terminal speaks the kitty protocol, return a Unicode- - # placeholder payload for a crisp image instead of half-blocks. Env - # detection (KITTY_WINDOW_ID / TERM / TERM_PROGRAM) is shared with the - # Ink process since it spawns us; the dashboard PTY (xterm.js) has no - # such env, so it falls through to half-blocks automatically. Only - # kitty is grid-safe in Ink — iTerm/sixel stay on the fallback. - if params.get("graphics"): - configured = str(pet_cfg.get("render_mode", "auto") or "auto").lower() - gmode = render.detect_terminal_graphics() if configured in ("", "auto") else configured - if gmode == "kitty": - image_id = render.kitty_image_id(pet.slug) - # kitty sizes from scaled pixels (_cell_box), so unicode_cols is moot here. - payload = PetRenderer( - str(pet.spritesheet), mode="kitty", scale=scale - ).kitty_payload(state, image_id=image_id) - if payload: - kcount = len(payload["frames"]) or 1 - return _ok( - rid, - { - "enabled": True, - "slug": pet.slug, - "displayName": pet.display_name, - "state": state, - "graphics": "kitty", - "imageId": image_id, - "color": render.kitty_color_hex(image_id), - "cols": payload["cols"], - "rows": payload["rows"], - "placeholder": payload["placeholder"], - "frames": payload["frames"], - "frameMs": constants.LOOP_MS / max(1, kcount), - "scale": scale, - }, - ) - - renderer = PetRenderer( - str(pet.spritesheet), - mode="unicode", - scale=scale, - unicode_cols=cols, - ) - count = renderer.frame_count(state) or 1 - frames = [] - for i in range(count): - grid = renderer.cells(state, i, cols=cols) - frames.append( - [[[*top, *bottom] for (top, bottom) in row] for row in grid] - ) - - return _ok( - rid, - { - "enabled": True, - "slug": pet.slug, - "displayName": pet.display_name, - "state": state, - "cols": cols, - "frameMs": constants.LOOP_MS / max(1, count), - "frames": frames, - "scale": scale, - }, - ) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.cells failed: %s", exc) + pet_cfg = _pet_display_cfg() + if not is_truthy_value(pet_cfg.get("enabled"), default=False): return _ok(rid, {"enabled": False}) + pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or "")) + if pet is None or not pet.exists: + return _ok(rid, {"enabled": False}) + + state = str(params.get("state") or constants.PetState.IDLE.value) + scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) + cols = int(params.get("cols") or 0) or constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0)) + base = {"enabled": True, "slug": pet.slug, "displayName": pet.display_name, "state": state} + + # Graphics path: a real TTY speaking kitty gets a Unicode-placeholder image + # instead of half-blocks. Env detection is shared with the Ink process (it + # spawns us); the dashboard PTY has no such env and falls through. Only + # kitty is grid-safe in Ink — iTerm/sixel stay on the fallback. + if params.get("graphics"): + configured = str(pet_cfg.get("render_mode", "auto") or "auto").lower() + gmode = render.detect_terminal_graphics() if configured in ("", "auto") else configured + if gmode == "kitty": + image_id = render.kitty_image_id(pet.slug) + # kitty sizes from scaled pixels, so unicode_cols is moot here. + payload = PetRenderer(str(pet.spritesheet), mode="kitty", scale=scale).kitty_payload( + state, image_id=image_id + ) + if payload: + return _ok( + rid, + { + **base, + "graphics": "kitty", + "imageId": image_id, + "color": render.kitty_color_hex(image_id), + "cols": payload["cols"], + "rows": payload["rows"], + "placeholder": payload["placeholder"], + "frames": payload["frames"], + "frameMs": constants.LOOP_MS / max(1, len(payload["frames"]) or 1), + "scale": scale, + }, + ) + + renderer = PetRenderer(str(pet.spritesheet), mode="unicode", scale=scale, unicode_cols=cols) + count = renderer.frame_count(state) or 1 + frames = [ + [[[*top, *bottom] for (top, bottom) in row] for row in renderer.cells(state, i, cols=cols)] + for i in range(count) + ] + return _ok( + rid, + {**base, "cols": cols, "frameMs": constants.LOOP_MS / max(1, count), "frames": frames, "scale": scale}, + ) @method("pet.gallery") @_profile_scoped +@_pet_guard("pet.gallery", fail_open={"enabled": False, "active": "", "pets": []}) def _(rid, params: dict) -> dict: - """List adoptable pets for the desktop appearance picker. + """Adoptable pets for the desktop picker: petdex gallery merged with local install state. - Returns the petdex gallery merged with local install state plus the - current config (active slug + enabled). Agent-independent. Fail-open: - returns whatever is installed locally if the gallery can't be reached, so - the picker still works offline. - - Param ``localOnly`` (bool): skip the remote petdex manifest fetch and return - only locally-installed pets. The desktop loads this first so the user's own - pets render instantly instead of waiting on the (possibly slow) manifest. + Fail-open to whatever is installed locally when the gallery is unreachable. + ``localOnly`` skips the remote manifest so the user's own pets render instantly. """ local_only = bool(params.get("localOnly")) + from agent.pet import store + + pet_cfg = _pet_display_cfg() + installed = {p.slug: p for p in store.installed_pets()} + + gallery: list[dict] = [] + seen: set[str] = set() try: - from agent.pet import store + from agent.pet.manifest import fetch_manifest, prefetch - try: - from hermes_cli.config import load_config + # Local-only still warms the manifest cache in the background. + if local_only: + prefetch() - cfg = load_config() - display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} - pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} - except Exception: - pet_cfg = {} + for entry in [] if local_only else fetch_manifest(): + seen.add(entry.slug) + gallery.append( + { + "slug": entry.slug, + "displayName": entry.display_name, + "installed": entry.slug in installed, + "spritesheetUrl": entry.spritesheet_url, + # petdex has no popularity metric; "curated" (its hand-picked + # set, identified by asset path) is the closest signal. + "curated": "/curated/" in entry.spritesheet_url, + "generated": entry.slug in installed and installed[entry.slug].generated, + } + ) + except Exception as exc: # noqa: BLE001 - offline: fall back to installed + logger.debug("pet.gallery manifest fetch failed: %s", exc) - installed = {p.slug: p for p in store.installed_pets()} + for slug, pet in installed.items(): + if slug not in seen: + gallery.append( + { + "slug": slug, + "displayName": pet.display_name, + "installed": True, + "spritesheetUrl": "", + "generated": pet.generated, + } + ) - gallery: list[dict] = [] - seen: set[str] = set() - try: - from agent.pet.manifest import fetch_manifest, prefetch + return _ok( + rid, + { + "enabled": is_truthy_value(pet_cfg.get("enabled"), default=False), + "active": str(pet_cfg.get("slug", "") or ""), + "pets": gallery, + }, + ) - # Local-only: skip the network entirely, but kick off a background - # warm so the follow-up full request usually hits a cached manifest. - if local_only: - prefetch() - for entry in [] if local_only else fetch_manifest(): - seen.add(entry.slug) - gallery.append( - { - "slug": entry.slug, - "displayName": entry.display_name, - "installed": entry.slug in installed, - "spritesheetUrl": entry.spritesheet_url, - # petdex exposes no popularity metric; "curated" (its - # hand-picked/official set, identified by the asset path) - # is the closest signal, so the picker can surface it first. - "curated": "/curated/" in entry.spritesheet_url, - "generated": entry.slug in installed and installed[entry.slug].generated, - } - ) - except Exception as exc: # noqa: BLE001 - offline: fall back to installed - logger.debug("pet.gallery manifest fetch failed: %s", exc) +def _with_slug(fn): + """Require ``params.slug`` (4004 "missing slug") and pass it as a 3rd arg.""" - # Always include locally-installed pets even if the gallery is unreachable. - for slug, pet in installed.items(): - if slug not in seen: - gallery.append( - { - "slug": slug, - "displayName": pet.display_name, - "installed": True, - "spritesheetUrl": "", - "generated": pet.generated, - } - ) + def handler(rid, params: dict) -> dict: + slug = str(params.get("slug") or "").strip() + if not slug: + return _err(rid, 4004, "missing slug") + return fn(rid, params, slug) - return _ok( - rid, - { - "enabled": is_truthy_value(pet_cfg.get("enabled"), default=False), - "active": str(pet_cfg.get("slug", "") or ""), - "pets": gallery, - }, - ) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.gallery failed: %s", exc) - return _ok(rid, {"enabled": False, "active": "", "pets": []}) + return handler @method("pet.select") @_profile_scoped -def _(rid, params: dict) -> dict: - """Adopt a pet from the desktop picker: install (if needed) + activate. +@_pet_guard("pet.select") +@_with_slug +def _(rid, params: dict, slug: str) -> dict: + """Adopt a pet: install (if needed) + activate; writes ``display.pet.*`` to config.""" + from agent.pet import store + from agent.pet.manifest import ManifestError + from hermes_cli.pets import _set_active - Params: ``slug`` (required). Writes ``display.pet.*`` to config and returns - ``{ok, slug, displayName}``. The surface re-pulls ``pet.info`` to render it. - """ - slug = str(params.get("slug") or "").strip() - if not slug: - return _err(rid, 4004, "missing slug") try: - from agent.pet import store - from agent.pet.manifest import ManifestError - from hermes_cli.pets import _set_active - - try: - pet = store.install_pet(slug) - except (store.PetStoreError, ManifestError) as exc: - return _err(rid, 5031, f"could not adopt '{slug}': {exc}") - _set_active(slug) - return _ok(rid, {"ok": True, "slug": slug, "displayName": pet.display_name}) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.select failed: %s", exc) - return _err(rid, 5031, f"pet.select failed: {exc}") + pet = store.install_pet(slug) + except (store.PetStoreError, ManifestError) as exc: + return _err(rid, 5031, f"could not adopt '{slug}': {exc}") + _set_active(slug) + return _ok(rid, {"ok": True, "slug": slug, "displayName": pet.display_name}) @method("pet.remove") @_profile_scoped -def _(rid, params: dict) -> dict: - """Uninstall a pet from the desktop picker (delete its on-disk directory). +@_pet_guard("pet.remove") +@_with_slug +def _(rid, params: dict, slug: str) -> dict: + """Uninstall a pet (delete its directory); if it was active, turn the display off.""" + from agent.pet import store + from hermes_cli.pets import _clear_active_if - Params: ``slug`` (required). If the removed pet was the active one, the - display is turned off so nothing tries to render a now-missing sprite. - Returns ``{ok, slug}`` where ``ok`` reflects whether a directory was deleted. - """ - slug = str(params.get("slug") or "").strip() - if not slug: - return _err(rid, 4004, "missing slug") + removed = store.remove_pet(slug) try: - from agent.pet import store - from hermes_cli.pets import _clear_active_if - - removed = store.remove_pet(slug) - - # If that was the active pet, stop surfaces pointing at a deleted sprite. - try: - _clear_active_if(slug) - except Exception as exc: # noqa: BLE001 - removal already succeeded - logger.debug("pet.remove config update failed: %s", exc) - - return _ok(rid, {"ok": removed, "slug": slug}) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.remove failed: %s", exc) - return _err(rid, 5031, f"pet.remove failed: {exc}") + _clear_active_if(slug) + except Exception as exc: # noqa: BLE001 - removal already succeeded + logger.debug("pet.remove config update failed: %s", exc) + return _ok(rid, {"ok": removed, "slug": slug}) @method("pet.export") @_profile_scoped -def _(rid, params: dict) -> dict: - """Export an installed pet as a re-importable ``.zip`` (pet.json + sprite). +@_pet_guard("pet.export") +@_with_slug +def _(rid, params: dict, slug: str) -> dict: + """Export an installed pet as a re-importable ``.zip`` → ``{ok, filename, zipBase64}``.""" + import base64 - Params: ``slug`` (required). Returns ``{ok, filename, zipBase64}`` — the - client decodes the base64 and saves it. Heavy-ish (reads + zips files) but - small; runs inline. - """ - slug = str(params.get("slug") or "").strip() - if not slug: - return _err(rid, 4004, "missing slug") - try: - import base64 + from agent.pet import store - from agent.pet import store - - filename, data = store.export_pet(slug) - return _ok( - rid, - {"ok": True, "filename": filename, "zipBase64": base64.standard_b64encode(data).decode("ascii")}, - ) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.export failed: %s", exc) - return _err(rid, 5031, f"pet.export failed: {exc}") + filename, data = store.export_pet(slug) + return _ok( + rid, + {"ok": True, "filename": filename, "zipBase64": base64.standard_b64encode(data).decode("ascii")}, + ) @method("pet.rename") @_profile_scoped -def _(rid, params: dict) -> dict: - """Rename an installed pet's display name + realign its slug/dir. - - Params: ``slug`` + ``name`` (both required). Lets the generate flow hatch - with a provisional name and apply the user's chosen name at adopt time. - Returns ``{ok, slug, displayName}`` with the (possibly new) slug. - """ - slug = str(params.get("slug") or "").strip() +@_pet_guard("pet.rename") +@_with_slug +def _(rid, params: dict, slug: str) -> dict: + """Rename a pet's display name + realign its slug/dir; follows the active slug in config.""" name = str(params.get("name") or "").strip() - if not slug: - return _err(rid, 4004, "missing slug") if not name: return _err(rid, 4004, "missing name") - try: - from agent.pet import store + from agent.pet import store - new_slug = store.rename_pet(slug, name) - if not new_slug: - return _err(rid, 5031, "pet.rename failed") + new_slug = store.rename_pet(slug, name) + if not new_slug: + return _err(rid, 5031, "pet.rename failed") + if new_slug != slug: + try: + from hermes_cli.pets import _rename_active_if - # The dir may have moved; if the renamed pet was active, follow the slug - # in config so surfaces don't point at the old (now-missing) directory. - if new_slug != slug: - try: - from hermes_cli.pets import _rename_active_if - - _rename_active_if(slug, new_slug) - except Exception as exc: # noqa: BLE001 - rename already succeeded - logger.debug("pet.rename config update failed: %s", exc) - - return _ok(rid, {"ok": True, "slug": new_slug, "displayName": name}) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.rename failed: %s", exc) - return _err(rid, 5031, f"pet.rename failed: {exc}") + _rename_active_if(slug, new_slug) + except Exception as exc: # noqa: BLE001 - rename already succeeded + logger.debug("pet.rename config update failed: %s", exc) + return _ok(rid, {"ok": True, "slug": new_slug, "displayName": name}) @method("pet.thumb") @_profile_scoped -def _(rid, params: dict) -> dict: - """Return a small idle-frame PNG (data URI) for one pet — the picker preview. +@_pet_guard("pet.thumb", fail_open=lambda params: {"ok": False, "slug": str(params.get("slug") or "").strip()}) +@_with_slug +def _(rid, params: dict, slug: str) -> dict: + """Small idle-frame PNG data URI for the picker preview (same-origin; the desktop + CSP / R2 hotlink rules break a CDN ````). ``url`` serves not-yet-installed pets.""" + import base64 - Cropped + cached server-side so the renderer gets a same-origin data URL - instead of a CDN ```` (which the desktop CSP / R2 hotlink rules break). - Params: ``slug`` (required), ``url`` (optional petdex spritesheet URL used - only for not-yet-installed pets). Fail-open: ``{ok: false}`` with no error. - """ - slug = str(params.get("slug") or "").strip() - if not slug: - return _err(rid, 4004, "missing slug") - try: - import base64 + from agent.pet import store - from agent.pet import store - - data = store.thumbnail_png(slug, source_url=str(params.get("url") or "")) - if not data: - return _ok(rid, {"ok": False, "slug": slug}) - - return _ok( - rid, - { - "ok": True, - "slug": slug, - "dataUri": "data:image/png;base64," + base64.standard_b64encode(data).decode("ascii"), - }, - ) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.thumb failed: %s", exc) + data = store.thumbnail_png(slug, source_url=str(params.get("url") or "")) + if not data: return _ok(rid, {"ok": False, "slug": slug}) + return _ok( + rid, + { + "ok": True, + "slug": slug, + "dataUri": "data:image/png;base64," + base64.standard_b64encode(data).decode("ascii"), + }, + ) @method("pet.disable") @_profile_scoped +@_pet_guard("pet.disable") def _(rid, params: dict) -> dict: - """Turn the pet off from the desktop picker (``display.pet.enabled=false``).""" - try: - from hermes_cli.pets import _set_enabled + """``display.pet.enabled=false`` from the desktop picker.""" + from hermes_cli.pets import _set_enabled - _set_enabled(False) - return _ok(rid, {"ok": True}) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.disable failed: %s", exc) - return _err(rid, 5031, f"pet.disable failed: {exc}") + _set_enabled(False) + return _ok(rid, {"ok": True}) @method("pet.scale") @_profile_scoped +@_pet_guard("pet.scale") def _(rid, params: dict) -> dict: - """Persist ``display.pet.scale`` from the desktop slider. Params: ``scale``. + """Persist ``display.pet.scale`` (clamped to engine bounds) from the desktop slider.""" + from hermes_cli.pets import set_pet_scale - Clamped to the engine bounds. The renderer updates its own ``$petInfo`` for - instant feedback; this just makes the change durable + visible to the other - terminal surfaces on their next read. - """ - try: - from hermes_cli.pets import set_pet_scale - - scale, err = set_pet_scale(params.get("scale")) - if err: - return _err(rid, 4004, err) - return _ok(rid, {"ok": True, "scale": scale}) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.scale failed: %s", exc) - return _err(rid, 5031, f"pet.scale failed: {exc}") + scale, err = set_pet_scale(params.get("scale")) + if err: + return _err(rid, 4004, err) + return _ok(rid, {"ok": True, "scale": scale}) @method("pet.cancel") def _(rid, params: dict) -> dict: """Signal an in-flight ``pet.generate``/``pet.hatch`` (by token) to stop. - Best-effort + idempotent: cancelling an unknown/finished token is a no-op. - Stays off the worker pool so it lands while a heavy generation is occupying - it. Returns ``{ok: True}``. + Idempotent; stays off the worker pool so it lands while a generation occupies it. """ token = str(params.get("token") or "").strip() if token: @@ -2333,45 +2022,32 @@ def _(rid, params: dict) -> dict: @method("pet.generate.status") +@_pet_guard("pet.generate.status", fail_open={"available": False, "providers": []}) def _(rid, params: dict) -> dict: - """Whether pet generation is possible right now. + """Whether pet generation is possible: a reference-capable image backend is configured.""" + from agent.pet.generate.imagegen import GenerationError, list_sprite_providers, resolve_provider - True only when a reference-capable image backend (Nous Portal / OpenRouter / - OpenAI gpt-image) is configured — the desktop checks this on open so it can - offer setup instead of a dead prompt. Cheap (config + plugin discovery). - """ try: - from agent.pet.generate.imagegen import ( - GenerationError, - list_sprite_providers, - resolve_provider, - ) - - try: - resolve_provider(require_references=True) - available = True - except GenerationError: - available = False - try: - providers = list_sprite_providers() - except Exception as exc: # noqa: BLE001 - picker is best-effort - logger.debug("pet provider list failed: %s", exc) - providers = [] - return _ok(rid, {"available": available, "providers": providers}) - except Exception as exc: # noqa: BLE001 - never break the surface - logger.debug("pet.generate.status failed: %s", exc) - return _ok(rid, {"available": False, "providers": []}) + resolve_provider(require_references=True) + available = True + except GenerationError: + available = False + try: + providers = list_sprite_providers() + except Exception as exc: # noqa: BLE001 - picker is best-effort + logger.debug("pet provider list failed: %s", exc) + providers = [] + return _ok(rid, {"available": available, "providers": providers}) @method("pet.generate") +@_pet_guard("pet.generate") def _(rid, params: dict) -> dict: - """Generate candidate base looks for a new pet (the draft/variant step). + """Generate candidate base looks for a new pet (the draft step). Heavy: worker pool. - Params: ``prompt`` (required unless ``referenceImage`` is given), ``count`` - (default 4), ``style`` (default ``auto``), ``referenceImage`` (optional data - URL — a user photo/reference every draft is grounded on, e.g. to make *their* - pet). Returns ``{ok, token, drafts:[{index, dataUri}]}`` — the token keys the - staged base images for a later ``pet.hatch``. Heavy (network): worker pool. + Params: ``prompt`` (required unless ``referenceImage`` — a data URL every draft + is grounded on), ``count`` (≤4), ``style``, ``provider``. Returns + ``{ok, token, drafts:[{index, dataUri}]}``; the token keys a later ``pet.hatch``. """ prompt = str(params.get("prompt") or "").strip() ref_raw = str(params.get("referenceImage") or "").strip() @@ -2383,221 +2059,198 @@ def _(rid, params: dict) -> dict: count = 4 style = str(params.get("style") or "auto").strip() or "auto" - try: - import shutil - import uuid + import shutil - from agent.pet.generate import generate_base_drafts - from agent.pet.generate.imagegen import GenerationError, resolve_provider + from agent.pet.generate import generate_base_drafts + from agent.pet.generate.imagegen import GenerationError, resolve_provider - root = _pet_gen_root() - _pet_gen_sweep(root) + root = _pet_gen_root() + _pet_gen_sweep(root) - # Token up front so each draft can be staged + streamed the moment it - # lands, instead of the user staring at a blank grid until all N finish. - token = uuid.uuid4().hex[:12] - _pet_cancel_arm(token) - stage = root / token - stage.mkdir(parents=True, exist_ok=True) + # Token up front so each draft is staged + streamed the moment it lands. + token = uuid.uuid4().hex[:12] + _pet_cancel_arm(token) + stage = root / token + stage.mkdir(parents=True, exist_ok=True) - reference_images = None - if ref_raw: - try: - reference_images = _pet_reference_images_from_data_url(ref_raw, stage) - except ValueError as exc: - _pet_cancel_release(token) - return _err(rid, 4004, str(exc)) - - # Optional desktop picker override: resolve the chosen provider up front so - # a bad/uncredentialed pick fails fast instead of mid-fan-out. - provider_name = str(params.get("provider") or "").strip() - sprite = None - if provider_name: - try: - sprite = resolve_provider(require_references=bool(reference_images), prefer=provider_name) - except GenerationError as exc: - _pet_cancel_release(token) - return _err(rid, 5031, str(exc)) - - concept = prompt or "a pet based on the reference image" - out: list[dict] = [] - - # Hand the token to the client up front (token-only init event) so a Stop - # fired before the first draft lands can still target this run. + reference_images = None + if ref_raw: try: - _emit("pet.generate.progress", "", {"token": token, "count": count}) - except Exception as exc: # noqa: BLE001 - streaming is best-effort - logger.debug("pet.generate init emit failed: %s", exc) - - def _on_draft(index: int, src) -> None: - dest = stage / f"draft-{index}.png" - try: - shutil.copyfile(src, dest) - data_uri = _pet_png_data_uri(dest) - except Exception as exc: # noqa: BLE001 - skip a bad draft, keep the rest - logger.debug("pet.generate draft %d failed: %s", index, exc) - return - out.append({"index": index, "dataUri": data_uri}) - # Stream this draft to the client so the grid fills in live. Best- - # effort: a transport hiccup must not abort the generation itself. - try: - _emit( - "pet.generate.progress", - "", - {"token": token, "index": index, "dataUri": data_uri, "count": count}, - ) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.generate progress emit failed: %s", exc) + reference_images = _pet_reference_images_from_data_url(ref_raw, stage) + except ValueError as exc: + _pet_cancel_release(token) + return _err(rid, 4004, str(exc)) + # Resolve a picker-chosen provider up front so a bad pick fails fast, not mid-fan-out. + provider_name = str(params.get("provider") or "").strip() + sprite = None + if provider_name: try: - generate_base_drafts( - concept, - n=count, - style=style, - reference_images=reference_images, - provider=sprite, - on_draft=_on_draft, - is_cancelled=lambda: _pet_is_cancelled(token), - ) + sprite = resolve_provider(require_references=bool(reference_images), prefer=provider_name) except GenerationError as exc: _pet_cancel_release(token) return _err(rid, 5031, str(exc)) - cancelled = _pet_is_cancelled(token) + concept = prompt or "a pet based on the reference image" + out: list[dict] = [] + + # Token-only init event so a Stop fired before the first draft can target this run. + try: + _emit("pet.generate.progress", "", {"token": token, "count": count}) + except Exception as exc: # noqa: BLE001 - streaming is best-effort + logger.debug("pet.generate init emit failed: %s", exc) + + def _on_draft(index: int, src) -> None: + dest = stage / f"draft-{index}.png" + try: + shutil.copyfile(src, dest) + data_uri = _pet_png_data_uri(dest) + except Exception as exc: # noqa: BLE001 - skip a bad draft, keep the rest + logger.debug("pet.generate draft %d failed: %s", index, exc) + return + out.append({"index": index, "dataUri": data_uri}) + # Stream the draft so the grid fills live; a transport hiccup must not abort generation. + try: + _emit( + "pet.generate.progress", + "", + {"token": token, "index": index, "dataUri": data_uri, "count": count}, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.generate progress emit failed: %s", exc) + + try: + generate_base_drafts( + concept, + n=count, + style=style, + reference_images=reference_images, + provider=sprite, + on_draft=_on_draft, + is_cancelled=lambda: _pet_is_cancelled(token), + ) + except GenerationError as exc: _pet_cancel_release(token) - if cancelled: - return _err(rid, 5031, "generation cancelled") - if not out: - return _err(rid, 5031, "generation produced no usable drafts") - out.sort(key=lambda d: d["index"]) - return _ok(rid, {"ok": True, "token": token, "drafts": out}) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.generate failed: %s", exc) - return _err(rid, 5031, f"pet.generate failed: {exc}") + return _err(rid, 5031, str(exc)) + + cancelled = _pet_is_cancelled(token) + _pet_cancel_release(token) + if cancelled: + return _err(rid, 5031, "generation cancelled") + if not out: + return _err(rid, 5031, "generation produced no usable drafts") + out.sort(key=lambda d: d["index"]) + return _ok(rid, {"ok": True, "token": token, "drafts": out}) @method("pet.hatch") +@_pet_guard("pet.hatch") def _(rid, params: dict) -> dict: - """Turn a chosen base draft into a full pet — installed but NOT yet active. + """Turn a chosen base draft into a full pet — installed but NOT yet active. Heavy: worker pool. - Generation is expensive and the result varies, so hatch produces a *preview* - the surface plays (all frames) before the user commits: the pet is written to - the store (so it can be rendered + later activated) but the active pet is left - untouched. Adopt with ``pet.select`` or throw it away with ``pet.remove``. - - Params: ``token`` + ``index`` (from ``pet.generate``), ``name`` (required), - ``description`` (optional), ``prompt`` (optional concept for row prompts), - ``style`` (optional). Returns ``{ok, slug, displayName, warnings, pet}`` where - ``pet`` is the renderer payload. Heavy (network + raster): worker pool. + The result is a preview the surface plays before the user commits (``pet.select`` + adopts, ``pet.remove`` discards). Params: ``token`` + ``index`` (from + ``pet.generate``), ``name`` (required), ``description``, ``prompt``, ``style``, + ``cancelToken``. Returns ``{ok, slug, displayName, warnings, pet}``. """ token = str(params.get("token") or "").strip() - # Hatch cancellation rides its own key, not the generation token: hatching a - # draft mid-generation means pet.generate is still releasing `token`, which - # would otherwise wipe the arm we set here. Falls back to `token` for clients - # that don't send one. + # Hatch cancellation rides its own key: pet.generate may still be releasing + # `token`, which would wipe the arm set here. Falls back for old clients. cancel_token = str(params.get("cancelToken") or "").strip() or token - index = params.get("index", 0) name = str(params.get("name") or "").strip() if not token: return _err(rid, 4004, "missing token") if not name: return _err(rid, 4004, "missing name") try: - index = int(index) + index = int(params.get("index", 0)) except (TypeError, ValueError): index = 0 - try: - from agent.pet import store - from agent.pet.generate import hatch_pet - from agent.pet.generate.imagegen import GenerationError, resolve_provider + from agent.pet import store + from agent.pet.generate import hatch_pet + from agent.pet.generate.imagegen import GenerationError, resolve_provider - base = _pet_gen_root() / token / f"draft-{index}.png" - if not base.is_file(): - return _err(rid, 4004, "draft expired — generate again") - - # Optional desktop picker override (rows always need reference grounding). - provider_name = str(params.get("provider") or "").strip() - sprite = None - if provider_name: - try: - sprite = resolve_provider(require_references=True, prefer=provider_name) - except GenerationError as exc: - return _err(rid, 5031, str(exc)) - - _pet_cancel_arm(cancel_token) - slug = store.unique_slug(name) - - def _on_progress(event: str, detail: str) -> None: - # Row progress is encoded as "::" so the egg - # screen can show "Drawing … (n/total)"; other phases - # (compose, save) pass through as-is. Best-effort streaming. - payload: dict = {"event": event, "detail": detail} - if event == "row" and detail.count(":") == 2: - state, done, total = detail.split(":") - payload = {"event": "row", "state": state, "done": done, "total": total} - try: - _emit("pet.hatch.progress", "", payload) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.hatch progress emit failed: %s", exc) + base = _pet_gen_root() / token / f"draft-{index}.png" + if not base.is_file(): + return _err(rid, 4004, "draft expired — generate again") + # Picker override (rows always need reference grounding). + provider_name = str(params.get("provider") or "").strip() + sprite = None + if provider_name: try: - result = hatch_pet( - base_image=base, - slug=slug, - display_name=name, - description=str(params.get("description") or ""), - concept=str(params.get("prompt") or name), - style=str(params.get("style") or "auto").strip() or "auto", - provider=sprite, - on_progress=_on_progress, - is_cancelled=lambda: _pet_is_cancelled(cancel_token), - ) + sprite = resolve_provider(require_references=True, prefer=provider_name) except GenerationError as exc: return _err(rid, 5031, str(exc)) - finally: - _pet_cancel_release(cancel_token) - pet = store.load_pet(result.slug) - payload = _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {} - return _ok( - rid, - { - "ok": True, - "slug": result.slug, - "displayName": result.display_name, - "warnings": result.validation.get("warnings", []), - "pet": payload, - }, + _pet_cancel_arm(cancel_token) + slug = store.unique_slug(name) + + def _on_progress(event: str, detail: str) -> None: + # Row progress is "::" so the egg screen can show + # "Drawing … (n/total)"; other phases pass through as-is. + payload: dict = {"event": event, "detail": detail} + if event == "row" and detail.count(":") == 2: + state, done, total = detail.split(":") + payload = {"event": "row", "state": state, "done": done, "total": total} + try: + _emit("pet.hatch.progress", "", payload) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.hatch progress emit failed: %s", exc) + + try: + result = hatch_pet( + base_image=base, + slug=slug, + display_name=name, + description=str(params.get("description") or ""), + concept=str(params.get("prompt") or name), + style=str(params.get("style") or "auto").strip() or "auto", + provider=sprite, + on_progress=_on_progress, + is_cancelled=lambda: _pet_is_cancelled(cancel_token), ) - except Exception as exc: # noqa: BLE001 - logger.debug("pet.hatch failed: %s", exc) - return _err(rid, 5031, f"pet.hatch failed: {exc}") + except GenerationError as exc: + return _err(rid, 5031, str(exc)) + finally: + _pet_cancel_release(cancel_token) + + pet = store.load_pet(result.slug) + payload = _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {} + return _ok( + rid, + { + "ok": True, + "slug": result.slug, + "displayName": result.display_name, + "warnings": result.validation.get("warnings", []), + "pet": payload, + }, + ) + + +# ── billing / subscription ─────────────────────────────────────────── +# All fail-open: a logged-out / unreachable portal yields an ``ok`` envelope +# with a typed ``error`` (via _serialize_billing_error) rather than a JSON-RPC +# error, so the TUI maps it to the right copy. ``billing:manage`` routes return +# error=insufficient_scope on 403, which drives the ``billing.step_up`` device flow. @method("billing.state") def _(rid, params: dict) -> dict: - """GET /api/billing/state → serialized BillingState (Screen 1 + 5). - - Fail-open like the other billing RPCs: a logged-out / unreachable portal yields - {ok:true, logged_in:false}. No scope required for this endpoint. - """ + """GET /api/billing/state → serialized BillingState. No scope required.""" try: from agent.billing_view import build_billing_state - state = build_billing_state() - return _ok(rid, _serialize_billing_state(state)) + return _ok(rid, _serialize_billing_state(build_billing_state())) except Exception: return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"}) @method("usage.bars") def _(rid, params: dict) -> dict: - """Shared dollar usage model (two-bar view) for /usage + /subscription. - - Fail-open: logged-out / unreachable portal → {ok:true, available:false}. - No scope required (read-only). - """ + """Shared dollar usage model (two-bar view) for /usage + /subscription.""" try: from agent.billing_usage import build_usage_model @@ -2608,274 +2261,204 @@ def _(rid, params: dict) -> dict: @method("subscription.state") def _(rid, params: dict) -> dict: - """GET /api/billing/subscription → serialized SubscriptionState. - - Fail-open like billing.state: logged-out / unreachable portal → - {ok:true, logged_in:false}. No scope required (read-only). - """ + """GET /api/billing/subscription → serialized SubscriptionState (read-only).""" try: from agent.subscription_view import build_subscription_state - state = build_subscription_state() - return _ok(rid, _serialize_subscription_state(state)) + return _ok(rid, _serialize_subscription_state(build_subscription_state())) except Exception: return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"}) @method("subscription.preview") def _(rid, params: dict) -> dict: - """POST /api/billing/subscription/preview → serialized quote or typed error. - - params: {subscription_type_id: str}. Chargeless effect quote. Requires - billing:manage (live Stripe calls + amounts), so a 403 → insufficient_scope - drives the device step-up exactly like the mutations. - """ + """POST /api/billing/subscription/preview → chargeless effect quote. billing:manage.""" from agent.subscription_view import subscription_change_preview_from_payload - from hermes_cli.nous_billing import BillingError, post_subscription_preview + from hermes_cli.nous_billing import post_subscription_preview tier_id = params.get("subscription_type_id") if not tier_id: - return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"}) - try: - preview = subscription_change_preview_from_payload( - post_subscription_preview(subscription_type_id=tier_id) - ) - return _ok(rid, _serialize_subscription_preview(preview)) - except BillingError as exc: - return _ok(rid, _serialize_billing_error(exc)) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + return _billing_invalid(rid, "subscription_type_id is required") + return _billing_call( + rid, + lambda: _serialize_subscription_preview( + subscription_change_preview_from_payload(post_subscription_preview(subscription_type_id=tier_id)) + ), + ) @method("subscription.change") def _(rid, params: dict) -> dict: - """PUT /api/billing/subscription/pending-change → {ok, message} or typed error. - - params: {subscription_type_id?: str, cancel?: bool}. Schedules a downgrade / - same-price change OR a cancellation at period end (chargeless). Requires - billing:manage. - """ - from hermes_cli.nous_billing import BillingError, put_subscription_pending_change + """PUT /api/billing/subscription/pending-change: schedule a downgrade / same-price + change OR a period-end cancellation (chargeless). billing:manage.""" + from hermes_cli.nous_billing import put_subscription_pending_change cancel = bool(params.get("cancel")) tier_id = params.get("subscription_type_id") if not cancel and not tier_id: - return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id or cancel is required"}) - try: + return _billing_invalid(rid, "subscription_type_id or cancel is required") + + def call(): result = put_subscription_pending_change(subscription_type_id=tier_id, cancel=cancel) - return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result}) - except BillingError as exc: - return _ok(rid, _serialize_billing_error(exc)) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + return {"ok": True, "message": result.get("message"), "payload": result} + + return _billing_call(rid, call) @method("subscription.resume") def _(rid, params: dict) -> dict: - """DELETE /api/billing/subscription/pending-change → {ok, message} or typed error. + """DELETE /api/billing/subscription/pending-change: clear a scheduled downgrade / + cancellation. Re-enables recurring spend → billing:manage + kill-switch.""" + from hermes_cli.nous_billing import delete_subscription_pending_change - Clears a scheduled downgrade or cancellation (resume / undo). Chargeless, but it - re-enables recurring spend → requires billing:manage and honors the kill-switch. - """ - from hermes_cli.nous_billing import BillingError, delete_subscription_pending_change - - try: + def call(): result = delete_subscription_pending_change() - return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result}) - except BillingError as exc: - return _ok(rid, _serialize_billing_error(exc)) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + return {"ok": True, "message": result.get("message"), "payload": result} + + return _billing_call(rid, call) @method("subscription.upgrade") def _(rid, params: dict) -> dict: - """POST /api/billing/subscription/upgrade → {ok, status, ...} or typed error. + """POST /api/billing/subscription/upgrade — the single money route: prorate + charge + flip plan. - params: {subscription_type_id: str, idempotency_key?: str}. The single money - route: prorate + charge the card on the subscription + flip the plan. SCA / - decline come back as status requires_action / payment_failed with a recovery_url - to finish in the portal. The idempotency key is minted if absent and echoed so - the TUI reuses it on retry of the SAME upgrade. Requires billing:manage. + SCA / decline come back as status requires_action / payment_failed with a + recovery_url. The idempotency key is minted if absent and echoed (also on + error) so the TUI reuses it on retry of the SAME upgrade. billing:manage. """ from agent.billing_view import new_idempotency_key - from hermes_cli.nous_billing import BillingError, post_subscription_upgrade + from hermes_cli.nous_billing import post_subscription_upgrade tier_id = params.get("subscription_type_id") if not tier_id: - return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"}) + return _billing_invalid(rid, "subscription_type_id is required") key = params.get("idempotency_key") or new_idempotency_key() - try: + + def call(): result = post_subscription_upgrade(subscription_type_id=tier_id, idempotency_key=key) - return _ok( - rid, - { - "ok": True, - "status": result.get("status"), - "target_tier_name": result.get("targetTierName"), - "recovery_url": result.get("recoveryUrl"), - "reason": result.get("reason"), - "idempotency_key": key, - }, - ) - except BillingError as exc: - env = _serialize_billing_error(exc) - env["idempotency_key"] = key # so the TUI can reuse on retry - return _ok(rid, env) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key}) + return { + "ok": True, + "status": result.get("status"), + "target_tier_name": result.get("targetTierName"), + "recovery_url": result.get("recoveryUrl"), + "reason": result.get("reason"), + "idempotency_key": key, + } + + return _billing_call(rid, call, extra={"idempotency_key": key}) @method("billing.charge") def _(rid, params: dict) -> dict: - """POST /api/billing/charge → {ok, chargeId} or a typed error envelope. - - params: {amount_usd: str|number, idempotency_key?: str}. If no key is - supplied, the server-side core mints a fresh one and returns it so the TUI can - reuse it on retry of the SAME purchase. - """ - from hermes_cli.nous_billing import BillingError, post_charge + """POST /api/billing/charge → {ok, charge_id, idempotency_key}; key minted if absent + and echoed (also on error) so the TUI reuses it on retry of the SAME purchase.""" + from hermes_cli.nous_billing import post_charge from agent.billing_view import new_idempotency_key amount = params.get("amount_usd") if amount is None: - return _ok(rid, {"ok": False, "error": "invalid_request", "message": "amount_usd is required"}) + return _billing_invalid(rid, "amount_usd is required") key = params.get("idempotency_key") or new_idempotency_key() - try: + + def call(): result = post_charge(amount_usd=amount, idempotency_key=key) - return _ok(rid, {"ok": True, "charge_id": result.get("chargeId"), "idempotency_key": key}) - except BillingError as exc: - env = _serialize_billing_error(exc) - env["idempotency_key"] = key # so the TUI can reuse on retry - return _ok(rid, env) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key}) + return {"ok": True, "charge_id": result.get("chargeId"), "idempotency_key": key} + + return _billing_call(rid, call, extra={"idempotency_key": key}) @method("billing.charge_status") def _(rid, params: dict) -> dict: - """GET /api/billing/charge/{id} → {ok, status, ...} or typed error. - - The poll. Caller drives the 2s/5-min cadence; this is a single status read. - """ - from hermes_cli.nous_billing import BillingError, get_charge_status + """GET /api/billing/charge/{id} — a single status read; the caller drives the poll cadence.""" + from hermes_cli.nous_billing import get_charge_status charge_id = params.get("charge_id") if not charge_id: - return _ok(rid, {"ok": False, "error": "invalid_charge_id", "message": "charge_id is required"}) - try: + return _billing_invalid(rid, "charge_id is required", error="invalid_charge_id") + + def call(): result = get_charge_status(charge_id) - return _ok( - rid, - { - "ok": True, - "status": result.get("status"), - "amount_usd": result.get("amountUsd"), - "settled_at": result.get("settledAt"), - "reason": result.get("reason"), - }, - ) - except BillingError as exc: - return _ok(rid, _serialize_billing_error(exc)) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + return { + "ok": True, + "status": result.get("status"), + "amount_usd": result.get("amountUsd"), + "settled_at": result.get("settledAt"), + "reason": result.get("reason"), + } + + return _billing_call(rid, call) @method("billing.auto_reload") def _(rid, params: dict) -> dict: - """PATCH /api/billing/auto-top-up → {ok:true} or typed error (Screen 2). + """PATCH /api/billing/auto-top-up. params: {enabled, threshold, top_up_amount}.""" + from hermes_cli.nous_billing import patch_auto_top_up - params: {enabled: bool, threshold: number, top_up_amount: number}. - """ - from hermes_cli.nous_billing import BillingError, patch_auto_top_up + enabled = bool(params.get("enabled")) + threshold = params.get("threshold") + top_up_amount = params.get("top_up_amount") + if threshold is None or top_up_amount is None: + return _billing_invalid(rid, "threshold and top_up_amount are required") - try: - enabled = bool(params.get("enabled")) - threshold = params.get("threshold") - top_up_amount = params.get("top_up_amount") - if threshold is None or top_up_amount is None: - return _ok(rid, {"ok": False, "error": "invalid_request", "message": "threshold and top_up_amount are required"}) + def call(): patch_auto_top_up(enabled=enabled, threshold=threshold, top_up_amount=top_up_amount) - return _ok(rid, {"ok": True}) - except BillingError as exc: - return _ok(rid, _serialize_billing_error(exc)) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + return {"ok": True} + + return _billing_call(rid, call) @method("billing.step_up") def _(rid, params: dict) -> dict: - """Run the lazy billing:manage step-up device flow → {ok, granted}. + """Lazy billing:manage step-up device flow → {ok, granted}; granted:false when the + server silently downscopes. - Triggered by the TUI after a billing call returns error=insufficient_scope. - Returns granted:false when the server silently downscopes (non-admin / unticked). - - Runs on the thread pool (in _LONG_HANDLERS): the device flow blocks for the - whole device-code lifetime (minutes), so it must not stall the main stdin loop. - The verification URL/code reach the TUI via an out-of-band ``billing.step_up. - verification`` event (a plain print would be dropped by the JSON-RPC stdout - pipe), and the browser is opened TUI-side via openExternalUrl — never with the - gateway's headless webbrowser.open (hence open_browser=False). + Runs on the thread pool (_LONG_HANDLERS): the device flow blocks for minutes. + The verification URL/code reach the TUI via the out-of-band + ``billing.step_up.verification`` event (a print would be lost in the JSON-RPC + stdout pipe) and the browser is opened TUI-side — never via the gateway's + headless webbrowser.open (open_browser=False). """ sid = params.get("session_id") or "" - try: + + def call(): from hermes_cli.auth import step_up_nous_billing_scope - from hermes_cli.nous_billing import BillingError def _on_verification(url: str, code: str) -> None: - _emit( - "billing.step_up.verification", - sid, - {"verification_url": url, "user_code": code}, - ) + _emit("billing.step_up.verification", sid, {"verification_url": url, "user_code": code}) - granted = step_up_nous_billing_scope( - open_browser=False, on_verification=_on_verification - ) - return _ok(rid, {"ok": True, "granted": bool(granted)}) - except BillingError as exc: - # Route typed billing errors (e.g. session_revoked when the token expires - # mid-device-flow) through the shared spine like the other write handlers, - # so the TUI maps them to the right copy instead of a generic failure. - env = _serialize_billing_error(exc) - env["granted"] = False - return _ok(rid, env) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False}) + granted = step_up_nous_billing_scope(open_browser=False, on_verification=_on_verification) + return {"ok": True, "granted": bool(granted)} + + return _billing_call(rid, call, extra={"granted": False}) + + +# ── session status / history / undo / compress / save / close ──────── @method("session.status") -def _(rid, params: dict) -> dict: - session, err = _sess_nowait(params, rid) - if err: - return err - +@_with_session +def _(rid, params: dict, session: dict) -> dict: from hermes_constants import display_hermes_home key = session.get("session_key") or params.get("session_id") or "" agent = session.get("agent") + + def _row(db) -> dict: + try: + return db.get_session(key) or {} + except Exception: + return {} + meta = {} - # Prefer the live session's bound profile db, else params.profile, else launch. - status_params = dict(params or {}) - if not status_params.get("profile") and session.get("profile_home"): - # profile_home is a path; still allow _session_db via a synthetic session - pass + # Prefer the live session's bound profile db, else params.profile / launch. with _session_db(session) as db: - if db is None: - # Fall back to ~params.profile naming for not-yet-mapped sessions. + if db is not None: + if key: + meta = _row(db) + else: with _profile_db(params) as db2: - db = db2 - if db and key: - try: - meta = db.get_session(key) or {} - except Exception: - meta = {} - db = None # prevent double-use - if db is not None and key: - try: - meta = db.get_session(key) or {} - except Exception: - meta = {} + if db2 and key: + meta = _row(db2) def _dt(value, fallback: datetime | None = None) -> datetime: if value: @@ -2897,12 +2480,7 @@ def _(rid, params: dict) -> dict: provider = getattr(agent, "provider", None) or mirror.get("provider") or "unknown" model = getattr(agent, "model", None) or mirror.get("model") or "(unknown)" project = _project_info_for_cwd(_display_session_cwd(session)) - lines = [ - "Hermes TUI Status", - "", - f"Session ID: {key}", - f"Path: {display_hermes_home()}", - ] + lines = ["Hermes TUI Status", "", f"Session ID: {key}", f"Path: {display_hermes_home()}"] if project: lines.append(f"Project: {project['name']}") title = (meta.get("title") or "").strip() @@ -2921,166 +2499,130 @@ def _(rid, params: dict) -> dict: @method("session.history") -def _(rid, params: dict) -> dict: - session, err = _sess_nowait(params, rid) - if err: - return err +@_with_session +def _(rid, params: dict, session: dict) -> dict: history = list(session.get("history", [])) if session.get("session_key"): with _session_db(session) as db: if db is not None: try: - # include_row_ids: the durable row id is how clients address - # a specific persisted turn (reactions, and the Desktop's - # content-based truncation-target resolution — #87059). The - # projection in _history_to_messages only forwards row_id - # when the row carries a stamp, so an unstamped read here - # silently strips the one durable address clients can use. + # include_row_ids: the durable row id is how clients address a + # persisted turn (reactions, content-based truncation targets); + # _history_to_messages only forwards row_id when stamped. history = db.get_messages_as_conversation( - session["session_key"], - include_ancestors=True, - include_row_ids=True, + session["session_key"], include_ancestors=True, include_row_ids=True ) except Exception: pass - return _ok( - rid, - { - "count": len(history), - "messages": _history_to_messages(history), - }, - ) + return _ok(rid, {"count": len(history), "messages": _history_to_messages(history)}) @method("session.undo") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err - # Reject during an in-flight turn. If we mutated history while - # the agent thread is running, prompt.submit's post-run history - # write would either clobber the undo (version matches) or - # silently drop the agent's output (version mismatch, see below). - # Neither is what the user wants — make them /interrupt first. +@_with_live_session +def _(rid, params: dict, session: dict) -> dict: + # Mutating history under a running turn would make prompt.submit's post-run + # write either clobber the undo or drop the agent's output — /interrupt first. + busy = _err(rid, 4009, "session busy — /interrupt the current turn before /undo") if session.get("running"): - return _err( - rid, 4009, "session busy — /interrupt the current turn before /undo" - ) + return busy removed = 0 with session["history_lock"]: if session.get("running"): - return _err( - rid, 4009, "session busy — /interrupt the current turn before /undo" - ) - history = _history_without_ephemeral_scaffolding( - session.get("history", []) - ) - # Truncate from the last *real* user turn. Popping only trailing - # assistant/tool then one user left timeline markers - # (async_delegation_complete, model_switch, …) or compaction - # handoffs as the undo target — so session.undo removed - # bookkeeping instead of the last exchange (#80622). - # Match user_originated_turn_view / CLI turn counting. + return busy + history = _history_without_ephemeral_scaffolding(session.get("history", [])) + # Truncate from the last *real* user turn: popping trailing assistant/tool + # then one user left timeline markers / compaction handoffs as the target. from agent.context_compressor import user_originated_turn_view user_indices = [ - index - for index, message in enumerate(history) - if user_originated_turn_view(message) is not None + index for index, message in enumerate(history) if user_originated_turn_view(message) is not None ] if user_indices: try: - _installed, _live_view, rewound_count = ( - _rewind_active_session_history(session, len(user_indices) - 1) + _installed, _live_view, removed = _rewind_active_session_history( + session, len(user_indices) - 1 ) - removed = rewound_count except Exception as exc: return _err(rid, 5008, f"undo: {exc}") return _ok(rid, {"removed": removed}) +def _compress_via_compute_host(rid, params: dict, session: dict) -> dict: + """``session.compress`` for a turn-isolated session: forward ``/compress`` to the host.""" + sid = str(params.get("session_id") or "") + focus_topic = str(params.get("focus_topic", "") or "").strip() + command = "/compress" + (f" {focus_topic}" if focus_topic else "") + + def _on_late_ack(late: dict, _sid=sid) -> None: + _adopt_late_compute_host_compress_ack(_sid, session, late, route_name="session.compress") + + try: + ack = _send_compute_host_control( + sid, + route_name="session.compress", + command=command, + wait=True, + # Follows compression.context_total_ceiling_seconds: the host legitimately runs that long. + timeout=_compute_host_compress_wait_seconds(), + on_late_ack=_on_late_ack, + ) + except queue.Empty: + # The waiter gave up but the host is still compressing; the late-ack + # handler adopts the rotated session and pushes session.info when it + # lands. Not an error (a 5019 here made clients report a timeout while + # compression later succeeded silently). + return _ok( + rid, + { + "status": "pending", + "turn_isolation": True, + "message": ( + "compression still running in the background; " + "the transcript will refresh when it finishes" + ), + }, + ) + except Exception as exc: + return _err(rid, 5019, f"compute-host compress failed: {exc}") + if ack.get("type") in {"control.error", "error"}: + return _err(rid, 4009, str(ack.get("message") or "compute-host compress failed")) + _apply_compute_host_metadata_mirror(session, ack) + host_result = ack.get("result") + if isinstance(host_result, dict): + # The host owns the isolated session; preserve its structured result + # verbatim (it carries `status: aborted` / `summary.aborted`). + return _ok(rid, {**host_result, "turn_isolation": True}) + host_info = ack.get("session_info") if isinstance(ack.get("session_info"), dict) else {} + host_messages = _history_to_messages(ack.get("messages")) if isinstance(ack.get("messages"), list) else [] + # `messages` goes at top level for the transcript replacement; don't send the + # same (large) transcript a second time inside the ack. + host_ack = {key: value for key, value in ack.items() if key != "messages"} + return _ok( + rid, + { + "status": "compressed", + "turn_isolation": True, + "host_ack": host_ack, + "info": host_info, + "messages": host_messages, + "usage": host_info.get("usage") if isinstance(host_info.get("usage"), dict) else {}, + }, + ) + + @method("session.compress") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) if err: return err - assert session is not None if _session_uses_compute_host(session): - sid = str(params.get("session_id") or "") - focus_topic = str(params.get("focus_topic", "") or "").strip() - command = "/compress" + (f" {focus_topic}" if focus_topic else "") - _late_session = session - - def _on_late_ack(late: dict, _sid=sid) -> None: - _adopt_late_compute_host_compress_ack(_sid, _late_session, late, route_name="session.compress") - - try: - ack = _send_compute_host_control( - sid, - route_name="session.compress", - command=command, - wait=True, - # Follows compression.context_total_ceiling_seconds instead of - # a fixed 120s: the host legitimately runs that long (#97948). - timeout=_compute_host_compress_wait_seconds(), - on_late_ack=_on_late_ack, - ) - except queue.Empty: - # The waiter gave up but the host is still compressing; the late - # ack handler adopts the rotated session and pushes session.info - # when it lands. Not an error — the old 5019 made Desktop/TUI - # report a timeout while compression later succeeded silently. - return _ok( - rid, - { - "status": "pending", - "turn_isolation": True, - "message": ( - "compression still running in the background; " - "the transcript will refresh when it finishes" - ), - }, - ) - except Exception as exc: - return _err(rid, 5019, f"compute-host compress failed: {exc}") - if ack.get("type") in {"control.error", "error"}: - return _err(rid, 4009, str(ack.get("message") or "compute-host compress failed")) - _apply_compute_host_metadata_mirror(session, ack) - host_result = ack.get("result") - if isinstance(host_result, dict): - # The host owns the isolated session's agent/history, so preserve - # its structured compression result verbatim. In particular this - # carries `status: aborted` and `summary.aborted`; flattening the - # old text-only acknowledgement made Desktop show aborted work as a - # success toast. - return _ok(rid, {**host_result, "turn_isolation": True}) - host_info = ack.get("session_info") if isinstance(ack.get("session_info"), dict) else {} - host_messages = _history_to_messages(ack.get("messages")) if isinstance(ack.get("messages"), list) else [] - # `messages` is returned at top level for the desktop transcript - # replacement. Keep the host acknowledgement metadata, but do not send - # the same (potentially large) transcript a second time inside it. - host_ack = {key: value for key, value in ack.items() if key != "messages"} - return _ok( - rid, - { - "status": "compressed", - "turn_isolation": True, - "host_ack": host_ack, - "info": host_info, - "messages": host_messages, - "usage": host_info.get("usage") if isinstance(host_info.get("usage"), dict) else {}, - }, - ) + return _compress_via_compute_host(rid, params, session) session, err = _sess(params, rid) if err: return err if session.get("running"): - return _err( - rid, 4009, "session busy — /interrupt the current turn before /compress" - ) - from agent.conversation_compression import ( - finalize_context_engine_compression_notification, - ) + return _err(rid, 4009, "session busy — /interrupt the current turn before /compress") + from agent.conversation_compression import finalize_context_engine_compression_notification sid = params.get("session_id", "") focus_topic = str(params.get("focus_topic", "") or "").strip() @@ -3095,21 +2637,18 @@ def _(rid, params: dict) -> dict: _agent = session["agent"] _sys_prompt = getattr(_agent, "_cached_system_prompt", "") or "" _tools = getattr(_agent, "tools", None) or None - before_tokens = ( - estimate_request_tokens_rough( - before_messages, system_prompt=_sys_prompt, tools=_tools - ) - if before_count - else 0 - ) + + def _tokens(msgs, sys_prompt, tools) -> int: + return estimate_request_tokens_rough(msgs, system_prompt=sys_prompt, tools=tools) if msgs else 0 + + before_tokens = _tokens(before_messages, _sys_prompt, _tools) if before_count >= 4: focus_suffix = f', focus: "{focus_topic}"' if focus_topic else "" _status_update( sid, "compressing", - f"⠋ compressing {before_count} messages " - f"(~{before_tokens:,} tok){focus_suffix}…", + f"⠋ compressing {before_count} messages (~{before_tokens:,} tok){focus_suffix}…", ) try: @@ -3123,20 +2662,11 @@ def _(rid, params: dict) -> dict: with session["history_lock"]: messages = list(session.get("history", [])) after_count = len(messages) - # Re-read system prompt + tools after compression — _compress_context - # may have rebuilt the system prompt (_cached_system_prompt=None). - _sys_prompt_after = ( - getattr(_agent, "_cached_system_prompt", "") or _sys_prompt - ) - _tools_after = getattr(_agent, "tools", None) or _tools - after_tokens = ( - estimate_request_tokens_rough( - messages, - system_prompt=_sys_prompt_after, - tools=_tools_after, - ) - if after_count - else 0 + # Re-read prompt + tools: _compress_context may have rebuilt the system prompt. + after_tokens = _tokens( + messages, + getattr(_agent, "_cached_system_prompt", "") or _sys_prompt, + getattr(_agent, "tools", None) or _tools, ) agent = session["agent"] _sync_session_key_after_compress(sid, session) @@ -3149,10 +2679,7 @@ def _(rid, params: dict) -> dict: ) info = _session_info(agent, session) _emit("session.info", sid, info) - finalize_context_engine_compression_notification( - agent, - committed=True, - ) + finalize_context_engine_compression_notification(agent, committed=True) return _ok( rid, { @@ -3165,50 +2692,31 @@ def _(rid, params: dict) -> dict: "summary": summary, "usage": usage, "info": info, - # Keep this identical to session.resume / session.history: - # raw tool results can contain large or sensitive payloads - # that belong in persisted history, not the transcript - # replacement response. + # Same projection as session.resume / session.history: raw tool + # results belong in persisted history, not the transcript response. "messages": _history_to_messages(messages), }, ) finally: - # Always clear the pinned compressing status so the bar - # reverts to neutral whether compaction succeeded, was a - # no-op, or raised. + # Always clear the pinned compressing status (success, no-op, or raise). _status_update(sid, "ready") except CompressionLockHeld as e: _status_update(sid, "ready") - from agent.manual_compression_feedback import ( - describe_compression_lock_skip, - ) - return _ok(rid, { - "compressed": False, - "lock_held": True, - "message": describe_compression_lock_skip(e.holder), - }) + from agent.manual_compression_feedback import describe_compression_lock_skip + + return _ok(rid, {"compressed": False, "lock_held": True, "message": describe_compression_lock_skip(e.holder)}) except Exception as e: - finalize_context_engine_compression_notification( - session["agent"], - committed=False, - ) + finalize_context_engine_compression_notification(session["agent"], committed=False) return _err(rid, 5005, str(e)) @method("session.save") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err - +@_with_live_session +def _(rid, params: dict, session: dict) -> dict: if _session_uses_compute_host(session): sid = str(params.get("session_id") or "") try: - ack = _send_compute_host_control( - sid, - route_name="session.save", - wait=True, - ) + ack = _send_compute_host_control(sid, route_name="session.save", wait=True) except Exception as exc: return _err(rid, 5011, f"compute-host session save failed: {exc}") if ack.get("type") in {"control.error", "error"}: @@ -3219,34 +2727,26 @@ def _(rid, params: dict) -> dict: return _ok(rid, result) agent = session["agent"] - # Mirror the classic CLI /save: snapshot under the Hermes profile home - # (~/.hermes/sessions/saved/) rather than the project/workspace CWD, and - # include the system prompt so the export matches the dashboard save. + # Mirror the classic CLI /save: snapshot under the profile home (not the + # workspace cwd) and include the system prompt so it matches the dashboard save. saved_dir = get_hermes_home() / "sessions" / "saved" try: saved_dir.mkdir(parents=True, exist_ok=True) except Exception as e: return _err(rid, 5011, f"failed to create save directory {saved_dir}: {e}") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - path = saved_dir / f"hermes_conversation_{timestamp}.json" - + path = saved_dir / f"hermes_conversation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" with session["history_lock"]: messages = list(session.get("history", [])) session_id = getattr(agent, "session_id", None) or session.get("session_key") or "" - # Prefer the agent's session_start datetime (matches the classic CLI export); - # fall back to the gateway session's created_at timestamp. + # Prefer the agent's session_start (classic CLI export); else the gateway created_at. agent_start = getattr(agent, "session_start", None) if isinstance(agent_start, datetime): session_start = agent_start.isoformat() else: created_at = session.get("created_at") - session_start = ( - datetime.fromtimestamp(created_at).isoformat() - if isinstance(created_at, (int, float)) - else "" - ) + session_start = datetime.fromtimestamp(created_at).isoformat() if isinstance(created_at, (int, float)) else "" try: with open(path, "w", encoding="utf-8") as f: @@ -3270,22 +2770,50 @@ def _(rid, params: dict) -> dict: @method("session.close") def _(rid, params: dict) -> dict: sid = params.get("session_id", "") - # Serialize only the ownership claim against session.resume / the orphan - # reaper. Finalization may run arbitrary plugin/agent cleanup and must not - # keep every unrelated session.resume waiting behind it. + # Serialize only the ownership claim against session.resume / the reaper; + # finalization may run arbitrary plugin cleanup and must not block other resumes. with _session_resume_lock: session = _pop_session_by_id(sid) closed = _teardown_popped_session(session, end_reason="tui_close") return _ok(rid, {"closed": closed}) +# ── session.branch ─────────────────────────────────────────────────── + + +def _visible_branch_history(messages) -> list: + """user/assistant rows with visible text, as FULL row copies (reasoning fields and + timeline-marker tags — display_kind/display_metadata — must survive the branch).""" + visible = [] + for message in messages or []: + if not isinstance(message, dict) or message.get("role") not in {"user", "assistant"}: + continue + if not _coerce_message_text(message.get("content")).strip(): + continue + visible.append(dict(message)) + return visible + + +_BRANCH_COPY_FIELDS = ( + "reasoning", + "reasoning_content", + "reasoning_details", + "codex_reasoning_items", + "codex_message_items", + # Timeline markers ride as role=user; dropping the tag re-plants them as bare + # user turns after a restart, corrupting the truncate ordinal address space. + "display_kind", + "display_metadata", + # Branch copies are history, not new activity: keep the parent's timestamps. + "timestamp", +) + + @method("session.branch") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err - # Branch must write into the parent's profile-scoped state.db (app-global - # remote mode). Using the launch handle would orphan branch rows + history. +@_with_live_session +def _(rid, params: dict, session: dict) -> dict: + # Branch writes into the parent's profile-scoped state.db (app-global remote + # mode); the launch handle would orphan branch rows + history. with _session_db(session) as db: if db is None: return _db_unavailable_error(rid, code=5008) @@ -3297,24 +2825,9 @@ def _(rid, params: dict) -> dict: if isinstance(msg, dict) ] - def _visible_branch_history(messages): - visible = [] - for message in messages or []: - if not isinstance(message, dict) or message.get("role") not in {"user", "assistant"}: - continue - if not _coerce_message_text(message.get("content")).strip(): - continue - # Keep the FULL row — the copy loop below preserves reasoning - # fields and timeline-marker tags (display_kind/display_metadata, - # #82756); a minimal role/content copy would silently drop them. - visible.append(dict(message)) - return visible - - # The live session history is the model projection. After compaction it - # may contain only a summary and the protected tail, while the persisted - # display projection still contains the complete visible transcript. A - # branch must snapshot the latter; otherwise the child permanently loses - # every turn archived before the fork. + # The live history is the MODEL projection — after compaction only a + # summary + protected tail. Snapshot the persisted display projection + # instead, or the child permanently loses every turn archived before the fork. history = None get_resume_conversations = getattr(db, "get_resume_conversations", None) if callable(get_resume_conversations): @@ -3334,111 +2847,39 @@ def _(rid, params: dict) -> dict: new_key = _new_session_key() new_sid = uuid.uuid4().hex[:8] source = _session_source(session) - lease = None # claimed lazily on the first turn (_ensure_active_session_slot) - branch_name = params.get("name", "") try: - if branch_name: - title = branch_name - else: - current = db.get_session_title(old_key) or "branch" - title = ( - db.get_next_title_in_lineage(current) - if hasattr(db, "get_next_title_in_lineage") - else f"{current} (branch)" - ) - db.create_session( + title = params.get("name", "") or _branch_title(db, old_key) + _create_branch_rows( + db, new_key, + old_key, + title, + history, source=source, - model=_resolve_model(), - # Stable _branched_from marker so list_sessions_rich() keeps the - # branch visible in /resume and /sessions. The TUI branch leaves - # the parent live (no end_reason='branched'), so the legacy - # end_reason heuristic never matches it — the marker is the only - # thing that surfaces TUI branches. See issue #20856. - model_config={"_branched_from": old_key}, - parent_session_id=old_key, cwd=_session_cwd(session), - # The branch stays on its parent's profile. Explicit stamp (not - # just the parent-backfill) so it holds even when the parent row - # predates the profile_name column. Launch-profile branches are - # stamped explicitly too — NULL rows drop out of profile-keyed - # sidebar matching and deep-link resolution (#99222). profile_name=( - Path(session["profile_home"]).name - if session.get("profile_home") - else _current_profile_name() + Path(session["profile_home"]).name if session.get("profile_home") else _current_profile_name() ), + copy_fields=_BRANCH_COPY_FIELDS, ) - # Copy the whole parent history in bounded-chunk transactions — - # a branch seed can be hundreds of rows, and per-row transactions - # were the write-amplification pattern removed in #23254. - db.append_messages_batch( - new_key, - [ - { - "role": msg.get("role", "user"), - "content": msg.get("content"), - "reasoning": msg.get("reasoning"), - "reasoning_content": msg.get("reasoning_content"), - "reasoning_details": msg.get("reasoning_details"), - "codex_reasoning_items": msg.get("codex_reasoning_items"), - "codex_message_items": msg.get("codex_message_items"), - # Timeline markers (model_switch, personality_switch, - # auto_continue, …) ride as role=user; dropping the tag - # here re-planted them as bare user turns after a - # restart, corrupting the truncate ordinal address - # space the same way #82756 did. - "display_kind": msg.get("display_kind"), - "display_metadata": msg.get("display_metadata"), - # Preserve the parent's original message timestamps — - # branch copies are history, not new activity (9d73006ad). - "timestamp": msg.get("timestamp"), - } - for msg in history - ], - chunk_rows=500, - ) - db.set_session_title(new_key, title) except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5008, f"branch failed: {e}") - # Bound before the try so the ownership finally below can never see them - # unbound, whatever raises inside. + # Bound before the try so the ownership finally can never see them unbound. branch_db = None branch_owns_db = False try: - # Bind the branched AGENT to the parent's profile, mirroring - # session.create/resume: home override so config/skills/memory resolve - # to the profile during the build, and the profile's own state.db - # handle so the live agent's message flushes — and any later - # compression rotation — persist there. Writing only the row to the - # parent's db while the agent stayed on the launch handle would - # recreate the cross-profile split one turn later. + # Bind the branched AGENT to the parent's profile like session.create/ + # resume: home + secret scope for the build, and the profile's own state.db + # handle so message flushes and later compression rotation persist there. parent_home = session.get("profile_home") if parent_home: - from hermes_state import SessionDB - - # DEDICATED handle, same ownership rule as session.resume: ours - # until the branched agent takes it below. _make_agent raising, or - # _init_session raising, both leave here without that transfer. + # DEDICATED handle, same ownership rule as session.resume: ours until + # the branched agent takes it below. from hermes_state import get_shared_session_db + branch_db = get_shared_session_db(Path(parent_home) / "state.db") branch_owns_db = True - home_token = ( - set_hermes_home_override(parent_home) if parent_home else None - ) - # The home override alone only moves config/skills/memory; credentials - # resolve through get_secret(), which without a scope falls through to - # process os.environ — the LAUNCH profile's .env. Install the parent's - # secret scope for the build, exactly as session.create/resume do - # (#67605), so the branched agent authenticates as its own profile. - secret_token = ( - set_secret_scope(build_profile_secret_scope(Path(parent_home))) - if parent_home - else None - ) - try: + with _profile_build_scope(parent_home): tokens = _set_session_context(new_key) try: agent = _make_agent( @@ -3447,9 +2888,7 @@ def _(rid, params: dict) -> dict: session_id=new_key, session_db=branch_db, platform_override=source, - context_cwd_is_launch_artifact=( - _context_cwd_is_launch_artifact(session) - ), + context_cwd_is_launch_artifact=_context_cwd_is_launch_artifact(session), ) finally: _clear_session_context(tokens) @@ -3465,28 +2904,19 @@ def _(rid, params: dict) -> dict: profile_home=parent_home, explicit_cwd=bool(session.get("explicit_cwd")), ) - # Ownership TRANSFER — the branched session's agent holds this - # handle for its whole life and closes it on teardown. Drop is - # unconditional for the same reason as session.resume: past - # _init_session the branched session is registered against this - # handle, so the finally must not close it. + # Ownership TRANSFER (unconditional drop, as in session.resume): past + # _init_session the branched session is registered against this handle. _transfer_db_to_agent(agent, branch_db) branch_owns_db = False - finally: - if secret_token is not None: - reset_secret_scope(secret_token) - if home_token is not None: - reset_hermes_home_override(home_token) if new_sid in _sessions: - _sessions[new_sid]["active_session_lease"] = lease + _sessions[new_sid]["active_session_lease"] = None # claimed lazily on the first turn except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5000, f"agent init failed on branch: {e}") finally: if branch_owns_db and branch_db is not None: with contextlib.suppress(Exception): from hermes_state import release_or_close + release_or_close(branch_db) branched_session = _sessions.get(new_sid) return _ok( @@ -3503,17 +2933,17 @@ def _(rid, params: dict) -> dict: ) +# ── interrupt / steer / redirect ───────────────────────────────────── + + @method("session.interrupt") def _(rid, params: dict) -> dict: - # Keypress barge-in: stopping the turn also silences its streaming TTS - # (voice is process-global, so no per-session scoping is needed). + # Keypress barge-in also silences streaming TTS (voice is process-global). _tts_stream_stop() session, err = _sess_nowait(params, rid) if err: return err - expected_hosted_task_id = str( - params.get("expected_hosted_task_id") or "" - ).strip() + expected_hosted_task_id = str(params.get("expected_hosted_task_id") or "").strip() if expected_hosted_task_id: with session["history_lock"]: active_task = session.get("_hosted_room_task") @@ -3534,9 +2964,9 @@ def _(rid, params: dict) -> dict: if err: return err _interrupt_session_turn(str(params.get("session_id") or ""), session) - # Retire the crash-recovery marker on a confirmed local Stop. Waiting for - # the run thread's finally leaves a window where a backend exit looks like - # a crash and session.resume auto-continues the turn the user just stopped. + # Retire the crash-recovery marker on a confirmed local Stop now: waiting for + # the run thread's finally leaves a window where a backend exit looks like a + # crash and session.resume auto-continues the turn the user just stopped. # Extra key covers compression rotating session_key mid-turn. with session["history_lock"]: active_marker_key = str(session.pop("_active_turn_marker_key", "") or "") @@ -3544,6 +2974,76 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "interrupted"}) +def _record_accepted_correction(session: dict, text: str) -> None: + """Record a steer/redirect on the live turn so a mid-turn resume rebuilds the user + bubble, and purge server-queue self-copies of the live original so post-turn + drain cannot re-fire the pre-correction prompt.""" + with session["history_lock"]: + _record_inflight_correction(session, text) + _drop_queued_duplicates_of_inflight_user(session) + session["last_active"] = time.time() + + +@method("session.steer") +def _(rid, params: dict) -> dict: + """Inject a user message into the next tool result without interrupting. + + Mirrors AIAgent.steer(): the text lands on the last tool result of the next + tool batch. No interrupt, no new user turn, no role alternation violation. + """ + text = (params.get("text") or "").strip() + if not text: + return _err(rid, 4002, "text is required") + session, err = _sess_nowait(params, rid) + if err: + return err + agent = session.get("agent") + if agent is None or not hasattr(agent, "steer"): + return _err(rid, 4010, "agent does not support steer") + try: + accepted = agent.steer(text) + except Exception as exc: + return _err(rid, 5000, f"steer failed: {exc}") + if accepted: + _record_accepted_correction(session, text) + return _ok(rid, {"status": "queued" if accepted else "rejected", "text": text}) + + +@method("session.redirect") +def _(rid, params: dict) -> dict: + """Redirect the active model turn while preserving valid work/context.""" + text = (params.get("text") or "").strip() + if not text: + return _err(rid, 4002, "text is required") + session, err = _sess_nowait(params, rid) + if err: + return err + agent = session.get("agent") + # Turn-build window: a fresh turn flips running=True with agent still None. + # Queue the correction server-side for the next turn instead of a misleading + # 4010 the client swallows into a lost follow-up. + if agent is None and session.get("running"): + _enqueue_prompt(session, text, current_transport() or _stdio_transport) + session["last_active"] = time.time() + return _ok(rid, {"status": "queued", "text": text}) + if ( + agent is None + or getattr(agent, "_supports_active_turn_redirect", False) is not True + or not hasattr(agent, "redirect") + ): + return _err(rid, 4010, "agent does not support active-turn redirect") + try: + accepted = agent.redirect(text) + except Exception as exc: + return _err(rid, 5000, f"redirect failed: {exc}") + if accepted: + _record_accepted_correction(session, text) + return _ok(rid, {"status": "redirected" if accepted else "rejected", "text": text}) + + +# ── delegation / spawn trees ───────────────────────────────────────── + + @method("delegation.status") def _(rid, params: dict) -> dict: from tools.delegate_tool import ( @@ -3568,8 +3068,7 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: from tools.delegate_tool import set_spawn_paused - paused = bool(params.get("paused", True)) - return _ok(rid, {"paused": set_spawn_paused(paused)}) + return _ok(rid, {"paused": set_spawn_paused(bool(params.get("paused", True)))}) @method("subagent.interrupt") @@ -3579,21 +3078,17 @@ def _(rid, params: dict) -> dict: subagent_id = str(params.get("subagent_id") or "").strip() if not subagent_id: return _err(rid, 4000, "subagent_id required") - ok = interrupt_subagent(subagent_id) - return _ok(rid, {"found": ok, "subagent_id": subagent_id}) + return _ok(rid, {"found": interrupt_subagent(subagent_id), "subagent_id": subagent_id}) @method("subagent.steer") def _(rid, params: dict) -> dict: """Queue steering text into a live delegated child without stopping it. - The redirection-side mirror of subagent.interrupt: resolves the child in - the delegation registry and calls AIAgent.steer(), which appends the text - to the child's last tool result at its next iteration boundary — the - in-flight tool call is never cut. "queued" is not "delivered": a child - already past its final tool batch has no boundary left to drain into, - and that race surfaces as ``missed_steer`` on the parent's completion - entry instead of being silently dropped. + Resolves the child in the delegation registry and calls AIAgent.steer(); the + in-flight tool call is never cut. "queued" is not "delivered": a child past + its final tool batch has no boundary left, and that race surfaces as + ``missed_steer`` on the parent's completion entry. """ from tools.delegate_tool import steer_subagent @@ -3607,9 +3102,7 @@ def _(rid, params: dict) -> dict: if err: return err invoking_session_id = str(params.get("session_id") or "").strip() - invoking_transport, invoking_session = _current_session_steer_authority( - invoking_session_id - ) + invoking_transport, invoking_session = _current_session_steer_authority(invoking_session_id) queued = False if invoking_transport is not None and invoking_session is not None: queued = steer_subagent( @@ -3619,14 +3112,7 @@ def _(rid, params: dict) -> dict: owner_transport=invoking_transport, owner_session_record=invoking_session, ) - return _ok( - rid, - { - "status": "queued" if queued else "rejected", - "subagent_id": subagent_id, - "text": text, - }, - ) + return _ok(rid, {"status": "queued" if queued else "rejected", "subagent_id": subagent_id, "text": text}) @method("spawn_tree.save") @@ -3636,15 +3122,12 @@ def _(rid, params: dict) -> dict: if not isinstance(subagents, list) or not subagents: return _err(rid, 4000, "subagents list required") - from datetime import datetime - started_at = params.get("started_at") finished_at = params.get("finished_at") or time.time() label = str(params.get("label") or "") ts = datetime.utcfromtimestamp(float(finished_at)).strftime("%Y%m%dT%H%M%S") - fname = f"{ts}.json" d = _spawn_tree_session_dir(session_id or "default") - path = d / fname + path = d / f"{ts}.json" try: payload = { "session_id": session_id, @@ -3668,7 +3151,6 @@ def _(rid, params: dict) -> dict: "count": len(subagents), }, ) - return _ok(rid, {"path": str(path), "session_id": session_id}) @@ -3676,11 +3158,8 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: session_id = str(params.get("session_id") or "").strip() limit = int(params.get("limit") or 50) - cross_session = bool(params.get("cross_session")) - - if cross_session: - root = _spawn_trees_root() - roots = [p for p in root.iterdir() if p.is_dir()] + if bool(params.get("cross_session")): + roots = [p for p in _spawn_trees_root().iterdir() if p.is_dir()] else: roots = [_spawn_tree_session_dir(session_id or "default")] @@ -3689,13 +3168,9 @@ def _(rid, params: dict) -> dict: indexed = _read_spawn_tree_index(d) if indexed: # Skip index entries whose snapshot file was manually deleted. - entries.extend( - e for e in indexed if (p := e.get("path")) and Path(p).exists() - ) + entries.extend(e for e in indexed if (p := e.get("path")) and Path(p).exists()) continue - - # Fallback for legacy (pre-index) sessions: full scan. O(N) reads - # but only runs once per session until the next save writes the index. + # Legacy (pre-index) sessions: full scan, once per session until the next save. for p in d.glob("*.json"): if p.name == _SPAWN_TREE_INDEX: continue @@ -3725,12 +3200,9 @@ def _(rid, params: dict) -> dict: @method("spawn_tree.load") def _(rid, params: dict) -> dict: - from pathlib import Path - raw_path = str(params.get("path") or "").strip() if not raw_path: return _err(rid, 4000, "path required") - # Reject paths escaping the spawn-trees root. root = _spawn_trees_root().resolve() try: @@ -3738,112 +3210,29 @@ def _(rid, params: dict) -> dict: resolved.relative_to(root) except (ValueError, OSError) as exc: return _err(rid, 4030, f"path outside spawn-trees root: {exc}") - try: payload = json.loads(resolved.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: return _err(rid, 5000, f"spawn_tree.load failed: {exc}") - return _ok(rid, payload) -@method("session.steer") -def _(rid, params: dict) -> dict: - """Inject a user message into the next tool result without interrupting. - - Mirrors AIAgent.steer(). Safe to call while a turn is running — the text - lands on the last tool result of the next tool batch and the model sees - it on its next iteration. No interrupt, no new user turn, no role - alternation violation. - """ - text = (params.get("text") or "").strip() - if not text: - return _err(rid, 4002, "text is required") - session, err = _sess_nowait(params, rid) - if err: - return err - agent = session.get("agent") - if agent is None or not hasattr(agent, "steer"): - return _err(rid, 4010, "agent does not support steer") - try: - accepted = agent.steer(text) - except Exception as exc: - return _err(rid, 5000, f"steer failed: {exc}") - if accepted: - # Record the correction on the live turn exactly like session.redirect - # does. Without this, a resume/reconnect while the turn is running - # rebuilds the transcript from the inflight snapshot and the steered - # text has no user bubble — the "my message vanished on reload" loss. - with session["history_lock"]: - _record_inflight_correction(session, text) - # #84417: steer does not cancel the live original, but a server - # queue self-copy of that original must still not re-fire after - # settle (same class as redirect). - _drop_queued_duplicates_of_inflight_user(session) - session["last_active"] = time.time() - return _ok(rid, {"status": "queued" if accepted else "rejected", "text": text}) - - -@method("session.redirect") -def _(rid, params: dict) -> dict: - """Redirect the active model turn while preserving valid work/context.""" - text = (params.get("text") or "").strip() - if not text: - return _err(rid, 4002, "text is required") - session, err = _sess_nowait(params, rid) - if err: - return err - agent = session.get("agent") - # Turn-build window: a fresh turn flips running=True and kicks off an async - # agent build, so session["agent"] is briefly None. That is not an - # unsupported runtime — queue the correction server-side so it reaches the - # model as the next turn, instead of a misleading 4010 the client silently - # swallows into a lost follow-up. - if agent is None and session.get("running"): - _enqueue_prompt(session, text, current_transport() or _stdio_transport) - session["last_active"] = time.time() - return _ok(rid, {"status": "queued", "text": text}) - if ( - agent is None - or getattr(agent, "_supports_active_turn_redirect", False) is not True - or not hasattr(agent, "redirect") - ): - return _err(rid, 4010, "agent does not support active-turn redirect") - try: - accepted = agent.redirect(text) - except Exception as exc: - return _err(rid, 5000, f"redirect failed: {exc}") - if accepted: - with session["history_lock"]: - _record_inflight_correction(session, text) - # #84417: purge server-queue self-duplicates of the live original - # so post-turn drain cannot restart the pre-correction prompt. - _drop_queued_duplicates_of_inflight_user(session) - session["last_active"] = time.time() - return _ok( - rid, - {"status": "redirected" if accepted else "rejected", "text": text}, - ) +# ── terminal / event replay ────────────────────────────────────────── @method("terminal.resize") -def _(rid, params: dict) -> dict: - session, err = _sess_nowait(params, rid) - if err: - return err +@_with_session +def _(rid, params: dict, session: dict) -> dict: session["cols"] = int(params.get("cols", 80)) return _ok(rid, {"cols": session["cols"]}) @method("session.events.since") def _(rid, params: dict) -> dict: - """Replay recorded events for a session newer than the client's last-seen seq. + """Replay recorded events newer than the client's last-seen seq (WS reconnect contract). - Reconnect contract (desktop / web clients): every event frame now carries - ``params.seq``. After a WS reconnect the client calls this with its last - observed seq; this returns the buffered frames in order so no mid-stream - event is lost. Frames older than the ring window report ``truncated`` so - the client knows to refetch history instead of silently accepting a gap. + Frames older than the ring window report ``truncated`` so the client refetches + history instead of silently accepting a gap. """ sid = str(params.get("session_id") or "") try: @@ -3853,17 +3242,18 @@ def _(rid, params: dict) -> dict: from tui_gateway import event_replay frames = event_replay.events_since(sid, last_seen) - return _ok(rid, { - "events": frames, - "latest_seq": event_replay.latest_seq(sid), - "truncated": event_replay.is_truncated(sid, last_seen), - "count": len(frames), - # Restart detection: seq counters are in-process, so after a gateway - # restart a client's old high watermark would silently match nothing. - # Clients compare this against the epoch they learned at gateway.ready - # and reset watermarks on mismatch. - "epoch": event_replay.replay_epoch(), - }) + return _ok( + rid, + { + "events": frames, + "latest_seq": event_replay.latest_seq(sid), + "truncated": event_replay.is_truncated(sid, last_seen), + "count": len(frames), + # seq counters are in-process: clients compare this against the epoch + # from gateway.ready and reset watermarks on mismatch (restart detection). + "epoch": event_replay.replay_epoch(), + }, + ) @method("session.events.stats") @@ -3875,5 +3265,10 @@ def _(rid, params: dict) -> dict: def register(server) -> None: - """Bind this module's handlers onto ``server``'s globals and registry.""" - _registry.install(server) + """Publish this module's helpers onto ``server`` and install its handlers. + + Helpers are module-level functions/classes, so install() alone would leave them + bound to THIS module's (empty) globals; ``bind_module`` rebinds them onto + server.py's namespace so they resolve the same free names as the handlers. + """ + bind_module(globals(), server, skip=("_",)) diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index e217ae5bc3..3344574b0a 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1,24 +1,133 @@ -"""Tools & system / slash.exec / insights / rollback / browser-plugins-cron-skills JSON-RPC handlers (moved verbatim from server.py). +"""Tools & system / slash / insights / rollback / plugins / cron / skills / MCP JSON-RPC handlers. -Handler bodies are byte-identical to their pre-split server.py form; they -are rebound onto server.py's globals at install time — see method_ctx.py. +Everything defined here is rebound onto server.py's globals at install time +(``method_ctx.bind_module``), so handler bodies AND module-level helpers may +reference server globals bare (``_ok``, ``_err``, ``_sessions``, ...). Names +must not collide with server.py's own; helpers here use a ``_cmd_`` / +``_slash_`` / ``_toolset_`` / ``_mcp_`` prefix. """ -from .method_ctx import HandlerRegistry +import sys + +from .method_ctx import HandlerRegistry, bind_module _registry = HandlerRegistry() method = _registry.method _profile_scoped = _registry.profile_scoped +# ─── Shared helpers ────────────────────────────────────────────────────────── + + +def _profile_scoped_rpc(fail_code: int, *, required=(), catch_resolve: bool = True): + """Wrap a handler body with the optional ``profile`` HERMES_HOME scope. + + Order preserved from the original handlers: ``required`` params are checked + first (4063 `` required``), then the profile is resolved (4064 when the + profile dir is missing), then the body runs; any body exception becomes + ``fail_code``. ``catch_resolve`` also maps resolve-time exceptions to + ``fail_code`` (cron/skills/catalog); the mcp.servers.* handlers let them + propagate to dispatch(). The override is always reset afterwards. + """ + + def deco(body): + def handler(rid, params: dict) -> dict: + for key, present in required: + if not present(params.get(key)): + return _err(rid, 4063, f"{key} required") + profile = str(params.get("profile") or "").strip() + token = None + if profile: + try: + from hermes_cli.profiles import get_profile_dir + from hermes_constants import set_hermes_home_override + + profile_dir = get_profile_dir(profile) + if not profile_dir or not profile_dir.is_dir(): + return _err(rid, 4064, f"profile '{profile}' not found") + token = set_hermes_home_override(str(profile_dir)) + except Exception as e: + if not catch_resolve: + raise + return _err(rid, fail_code, str(e)) + try: + return body(rid, params) + except Exception as e: + return _err(rid, fail_code, str(e)) + finally: + _mcp_reset_profile(token) + + handler.__doc__ = body.__doc__ + return handler + + return deco + + +def _stripped(v) -> bool: + return bool(str(v or "").strip()) + + +def _nonempty(v) -> bool: + return not (v is None or str(v) == "") + + +_NAME = (("name", _stripped),) +_NAME_SESSION = (("name", _stripped), ("session_id", _stripped)) + + +def _mcp_server_scoped(body): + """mcp.servers.* contract: ``name`` required, profile scope, body errors → 5024.""" + return _profile_scoped_rpc(5024, required=_NAME, catch_resolve=False)(body) + + +def _busy_error(rid, session, cmd: str): + if session.get("running"): + return _err(rid, 4009, f"session busy — /interrupt the current turn before /{cmd}") + return None + + +def _user_turn_indices(session): + """(history, indices of user-originated turns) minus ephemeral scaffolding. Call under history_lock.""" + from agent.context_compressor import user_originated_turn_view + + history = _history_without_ephemeral_scaffolding(session.get("history", [])) + return history, [i for i, m in enumerate(history) if user_originated_turn_view(m) is not None] + + +def _clip(text: str, n: int = 120) -> str: + return text[:n] + ("…" if len(text) > n else "") + + +def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]: + from toolsets import get_all_toolsets, get_toolset_info + + session = _sessions.get(params.get("session_id", "")) + enabled = ( + set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or []) + ) + items = [] + for name in sorted(get_all_toolsets().keys()): + info = get_toolset_info(name) + if not info: + continue + row = { + "name": name, + "description": info["description"], + "tool_count": info["tool_count"], + "enabled": name in enabled if enabled else True, + } + if with_tools: + row["tools"] = info["resolved_tools"] + items.append(row) + return items + + +# ─── System / process ──────────────────────────────────────────────────────── + + @method("system.battery") def _(rid, params: dict) -> dict: - """Return the host battery status for the status-bar read-out. - - Always resolves with a payload; ``available: false`` means there is no - battery (desktop/server/VM) or the read failed. The TUI only polls this - while the battery indicator is enabled. - """ + """Host battery for the status bar. Always resolves; ``available: false`` = no battery or read failed.""" try: from agent.battery import battery_category, read_battery @@ -60,8 +169,7 @@ def _(rid, params: dict) -> dict: @method("process.kill") def _(rid, params: dict) -> dict: - """Kill ONE background process — scoped to the caller's session so one - window can't reap another session's work (unlike process.stop's kill_all).""" + """Kill ONE background process, scoped to the caller's session (unlike process.stop's kill_all).""" session, err = _sess(params, rid) if err: return err @@ -72,9 +180,7 @@ def _(rid, params: dict) -> dict: from tools.process_registry import process_registry proc = process_registry.get(proc_id) - if proc is None or str(getattr(proc, "session_key", "") or "") != str( - session.get("session_key") or "" - ): + if proc is None or str(getattr(proc, "session_key", "") or "") != str(session.get("session_key") or ""): return _err(rid, 4044, f"no such process: {proc_id}") return _ok(rid, process_registry.kill_process(proc_id)) except Exception as e: @@ -85,15 +191,11 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id", "")) try: - # Gate: /reload-mcp invalidates the prompt cache for this session. - # Respect the ``approvals.mcp_reload_confirm`` config toggle — if - # set (default true) AND the caller did not pass ``confirm=true`` - # in params, surface a warning to the transcript instead of just - # reloading silently. Users pass confirm=true either by - # re-invoking after reading the warning, or by setting the - # config key to false permanently. - user_confirm = bool(params.get("confirm", False)) - if not user_confirm: + # /reload-mcp invalidates the prompt cache. Unless the caller passed + # confirm=true, honour ``approvals.mcp_reload_confirm`` (default true) by + # returning a confirm_required payload instead of reloading; Ink prints + # ``message`` and re-invokes with confirm=true (or flips the config). + if not bool(params.get("confirm", False)): try: from hermes_cli.config import load_config as _load_config @@ -105,11 +207,6 @@ def _(rid, params: dict) -> dict: except Exception: _confirm_required = True if _confirm_required: - # Return a structured response the Ink client can surface - # as a warning/confirmation without actually reloading yet. - # Ink's ops.ts reads ``status`` and prints ``message`` to - # the transcript; a follow-up invocation with confirm=true - # (or an `always` choice that flips the config) proceeds. return _ok( rid, { @@ -126,8 +223,7 @@ def _(rid, params: dict) -> dict: if session and _session_uses_compute_host(session): try: ack = _get_compute_host_supervisor().reload_mcp( - str(params.get("session_id") or ""), - request_id=f"reload-mcp-{rid}", + str(params.get("session_id") or ""), request_id=f"reload-mcp-{rid}" ) except Exception as exc: return _err(rid, 5019, f"compute-host reload_mcp failed: {exc}") @@ -136,49 +232,35 @@ def _(rid, params: dict) -> dict: from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, reprobe_tool_availability def _refresh_session_agent() -> None: - """Rebuild THIS session's cached tool snapshot from the live - registry and push session.info. The agent snapshots tools once at - build and never re-reads the registry, so an explicit rebuild is - required (mirrors gateway/run.py::_execute_mcp_reload). Runs under - _mcp_reload_lock so the registry it reads can't be torn down by a - concurrent reload mid-refresh.""" + """Rebuild THIS session's cached tool snapshot from the live registry and + push session.info (the agent never re-reads the registry on its own; + mirrors gateway/run.py::_execute_mcp_reload). Runs under _mcp_reload_lock + so a concurrent reload can't tear the registry down mid-refresh.""" if not session: return agent = session["agent"] try: from tools.mcp_tool import refresh_agent_mcp_tools - # Explicit reload: re-resolve enabled toolsets so a server the - # user just enabled in config this session is picked up. - refresh_agent_mcp_tools( - agent, - enabled_override=_load_enabled_toolsets(), - quiet_mode=True, - ) + # Re-resolve enabled toolsets so a server enabled in config this + # session is picked up. + refresh_agent_mcp_tools(agent, enabled_override=_load_enabled_toolsets(), quiet_mode=True) except Exception as _exc: - logger.warning( - "Failed to refresh cached agent tools after /reload-mcp: %s", - _exc, - ) + logger.warning("Failed to refresh cached agent tools after /reload-mcp: %s", _exc) _emit("session.info", params.get("session_id", ""), _session_info(agent, session)) global _mcp_reload_gen, _mcp_reload_loaded_rev - # The revision the CALLER is asking to load (the mcp_rev its poll - # observed). Empty on legacy clients and manual /reload-mcp — those - # coalesce on generation alone, as before. + # Revision the CALLER wants loaded (the mcp_rev its poll observed). Empty + # on legacy clients / manual /reload-mcp — those coalesce on generation alone. req_rev = str(params.get("rev") or "") def _do_full_reload() -> None: - """shutdown+discover+refresh under the lock, then mark a completed - generation. The lock spans the refresh too: releasing after - discover would let a second reload tear the registry down while - this one is still reading it to rebuild the session snapshot. - - Config can change WHILE discover is connecting servers (a slow - reload racing a config edit): re-hash after discovery and repeat - until the hash is stable, so the generation we mark completed - always reflects the config that was actually loaded.""" + """shutdown+discover+refresh under the lock, then mark a completed generation. + The lock spans the refresh too, else a second reload could tear the registry + down while this one is still rebuilding the session snapshot. Config can + change WHILE discover connects: re-hash after discovery and repeat until + stable, so the marked generation reflects the config actually loaded.""" global _mcp_reload_gen, _mcp_reload_loaded_rev loaded = _compute_mcp_rev() @@ -195,17 +277,12 @@ def _(rid, params: dict) -> dict: _mcp_reload_loaded_rev = loaded _mcp_reload_gen += 1 - # Serialize reloads. The LEADER (won the non-blocking acquire) runs the - # full reload. A FOLLOWER (lock busy) snapshots the generation, waits, - # then — still holding the lock — checks whether a reload that - # actually COMPLETED while it waited satisfies ITS request: the - # generation must have advanced (leader didn't throw) AND the loaded - # revision must match the one this follower was asked to apply. Both - # true → just refresh its own agent against the fresh registry - # (coalesced). Leader threw, or leader loaded an older revision than - # this request observed → re-run the full reload, so a failed or - # stale leader can never leave a follower acking a revision that was - # never loaded. + # Serialize reloads. LEADER (won the non-blocking acquire) runs the full + # reload. FOLLOWER snapshots the generation, waits, then — still holding the + # lock — coalesces only if a reload COMPLETED meanwhile (generation advanced, + # so the leader didn't throw) AND it loaded the revision this request asked + # for; otherwise it re-runs the full reload so a failed/stale leader can + # never leave a follower acking a revision that was never loaded. if _mcp_reload_lock.acquire(blocking=False): try: _do_full_reload() @@ -234,15 +311,10 @@ def _(rid, params: dict) -> dict: @method("reload.env") def _(rid, params: dict) -> dict: - """Re-read ``~/.hermes/.env`` into the gateway process via - ``hermes_cli.config.reload_env``, matching classic CLI's ``/reload`` - handler. Newly added API keys take effect on the next agent call - without restarting the TUI. + """Re-read ``~/.hermes/.env`` into the gateway (classic CLI ``/reload`` parity). - The credential pool / provider routing for any *already-constructed* - agent does not auto-rebuild — that's the same behaviour as classic - CLI's ``/reload``. Users who want a brand-new credential resolution - should follow with ``/new``. + Already-constructed agents keep their credential pool / provider routing — + same as classic CLI; ``/new`` gets a fresh credential resolution. """ try: from hermes_cli.config import reload_env @@ -253,24 +325,27 @@ def _(rid, params: dict) -> dict: return _err(rid, 5015, str(e)) +# ─── Command catalog / dispatch ────────────────────────────────────────────── + + @method("commands.catalog") def _(rid, params: dict) -> dict: """Registry-backed slash metadata for the TUI — categorized, no aliases.""" try: - from hermes_cli.commands import ( - COMMAND_REGISTRY, - SUBCOMMANDS, - _build_description, - command_desktop_meta, - ) + from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description, command_desktop_meta all_pairs: list[list[str]] = [] canon: dict[str, str] = {} commands: dict[str, dict[str, str | None]] = {} - categories: list[dict] = [] cat_map: dict[str, list[list[str]]] = {} cat_order: list[str] = [] + def bucket(cat: str) -> list[list[str]]: + if cat not in cat_map: + cat_map[cat] = [] + cat_order.append(cat) + return cat_map[cat] + for cmd in COMMAND_REGISTRY: meta = command_desktop_meta(cmd) commands[f"/{cmd.name}"] = dict(meta) @@ -287,35 +362,22 @@ def _(rid, params: dict) -> dict: desc = _build_description(cmd) all_pairs.append([c, desc]) - - cat = cmd.category - if cat not in cat_map: - cat_map[cat] = [] - cat_order.append(cat) - cat_map[cat].append([c, desc]) + bucket(cmd.category).append([c, desc]) for name, desc, cat in _TUI_EXTRA: - # Dedup guard: skip TUI extras that collide with a registry - # command or one of its aliases (e.g. the historical /compact - # collision, #57133, or /sessions which the registry also - # advertises). The registry entry is canonical. + # A TUI extra colliding with a registry command/alias (e.g. /compact, + # /sessions) is skipped: the registry entry is canonical. if name.lower() in canon: continue canon[name.lower()] = name all_pairs.append([name, desc]) - if cat not in cat_map: - cat_map[cat] = [] - cat_order.append(cat) - cat_map[cat].append([name, desc]) + bucket(cat).append([name, desc]) warning = "" try: qcmds = _load_cfg().get("quick_commands", {}) or {} if isinstance(qcmds, dict) and qcmds: - bucket = "User commands" - if bucket not in cat_map: - cat_map[bucket] = [] - cat_order.append(bucket) + rows = bucket("User commands") for qname, qc in sorted(qcmds.items()): if not isinstance(qc, dict): continue @@ -328,10 +390,9 @@ def _(rid, params: dict) -> dict: default_desc = f"alias → {qc.get('target', '')}" else: default_desc = qtype or "quick command" - qdesc = str(qc.get("description") or default_desc) - qdesc = qdesc[:120] + ("…" if len(qdesc) > 120 else "") + qdesc = _clip(str(qc.get("description") or default_desc)) all_pairs.append([key, qdesc]) - cat_map[bucket].append([key, qdesc]) + rows.append([key, qdesc]) except Exception as e: if not warning: warning = f"quick_commands discovery unavailable: {e}" @@ -341,10 +402,7 @@ def _(rid, params: dict) -> dict: plugin_cmds = get_plugin_commands() or {} if plugin_cmds: - bucket = "Plugin commands" - if bucket not in cat_map: - cat_map[bucket] = [] - cat_order.append(bucket) + rows = bucket("Plugin commands") for pname, info in sorted(plugin_cmds.items()): if not isinstance(info, dict): continue @@ -352,10 +410,9 @@ def _(rid, params: dict) -> dict: if key.lower() in canon: continue canon[key.lower()] = key - pdesc = str(info.get("description") or "Plugin command") - pdesc = pdesc[:120] + ("…" if len(pdesc) > 120 else "") + pdesc = _clip(str(info.get("description") or "Plugin command")) all_pairs.append([key, pdesc]) - cat_map[bucket].append([key, pdesc]) + rows.append([key, pdesc]) hint = str(info.get("args_hint") or "").strip() mode = info.get("argument_mode") if mode not in {"options", "text", "mixed"}: @@ -370,33 +427,26 @@ def _(rid, params: dict) -> dict: try: from agent.skill_commands import scan_skill_commands - # Usage + origin per skill command. Surfaces here rather than in a - # second RPC because every consumer that renders the catalog also - # wants to rank it, and both reads are cheap sidecar files already - # loaded once per catalog build. + # Usage + origin ride along here (not a second RPC): every catalog + # consumer also ranks it, and both sidecars are already loaded. usage, origin_of = _skill_usage_lookup() for k, info in sorted(scan_skill_commands().items()): - d = str(info.get("description", "Skill")) - all_pairs.append([k, d[:120] + ("…" if len(d) > 120 else "")]) + all_pairs.append([k, _clip(str(info.get("description", "Skill")))]) name = str(info.get("name") or k.lstrip("/")) skills[k] = {"usage": usage(name), "origin": origin_of(name)} skill_count += 1 except Exception as e: warning = f"skill discovery unavailable: {e}" - for cat in cat_order: - categories.append({"name": cat, "pairs": cat_map[cat]}) - - sub = {k: v[:] for k, v in SUBCOMMANDS.items()} return _ok( rid, { "pairs": all_pairs, - "sub": sub, + "sub": {k: v[:] for k, v in SUBCOMMANDS.items()}, "canon": canon, "commands": commands, - "categories": categories, + "categories": [{"name": cat, "pairs": cat_map[cat]} for cat in cat_order], "skills": skills, "skill_count": skill_count, "warning": warning, @@ -416,31 +466,28 @@ def _(rid, params: dict) -> dict: if hint: return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""}) try: - # CREATE_NO_WINDOW on Windows — under the desktop GUI's windowless - # parent, this spawn otherwise flashes a console (#56747). + # CREATE_NO_WINDOW on Windows: under the windowless desktop parent this + # spawn otherwise flashes a console. from hermes_cli._subprocess_compat import windows_hide_flags r = subprocess.run( [sys.executable, "-m", "hermes_cli.main", *argv], capture_output=True, text=True, - # Force UTF-8 + lossy decode so non-UTF-8 child output can't crash - # the gateway thread on locale-mismatched Windows. See #53137. + # UTF-8 + lossy decode: non-UTF-8 child output must not crash the + # gateway thread on locale-mismatched Windows. encoding="utf-8", errors="replace", timeout=min(int(params.get("timeout", 240)), 600), cwd=os.getcwd(), - # cli.exec runs `python -m hermes_cli.main` (can drive the agent) → - # needs provider credentials. Tier-1 secrets still stripped (#29157). + # Can drive the agent → needs provider credentials; tier-1 secrets still stripped. env=hermes_subprocess_env(inherit_credentials=True), stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(), ) parts = [r.stdout or "", r.stderr or ""] out = "\n".join(p for p in parts if p).strip() or "(no output)" - return _ok( - rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]} - ) + return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]}) except subprocess.TimeoutExpired: return _err(rid, 5016, "cli.exec: timeout") except Exception as e: @@ -454,74 +501,57 @@ def _(rid, params: dict) -> dict: r = resolve_command(params.get("name", "")) if r: - return _ok( - rid, - { - "canonical": r.name, - "description": r.description, - "category": r.category, - }, - ) + return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category}) return _err(rid, 4011, f"unknown command: {params.get('name')}") except Exception as e: return _err(rid, 5012, str(e)) -@method("command.dispatch") -def _(rid, params: dict) -> dict: - name, arg = params.get("name", "").lstrip("/"), params.get("arg", "") - resolved = _resolve_name(name) - if resolved != name: - name = resolved - session = _sessions.get(params.get("session_id", "")) +# command.dispatch stages. Each takes (rid, params, session, name, arg) and +# returns a JSON-RPC envelope, or None to fall through to the next stage. + +def _dispatch_quick(rid, params, session, name, arg): qcmds = _load_cfg().get("quick_commands", {}) - if name in qcmds: - qc = qcmds[name] - if qc.get("type") == "exec": - # Sanitize env to prevent credential leakage — - # quick commands run in the TUI server process which - # has all API keys in os.environ. - from tools.environments.local import build_subprocess_env - sanitized_env = build_subprocess_env() - from hermes_cli._subprocess_compat import windows_hide_flags + if name not in qcmds: + return None + qc = qcmds[name] + if qc.get("type") == "exec": + # Sanitized env: quick commands run in the TUI server process, which + # holds every API key in os.environ. + from tools.environments.local import build_subprocess_env - r = subprocess.run( - qc.get("command", ""), - shell=True, - capture_output=True, - text=True, - # Force UTF-8 + lossy decode so non-UTF-8 child output can't - # crash the gateway thread on locale-mismatched Windows (#53137). - encoding="utf-8", errors="replace", - timeout=30, - stdin=subprocess.DEVNULL, - env=sanitized_env, - creationflags=windows_hide_flags(), - ) - output = ( - (r.stdout or "") - + ("\n" if r.stdout and r.stderr else "") - + (r.stderr or "") - ).strip()[:4000] - if output: - from agent.redact import redact_sensitive_text - output = redact_sensitive_text(output) - if r.returncode != 0: - return _err( - rid, - 4018, - output or f"quick command failed with exit code {r.returncode}", - ) - return _ok(rid, {"type": "exec", "output": output}) - if qc.get("type") == "alias": - return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) + sanitized_env = build_subprocess_env() + from hermes_cli._subprocess_compat import windows_hide_flags - try: - from hermes_cli.plugins import ( - get_plugin_command_handler, - resolve_plugin_command_result, + r = subprocess.run( + qc.get("command", ""), + shell=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", # lossy decode: see cli.exec + timeout=30, + stdin=subprocess.DEVNULL, + env=sanitized_env, + creationflags=windows_hide_flags(), ) + output = ((r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "")).strip()[:4000] + if output: + from agent.redact import redact_sensitive_text + + output = redact_sensitive_text(output) + if r.returncode != 0: + return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}") + return _ok(rid, {"type": "exec", "output": output}) + if qc.get("type") == "alias": + return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) + return None + + +def _dispatch_plugin(rid, params, session, name, arg): + try: + from hermes_cli.plugins import get_plugin_command_handler, resolve_plugin_command_result handler = get_plugin_command_handler(name) if handler: @@ -529,68 +559,56 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"type": "plugin", "output": str(result or "")}) except Exception: pass + return None + +def _dispatch_bundle(rid, params, session, name, arg): try: - from agent.skill_bundles import ( - build_bundle_invocation_message, - get_skill_bundles, - resolve_bundle_command_key, - ) - + from agent.skill_bundles import build_bundle_invocation_message, get_skill_bundles, resolve_bundle_command_key from hermes_cli.commands import resolve_command - bundle_key = ( - resolve_bundle_command_key(name) - if resolve_command(name) is None - else None - ) + bundle_key = resolve_bundle_command_key(name) if resolve_command(name) is None else None except Exception: bundle_key = None - - if bundle_key is not None: - try: - bundle_result = build_bundle_invocation_message( - bundle_key, - arg, - task_id=session.get("session_key", "") if session else "", - platform=_resolve_session_platform(), - ) - except Exception as exc: - return _err(rid, 4018, f"bundle dispatch failed: {exc}") - - if not bundle_result: - return _err(rid, 4018, f"failed to load bundle: {bundle_key}") - - msg, loaded_names, missing = bundle_result - bundle_info = get_skill_bundles().get(bundle_key, {}) - bundle_name = bundle_info.get("name", bundle_key.lstrip("/")) - notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)" - if missing: - notice += f"\nSkipped missing skills: {', '.join(missing)}" - return _ok( - rid, - { - "type": "send", - "message": msg, - "notice": notice, - # UIs render this, never `message` — the expanded bundle body - # is model-facing scaffolding (see _skill_scaffold_projection). - "display": _skill_scaffold_projection(msg), - }, - ) - + if bundle_key is None: + return None try: - from agent.skill_commands import ( - scan_skill_commands, - build_skill_invocation_message, + bundle_result = build_bundle_invocation_message( + bundle_key, + arg, + task_id=session.get("session_key", "") if session else "", + platform=_resolve_session_platform(), ) + except Exception as exc: + return _err(rid, 4018, f"bundle dispatch failed: {exc}") + if not bundle_result: + return _err(rid, 4018, f"failed to load bundle: {bundle_key}") + + msg, loaded_names, missing = bundle_result + bundle_name = get_skill_bundles().get(bundle_key, {}).get("name", bundle_key.lstrip("/")) + notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)" + if missing: + notice += f"\nSkipped missing skills: {', '.join(missing)}" + return _ok( + rid, + { + "type": "send", + "message": msg, + "notice": notice, + # UIs render `display`, never `message`: the expanded body is model-facing scaffolding. + "display": _skill_scaffold_projection(msg), + }, + ) + + +def _dispatch_skill(rid, params, session, name, arg): + try: + from agent.skill_commands import scan_skill_commands, build_skill_invocation_message cmds = scan_skill_commands() key = f"/{name}" if key in cmds: - msg = build_skill_invocation_message( - key, arg, task_id=session.get("session_key", "") if session else "" - ) + msg = build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "") if msg: return _ok( rid, @@ -598,588 +616,493 @@ def _(rid, params: dict) -> dict: "type": "skill", "message": msg, "name": cmds[key].get("name", name), - # UIs render this, never `message` — the expanded skill - # body is model-facing scaffolding. - "display": _skill_scaffold_projection(msg), + "display": _skill_scaffold_projection(msg), # UIs render this, never `message` }, ) except Exception: pass + return None - # ── Commands that queue messages onto _pending_input in the CLI ─── - # In the TUI the slash worker subprocess has no reader for that queue, - # so we handle them here and return a structured payload. - if name in {"queue", "q"}: +# Built-in commands that queue messages onto _pending_input in the CLI. The TUI +# slash worker has no reader for that queue, so they are handled here and return +# a structured payload. + + +def _cmd_queue(rid, params, session, name, arg): + if not arg: + return _err(rid, 4004, "usage: /queue ") + return _ok(rid, {"type": "send", "message": arg}) + + +def _cmd_learn(rid, params, session, name, arg): + # Standards-guided prompt submitted as a normal turn; the live agent gathers + # sources with its own tools and authors the skill via skill_manage. + from agent.learn_prompt import build_learn_prompt + + return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)}) + + +def _cmd_plan(rid, params, session, name, arg): + # Plan-mode prompt as a normal turn (same pattern as /learn); the agent saves + # the plan under .hermes/plans/ via write_file. + from agent.plan_prompt import build_plan_prompt + + return _ok(rid, {"type": "send", "message": build_plan_prompt(arg)}) + + +def _cmd_init(rid, params, session, name, arg): + # Generate-or-update AGENTS.md as a normal turn (same pattern as /learn). + from hermes_cli.init_command import build_init_prompt_for_cwd + + return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd(extra=arg)}) + + +def _cmd_moa(rid, params, session, name, arg): + # One-shot sugar: run ONE prompt through the default MoA preset, then restore + # the prior model. Switching for the whole session goes through the model + # picker (MoA presets surface as a virtual "Mixture of Agents" provider). + try: + from hermes_cli.moa_config import moa_usage, normalize_moa_config + if not arg: - return _err(rid, 4004, "usage: /queue ") - return _ok(rid, {"type": "send", "message": arg}) - - if name == "learn": - # Open-ended: build the standards-guided prompt and submit it as a - # normal agent turn. The live agent gathers whatever the user - # described (dirs, URLs, this conversation, pasted text) with its own - # tools and authors the skill via skill_manage. Works on any backend. - from agent.learn_prompt import build_learn_prompt - - return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)}) - if name == "plan": - # Plan mode: build the plan-mode prompt and submit it as a normal - # agent turn (same pattern as /learn). The live agent inspects the - # workspace read-only and saves the markdown plan under - # .hermes/plans/ via write_file. Works on any backend. - from agent.plan_prompt import build_plan_prompt - - return _ok(rid, {"type": "send", "message": build_plan_prompt(arg)}) - if name == "init": - # Generate-or-update AGENTS.md: build the guidance-laden prompt and - # submit it as a normal agent turn (same pattern as /learn). The live - # agent scans the project with its own read-only tools and writes or - # merge-updates AGENTS.md via write_file. Works on any backend. - from hermes_cli.init_command import build_init_prompt_for_cwd - - return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd(extra=arg)}) - if name == "moa": - # /moa is one-shot sugar only: run a single prompt through the default - # MoA preset, then restore the prior model. To *switch* to a MoA preset - # for the rest of the session, pick it from the model picker (MoA - # presets surface as a virtual "Mixture of Agents" provider). - try: - from hermes_cli.moa_config import moa_usage, normalize_moa_config - - if not arg: - return _err(rid, 4004, moa_usage()) - if not session: - return _err(rid, 4001, "no active session") - sid = params.get("session_id", "") - moa_cfg = normalize_moa_config(_load_cfg().get("moa") or {}) - preset = moa_cfg["default_preset"] - # Record the live model identity so it can be restored after the - # one-shot turn, then swap the agent's client in place (#53444: - # setting session["model_override"] alone never switched the - # already-built agent, so the turn silently ran on the old model). - agent = session.get("agent") - session["moa_one_shot_restore"] = { - "override": session.get("model_override"), - "model": getattr(agent, "model", None) if agent else None, - "provider": getattr(agent, "provider", None) if agent else None, - } - if agent is not None: - # Live agent: swap its client in place so THIS turn runs MoA. - try: - _apply_model_switch( - sid, - session, - f"{preset} --provider moa", - confirm_expensive_model=False, - pin_session_override=True, - # One-shot turn-scoped swap — never persist the MoA - # virtual provider to config.yaml. - persist_override=False, - ) - except Exception as exc: - session.pop("moa_one_shot_restore", None) - return _err(rid, 5030, f"moa unavailable: {exc}") - else: - # No agent built yet (lazy/fresh session): the override is - # consumed by the first build, so the turn runs MoA without an - # in-place switch. - session["model_override"] = { - "provider": "moa", - "model": preset, - "base_url": "moa://local", - "api_key": "moa-virtual-provider", - "api_mode": "chat_completions", - } - return _ok( - rid, - { - "type": "send", - "notice": f"MoA one-shot queued with preset {preset}; previous model will be restored after this turn.", - "message": arg, - }, - ) - except Exception as exc: - return _err(rid, 5030, f"moa unavailable: {exc}") - - if name == "focus": - # /focus is display-only. Route it through the same config.set branch the - # Ink TUI slash command uses so both surfaces share one state machine and - # one persistence path. Returns a plain notice line for the transcript. - from hermes_cli.focus_view import ( - format_focus_status, - format_focus_toggle_message, - resolve_focus_arg, - ) - - _display_focus = _load_cfg().get("display") - _d_focus: dict = _display_focus if isinstance(_display_focus, dict) else {} - _cur_focus = bool(_d_focus.get("focus_view", False)) - _action, _target = resolve_focus_arg(arg, _cur_focus) - if _action == "usage": - return _err(rid, 4004, "usage: /focus [on|off|status]") - if _action == "status": - _saved = _d_focus.get("focus_saved_tool_progress") or _load_tool_progress_mode() - return _ok( - rid, - {"type": "exec", "output": format_focus_status(_cur_focus, _saved)}, - ) - _res = _methods["config.set"]( - rid, - { - "key": "focus", - "value": "on" if _target else "off", - "session_id": params.get("session_id", ""), - }, - ) - if "error" in _res: - return _res - _payload = _res.get("result") or {} - return _ok( - rid, - { - "type": "exec", - "output": format_focus_toggle_message( - bool(_target), _payload.get("tool_progress") or "all" - ), - }, - ) - - if name == "retry": - if not session: - return _err(rid, 4001, "no active session to retry") - if session.get("running"): - return _err( - rid, 4009, "session busy — /interrupt the current turn before /retry" - ) - from agent.context_compressor import ( - history_before_user_originated_turn, - retryable_user_text, - user_originated_turn_view, - ) - - with session["history_lock"]: - if session.get("running"): - return _err( - rid, - 4009, - "session busy — /interrupt the current turn before /retry", - ) - if session.get("attached_images"): - return _err( - rid, - 4018, - "retry cannot safely reconstruct or combine attached media", - ) - history = _history_without_ephemeral_scaffolding( - session.get("history", []) - ) - user_indices = [ - index - for index, message in enumerate(history) - if user_originated_turn_view(message) is not None - ] - if not user_indices: - return _err(rid, 4018, "no previous user message to retry") - _prefix, live_view = history_before_user_originated_turn( - history, user_indices[-1] - ) - try: - content = retryable_user_text(live_view.get("content")) - except ValueError as exc: - return _err(rid, 4018, str(exc)) - try: - _active, durable_live_view, _rewound_count = ( - _rewind_active_session_history( - session, - len(user_indices) - 1, - require_retryable=True, - ) - ) - except ValueError as exc: - return _err(rid, 4018, str(exc)) - except Exception as exc: - return _err(rid, 5008, f"retry: failed to persist history: {exc}") - content = retryable_user_text(durable_live_view.get("content")) - return _ok(rid, {"type": "send", "message": content}) - - if name == "steer": - if not arg: - return _err(rid, 4004, "usage: /steer ") - agent = session.get("agent") if session else None - if agent and hasattr(agent, "steer"): - try: - accepted = agent.steer(arg) - if accepted: - return _ok( - rid, - { - "type": "exec", - "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}", - }, - ) - except Exception: - pass - # Fallback: no active run, treat as next-turn message - return _ok(rid, {"type": "send", "message": arg}) - - if name == "goal": + return _err(rid, 4004, moa_usage()) if not session: return _err(rid, 4001, "no active session") - try: - from hermes_cli.goals import GoalManager - except Exception as exc: - return _err(rid, 5030, f"goals unavailable: {exc}") - - sid_key = session.get("session_key") or "" - if not sid_key: - return _err(rid, 4001, "no session key") - - try: - goals_cfg = _load_cfg().get("goals") or {} - max_turns = int(goals_cfg.get("max_turns", 20) or 20) - except Exception: - max_turns = 20 - mgr = GoalManager(session_id=sid_key, default_max_turns=max_turns) - - lower = arg.strip().lower() - if not arg.strip() or lower == "status": - return _ok(rid, {"type": "exec", "output": mgr.status_line()}) - if lower == "pause": - state = mgr.pause(reason="user-paused") - out = "No goal set." if state is None else f"⏸ Goal paused: {state.goal}" - return _ok(rid, {"type": "exec", "output": out}) - if lower == "resume": - state = mgr.resume() - if state is None: - return _ok(rid, {"type": "exec", "output": "No goal to resume."}) - # Resume must restart work, not just flip persisted state - # (#75362). An `exec` result is display-only — nothing would - # re-enter the conversation loop until the user typed another - # message. Return a `send` dispatch carrying the canonical - # continuation prompt so the client fires the next turn - # immediately; `display` keeps the transcript showing the - # concise invocation instead of the model-facing scaffolding. - prompt = mgr.next_continuation_prompt() - notice = f"▶ Goal resumed: {state.goal}\nContinuing now — taking the next step." - if not prompt: - return _ok(rid, {"type": "exec", "output": f"▶ Goal resumed: {state.goal}"}) - return _ok( - rid, - { - "type": "send", - "notice": notice, - "message": prompt, - "display": "/goal resume", - }, - ) - if lower in {"clear", "stop", "done"}: - had = mgr.has_goal() - mgr.clear() - return _ok( - rid, - { - "type": "exec", - "output": "✓ Goal cleared." if had else "No active goal.", - }, - ) - - # Otherwise — treat the remaining text as the new goal. - try: - state = mgr.set(arg) - except ValueError as exc: - return _err(rid, 4004, f"invalid goal: {exc}") - - notice = ( - f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n" - "I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n" - "Controls: /goal status · /goal pause · /goal resume · /goal clear" - ) - # Send the goal text as the kickoff prompt. The TUI client sees - # {type: send, notice, message} → renders `notice` as a sys line, - # then submits `message` as a user turn. The post-turn judge - # wired in _run_prompt_submit takes over from there. - return _ok( - rid, - {"type": "send", "notice": notice, "message": state.goal}, - ) - - if name == "loop": - # /loop — recurring in-session wakeups (Claude Code parity). State - # mutation via the shared dispatcher; the notification poller thread - # fires due wakeups into this session while it's idle. - if not session: - return _err(rid, 4001, "no active session") - try: - from hermes_cli.loops import LoopManager, dispatch_loop_command - except Exception as exc: - return _err(rid, 5030, f"loops unavailable: {exc}") - - sid_key = session.get("session_key") or "" - if not sid_key: - return _err(rid, 4001, "no session key") - - mgr = LoopManager(session_id=sid_key) - result = dispatch_loop_command(mgr, arg) - output = result.get("output") or "" - if result.get("created"): - try: - from hermes_cli.loops import goal_blocks_loop_tick - - if goal_blocks_loop_tick(sid_key): - output += ( - "\nNote: an active /goal is driving this session — loop " - "wakeups defer until the goal finishes, pauses, or parks." - ) - except Exception: - pass - return _ok(rid, {"type": "exec", "output": output}) - - if name == "undo": - # /undo [N]: back up N user turns (default 1), soft-delete the - # truncated rows on disk, and prefill the composer with the text - # of the user message we backed up to so it can be edited and - # resubmitted. N=1 is the Claude-Code-style single-step undo; - # /undo 3 backs up three user turns at once. See issue #21910. - if not session: - return _err(rid, 4001, "no active session to undo") - if session.get("running"): - return _err( - rid, 4009, "session busy — /interrupt the current turn before /undo" - ) - session_key = session.get("session_key", "") - if not session_key: - return _err(rid, 4001, "no session key for undo") - # Parse the optional count argument (e.g. "/undo 3" → 3). - n = 1 - arg_str = (arg or "").strip() - if arg_str: - try: - n = int(arg_str.split()[0]) - except (ValueError, IndexError): - return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N") - if n < 1: - n = 1 - from agent.context_compressor import ( - user_originated_turn_view, - ) - from agent.message_content import flatten_message_text - - with session["history_lock"]: - if session.get("running"): - return _err( - rid, - 4009, - "session busy — /interrupt the current turn before /undo", - ) - history = _history_without_ephemeral_scaffolding( - session.get("history", []) - ) - user_indices = [ - index - for index, message in enumerate(history) - if user_originated_turn_view(message) is not None - ] - if not user_indices: - return _err(rid, 4018, "no user messages to undo") - turns_undone = min(n, len(user_indices)) - target_position = len(user_indices) - turns_undone - try: - active, live_view, rewound_count = _rewind_active_session_history( - session, target_position - ) - except ValueError as exc: - return _err(rid, 4004, f"undo: {exc}") - except Exception as exc: - return _err(rid, 5008, f"undo: {exc}") - target_text = flatten_message_text(live_view.get("content")) - # Notify memory providers — same hook /branch fires, plus the - # rewound flag so providers caching per-turn document state - # know to invalidate. See #6672 + #21910. - agent = session.get("agent") - if agent is not None: - mm = getattr(agent, "_memory_manager", None) - if mm is not None: - try: - mm.on_session_switch( - session_key, - parent_session_id="", - reset=False, - rewound=True, - ) - except Exception: - pass - if hasattr(agent, "_invalidate_system_prompt"): - try: - agent._invalidate_system_prompt() - except Exception: - pass - if hasattr(agent, "_last_flushed_db_idx"): - try: - agent._last_flushed_db_idx = len(active) - except Exception: - pass - turn_word = "turn" if turns_undone == 1 else "turns" - notice = ( - f"↶ Undid {turns_undone} {turn_word} ({rewound_count} message(s)). " - "Edit and resubmit, or send a new message." - ) - return _ok( - rid, - {"type": "prefill", "message": target_text, "notice": notice}, - ) - - if name in {"snapshot", "snap"}: - subcommand = arg.split(maxsplit=1)[0].lower() if arg else "" - if subcommand in {"restore", "rewind"}: - return _ok( - rid, - { - "type": "exec", - "output": ( - "/snapshot restore is blocked in the TUI because it changes " - "config/state on disk while the live agent has cached settings. " - "Run it in the classic CLI, then restart the TUI." - ), - }, - ) - - if name in {"compress", "compact"}: - if not session: - return _err(rid, 4001, "no active session to compress") - if session.get("running"): - return _err( - rid, 4009, "session busy — /interrupt the current turn before /compress" - ) - from agent.conversation_compression import ( - finalize_context_engine_compression_notification, - ) - sid = params.get("session_id", "") - if _session_uses_compute_host(session): - command = f"/{name}" + (f" {arg}" if arg else "") - _late_session = session - - def _on_late_ack(late: dict, _sid=sid) -> None: - _adopt_late_compute_host_compress_ack(_sid, _late_session, late, route_name="slash.compress") - + preset = normalize_moa_config(_load_cfg().get("moa") or {})["default_preset"] + # Record the live model identity for post-turn restore, then swap the + # agent's client in place: setting session["model_override"] alone never + # switches an already-built agent. + agent = session.get("agent") + session["moa_one_shot_restore"] = { + "override": session.get("model_override"), + "model": getattr(agent, "model", None) if agent else None, + "provider": getattr(agent, "provider", None) if agent else None, + } + if agent is not None: try: - ack = _send_compute_host_control( + _apply_model_switch( sid, - route_name="slash.compress", - command=command, - wait=True, - timeout=_compute_host_compress_wait_seconds(), - on_late_ack=_on_late_ack, + session, + f"{preset} --provider moa", + confirm_expensive_model=False, + pin_session_override=True, + persist_override=False, # turn-scoped: never persist the MoA provider to config.yaml ) - except queue.Empty: + except Exception as exc: + session.pop("moa_one_shot_restore", None) + return _err(rid, 5030, f"moa unavailable: {exc}") + else: + # Lazy/fresh session: the override is consumed by the first build. + session["model_override"] = { + "provider": "moa", + "model": preset, + "base_url": "moa://local", + "api_key": "moa-virtual-provider", + "api_mode": "chat_completions", + } + return _ok( + rid, + { + "type": "send", + "notice": f"MoA one-shot queued with preset {preset}; previous model will be restored after this turn.", + "message": arg, + }, + ) + except Exception as exc: + return _err(rid, 5030, f"moa unavailable: {exc}") + + +def _cmd_focus(rid, params, session, name, arg): + # Display-only. Routed through the same config.set branch the Ink slash + # command uses so both surfaces share one state machine and persistence path. + from hermes_cli.focus_view import format_focus_status, format_focus_toggle_message, resolve_focus_arg + + _display_focus = _load_cfg().get("display") + _d_focus: dict = _display_focus if isinstance(_display_focus, dict) else {} + _cur_focus = bool(_d_focus.get("focus_view", False)) + _action, _target = resolve_focus_arg(arg, _cur_focus) + if _action == "usage": + return _err(rid, 4004, "usage: /focus [on|off|status]") + if _action == "status": + _saved = _d_focus.get("focus_saved_tool_progress") or _load_tool_progress_mode() + return _ok(rid, {"type": "exec", "output": format_focus_status(_cur_focus, _saved)}) + _res = _methods["config.set"]( + rid, {"key": "focus", "value": "on" if _target else "off", "session_id": params.get("session_id", "")} + ) + if "error" in _res: + return _res + _payload = _res.get("result") or {} + return _ok( + rid, + {"type": "exec", "output": format_focus_toggle_message(bool(_target), _payload.get("tool_progress") or "all")}, + ) + + +def _cmd_retry(rid, params, session, name, arg): + if not session: + return _err(rid, 4001, "no active session to retry") + if busy := _busy_error(rid, session, "retry"): + return busy + from agent.context_compressor import history_before_user_originated_turn, retryable_user_text + + with session["history_lock"]: + if busy := _busy_error(rid, session, "retry"): + return busy + if session.get("attached_images"): + return _err(rid, 4018, "retry cannot safely reconstruct or combine attached media") + history, user_indices = _user_turn_indices(session) + if not user_indices: + return _err(rid, 4018, "no previous user message to retry") + _prefix, live_view = history_before_user_originated_turn(history, user_indices[-1]) + try: + content = retryable_user_text(live_view.get("content")) + except ValueError as exc: + return _err(rid, 4018, str(exc)) + try: + _active, durable_live_view, _rewound_count = _rewind_active_session_history( + session, len(user_indices) - 1, require_retryable=True + ) + except ValueError as exc: + return _err(rid, 4018, str(exc)) + except Exception as exc: + return _err(rid, 5008, f"retry: failed to persist history: {exc}") + content = retryable_user_text(durable_live_view.get("content")) + return _ok(rid, {"type": "send", "message": content}) + + +def _cmd_steer(rid, params, session, name, arg): + if not arg: + return _err(rid, 4004, "usage: /steer ") + agent = session.get("agent") if session else None + if agent and hasattr(agent, "steer"): + try: + if agent.steer(arg): return _ok( rid, { "type": "exec", - "status": "pending", - "output": ( - "compression still running in the background; " - "the transcript will refresh when it finishes" - ), + "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}", }, ) - except Exception as exc: - return _err(rid, 5019, f"compute-host slash.compress failed: {exc}") - if ack.get("type") in {"control.error", "error"}: - return _err( - rid, - 4009, - str(ack.get("message") or "compute-host slash.compress failed"), - ) - _apply_compute_host_metadata_mirror(session, ack) - return _ok( - rid, - {"type": "exec", "output": str(ack.get("output") or "")}, - ) - try: - from agent.manual_compression_feedback import summarize_manual_compression - from agent.model_metadata import estimate_request_tokens_rough + except Exception: + pass + # No active run: treat as next-turn message. + return _ok(rid, {"type": "send", "message": arg}) - with session["history_lock"]: - before_messages = list(session.get("history", [])) - history_version = int(session.get("history_version", 0)) - before_count = len(before_messages) - _agent = session["agent"] - _sys_prompt = getattr(_agent, "_cached_system_prompt", "") or "" - _tools = getattr(_agent, "tools", None) or None - before_tokens = ( - estimate_request_tokens_rough( - before_messages, system_prompt=_sys_prompt, tools=_tools + +def _cmd_goal(rid, params, session, name, arg): + if not session: + return _err(rid, 4001, "no active session") + try: + from hermes_cli.goals import GoalManager + except Exception as exc: + return _err(rid, 5030, f"goals unavailable: {exc}") + + sid_key = session.get("session_key") or "" + if not sid_key: + return _err(rid, 4001, "no session key") + + try: + max_turns = int((_load_cfg().get("goals") or {}).get("max_turns", 20) or 20) + except Exception: + max_turns = 20 + mgr = GoalManager(session_id=sid_key, default_max_turns=max_turns) + + lower = arg.strip().lower() + if not arg.strip() or lower == "status": + return _ok(rid, {"type": "exec", "output": mgr.status_line()}) + if lower == "pause": + state = mgr.pause(reason="user-paused") + out = "No goal set." if state is None else f"⏸ Goal paused: {state.goal}" + return _ok(rid, {"type": "exec", "output": out}) + if lower == "resume": + state = mgr.resume() + if state is None: + return _ok(rid, {"type": "exec", "output": "No goal to resume."}) + # Resume must restart work, not just flip persisted state: an `exec` + # result is display-only, so return a `send` carrying the continuation + # prompt; `display` keeps the transcript free of model-facing scaffolding. + prompt = mgr.next_continuation_prompt() + if not prompt: + return _ok(rid, {"type": "exec", "output": f"▶ Goal resumed: {state.goal}"}) + return _ok( + rid, + { + "type": "send", + "notice": f"▶ Goal resumed: {state.goal}\nContinuing now — taking the next step.", + "message": prompt, + "display": "/goal resume", + }, + ) + if lower in {"clear", "stop", "done"}: + had = mgr.has_goal() + mgr.clear() + return _ok(rid, {"type": "exec", "output": "✓ Goal cleared." if had else "No active goal."}) + + # Remaining text = the new goal. The client renders `notice` as a sys line + # then submits `message`; the post-turn judge in _run_prompt_submit takes over. + try: + state = mgr.set(arg) + except ValueError as exc: + return _err(rid, 4004, f"invalid goal: {exc}") + notice = ( + f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n" + "I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n" + "Controls: /goal status · /goal pause · /goal resume · /goal clear" + ) + return _ok(rid, {"type": "send", "notice": notice, "message": state.goal}) + + +def _cmd_loop(rid, params, session, name, arg): + # Recurring in-session wakeups; the notification poller fires due wakeups + # into this session while it's idle. + if not session: + return _err(rid, 4001, "no active session") + try: + from hermes_cli.loops import LoopManager, dispatch_loop_command + except Exception as exc: + return _err(rid, 5030, f"loops unavailable: {exc}") + + sid_key = session.get("session_key") or "" + if not sid_key: + return _err(rid, 4001, "no session key") + + result = dispatch_loop_command(LoopManager(session_id=sid_key), arg) + output = result.get("output") or "" + if result.get("created"): + try: + from hermes_cli.loops import goal_blocks_loop_tick + + if goal_blocks_loop_tick(sid_key): + output += ( + "\nNote: an active /goal is driving this session — loop " + "wakeups defer until the goal finishes, pauses, or parks." ) - if before_count - else 0 - ) - removed, usage = _compress_session_history( - session, - arg.strip() or None, - approx_tokens=before_tokens, - before_messages=before_messages, - history_version=history_version, - ) - with session["history_lock"]: - after_messages = list(session.get("history", [])) - after_count = len(after_messages) - _sys_prompt_after = ( - getattr(_agent, "_cached_system_prompt", "") or _sys_prompt - ) - _tools_after = getattr(_agent, "tools", None) or _tools - after_tokens = ( - estimate_request_tokens_rough( - after_messages, - system_prompt=_sys_prompt_after, - tools=_tools_after, - ) - if after_count - else 0 - ) - _sync_session_key_after_compress(sid, session) - summary = summarize_manual_compression( - before_messages, - after_messages, - before_tokens, - after_tokens, - compression_state=getattr(_agent, "context_compressor", None), - ) - _emit("session.info", sid, _session_info(session.get("agent"), session)) - finalize_context_engine_compression_notification( - _agent, - committed=True, + except Exception: + pass + return _ok(rid, {"type": "exec", "output": output}) + + +def _cmd_undo(rid, params, session, name, arg): + # /undo [N]: back up N user turns (default 1), soft-delete the truncated rows + # on disk, and prefill the composer with the backed-up user text. + if not session: + return _err(rid, 4001, "no active session to undo") + if busy := _busy_error(rid, session, "undo"): + return busy + session_key = session.get("session_key", "") + if not session_key: + return _err(rid, 4001, "no session key for undo") + n = 1 + arg_str = (arg or "").strip() + if arg_str: + try: + n = int(arg_str.split()[0]) + except (ValueError, IndexError): + return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N") + if n < 1: + n = 1 + from agent.message_content import flatten_message_text + + with session["history_lock"]: + if busy := _busy_error(rid, session, "undo"): + return busy + _history, user_indices = _user_turn_indices(session) + if not user_indices: + return _err(rid, 4018, "no user messages to undo") + turns_undone = min(n, len(user_indices)) + try: + active, live_view, rewound_count = _rewind_active_session_history(session, len(user_indices) - turns_undone) + except ValueError as exc: + return _err(rid, 4004, f"undo: {exc}") + except Exception as exc: + return _err(rid, 5008, f"undo: {exc}") + target_text = flatten_message_text(live_view.get("content")) + # Notify memory providers (same hook /branch fires) with rewound=True so + # providers caching per-turn document state invalidate. + agent = session.get("agent") + if agent is not None: + mm = getattr(agent, "_memory_manager", None) + if mm is not None: + try: + mm.on_session_switch(session_key, parent_session_id="", reset=False, rewound=True) + except Exception: + pass + if hasattr(agent, "_invalidate_system_prompt"): + try: + agent._invalidate_system_prompt() + except Exception: + pass + if hasattr(agent, "_last_flushed_db_idx"): + try: + agent._last_flushed_db_idx = len(active) + except Exception: + pass + turn_word = "turn" if turns_undone == 1 else "turns" + notice = ( + f"↶ Undid {turns_undone} {turn_word} ({rewound_count} message(s)). Edit and resubmit, or send a new message." + ) + return _ok(rid, {"type": "prefill", "message": target_text, "notice": notice}) + + +def _cmd_snapshot(rid, params, session, name, arg): + subcommand = arg.split(maxsplit=1)[0].lower() if arg else "" + if subcommand not in {"restore", "rewind"}: + return None + return _ok( + rid, + { + "type": "exec", + "output": ( + "/snapshot restore is blocked in the TUI because it changes " + "config/state on disk while the live agent has cached settings. " + "Run it in the classic CLI, then restart the TUI." + ), + }, + ) + + +def _cmd_compress(rid, params, session, name, arg): + if not session: + return _err(rid, 4001, "no active session to compress") + if busy := _busy_error(rid, session, "compress"): + return busy + from agent.conversation_compression import finalize_context_engine_compression_notification + + sid = params.get("session_id", "") + if _session_uses_compute_host(session): + command = f"/{name}" + (f" {arg}" if arg else "") + _late_session = session + + def _on_late_ack(late: dict, _sid=sid) -> None: + _adopt_late_compute_host_compress_ack(_sid, _late_session, late, route_name="slash.compress") + + try: + ack = _send_compute_host_control( + sid, + route_name="slash.compress", + command=command, + wait=True, + timeout=_compute_host_compress_wait_seconds(), + on_late_ack=_on_late_ack, ) + except queue.Empty: return _ok( rid, { "type": "exec", - "output": "\n".join( - filter(None, [summary["headline"], summary["token_line"], summary.get("note")]) - ), + "status": "pending", + "output": "compression still running in the background; the transcript will refresh when it finishes", }, ) - except CompressionLockHeld as e: - # Lock-skip is a clean no-op, not a failure: report it as - # normal command output (matching the slash-mirror and - # session.compress RPC), never as a "compress failed" error. - # _compress_session_history already discarded the deferred - # context-engine notification before raising. - from agent.manual_compression_feedback import ( - describe_compression_lock_skip, - ) - return _ok( - rid, - {"type": "exec", "output": describe_compression_lock_skip(e.holder)}, - ) except Exception as exc: - finalize_context_engine_compression_notification( - session["agent"], - committed=False, - ) - return _err(rid, 5009, f"compress failed: {exc}") + return _err(rid, 5019, f"compute-host slash.compress failed: {exc}") + if ack.get("type") in {"control.error", "error"}: + return _err(rid, 4009, str(ack.get("message") or "compute-host slash.compress failed")) + _apply_compute_host_metadata_mirror(session, ack) + return _ok(rid, {"type": "exec", "output": str(ack.get("output") or "")}) + try: + from agent.manual_compression_feedback import summarize_manual_compression + from agent.model_metadata import estimate_request_tokens_rough + with session["history_lock"]: + before_messages = list(session.get("history", [])) + history_version = int(session.get("history_version", 0)) + _agent = session["agent"] + _sys_prompt = getattr(_agent, "_cached_system_prompt", "") or "" + _tools = getattr(_agent, "tools", None) or None + before_tokens = ( + estimate_request_tokens_rough(before_messages, system_prompt=_sys_prompt, tools=_tools) + if before_messages + else 0 + ) + removed, usage = _compress_session_history( + session, + arg.strip() or None, + approx_tokens=before_tokens, + before_messages=before_messages, + history_version=history_version, + ) + with session["history_lock"]: + after_messages = list(session.get("history", [])) + after_tokens = ( + estimate_request_tokens_rough( + after_messages, + system_prompt=getattr(_agent, "_cached_system_prompt", "") or _sys_prompt, + tools=getattr(_agent, "tools", None) or _tools, + ) + if after_messages + else 0 + ) + _sync_session_key_after_compress(sid, session) + summary = summarize_manual_compression( + before_messages, + after_messages, + before_tokens, + after_tokens, + compression_state=getattr(_agent, "context_compressor", None), + ) + _emit("session.info", sid, _session_info(session.get("agent"), session)) + finalize_context_engine_compression_notification(_agent, committed=True) + return _ok( + rid, + { + "type": "exec", + "output": "\n".join(filter(None, [summary["headline"], summary["token_line"], summary.get("note")])), + }, + ) + except CompressionLockHeld as e: + # Lock-skip is a clean no-op (matches the slash mirror and session.compress + # RPC), never a "compress failed" error. _compress_session_history already + # discarded the deferred context-engine notification before raising. + from agent.manual_compression_feedback import describe_compression_lock_skip + + return _ok(rid, {"type": "exec", "output": describe_compression_lock_skip(e.holder)}) + except Exception as exc: + finalize_context_engine_compression_notification(session["agent"], committed=False) + return _err(rid, 5009, f"compress failed: {exc}") + + +def _slash_builtin_table() -> dict: + """name → handler. Built per call so the entries resolve to the rebound helpers.""" + return { + "queue": _cmd_queue, + "q": _cmd_queue, + "learn": _cmd_learn, + "plan": _cmd_plan, + "init": _cmd_init, + "moa": _cmd_moa, + "focus": _cmd_focus, + "retry": _cmd_retry, + "steer": _cmd_steer, + "goal": _cmd_goal, + "loop": _cmd_loop, + "undo": _cmd_undo, + "snapshot": _cmd_snapshot, + "snap": _cmd_snapshot, + "compress": _cmd_compress, + "compact": _cmd_compress, + } + + +@method("command.dispatch") +def _(rid, params: dict) -> dict: + name, arg = params.get("name", "").lstrip("/"), params.get("arg", "") + name = _resolve_name(name) + session = _sessions.get(params.get("session_id", "")) + + # Stage order is load-bearing: quick > plugin > bundle > skill > built-in. + for stage in (_dispatch_quick, _dispatch_plugin, _dispatch_bundle, _dispatch_skill): + res = stage(rid, params, session, name, arg) + if res is not None: + return res + builtin = _slash_builtin_table().get(name) + if builtin is not None: + res = builtin(rid, params, session, name, arg) + if res is not None: + return res return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}") @@ -1193,61 +1116,39 @@ def _(rid, params: dict) -> dict: if not cmd: return _err(rid, 4004, "empty command") - # Skill and bundle slash commands plus _pending_input commands must NOT go - # through the slash worker — see _PENDING_INPUT_COMMANDS definition above. - # Plugin commands must also avoid the worker, but unlike skills and - # pending-input commands they still return normal slash.exec output so the - # TUI keeps the pager path. + # Skill/bundle and _pending_input commands must NOT reach the slash worker + # (see _PENDING_INPUT_COMMANDS). Plugin commands also bypass the worker but + # still return normal slash.exec output so the TUI keeps the pager path. _cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd _cmd_parts = _cmd_text.split(maxsplit=1) _cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower() _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else "" + sid = params.get("session_id", "") - live_output = _live_slash_command_output( - params.get("session_id", ""), session, _cmd_base, _cmd_arg - ) + live_output = _live_slash_command_output(sid, session, _cmd_base, _cmd_arg) if live_output is not None: return _ok(rid, {"output": live_output or "(no output)"}) if _cmd_base in _PENDING_INPUT_COMMANDS: - # Route directly to command.dispatch instead of returning an error - # that requires the frontend to retry. Some TUI clients fail the - # fallback, leaving the command empty and showing "empty command". - return _methods["command.dispatch"]( - rid, - { - "name": _cmd_base, - "arg": _cmd_arg, - "session_id": params.get("session_id", ""), - }, - ) + # Route straight to command.dispatch rather than erroring and relying on a + # client-side retry (some clients fail the fallback → "empty command"). + return _methods["command.dispatch"](rid, {"name": _cmd_base, "arg": _cmd_arg, "session_id": sid}) if _cmd_base in _WORKER_BLOCKED_COMMANDS: subcommand = _cmd_arg.split(maxsplit=1)[0].lower() if _cmd_arg else "" if subcommand in {"restore", "rewind"}: return _err( - rid, - 4018, - "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore", + rid, 4018, "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore" ) try: from agent.skill_bundles import resolve_bundle_command_key from hermes_cli.commands import resolve_command - _bundle_key = ( - resolve_bundle_command_key(_cmd_base) - if resolve_command(_cmd_base) is None - else None - ) + _bundle_key = resolve_bundle_command_key(_cmd_base) if resolve_command(_cmd_base) is None else None if _bundle_key is not None: return _methods["command.dispatch"]( - rid, - { - "name": _bundle_key.lstrip("/"), - "arg": _cmd_arg, - "session_id": params.get("session_id", ""), - }, + rid, {"name": _bundle_key.lstrip("/"), "arg": _cmd_arg, "session_id": sid} ) except Exception: pass @@ -1256,21 +1157,15 @@ def _(rid, params: dict) -> dict: from agent.skill_commands import get_skill_commands from hermes_constants import reset_hermes_home_override, set_hermes_home_override - # Re-bind HERMES_HOME to the session's profile so get_skill_commands() - # sees that profile's skills.external_dirs rather than whatever the - # process-level env happens to carry (#88023): dispatch() runs this - # handler on the pool with a copied context, and nothing upstream of - # here binds the override for slash.exec. + # Bind HERMES_HOME to the session's profile so get_skill_commands() sees + # that profile's skills.external_dirs: dispatch() runs this on the pool + # with a copied context and nothing upstream binds the override here. _profile_home = session.get("profile_home") - _home_token = ( - set_hermes_home_override(_profile_home) if _profile_home else None - ) + _home_token = set_hermes_home_override(_profile_home) if _profile_home else None try: _cmd_key = f"/{_cmd_base}" if _cmd_key in get_skill_commands(): - return _err( - rid, 4018, f"skill command: use command.dispatch for {_cmd_key}" - ) + return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}") finally: if _home_token is not None: reset_hermes_home_override(_home_token) @@ -1281,10 +1176,7 @@ def _(rid, params: dict) -> dict: resolve_plugin_command_result = None if _cmd_base: try: - from hermes_cli.plugins import ( - get_plugin_command_handler, - resolve_plugin_command_result, - ) + from hermes_cli.plugins import get_plugin_command_handler, resolve_plugin_command_result plugin_handler = get_plugin_command_handler(_cmd_base) except Exception: @@ -1300,12 +1192,10 @@ def _(rid, params: dict) -> dict: worker = session.get("slash_worker") if not worker: - # On-demand spawn is now the ONLY spawn path for a fresh session - # (eager pre-warm removed), and slash.exec handlers run on the RPC - # thread pool — two concurrent slash commands on the same session - # could both observe slash_worker=None and each fork a full - # MCP-fleet worker (the loser of the _attach_worker race would leak - # unclosed). Serialize first-use spawn per session. + # On-demand spawn is the ONLY spawn path, and slash.exec runs on the RPC + # pool: two concurrent commands could both see slash_worker=None and each + # fork a full MCP-fleet worker (the _attach_worker loser leaks). Serialize + # first-use spawn per session. with _sessions_lock: spawn_lock = session.setdefault("_slash_spawn_lock", threading.Lock()) with spawn_lock: @@ -1317,13 +1207,13 @@ def _(rid, params: dict) -> dict: getattr(session.get("agent"), "model", _resolve_model()), profile_home=session.get("profile_home"), ) - _attach_worker(params.get("session_id", ""), session, worker) + _attach_worker(sid, session, worker) except Exception as e: return _err(rid, 5030, f"slash worker start failed: {e}") try: output = worker.run(cmd) - warning = _mirror_slash_side_effects(params.get("session_id", ""), session, cmd) + warning = _mirror_slash_side_effects(sid, session, cmd) payload = {"output": output or "(no output)"} if warning: payload["warning"] = warning @@ -1337,6 +1227,9 @@ def _(rid, params: dict) -> dict: return _err(rid, 5030, str(e)) +# ─── Insights / rollback / browser / config ────────────────────────────────── + + @method("insights.get") def _(rid, params: dict) -> dict: days = params.get("days", 30) @@ -1345,19 +1238,8 @@ def _(rid, params: dict) -> dict: return _db_unavailable_error(rid, code=5017) try: cutoff = time.time() - days * 86400 - rows = [ - s - for s in db.list_sessions_rich(limit=500, compact_rows=True) - if (s.get("started_at") or 0) >= cutoff - ] - return _ok( - rid, - { - "days": days, - "sessions": len(rows), - "messages": sum(s.get("message_count", 0) for s in rows), - }, - ) + rows = [s for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff] + return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)}) except Exception as e: return _err(rid, 5017, str(e)) @@ -1401,17 +1283,11 @@ def _(rid, params: dict) -> dict: file_path = params.get("file_path", "") if not target: return _err(rid, 4014, "hash required") - # Full-history rollback mutates session history. Rejecting during - # an in-flight turn prevents prompt.submit from silently dropping - # the agent's output (version mismatch path) or clobbering the - # rollback (version-matches path). A file-scoped rollback only - # touches disk, so we allow it. + # A full-history rollback mutates session history, so it is rejected during + # an in-flight turn (prompt.submit would drop the agent's output or clobber + # the rollback). A file-scoped rollback only touches disk and is allowed. if not file_path and session.get("running"): - return _err( - rid, - 4009, - "session busy — /interrupt the current turn before full rollback.restore", - ) + return _err(rid, 4009, "session busy — /interrupt the current turn before full rollback.restore") try: def go(mgr, cwd): @@ -1420,27 +1296,15 @@ def _(rid, params: dict) -> dict: if result.get("success") and not file_path: removed = 0 with session["history_lock"]: - history = _history_without_ephemeral_scaffolding( - session.get("history", []) - ) - from agent.context_compressor import user_originated_turn_view - - user_indices = [ - index - for index, message in enumerate(history) - if user_originated_turn_view(message) is not None - ] + _history, user_indices = _user_turn_indices(session) if user_indices: try: - _active, _live_view, removed = ( - _rewind_active_session_history( - session, len(user_indices) - 1 - ) + _active, _live_view, removed = _rewind_active_session_history( + session, len(user_indices) - 1 ) except Exception as exc: raise RuntimeError( - "checkpoint restored, but session history rewind " - f"failed: {exc}" + f"checkpoint restored, but session history rewind failed: {exc}" ) from exc result["history_removed"] = removed return result @@ -1459,10 +1323,7 @@ def _(rid, params: dict) -> dict: if not target: return _err(rid, 4014, "hash required") try: - r = _with_checkpoints( - session, - lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target)), - ) + r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target))) raw = r.get("diff", "")[:4000] payload = {"stat": r.get("stat", ""), "diff": raw} rendered = render_diff(raw, session.get("cols", 80)) @@ -1476,17 +1337,13 @@ def _(rid, params: dict) -> dict: @method("browser.manage") def _(rid, params: dict) -> dict: action = params.get("action", "status") - if action == "status": url = _resolve_browser_cdp_url() return _ok(rid, {"connected": bool(url), "url": url}) - if action == "disconnect": return _browser_disconnect(rid) - if action != "connect": return _err(rid, 4015, f"unknown action: {action}") - return _browser_connect(rid, params) @@ -1499,11 +1356,7 @@ def _(rid, params: dict) -> dict: rid, { "plugins": [ - { - "name": n, - "version": getattr(i, "version", "?"), - "enabled": getattr(i, "enabled", True), - } + {"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)} for n, i in get_plugin_manager()._plugins.items() ] }, @@ -1524,14 +1377,7 @@ def _(rid, params: dict) -> dict: base_url = os.environ.get("HERMES_BASE_URL", "") or cfg.get("base_url", "") sections = [ - { - "title": "Model", - "rows": [ - ["Model", model], - ["Base URL", base_url or "(default)"], - ["API Key", masked], - ], - }, + {"title": "Model", "rows": [["Model", model], ["Base URL", base_url or "(default)"], ["API Key", masked]]}, { "title": "Agent", "rows": [ @@ -1542,10 +1388,7 @@ def _(rid, params: dict) -> dict: }, { "title": "Environment", - "rows": [ - ["Working Dir", os.getcwd()], - ["Config File", str(_hermes_home / "config.yaml")], - ], + "rows": [["Working Dir", os.getcwd()], ["Config File", str(_hermes_home / "config.yaml")]], }, ] return _ok(rid, {"sections": sections}) @@ -1553,52 +1396,35 @@ def _(rid, params: dict) -> dict: return _err(rid, 5030, str(e)) +# ─── Tools / toolsets / agents ─────────────────────────────────────────────── + + @method("tools.list") def _(rid, params: dict) -> dict: try: - from toolsets import get_all_toolsets, get_toolset_info - - session = _sessions.get(params.get("session_id", "")) - enabled = ( - set(getattr(session["agent"], "enabled_toolsets", []) or []) - if session - else set(_load_enabled_toolsets() or []) - ) - - items = [] - for name in sorted(get_all_toolsets().keys()): - info = get_toolset_info(name) - if not info: - continue - items.append( - { - "name": name, - "description": info["description"], - "tool_count": info["tool_count"], - "enabled": name in enabled if enabled else True, - "tools": info["resolved_tools"], - } - ) - return _ok(rid, {"toolsets": items}) + return _ok(rid, {"toolsets": _toolset_rows(params, with_tools=True)}) except Exception as e: return _err(rid, 5031, str(e)) +@method("toolsets.list") +def _(rid, params: dict) -> dict: + try: + return _ok(rid, {"toolsets": _toolset_rows(params, with_tools=False)}) + except Exception as e: + return _err(rid, 5032, str(e)) + + @method("tools.show") def _(rid, params: dict) -> dict: try: from model_tools import get_toolset_for_tool, get_tool_definitions session = _sessions.get(params.get("session_id", "")) - enabled = ( - getattr(session["agent"], "enabled_toolsets", None) - if session - else _load_enabled_toolsets() - ) - # Pre-assembly list: /tools is a discovery surface and must show - # tools deferred behind the tool_search bridge (same as the CLI). - tools = get_tool_definitions(enabled_toolsets=enabled, quiet_mode=True, - skip_tool_search_assembly=True) + enabled = getattr(session["agent"], "enabled_toolsets", None) if session else _load_enabled_toolsets() + # Pre-assembly list: /tools is a discovery surface and must show tools + # deferred behind the tool_search bridge (same as the CLI). + tools = get_tool_definitions(enabled_toolsets=enabled, quiet_mode=True, skip_tool_search_assembly=True) sections = {} for tool in sorted(tools, key=lambda t: t["function"]["name"]): @@ -1606,20 +1432,12 @@ def _(rid, params: dict) -> dict: desc = str(tool["function"].get("description", "") or "").split("\n")[0] if ". " in desc: desc = desc[: desc.index(". ") + 1] - sections.setdefault(get_toolset_for_tool(name) or "unknown", []).append( - { - "name": name, - "description": desc, - } - ) + sections.setdefault(get_toolset_for_tool(name) or "unknown", []).append({"name": name, "description": desc}) return _ok( rid, { - "sections": [ - {"name": name, "tools": rows} - for name, rows in sorted(sections.items()) - ], + "sections": [{"name": name, "tools": rows} for name, rows in sorted(sections.items())], "total": len(tools), }, ) @@ -1630,9 +1448,7 @@ def _(rid, params: dict) -> dict: @method("tools.configure") def _(rid, params: dict) -> dict: action = str(params.get("action", "") or "").strip().lower() - targets = [ - str(name).strip() for name in params.get("names", []) or [] if str(name).strip() - ] + targets = [str(name).strip() for name in params.get("names", []) or [] if str(name).strip()] if action not in {"disable", "enable"}: return _err(rid, 4017, f"unknown tools action: {action}") if not targets: @@ -1649,9 +1465,7 @@ def _(rid, params: dict) -> dict: ) cfg = load_config() - valid_toolsets = { - ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS - } | _get_plugin_toolset_keys() + valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys() toolset_targets = [name for name in targets if ":" not in name] mcp_targets = [name for name in targets if ":" in name] unknown = [name for name in toolset_targets if name not in valid_toolsets] @@ -1660,25 +1474,17 @@ def _(rid, params: dict) -> dict: if toolset_targets: _apply_toolset_change(cfg, "cli", toolset_targets, action) - missing_servers = ( - _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set() - ) + missing_servers = _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set() save_config(cfg) - session = _sessions.get(params.get("session_id", "")) - info = ( - _reset_session_agent(params.get("session_id", ""), session) - if session - else None - ) - enabled = sorted( - _get_platform_tools(load_config(), "cli", include_default_mcp_servers=False) - ) + sid = params.get("session_id", "") + session = _sessions.get(sid) + info = _reset_session_agent(sid, session) if session else None + enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False)) changed = [ name for name in targets - if name not in unknown - and (":" not in name or name.split(":", 1)[0] not in missing_servers) + if name not in unknown and (":" not in name or name.split(":", 1)[0] not in missing_servers) ] return _ok( @@ -1696,42 +1502,11 @@ def _(rid, params: dict) -> dict: return _err(rid, 5035, str(e)) -@method("toolsets.list") -def _(rid, params: dict) -> dict: - try: - from toolsets import get_all_toolsets, get_toolset_info - - session = _sessions.get(params.get("session_id", "")) - enabled = ( - set(getattr(session["agent"], "enabled_toolsets", []) or []) - if session - else set(_load_enabled_toolsets() or []) - ) - - items = [] - for name in sorted(get_all_toolsets().keys()): - info = get_toolset_info(name) - if not info: - continue - items.append( - { - "name": name, - "description": info["description"], - "tool_count": info["tool_count"], - "enabled": name in enabled if enabled else True, - } - ) - return _ok(rid, {"toolsets": items}) - except Exception as e: - return _err(rid, 5032, str(e)) - - @method("agents.list") def _(rid, params: dict) -> dict: try: from tools.process_registry import process_registry - procs = process_registry.list_sessions() return _ok( rid, { @@ -1742,7 +1517,7 @@ def _(rid, params: dict) -> dict: "status": p["status"], "uptime": p["uptime_seconds"], } - for p in procs + for p in process_registry.list_sessions() ] }, ) @@ -1750,101 +1525,61 @@ def _(rid, params: dict) -> dict: return _err(rid, 5033, str(e)) +# ─── Cron / learning / skills ──────────────────────────────────────────────── + + @method("cron.manage") +@_profile_scoped_rpc(5023) def _(rid, params: dict) -> dict: + """cronjob() keys off HERMES_HOME, so the optional ``profile`` scope lets a + per-profile cron store be listed/mutated even when that profile runs its own + gateway (mirrors skills.manage / mcp.catalog).""" + from tools.cronjob_tools import cronjob + action, jid = params.get("action", "list"), params.get("name", "") - # Optional profile scoping: cronjob() keys off HERMES_HOME, so scoping the - # env override lets a per-profile cron store be listed/mutated even when - # that profile runs a separate gateway. Omitted/None = the launch profile. - # Mirrors ``skills.manage`` / ``mcp.catalog``. - profile = str(params.get("profile") or "").strip() - token = None - if profile: - try: - from hermes_cli.profiles import get_profile_dir - from hermes_constants import set_hermes_home_override - - profile_dir = get_profile_dir(profile) - if not profile_dir or not profile_dir.is_dir(): - return _err(rid, 4064, f"profile '{profile}' not found") - token = set_hermes_home_override(str(profile_dir)) - except Exception as e: - return _err(rid, 5023, str(e)) - try: - from tools.cronjob_tools import cronjob - - if action == "list": - # Paused jobs are excluded by default, which reads as deletion in - # any UI with an enable/disable toggle — forward the flag. - result = json.loads( + if action == "list": + # Paused jobs are excluded by default, which reads as deletion in any UI + # with an enable/disable toggle — forward the flag. + result = json.loads( + cronjob(action="list", include_disabled=is_truthy_value(params.get("include_disabled", False))) + ) + # ``scoped`` proves the gateway honored the profile scope: new clients may + # treat every job as owned by that profile; older gateways omit it and + # keep the safe [bot:] compatibility filter. + profile = str(params.get("profile") or "").strip() + if profile: + result["scoped"] = profile + return _ok(rid, result) + if action == "add": + return _ok( + rid, + json.loads( cronjob( - action="list", - include_disabled=is_truthy_value(params.get("include_disabled", False)), + action="create", + name=jid, + schedule=params.get("schedule", ""), + prompt=params.get("prompt", ""), + # Optional repeat cap; None keeps the schedule-kind default. + repeat=int(params["repeat"]) if str(params.get("repeat", "")).strip().isdigit() else None, + # Optional continuity toggle: previous output injected into each run. + continuity=( + is_truthy_value(params.get("continuity")) if params.get("continuity") is not None else None + ), + # Optional delivery target, e.g. 'bot-chat[:name]'; empty keeps the cronjob() default. + deliver=(str(params.get("deliver") or "").strip() or None), ) - ) - # This marker proves the gateway honored the optional profile - # scope. New clients may therefore treat every returned job as - # owned by that profile; older gateways omit it, preserving the - # safe [bot:] compatibility filter. - if profile: - result["scoped"] = profile - return _ok(rid, result) - if action == "add": - return _ok( - rid, - json.loads( - cronjob( - action="create", - name=jid, - schedule=params.get("schedule", ""), - prompt=params.get("prompt", ""), - # Optional repeat cap ("run N times"); None keeps the - # schedule-kind default (once for one-shot, forever - # for recurring). - repeat=( - int(params["repeat"]) - if str(params.get("repeat", "")).strip().isdigit() - else None - ), - # Optional continuity toggle: the job's own previous - # output is injected into each run (stored as the - # reserved "self" entry in context_from). - continuity=( - is_truthy_value(params.get("continuity")) - if params.get("continuity") is not None - else None - ), - # Optional delivery target — notably 'bot-chat[:name]' - # (canonical Bot Chat injection) from the Desktop Bot - # Mode cronjob dialog. Omitted/empty keeps the - # cronjob() default. - deliver=(str(params.get("deliver") or "").strip() or None), - ) - ), - ) - if action in {"remove", "pause", "resume"}: - return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) - return _err(rid, 4016, f"unknown cron action: {action}") - except Exception as e: - return _err(rid, 5023, str(e)) - finally: - if token is not None: - try: - from hermes_constants import reset_hermes_home_override - - reset_hermes_home_override(token) - except Exception: - pass + ), + ) + if action in {"remove", "pause", "resume"}: + return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) + return _err(rid, 4016, f"unknown cron action: {action}") @method("learning.frames") def _(rid, params: dict) -> dict: - """Pre-render the learning timeline for the TUI ``/journey`` overlay. - - Returns ``frames`` (reveal 0→1) plus static legend/summary/bucket metadata, - so Ink can render and walk the tree locally without round-tripping the - gateway. Shares its renderer with the ``hermes journey`` CLI. - """ + """Pre-render the learning timeline for the TUI ``/journey`` overlay: ``frames`` + (reveal 0→1) plus legend/summary/bucket metadata so Ink walks the tree locally. + Shares its renderer with ``hermes journey``.""" try: cols = int(params.get("cols", 80) or 80) rows = int(params.get("rows", 24) or 24) @@ -1894,594 +1629,59 @@ def _(rid, params: dict) -> dict: return _err(rid, 5000, f"learning.edit failed: {exc}") +def _skills_list(rid, params, query): + from hermes_cli.banner import get_available_skills + + return _ok(rid, {"skills": get_available_skills()}) + + +def _skills_search(rid, params, query): + from tools.skills_hub import GitHubAuth, create_source_router, unified_search + + raw = unified_search(query, create_source_router(GitHubAuth()), source_filter="all", limit=20) or [] + return _ok(rid, {"results": [{"name": r.name, "description": r.description} for r in raw]}) + + +def _skills_install(rid, params, query): + from hermes_cli.skills_hub import do_install + + class _Q: + def print(self, *a, **k): + pass + + do_install(query, skip_confirm=True, console=_Q()) + return _ok(rid, {"installed": True, "name": query}) + + +def _skills_browse(rid, params, query): + from hermes_cli.skills_hub import browse_skills + + pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1) + return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20)))) + + +def _skills_inspect(rid, params, query): + from hermes_cli.skills_hub import inspect_skill + + return _ok(rid, {"info": inspect_skill(query) or {}}) + + @method("skills.manage") +@_profile_scoped_rpc(5024) def _(rid, params: dict) -> dict: + """list/install operate on the scoped profile's skills dir; search/browse/ + inspect hit the shared hub catalog (the override is harmless there).""" action, query = params.get("action", "list"), params.get("query", "") - # Optional profile scoping: list/install operate on that profile's - # skills dir (capabilities UIs manage a bot's skills from the main - # window). Search/browse/inspect hit the shared hub catalog — the - # override is harmless there and keeps the semantics uniform. - profile = str(params.get("profile") or "").strip() - token = None - if profile: - try: - from hermes_cli.profiles import get_profile_dir - from hermes_constants import set_hermes_home_override - - profile_dir = get_profile_dir(profile) - if not profile_dir or not profile_dir.is_dir(): - return _err(rid, 4064, f"profile '{profile}' not found") - token = set_hermes_home_override(str(profile_dir)) - except Exception as e: - return _err(rid, 5024, str(e)) - try: - if action == "list": - from hermes_cli.banner import get_available_skills - - return _ok(rid, {"skills": get_available_skills()}) - if action == "search": - from tools.skills_hub import ( - GitHubAuth, - create_source_router, - unified_search, - ) - - raw = ( - unified_search( - query, - create_source_router(GitHubAuth()), - source_filter="all", - limit=20, - ) - or [] - ) - return _ok( - rid, - { - "results": [ - {"name": r.name, "description": r.description} for r in raw - ] - }, - ) - if action == "install": - from hermes_cli.skills_hub import do_install - - class _Q: - def print(self, *a, **k): - pass - - do_install(query, skip_confirm=True, console=_Q()) - return _ok(rid, {"installed": True, "name": query}) - if action == "browse": - from hermes_cli.skills_hub import browse_skills - - pg = int(params.get("page", 0) or 0) or ( - int(query) if query.isdigit() else 1 - ) - return _ok( - rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20))) - ) - if action == "inspect": - from hermes_cli.skills_hub import inspect_skill - - return _ok(rid, {"info": inspect_skill(query) or {}}) + handler = { + "list": _skills_list, + "search": _skills_search, + "install": _skills_install, + "browse": _skills_browse, + "inspect": _skills_inspect, + }.get(action) + if handler is None: return _err(rid, 4017, f"unknown skills action: {action}") - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - if token is not None: - try: - from hermes_constants import reset_hermes_home_override - - reset_hermes_home_override(token) - except Exception: - pass - - -@method("mcp.catalog") -def _(rid, params: dict) -> dict: - """Bundled MCP catalog with per-profile install/enable state. - - Params: optional ``profile`` (defaults to the launch profile). Result: - ``{servers: [{name, description, installed, enabled, requires: [env - keys], transport}]}`` — the same catalog `hermes mcp` offers, so - capability UIs can present the full menu and know which entries need - setup (missing requires) before they'll work. - """ - profile = str(params.get("profile") or "").strip() - token = None - try: - if profile: - from hermes_cli.profiles import get_profile_dir - from hermes_constants import set_hermes_home_override - - profile_dir = get_profile_dir(profile) - if not profile_dir or not profile_dir.is_dir(): - return _err(rid, 4064, f"profile '{profile}' not found") - token = set_hermes_home_override(str(profile_dir)) - - from hermes_cli import mcp_catalog - - out = [] - for entry in mcp_catalog.list_catalog(): - try: - requires = [str(k) for k in (getattr(entry, "env_keys", None) or [])] - except Exception: - requires = [] - out.append( - { - "name": entry.name, - "description": getattr(entry, "description", "") or "", - "installed": bool(mcp_catalog.is_installed(entry.name)), - "enabled": bool(mcp_catalog.is_enabled(entry.name)), - "requires": requires, - # TransportSpec object — reduce to its kind string. - "transport": str( - getattr(getattr(entry, "transport", None), "kind", "") - or getattr(entry, "transport", "") - or "stdio" - ), - } - ) - return _ok(rid, {"servers": out}) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - if token is not None: - try: - from hermes_constants import reset_hermes_home_override - - reset_hermes_home_override(token) - except Exception: - pass - - -# ─── Per-profile MCP server lifecycle (mcp.servers.*) ──────────────────────── -# -# Gateway RPCs mirroring the dashboard's REST surface -# (hermes_cli/web_routers/mcp.py) so a desktop plugin can manage MCP servers for -# ANY profile, not just the launch profile. Each accepts an optional ``profile`` -# param that scopes HERMES_HOME via set_hermes_home_override (omitted/None = the -# launch profile) in a try/finally, exactly like ``skills.manage`` / ``mcp.catalog``. -# All persistence reuses hermes_cli/mcp_config.py helpers — no logic is duplicated. -# Shared helpers (resolve_profile / reset_profile / summarize_server) live in -# tui_gateway.mcp_rpc_helpers and are imported at call time: these handlers are -# rebound onto server.py's globals at install time, so a plain module-level def -# here would not be reachable from the rebound handler body. - - -@method("mcp.servers.list") -def _(rid, params: dict) -> dict: - """List a profile's configured MCP servers. - - Params: optional ``profile``. Result: ``{servers: [{name, transport, url, - command, args, env (key names only), auth, oauth_tokens_present, enabled, - tools}]}``. Reuses ``mcp_config._get_mcp_servers`` under the home override. - """ - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from hermes_cli.mcp_config import _get_mcp_servers - - servers = _get_mcp_servers() - return _ok( - rid, - { - "servers": [ - _mcp_summarize_server(name, cfg) - for name, cfg in sorted(servers.items()) - ] - }, - ) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) - - -@method("mcp.servers.add") -def _(rid, params: dict) -> dict: - """Add/save an MCP server to a profile's config.yaml. - - Params: optional ``profile``, ``name`` (required), and EITHER: - - ``preset`` (a catalog preset id) → applied via ``_apply_mcp_preset``, or - - ``config`` (an mcp_servers entry dict: url/command/args/env/headers/ - auth/tools) → saved via ``_save_mcp_server``. - If ``bearer_token`` is given (header auth), it is written to the profile's - .env via ``_save_bearer_auth_token`` and only the safe ``Authorization`` - header template is persisted in config.yaml. - - Result: ``{ok: true, name, server: }``. Duplicate names error. - """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from hermes_cli.mcp_config import ( - _apply_mcp_preset, - _get_mcp_servers, - _save_bearer_auth_token, - _save_mcp_server, - ) - - if name in _get_mcp_servers(): - return _err(rid, 4090, f"server '{name}' already exists") - - preset = str(params.get("preset") or "").strip() - raw_cfg = params.get("config") - server_config: dict = dict(raw_cfg) if isinstance(raw_cfg, dict) else {} - - if preset: - # _apply_mcp_preset fills url/command/args from a known preset when - # transport details were omitted; it mutates server_config in place. - _apply_mcp_preset( - name, - preset_name=preset, - url=server_config.get("url"), - command=server_config.get("command"), - cmd_args=list(server_config.get("args") or []), - server_config=server_config, - ) - - if not server_config.get("url") and not server_config.get("command"): - return _err( - rid, - 4063, - "config must specify a 'url' (http) or 'command' (stdio), or a valid 'preset'", - ) - - bearer_token = params.get("bearer_token") - if bearer_token: - # Persist the secret in .env; store only the interpolation template. - server_config["headers"] = _save_bearer_auth_token(name, str(bearer_token)) - - if not _save_mcp_server(name, server_config): - return _err( - rid, - 4001, - f"server '{name}' rejected: suspicious command/args configuration", - ) - saved = _get_mcp_servers().get(name, server_config) - return _ok(rid, {"ok": True, "name": name, "server": _mcp_summarize_server(name, saved)}) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) - - -@method("mcp.servers.set_api_key") -def _(rid, params: dict) -> dict: - """Store a required API key / credential for a server in a profile. - - Params: optional ``profile``, ``name`` (required), ``value`` (required, - the secret), and optional ``env_var`` (defaults to the server's canonical - ``MCP__API_KEY`` key). The secret is written to that profile's .env - via ``save_env_value``; the config.yaml entry is updated to reference it — - a header template ``Authorization: Bearer ${ENV}`` for http servers, or an - ``env: {VAR: "${ENV}"}`` reference for stdio servers — matching how - ``cmd_mcp_configure`` / ``_save_bearer_auth_token`` wire secrets. - - Result: ``{ok: true, name, env_var, server: }``. - """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") - value = params.get("value") - if value is None or str(value) == "": - return _err(rid, 4063, "value required") - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from hermes_cli.config import load_config, save_config, save_env_value - from hermes_cli.mcp_config import ( - _bearer_auth_headers, - _env_key_for_server, - _get_mcp_servers, - _strip_bearer_prefix, - ) - - servers = _get_mcp_servers() - if name not in servers: - return _err(rid, 4064, f"server '{name}' not found") - - env_var = str(params.get("env_var") or "").strip() or _env_key_for_server(name) - - entry = servers[name] - if not isinstance(entry, dict): - return _err(rid, 4001, "malformed server config") - - if entry.get("url"): - # http/sse server: store a bearer token + Authorization template. - normalized = _strip_bearer_prefix(str(value)) - if not normalized or normalized.lower() == "bearer": - return _err(rid, 4063, "value is not a valid credential") - save_env_value(env_var, normalized) - if env_var == _env_key_for_server(name): - headers = _bearer_auth_headers(name) - else: - headers = {"Authorization": f"Bearer ${{{env_var}}}"} - entry["headers"] = headers - else: - # stdio server: reference the secret from the process env block. - save_env_value(env_var, str(value)) - env_block = entry.get("env") - if not isinstance(env_block, dict): - env_block = {} - env_block[env_var] = f"${{{env_var}}}" - entry["env"] = env_block - - cfg = load_config() - cfg.setdefault("mcp_servers", {})[name] = entry - save_config(cfg) - return _ok( - rid, - { - "ok": True, - "name": name, - "env_var": env_var, - "server": _mcp_summarize_server(name, entry), - }, - ) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) - - -@method("mcp.servers.test") -def _(rid, params: dict) -> dict: - """Probe a profile's MCP server: connect, list tools, disconnect. - - Params: optional ``profile``, ``name`` (required). Result on success: - ``{ok: true, tools: [{name, description}], prompts, resources, - oauth_tokens_present}``. On failure: ``{ok: false, error, tools: [], - oauth_needed}``. Reuses ``mcp_config._probe_single_server`` + - ``_oauth_tokens_present`` — same logic as the /test dashboard route. - - Runs on the RPC thread pool (see _LONG_HANDLERS): a cold stdio `npx` - spawn can block for many seconds. - """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from hermes_cli.mcp_config import ( - _get_mcp_servers, - _oauth_tokens_present, - _probe_single_server, - ) - - servers = _get_mcp_servers() - if name not in servers: - return _err(rid, 4064, f"server '{name}' not found") - - cfg = servers[name] - # An `auth: oauth` server that serves tools/list anonymously would probe - # OK with no token — a false green. Require a token on disk for it. - needs_oauth_token = cfg.get("auth") == "oauth" - details: dict = {} - try: - tools = _probe_single_server(name, cfg, details=details) - token_present = _oauth_tokens_present(name) if needs_oauth_token else True - except Exception as exc: - return _ok( - rid, - { - "ok": False, - "error": str(exc), - "tools": [], - "oauth_needed": needs_oauth_token, - "oauth_tokens_present": _oauth_tokens_present(name) - if needs_oauth_token - else None, - }, - ) - if not token_present: - return _ok( - rid, - { - "ok": False, - "error": "OAuth authentication required — no token found.", - "tools": [], - "oauth_needed": True, - "oauth_tokens_present": False, - }, - ) - return _ok( - rid, - { - "ok": True, - "tools": [{"name": t, "description": d} for t, d in tools], - "prompts": details.get("prompts", 0), - "resources": details.get("resources", 0), - "oauth_needed": needs_oauth_token, - "oauth_tokens_present": True if needs_oauth_token else None, - }, - ) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) - - -@method("mcp.servers.remove") -def _(rid, params: dict) -> dict: - """Remove a server from a profile's config.yaml. - - Params: optional ``profile``, ``name`` (required). Result: - ``{ok: true, removed: bool}``. Reuses ``mcp_config._remove_mcp_server``. - """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from hermes_cli.mcp_config import _remove_mcp_server - - removed = _remove_mcp_server(name) - if not removed: - return _err(rid, 4064, f"server '{name}' not found") - return _ok(rid, {"ok": True, "removed": True}) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) - - -@method("mcp.servers.oauth.start") -def _(rid, params: dict) -> dict: - """Begin a session-backed OAuth flow for an MCP server in a profile. - - Params: optional ``profile``, ``name`` (required), optional - ``client_redirect_uri``. Result: - ``{ok: true, session_id, auth_url, flow: "pkce"}``. - - The client (desktop) opens ``auth_url`` in the native browser - (``window.hermesDesktop.openExternal``) and then polls - ``mcp.servers.oauth.poll`` with the returned ``session_id`` until - ``status == "approved"``. This mirrors the provider-OAuth start/poll model - (``/api/providers/oauth/{id}/start`` + ``/poll``): a background worker drives - the SAME interactive MCP OAuth machinery ``hermes mcp login`` uses - (``_probe_single_server`` under ``force_interactive_oauth``), and a loopback - listener captures the browser redirect — no FastAPI request object needed. - - ``client_redirect_uri`` (remote backends): a loopback URL the CLIENT hosts - on its own machine (``http://127.0.0.1:/callback``). When supplied, - the gateway binds NO listener — the provider redirects to the client's - listener and the client relays the code via ``mcp.servers.oauth.callback``. - This is the only flow that works when the desktop app and the gateway run - on different machines (SSH/Tailscale remote backend), where the gateway's - own 127.0.0.1 listener is unreachable from the user's browser. - - Runs on the RPC thread pool (see _LONG_HANDLERS): start blocks briefly for - the authorization URL to be published. - """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") - client_redirect_uri = str(params.get("client_redirect_uri") or "").strip() or None - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from hermes_cli.mcp_config import _get_mcp_servers - from hermes_constants import get_hermes_home - from tui_gateway import mcp_oauth_sessions - - servers = _get_mcp_servers() - if name not in servers: - return _err(rid, 4064, f"server '{name}' not found") - cfg = dict(servers[name]) - if not cfg.get("url"): - return _err( - rid, 4001, "stdio servers authenticate via env keys, not OAuth" - ) - if cfg.get("headers") and cfg.get("auth") != "oauth": - return _err( - rid, 4001, "this server uses header/API-key auth, not OAuth" - ) - cfg["auth"] = "oauth" - - hermes_home = str(get_hermes_home().expanduser().resolve(strict=False)) - result = mcp_oauth_sessions.start_flow( - hermes_home, name, cfg, client_redirect_uri=client_redirect_uri - ) - return _ok( - rid, - { - "ok": True, - "session_id": result["session_id"], - "auth_url": result["auth_url"], - "flow": result["flow"], - }, - ) - except ValueError as e: - return _err(rid, 4001, str(e)) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) - - -@method("mcp.servers.oauth.poll") -def _(rid, params: dict) -> dict: - """Poll a session-backed MCP OAuth flow. - - Params: optional ``profile``, ``name`` (required), ``session_id`` (required, - from ``mcp.servers.oauth.start``). Result: ``{ok: true, status: - "pending"|"approved"|"error", error_message?, auth_url?, tools?}``. - - On ``approved`` the OAuth tokens have been persisted for that server in that - profile (verified via ``_oauth_tokens_present`` inside the worker). The - profile scope is applied here too so a same-profile reconnect / token read - resolves correctly. - """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") - session_id = str(params.get("session_id") or "").strip() - if not session_id: - return _err(rid, 4063, "session_id required") - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from tui_gateway import mcp_oauth_sessions - - result = mcp_oauth_sessions.poll_flow(session_id, name) - return _ok(rid, {"ok": True, **result}) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) - - -@method("mcp.servers.oauth.callback") -def _(rid, params: dict) -> dict: - """Relay a client-captured OAuth redirect into a running MCP OAuth flow. - - Remote-backend companion to ``mcp.servers.oauth.start`` with - ``client_redirect_uri``: the desktop app's local loopback listener caught - the provider redirect on the user's machine and forwards its query params - here. Params: optional ``profile``, ``name`` (required), ``session_id`` - (required), ``code``, ``state``, ``error``. Result: ``{ok: true}`` once the - callback is accepted (state verified inside the flow bridge), or - ``{ok: false, error_message}`` on mismatch/expiry. - """ - name = str(params.get("name") or "").strip() - if not name: - return _err(rid, 4063, "name required") - session_id = str(params.get("session_id") or "").strip() - if not session_id: - return _err(rid, 4063, "session_id required") - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from tui_gateway import mcp_oauth_sessions - - result = mcp_oauth_sessions.deliver_callback_flow( - session_id, - name, - code=str(params.get("code") or "") or None, - state=str(params.get("state") or "") or None, - error=str(params.get("error") or "") or None, - ) - return _ok(rid, result) - except Exception as e: - return _err(rid, 5024, str(e)) - finally: - _mcp_reset_profile(token) + return handler(rid, params, query) @method("skills.reload") @@ -2509,138 +1709,370 @@ def _(rid, params: dict) -> dict: return _err(rid, 5025, str(e)) -@method("plugins.manage") +# ─── MCP catalog + per-profile server lifecycle (mcp.servers.*) ───────────── +# +# Gateway mirrors of the dashboard REST surface (hermes_cli/web_routers/mcp.py) so +# a desktop plugin can manage MCP servers for ANY profile. Persistence reuses +# hermes_cli/mcp_config.py; summaries come from tui_gateway.mcp_rpc_helpers. + + +@method("mcp.catalog") +@_profile_scoped_rpc(5024) def _(rid, params: dict) -> dict: - """List installed plugins with activation state, or toggle one on/off. + """Bundled MCP catalog with per-profile install/enable state: ``{servers: + [{name, description, installed, enabled, requires: [env keys], transport}]}`` + — the same menu `hermes mcp` offers, so UIs know which entries need setup.""" + from hermes_cli import mcp_catalog - Backs the TUI Plugins Hub. Uses the same disk-discovery + enable/disable - primitives as ``hermes plugins`` / the dashboard, so the three surfaces - agree on what's installed and what's enabled. + out = [] + for entry in mcp_catalog.list_catalog(): + try: + requires = [str(k) for k in (getattr(entry, "env_keys", None) or [])] + except Exception: + requires = [] + out.append( + { + "name": entry.name, + "description": getattr(entry, "description", "") or "", + "installed": bool(mcp_catalog.is_installed(entry.name)), + "enabled": bool(mcp_catalog.is_enabled(entry.name)), + "requires": requires, + # TransportSpec object — reduce to its kind string. + "transport": str( + getattr(getattr(entry, "transport", None), "kind", "") or getattr(entry, "transport", "") or "stdio" + ), + } + ) + return _ok(rid, {"servers": out}) - Actions: - - ``list`` → {"plugins": [{name, key, version, description, source, - status, portable}], "user_count": N, "bundled_count": M} - - ``toggle`` → flip ``key`` (or ``name``) based on ``enable`` (bool). - Returns the refreshed row plus {"ok", "unchanged"}. - - ``install`` → git-clone into ``~/.hermes/plugins/`` (non-interactive). - Params: ``identifier`` or ``repo``, optional ``force``, - ``enable`` (default True). Returns dashboard install dict. - Accepts an optional ``profile`` param (same contract as mcp.servers.*): - plugins live under each profile's HERMES_HOME, so a client can list or - toggle another profile's plugins without switching the whole app. - """ - action = params.get("action", "list") - token, err = _mcp_resolve_profile(rid, params) - if err: - return err - try: - from hermes_cli.plugins_cmd import ( - _bundled_default_on, - _discover_all_plugins, - _get_disabled_set, - _get_enabled_set, - _is_portable_plugin_dir, - _plugin_status, +@method("mcp.servers.list") +@_profile_scoped_rpc(5024, catch_resolve=False) +def _(rid, params: dict) -> dict: + """``{servers: [{name, transport, url, command, args, env (key names only), + auth, oauth_tokens_present, enabled, tools}]}`` for the scoped profile.""" + from hermes_cli.mcp_config import _get_mcp_servers + + servers = _get_mcp_servers() + return _ok(rid, {"servers": [_mcp_summarize_server(name, cfg) for name, cfg in sorted(servers.items())]}) + + +@method("mcp.servers.add") +@_mcp_server_scoped +def _(rid, params: dict) -> dict: + """Add a server to the profile's config.yaml. ``name`` plus EITHER ``preset`` + (catalog id, via ``_apply_mcp_preset``) or ``config`` (url/command/args/env/ + headers/auth/tools). ``bearer_token`` goes to the profile's .env; only the + ``Authorization`` header template is persisted. Duplicate names → 4090.""" + from hermes_cli.mcp_config import _apply_mcp_preset, _get_mcp_servers, _save_bearer_auth_token, _save_mcp_server + + name = str(params.get("name") or "").strip() + if name in _get_mcp_servers(): + return _err(rid, 4090, f"server '{name}' already exists") + + preset = str(params.get("preset") or "").strip() + raw_cfg = params.get("config") + server_config: dict = dict(raw_cfg) if isinstance(raw_cfg, dict) else {} + + if preset: + # Fills url/command/args from the preset when omitted; mutates server_config in place. + _apply_mcp_preset( + name, + preset_name=preset, + url=server_config.get("url"), + command=server_config.get("command"), + cmd_args=list(server_config.get("args") or []), + server_config=server_config, ) - def _rows(): - enabled = _get_enabled_set() - disabled = _get_disabled_set() - out = [] - for name, version, desc, source, _dir, key in sorted( - _discover_all_plugins() - ): - status = _plugin_status(name, enabled, disabled, key=key) - # Bundled backends/platforms/providers are active without an - # explicit enable (they "just work" — plugins.py). Reporting - # them "not enabled" reads as OFF in clients when they are in - # fact running; surface the truthful default instead. - if ( - status == "not enabled" - and source == "bundled" - and _bundled_default_on(_dir) - ): - status = "enabled" - out.append( - { - "name": name, - # Canonical registry key (e.g. ``image_gen/fal``). Names - # can collide across category dirs — both fal backends - # are named "fal" — so toggles must address the key. - "key": key, - "version": str(version or ""), - "description": desc or "", - "source": source, - "status": status, - # Agent Plugins v1 package (plugin.json — the portable - # skills/MCP format) vs a native Hermes plugin. - "portable": _is_portable_plugin_dir(_dir), - } - ) - return out + if not server_config.get("url") and not server_config.get("command"): + return _err(rid, 4063, "config must specify a 'url' (http) or 'command' (stdio), or a valid 'preset'") - if action == "list": - rows = _rows() - user_count = sum(1 for r in rows if r["source"] != "bundled") - return _ok( - rid, - { - "plugins": rows, - "user_count": user_count, - "bundled_count": len(rows) - user_count, - }, - ) + bearer_token = params.get("bearer_token") + if bearer_token: + server_config["headers"] = _save_bearer_auth_token(name, str(bearer_token)) - if action == "toggle": - from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled + if not _save_mcp_server(name, server_config): + return _err(rid, 4001, f"server '{name}' rejected: suspicious command/args configuration") + saved = _get_mcp_servers().get(name, server_config) + return _ok(rid, {"ok": True, "name": name, "server": _mcp_summarize_server(name, saved)}) - # Prefer the canonical key — bare names are ambiguous when two - # category plugins share one (image_gen/fal vs video_gen/fal). - ident = (params.get("key") or params.get("name") or "").strip() - if not ident: - return _err(rid, 4019, "plugins.toggle requires a 'key' or 'name'") - enable = bool(params.get("enable")) - result = dashboard_set_agent_plugin_enabled(ident, enabled=enable) - if not result.get("ok"): - return _err(rid, 5026, result.get("error") or "toggle failed") - row = next( - (r for r in _rows() if ident in (r["key"], r["name"])), None - ) - return _ok( - rid, - { - "ok": True, - "unchanged": bool(result.get("unchanged")), - "name": ident, - "plugin": row, - }, - ) - if action == "install": - from hermes_cli.plugins_cmd import dashboard_install_plugin +@method("mcp.servers.set_api_key") +@_profile_scoped_rpc(5024, required=(("name", _stripped), ("value", _nonempty)), catch_resolve=False) +def _(rid, params: dict) -> dict: + """Store a credential for a server: the secret goes to the profile's .env under + ``env_var`` (default ``MCP__API_KEY``); config.yaml gets a reference — + ``Authorization: Bearer ${ENV}`` header for http, ``env: {VAR: "${ENV}"}`` for + stdio — matching ``cmd_mcp_configure`` / ``_save_bearer_auth_token``.""" + from hermes_cli.config import load_config, save_config, save_env_value + from hermes_cli.mcp_config import _bearer_auth_headers, _env_key_for_server, _get_mcp_servers, _strip_bearer_prefix - ident = ( - params.get("identifier") or params.get("repo") or "" - ).strip() - if not ident: - return _err( - rid, 4019, "plugins.install requires 'identifier' or 'repo'" - ) - result = dashboard_install_plugin( - ident, - force=bool(params.get("force")), - enable=params.get("enable", True), - ) - if not result.get("ok"): - return _err(rid, 5026, result.get("error") or "install failed") - return _ok(rid, result) + name = str(params.get("name") or "").strip() + value = params.get("value") + servers = _get_mcp_servers() + if name not in servers: + return _err(rid, 4064, f"server '{name}' not found") + env_var = str(params.get("env_var") or "").strip() or _env_key_for_server(name) + + entry = servers[name] + if not isinstance(entry, dict): + return _err(rid, 4001, "malformed server config") + + if entry.get("url"): + normalized = _strip_bearer_prefix(str(value)) + if not normalized or normalized.lower() == "bearer": + return _err(rid, 4063, "value is not a valid credential") + save_env_value(env_var, normalized) + if env_var == _env_key_for_server(name): + headers = _bearer_auth_headers(name) + else: + headers = {"Authorization": f"Bearer ${{{env_var}}}"} + entry["headers"] = headers + else: + save_env_value(env_var, str(value)) + env_block = entry.get("env") + if not isinstance(env_block, dict): + env_block = {} + env_block[env_var] = f"${{{env_var}}}" + entry["env"] = env_block + + cfg = load_config() + cfg.setdefault("mcp_servers", {})[name] = entry + save_config(cfg) + return _ok(rid, {"ok": True, "name": name, "env_var": env_var, "server": _mcp_summarize_server(name, entry)}) + + +@method("mcp.servers.test") +@_mcp_server_scoped +def _(rid, params: dict) -> dict: + """Connect, list tools, disconnect (``_probe_single_server``). Success: + ``{ok, tools, prompts, resources, oauth_needed, oauth_tokens_present}``; + failure: ``{ok: false, error, tools: [], oauth_needed}``. Runs on the RPC + pool (_LONG_HANDLERS): a cold stdio `npx` spawn can block for seconds.""" + from hermes_cli.mcp_config import _get_mcp_servers, _oauth_tokens_present, _probe_single_server + + name = str(params.get("name") or "").strip() + servers = _get_mcp_servers() + if name not in servers: + return _err(rid, 4064, f"server '{name}' not found") + + cfg = servers[name] + # An `auth: oauth` server that serves tools/list anonymously would probe OK + # with no token — a false green. Require a token on disk for it. + needs_oauth_token = cfg.get("auth") == "oauth" + details: dict = {} + try: + tools = _probe_single_server(name, cfg, details=details) + token_present = _oauth_tokens_present(name) if needs_oauth_token else True + except Exception as exc: + return _ok( + rid, + { + "ok": False, + "error": str(exc), + "tools": [], + "oauth_needed": needs_oauth_token, + "oauth_tokens_present": _oauth_tokens_present(name) if needs_oauth_token else None, + }, + ) + if not token_present: + return _ok( + rid, + { + "ok": False, + "error": "OAuth authentication required — no token found.", + "tools": [], + "oauth_needed": True, + "oauth_tokens_present": False, + }, + ) + return _ok( + rid, + { + "ok": True, + "tools": [{"name": t, "description": d} for t, d in tools], + "prompts": details.get("prompts", 0), + "resources": details.get("resources", 0), + "oauth_needed": needs_oauth_token, + "oauth_tokens_present": True if needs_oauth_token else None, + }, + ) + + +@method("mcp.servers.remove") +@_mcp_server_scoped +def _(rid, params: dict) -> dict: + """Remove a server from the profile's config.yaml → ``{ok: true, removed: true}``.""" + from hermes_cli.mcp_config import _remove_mcp_server + + name = str(params.get("name") or "").strip() + if not _remove_mcp_server(name): + return _err(rid, 4064, f"server '{name}' not found") + return _ok(rid, {"ok": True, "removed": True}) + + +@method("mcp.servers.oauth.start") +@_mcp_server_scoped +def _(rid, params: dict) -> dict: + """Begin a session-backed OAuth flow → ``{ok, session_id, auth_url, flow: "pkce"}``. + + The client opens ``auth_url`` in the native browser and polls + ``mcp.servers.oauth.poll`` until ``status == "approved"``. A background + worker drives the same interactive machinery as ``hermes mcp login`` + (``_probe_single_server`` under ``force_interactive_oauth``) with a loopback + listener for the redirect. ``client_redirect_uri`` (remote backends): the + CLIENT hosts the loopback and relays the code via + ``mcp.servers.oauth.callback`` — the only flow that works when desktop and + gateway are on different machines. Runs on the RPC pool (_LONG_HANDLERS).""" + name = str(params.get("name") or "").strip() + client_redirect_uri = str(params.get("client_redirect_uri") or "").strip() or None + try: + from hermes_cli.mcp_config import _get_mcp_servers + from hermes_constants import get_hermes_home + from tui_gateway import mcp_oauth_sessions + + servers = _get_mcp_servers() + if name not in servers: + return _err(rid, 4064, f"server '{name}' not found") + cfg = dict(servers[name]) + if not cfg.get("url"): + return _err(rid, 4001, "stdio servers authenticate via env keys, not OAuth") + if cfg.get("headers") and cfg.get("auth") != "oauth": + return _err(rid, 4001, "this server uses header/API-key auth, not OAuth") + cfg["auth"] = "oauth" + + hermes_home = str(get_hermes_home().expanduser().resolve(strict=False)) + result = mcp_oauth_sessions.start_flow(hermes_home, name, cfg, client_redirect_uri=client_redirect_uri) + except ValueError as e: + return _err(rid, 4001, str(e)) + return _ok( + rid, {"ok": True, "session_id": result["session_id"], "auth_url": result["auth_url"], "flow": result["flow"]} + ) + + +@method("mcp.servers.oauth.poll") +@_profile_scoped_rpc(5024, required=_NAME_SESSION, catch_resolve=False) +def _(rid, params: dict) -> dict: + """Poll a flow → ``{ok, status: pending|approved|error, error_message?, auth_url?, + tools?}``. On ``approved`` the tokens are persisted for that server/profile; + the profile scope applies here too so a same-profile token read resolves.""" + from tui_gateway import mcp_oauth_sessions + + name = str(params.get("name") or "").strip() + session_id = str(params.get("session_id") or "").strip() + result = mcp_oauth_sessions.poll_flow(session_id, name) + return _ok(rid, {"ok": True, **result}) + + +@method("mcp.servers.oauth.callback") +@_profile_scoped_rpc(5024, required=_NAME_SESSION, catch_resolve=False) +def _(rid, params: dict) -> dict: + """Relay a client-captured OAuth redirect (``code``/``state``/``error``) into a + running flow started with ``client_redirect_uri``. ``{ok: true}`` once + accepted (state verified in the flow bridge), else ``{ok: false, error_message}``.""" + from tui_gateway import mcp_oauth_sessions + + name = str(params.get("name") or "").strip() + session_id = str(params.get("session_id") or "").strip() + result = mcp_oauth_sessions.deliver_callback_flow( + session_id, + name, + code=str(params.get("code") or "") or None, + state=str(params.get("state") or "") or None, + error=str(params.get("error") or "") or None, + ) + return _ok(rid, result) + + +# ─── Plugins ───────────────────────────────────────────────────────────────── + + +def _plugin_rows() -> list[dict]: + from hermes_cli.plugins_cmd import ( + _bundled_default_on, + _discover_all_plugins, + _get_disabled_set, + _get_enabled_set, + _is_portable_plugin_dir, + _plugin_status, + ) + + enabled = _get_enabled_set() + disabled = _get_disabled_set() + out = [] + for name, version, desc, source, _dir, key in sorted(_discover_all_plugins()): + status = _plugin_status(name, enabled, disabled, key=key) + # Bundled backends/platforms/providers run without an explicit enable; + # report the truthful default instead of "not enabled" (reads as OFF). + if status == "not enabled" and source == "bundled" and _bundled_default_on(_dir): + status = "enabled" + out.append( + { + "name": name, + # Canonical registry key (``image_gen/fal``): names collide across + # category dirs, so toggles must address the key. + "key": key, + "version": str(version or ""), + "description": desc or "", + "source": source, + "status": status, + # Agent Plugins v1 package (plugin.json) vs a native Hermes plugin. + "portable": _is_portable_plugin_dir(_dir), + } + ) + return out + + +def _plugins_list(rid, params): + rows = _plugin_rows() + user_count = sum(1 for r in rows if r["source"] != "bundled") + return _ok(rid, {"plugins": rows, "user_count": user_count, "bundled_count": len(rows) - user_count}) + + +def _plugins_toggle(rid, params): + from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled + + # Prefer the canonical key — bare names are ambiguous across categories. + ident = (params.get("key") or params.get("name") or "").strip() + if not ident: + return _err(rid, 4019, "plugins.toggle requires a 'key' or 'name'") + result = dashboard_set_agent_plugin_enabled(ident, enabled=bool(params.get("enable"))) + if not result.get("ok"): + return _err(rid, 5026, result.get("error") or "toggle failed") + row = next((r for r in _plugin_rows() if ident in (r["key"], r["name"])), None) + return _ok(rid, {"ok": True, "unchanged": bool(result.get("unchanged")), "name": ident, "plugin": row}) + + +def _plugins_install(rid, params): + from hermes_cli.plugins_cmd import dashboard_install_plugin + + ident = (params.get("identifier") or params.get("repo") or "").strip() + if not ident: + return _err(rid, 4019, "plugins.install requires 'identifier' or 'repo'") + result = dashboard_install_plugin(ident, force=bool(params.get("force")), enable=params.get("enable", True)) + if not result.get("ok"): + return _err(rid, 5026, result.get("error") or "install failed") + return _ok(rid, result) + + +@method("plugins.manage") +@_profile_scoped_rpc(5026, catch_resolve=False) +def _(rid, params: dict) -> dict: + """TUI Plugins Hub backend, sharing discovery + enable/disable primitives with + ``hermes plugins`` and the dashboard. + - ``list`` → {plugins: [{name, key, version, description, source, status, + portable}], user_count, bundled_count} + - ``toggle`` → flip ``key`` (or ``name``) per ``enable``; returns the row + {ok, unchanged} + - ``install`` → git-clone ``identifier``/``repo`` into ~/.hermes/plugins/ + (``force``, ``enable`` default True); returns the dashboard dict. + Optional ``profile`` scopes to that profile's HERMES_HOME (mcp.servers.* contract).""" + action = params.get("action", "list") + handler = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install}.get(action) + if handler is None: return _err(rid, 4017, f"unknown plugins action: {action}") - except Exception as e: - return _err(rid, 5026, str(e)) - finally: - _mcp_reset_profile(token) + return handler(rid, params) @method("shell.exec") @@ -2653,35 +2085,28 @@ def _(rid, params: dict) -> dict: is_hardline, hardline_desc = detect_hardline_command(cmd) if is_hardline: - return _err( - rid, 4005, f"blocked (hardline): {hardline_desc}. Use the agent for dangerous commands." - ) + return _err(rid, 4005, f"blocked (hardline): {hardline_desc}. Use the agent for dangerous commands.") is_dangerous, _, desc = detect_dangerous_command(cmd) if is_dangerous: - return _err( - rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands." - ) + return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.") except ImportError: return _err(rid, 5001, "shell.exec unavailable: approval safety module not importable") try: from hermes_cli._subprocess_compat import windows_hide_flags r = subprocess.run( - cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd(), - # Force UTF-8 + lossy decode so non-UTF-8 child output can't crash - # the gateway thread on locale-mismatched Windows (#53137). - encoding="utf-8", errors="replace", + cmd, + shell=True, + capture_output=True, + text=True, + timeout=30, + cwd=os.getcwd(), + encoding="utf-8", + errors="replace", # lossy decode: see cli.exec stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(), ) - return _ok( - rid, - { - "stdout": r.stdout[-4000:], - "stderr": r.stderr[-2000:], - "code": r.returncode, - }, - ) + return _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) except subprocess.TimeoutExpired: return _err(rid, 5002, "command timed out (30s)") except Exception as e: @@ -2689,5 +2114,5 @@ def _(rid, params: dict) -> dict: def register(server) -> None: - """Bind this module's handlers onto ``server``'s globals and registry.""" - _registry.install(server) + """Rebind this module's helpers + handlers onto ``server`` and register the handlers.""" + bind_module(globals(), server, skip=("_",)) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 52b0a72d38..bf66c328e3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9963,36 +9963,6 @@ def _coerce_seed_history(value: Any) -> list[dict]: return history -def _content_display_text(content: Any) -> str: - if content is None: - return "" - if isinstance(content, str): - return content - if isinstance(content, (int, float)): - return str(content) - if isinstance(content, list): - parts = [] - for part in content: - text = _content_display_text(part).strip() - if text: - parts.append(text) - return "\n".join(parts) - if isinstance(content, dict): - kind = content.get("type") - if kind in {"text", "input_text", "output_text"}: - return str(content.get("text") or content.get("content") or "") - if kind in {"image_url", "input_image", "image"}: - return "[image]" - if kind in {"input_audio", "audio"}: - return "[audio]" - if kind: - return f"[{kind}]" - if "text" in content: - return str(content.get("text") or "") - return "[structured content]" - return str(content) - - def _inflight_text(value: Any) -> str: return _content_display_text(value).strip() @@ -17185,13 +17155,6 @@ def _tts_stream_stop(user_barge: bool = True) -> None: pass -def _tts_stream_barge_in_monitor(stop: threading.Event, done: threading.Event) -> None: - """Deprecated shim — playback-only monitor replaced by the full-duplex - agent-turn listener (see ``_full_duplex_listener``). Kept as a name so - stray callers arm the new listener instead of a per-playback mic.""" - _arm_full_duplex_listener() - - # ── Full-duplex agent-turn listener (one mic, whole turn) ────────────────── # Replaces the per-playback barge monitors: those only opened the mic once # TTS playback started (deaf during LLM generation) and calibrated the VAD