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`.
270 lines
12 KiB
Python
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
|