refactor(tui): resume — verified partial work (acp_adapter + methods_* compaction, bind_module via globals())
This commit is contained in:
+25
-44
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+160
-416
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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=("_",))
|
||||
|
||||
+427
-555
File diff suppressed because it is too large
Load Diff
+648
-1001
File diff suppressed because it is too large
Load Diff
+1862
-2467
File diff suppressed because it is too large
Load Diff
+1244
-1819
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user