Files
hermes-agent/tools/connectors/operation.py
alt-glitch c2b13dc3d8 feat(desktop): every desktop chat can install catalog plugins; tools gated by session platform
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.

This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).

The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
  read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
  again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
  holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
  card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
  ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
  installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
  `already_installed`, `requires_hermes`); the card words them in English
  strings (translations follow later). Failure reasons stay raw text, and
  `detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
  the row's profile name is only a label, with no `'default'` fallback in
  Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
  `_enable_placed`.

Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
2026-10-07 01:01:14 +05:30

270 lines
12 KiB
Python

"""One backend-owned connection operation per ``manage_connections`` call. Pure data, no I/O."""
from __future__ import annotations
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, ClassVar, Dict, List, Optional
from tools.connectors.contract import Actor, SettleReason, TargetState, allowed, resolves
# Not a config key: a user-tunable wait with clamp rails was a foot-gun (PR1 shipped one, unmerged).
OPERATION_DEADLINE_SECONDS = 300.0
class IllegalTransition(ValueError):
pass
@dataclass
class Target:
name: str
kind: str
action: str
state: TargetState = TargetState.pending
detail: str = ""
instructions: str = ""
connect_url: Optional[str] = None
# The vendor account a managed mint created or observed. Not the desktop transport's connection id.
connection_id: Optional[str] = None
# Opaque per-attempt handle when the gateway mints one (absent today; the status route adds it).
attempt: Optional[str] = None
# Earliest the watcher may read this target's account again; set from a 429's Retry-After so a
# rate-limited route is not hammered once per second.
next_read_at: float = 0.0
# The credentials an MCP install still needs ({name, prompt, required}); the card draws a
# field per entry and holds its verb until every required one has text.
required_env: List[Dict[str, Any]] = field(default_factory=list)
# Fields a transition passes through to the model (``tools`` on a connected MCP target).
extra: Dict[str, Any] = field(default_factory=dict)
# The install step an ``initiated`` catalog row is in (an ``InstallPhase`` id); drawn only then.
phase: Optional[str] = None
@property
def resolved(self) -> bool:
return resolves(self.kind, self.state)
def snapshot(self, *, with_url: bool = True) -> Dict[str, Any]:
out: Dict[str, Any] = {"name": self.name, "kind": self.kind, "action": self.action, "state": self.state.value,
"resolved": self.resolved}
if self.phase and self.state == TargetState.initiated:
out["phase"] = self.phase
if self.detail:
out["detail"] = self.detail
if self.instructions:
out["instructions"] = self.instructions
if with_url and self.connect_url:
out["connect_url"] = self.connect_url
if self.connection_id:
out["connection_id"] = self.connection_id
if self.attempt:
out["attempt"] = self.attempt
if self.required_env:
out["required_env"] = self.required_env
out.update(self.extra)
return out
@dataclass
class ConnectionOperation:
# The gateway installs its ``connection.update`` emitter here once; pure data otherwise.
on_change: ClassVar[
Optional[Callable[["ConnectionOperation", Optional[Dict[str, Any]], Dict[str, Any]], None]]
] = None
targets: List[Target]
session_key: str = ""
# Stamped by ``live.open``: the profile home the operation was opened under.
profile_key: str = ""
# The model's id for the call that opened the operation; the card binds to that tool row only.
tool_call_id: Optional[str] = None
op_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
created_at: float = field(default_factory=time.time)
deadline_at: float = 0.0
settled_at: Optional[float] = None
settled_by: Optional[SettleReason] = None
# Monotonic write counter. Every frame carries the seq of the snapshot it was built from, so a
# renderer that keeps the highest seq per op can drop a frame that arrives after a newer one.
seq: int = 0
# Set on every transition and on settle; the waiting loop sleeps on it.
wake: threading.Event = field(default_factory=threading.Event, repr=False)
_settled_snapshot: Optional[Dict[str, Any]] = field(default=None, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def __post_init__(self) -> None:
if not self.deadline_at:
self.deadline_at = self.created_at + OPERATION_DEADLINE_SECONDS
def target(self, name: str) -> Optional[Target]:
return next((t for t in self.targets if t.name == name), None)
def transition(
self, name: str, to: TargetState, actor: Actor, *, detail: Optional[str] = None,
connect_url: Optional[str] = None, connection_id: Optional[str] = None, attempt: Optional[str] = None,
**extra: Any,
) -> Optional[Dict[str, Any]]:
"""Move one target; the contract decides whether ``actor`` may. Returns the change, or None
when the target is already in ``to``. Allowed after settlement: the frozen result stays."""
target = self.target(name)
if target is None:
raise IllegalTransition(f"unknown target {name!r}")
with self._lock:
if target.state == to:
return None
if allowed(target.kind, target.state, to) != actor:
raise IllegalTransition(f"{target.kind} {name}: {target.state.value} -> {to.value} by {actor.value}")
change = {"target": name, "from": target.state.value, "to": to.value, "actor": actor.value}
target.state = to
if detail is not None:
target.detail = detail
change["detail"] = target.detail
if connect_url is not None:
target.connect_url = connect_url
if connection_id is not None:
target.connection_id = connection_id
if attempt is not None:
target.attempt = attempt
if extra:
target.extra = dict(extra)
snapshot = self._bump_locked()
self.wake.set()
self._changed(change, snapshot)
return change
def refresh(self, name: str, *, connect_url: Optional[str], detail: str, actor: Actor = Actor.user,
phase: Optional[str] = None) -> None:
"""Replace a target's link, detail and install phase without a state change (a repeated
failure, or the next step of an install).
``actor`` says who produced the new text: a second failure of a backend attempt is the
backend's report, not the user's move, and the frame must not claim otherwise."""
target = self.target(name)
if target is None:
raise IllegalTransition(f"unknown target {name!r}")
with self._lock:
target.connect_url = connect_url
target.detail = detail
target.phase = phase
change = {"target": name, "from": target.state.value, "to": target.state.value, "actor": actor.value,
"detail": detail}
snapshot = self._bump_locked()
self.wake.set()
self._changed(change, snapshot)
def _bump_locked(self) -> Dict[str, Any]:
"""Advance the write counter and take the snapshot that frame carries. Both happen under
``_lock`` so a second writer cannot backdate this frame with its own state.
After settlement the frozen snapshot is the frame, seq included: a later write changes
nothing the renderer can see, so it must not leave the operation naming a seq no frame
carried (the resume snapshot would wait for a frame that never comes)."""
if self._settled_snapshot is None:
self.seq += 1
return self._result_locked()
def _changed(self, change: Optional[Dict[str, Any]], snapshot: Dict[str, Any]) -> None:
hook = type(self).on_change
if hook is not None:
hook(self, change, snapshot)
@property
def all_resolved(self) -> bool:
return bool(self.targets) and all(t.resolved for t in self.targets)
@property
def settled(self) -> bool:
return self.settled_at is not None
def remaining_seconds(self, now: Optional[float] = None) -> float:
return max(0.0, self.deadline_at - (time.time() if now is None else now))
def settle(self, by: SettleReason, now: Optional[float] = None) -> bool:
"""Compare-and-set: the first caller freezes the result."""
with self._lock:
if self.settled_at is not None:
return False
self.settled_at = time.time() if now is None else now
self.settled_by = by
for target in self.targets:
if not target.resolved:
target.state = TargetState.not_connected
self._settled_snapshot = self._bump_locked()
self.wake.set()
self._changed(None, self._settled_snapshot)
return True
def settle_if_all_resolved(self) -> bool:
return self.all_resolved and self.settle(SettleReason.all_resolved)
def _snapshot_locked(self, *, with_urls: bool = True) -> Dict[str, Any]:
return {
"op_id": self.op_id,
"seq": self.seq,
"deadline_at": self.deadline_at,
"settled_at": self.settled_at,
"settled_by": self.settled_by.value if self.settled_by else None,
"targets": [t.snapshot(with_url=with_urls) for t in self.targets],
}
def _result_locked(self, *, with_urls: bool = True) -> Dict[str, Any]:
if self._settled_snapshot is not None:
targets = [dict(t) for t in self._settled_snapshot["targets"]]
if not with_urls:
for t in targets:
t.pop("connect_url", None)
return dict(self._settled_snapshot, targets=targets)
return self._snapshot_locked(with_urls=with_urls)
def snapshot(self, *, with_urls: bool = True) -> Dict[str, Any]:
with self._lock:
return self._result_locked(with_urls=with_urls)
def result(self, *, with_urls: bool = True) -> Dict[str, Any]:
with self._lock:
result = self._result_locked(with_urls=with_urls)
for target in result["targets"]:
target.pop("connection_id", None)
target.pop("resolved", None) # the card's flag; the model reads state
if result.get("settled_at") is None:
return result
from tools.registry import registry
from tools.tool_search_catalog import build_catalog_listing_with_form
for target in result["targets"]:
# Setup instructions are the card's text for the user. A catalog entry's notes can be
# older than this flow ("restart your session to load the tools"), and the model would
# repeat them after a result that says the tools are callable now.
target.pop("instructions", None)
names = target.get("tools") if target.get("kind") == "mcp" else None
if not names:
continue
definitions = registry.get_definitions(set(names), quiet=True)
listing, _form = build_catalog_listing_with_form(definitions, max_tokens=1500)
if listing:
target["tools_listing"] = listing
return result
def request_payload(self) -> Dict[str, Any]:
"""The ``connection.request`` payload and the resume snapshot: identity, live target snapshots
(links included, the panel owns them), server-owned deadline."""
with self._lock:
targets = [t.snapshot() for t in self.targets]
seq = self.seq
payload: Dict[str, Any] = {
"op_id": self.op_id,
"seq": seq,
"deadline_at": self.deadline_at,
"timeout_seconds": OPERATION_DEADLINE_SECONDS,
"targets": targets,
}
if self.tool_call_id:
payload["tool_call_id"] = self.tool_call_id
return payload
class DetachedOperation(ConnectionOperation):
on_change = None