refactor(tui): resume — verified partial work (acp_adapter + methods_* compaction, bind_module via globals())

This commit is contained in:
Teknium
2026-09-02 10:40:40 -07:00
parent 93fead86dd
commit beacf4e942
26 changed files with 7146 additions and 10897 deletions
+25 -44
View File
@@ -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
+68 -150
View File
@@ -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
+58 -125
View File
@@ -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)
+69 -155
View File
@@ -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
+32 -98
View File
@@ -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
+37 -62
View File
@@ -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": <dict>}}`` 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}}
+951 -1501
View File
File diff suppressed because it is too large Load Diff
+160 -416
View File
@@ -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
+524 -893
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -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(
+1 -2
View File
@@ -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():
-19
View File
@@ -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)
# ---------------------------------------------------------------------------
@@ -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
+80 -110
View File
@@ -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)
+23 -56
View File
@@ -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},
)
+209 -367
View File
@@ -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
+12 -8
View File
@@ -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)
+65 -74
View File
@@ -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)
+114 -173
View File
@@ -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})
+215 -376
View File
@@ -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]:
"""`@<profile>` completions: agent profiles as mentionable names (multi-agent
UIs and the Bot Mode plugin route `@<profile>` 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:
"""`@<prefix>:<query>` 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]:
"""`@<profile>` completions: agent profiles as mentionable names.
Multi-agent UIs (and the Bot Mode plugin) route `@<profile>` 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: `@<prefix>:<query>` 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 `@<prefix>:<query>` 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=("_",))
+322 -376
View File
@@ -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=("_",))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1244 -1819
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -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