The setup profile's `setup` toolset was empty. It now carries one tool, manage_catalog: - search: catalog plugins (the Plugins tab's live catalog resolver) and hub skills, with whether each is already installed in the default profile. Read-only. - install: opens the same connection operation manage_connections opens, with rows of kind plugin / skill. Nothing installs until the user approves a row. An approved row installs into `default` (or the profile the Advanced modal named) through dashboard_install_plugin / the hub's headless install, so the catalog pin, kill list, security scan and live activation (#119644) are the host's. The row settles with the live MCP tool names and the plugin's skill. The model sends catalog ids and an action only; every other key is refused before anything runs. An unknown id or a plugin this OS cannot run is drawn failed with the installer's own text. Anywhere a catalog card cannot be drawn (TUI, CLI, messaging, registry dispatch) the result is the `hermes plugins install` / `hermes skills install` pointer. - contract: plugin/skill targets follow the MCP transitions. - run.apply_answer / reissue route a card answer to the module that owns the operation. - tool_search: `setup` joins the direct-surface toolsets, so the guide's one tool is never deferred behind tool_search. - docs: tools reference, toolsets reference, plugin catalog page. Linear NS-964.
161 lines
6.0 KiB
Python
161 lines
6.0 KiB
Python
"""The one lifecycle every ``manage_connections`` operation follows, whatever the target kind.
|
|
|
|
``run_operation`` mints the op, registers it in ``live``, lets the kind prepare its targets (a
|
|
managed mint, an MCP catalog check), emits the card through the session callback, then loops:
|
|
sleep on ``op.wake`` for at most one tick, run the kind's ``observe`` hook, settle when every
|
|
target is resolved or the deadline passes. ``connection.respond`` reaches the loop by transitioning
|
|
the op through ``live`` and setting ``wake``; ``/stop`` sets the thread interrupt flag."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
|
|
from tools.connectors import live
|
|
from tools.connectors.contract import SettleReason
|
|
from tools.connectors.operation import ConnectionOperation, Target
|
|
|
|
UNKNOWN_TARGET = "unknown_target"
|
|
MIXED_KINDS = "mixed_kinds"
|
|
LINK_STILL_VALID = "link_still_valid"
|
|
SETTLED = "settled"
|
|
NOT_ALLOWED = "not_allowed"
|
|
REFUSED = "refused"
|
|
|
|
WATCH_INTERVAL_SECONDS = 5.0
|
|
# The interrupt flag has no wake hook, so the tick sleep is sliced and the flag read each slice.
|
|
_WAKE_SLICE_SECONDS = 0.25
|
|
|
|
Callback = Callable[[Dict[str, Any]], Optional[str]]
|
|
|
|
|
|
@dataclass
|
|
class Kind:
|
|
"""Per-kind hooks. ``prepare`` runs once before the card; ``observe`` runs every tick and may
|
|
transition targets; ``note`` is the model-facing guidance appended to the settled result."""
|
|
|
|
prepare: Callable[[ConnectionOperation], None]
|
|
observe: Callable[[ConnectionOperation], None]
|
|
note: str
|
|
|
|
|
|
def reissue(operation: ConnectionOperation, names: Sequence[str]) -> Optional[str]:
|
|
from tools.connectors.contract import Actor, TargetState, allowed
|
|
|
|
targets = [operation.target(name) for name in names]
|
|
if any(target is None for target in targets):
|
|
return UNKNOWN_TARGET
|
|
if len({target.kind for target in targets}) != 1:
|
|
return MIXED_KINDS
|
|
stale = [target.name for target in targets if target.state in (TargetState.failed, TargetState.expired)]
|
|
if len(stale) != len(targets):
|
|
return LINK_STILL_VALID
|
|
if operation.settled:
|
|
return SETTLED
|
|
if any(allowed(target.kind, target.state, TargetState.initiated) is None for target in targets):
|
|
return NOT_ALLOWED
|
|
if targets[0].kind == "connector":
|
|
from tools.connectors.managed import managed_client, mint
|
|
|
|
mint(managed_client(), operation, stale, reinitiate=True, actor=Actor.user)
|
|
return None
|
|
from tools.connectors import catalog
|
|
from tools.connectors.mcp import retry
|
|
|
|
rerun = catalog.retry if catalog.owns(operation.op_id) else retry
|
|
return REFUSED if rerun(operation, stale) else None
|
|
|
|
|
|
def apply_answer(operation: ConnectionOperation, raw: str) -> None:
|
|
"""Hand the card's answer to the module running this operation (a catalog install or an MCP
|
|
one); a managed operation's card only skips and continues, which the MCP fold also covers."""
|
|
from tools.connectors import catalog, mcp
|
|
|
|
(catalog.apply_answer if catalog.owns(operation.op_id) else mcp.apply_answer)(operation, raw)
|
|
|
|
|
|
def run_operation(
|
|
targets: List[Target],
|
|
kind: Kind,
|
|
*,
|
|
session_key: str,
|
|
tool_call_id: Optional[str],
|
|
connection_callback: Optional[Callback],
|
|
tick_seconds: Optional[float] = None,
|
|
with_urls_in_result: bool,
|
|
) -> str:
|
|
"""Block the tool thread until the operation settles; return the tool's JSON string."""
|
|
operation = ConnectionOperation(targets, session_key=session_key, tool_call_id=tool_call_id)
|
|
try:
|
|
live.open(operation)
|
|
except live.OperationAlreadyOpen as exc:
|
|
from tools.registry import tool_error
|
|
|
|
return tool_error(
|
|
f"a connection operation is already open in this session ({exc.existing.op_id}); it settles "
|
|
"when the user finishes with the card, on Continue, or at its deadline. Do not start another."
|
|
)
|
|
return drive_operation(
|
|
operation,
|
|
kind,
|
|
connection_callback=connection_callback,
|
|
tick_seconds=tick_seconds,
|
|
with_urls_in_result=with_urls_in_result,
|
|
)
|
|
|
|
|
|
def drive_operation(
|
|
operation: ConnectionOperation,
|
|
kind: Kind,
|
|
*,
|
|
connection_callback: Optional[Callback],
|
|
tick_seconds: Optional[float] = None,
|
|
with_urls_in_result: bool,
|
|
) -> str:
|
|
try:
|
|
kind.prepare(operation)
|
|
operation.settle_if_all_resolved()
|
|
if connection_callback is not None and not operation.settled:
|
|
connection_callback(operation.request_payload())
|
|
_watch(operation, kind, tick_seconds)
|
|
finally:
|
|
live.close(operation)
|
|
payload = operation.result(with_urls=with_urls_in_result)
|
|
payload["status"] = "settled"
|
|
payload["note"] = kind.note
|
|
return json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
def _watch(operation: ConnectionOperation, kind: Kind, tick_seconds: Optional[float]) -> None:
|
|
from tools.interrupt import is_interrupted
|
|
|
|
tick = WATCH_INTERVAL_SECONDS if tick_seconds is None else tick_seconds
|
|
while not operation.settled:
|
|
if is_interrupted():
|
|
operation.settle(SettleReason.interrupt)
|
|
return
|
|
if time.time() >= operation.deadline_at:
|
|
operation.settle(SettleReason.deadline)
|
|
return
|
|
kind.observe(operation)
|
|
# The card or the clock may have settled the op during the read; its result is frozen.
|
|
if operation.settled or operation.settle_if_all_resolved():
|
|
return
|
|
_sleep_until_wake(operation, tick)
|
|
|
|
|
|
def _sleep_until_wake(operation: ConnectionOperation, tick: float) -> None:
|
|
"""Sleep up to one tick, leaving early on ``wake``, the deadline, or the interrupt flag."""
|
|
from tools.interrupt import is_interrupted
|
|
|
|
until = min(time.time() + tick, operation.deadline_at)
|
|
while not operation.settled and not is_interrupted():
|
|
remaining = until - time.time()
|
|
if remaining <= 0:
|
|
break
|
|
if operation.wake.wait(min(_WAKE_SLICE_SECONDS, remaining)):
|
|
break
|
|
operation.wake.clear()
|