Files
2026-07-13 13:12:00 +08:00

203 lines
7.5 KiB
Python

"""Generic ACP-agent registry for ``omnigent setup`` and the runtime.
The generic ``acp`` harness (see :func:`omnigent.runtime.workflow._build_acp_spawn_env`
and :mod:`omnigent.inner.acp_harness`) drives *any* agent that speaks the Agent
Client Protocol. Which agents are available is pure user config: a list of named
commands in a dedicated top-level ``acp:`` block of ``~/.omnigent/config.yaml``::
acp:
agents:
- {name: Gemini CLI, command: gemini --experimental-acp}
- {name: Claude Code, command: npx -y @zed-industries/claude-code-acp}
- {name: Goose, command: goose acp, model: gpt-5.3}
Each agent gets a stable ``slug`` derived from its name; a picked
``acp:<slug>`` (carried in the spec, resolved at spawn) looks the command back up
here. Auth is each agent's own — Omnigent stores no credential, so unlike the
``providers:`` / ``cursor:`` blocks there is no secret reference. A dedicated
block (not the shared gateway ``auth:``) keeps these commands from being
mis-consumed by the SDK harnesses.
This module is pure read + settings-builder (mirroring
:mod:`omnigent.onboarding.cursor_auth`): the CLI orchestrates writes through
:func:`omnigent.cli._save_global_config` so there is no cli↔onboarding cycle.
"""
from __future__ import annotations
import re
import shutil
from dataclasses import dataclass
from omnigent.onboarding.provider_config import load_config
# The dedicated top-level config block and the list field inside it.
ACP_CONFIG_KEY = "acp"
_AGENTS_FIELD = "agents"
@dataclass(frozen=True)
class AcpAgentEntry:
"""One configured ACP agent.
:param slug: Stable id derived from :attr:`name` (see :func:`slugify`); the
addressable half of the ``acp:<slug>`` harness id.
:param name: Human display name, e.g. ``"Gemini CLI"``.
:param command: The command to launch, e.g. ``"gemini --experimental-acp"``.
:param model: Optional model id (only honored by agents that accept a model
in ``session/new``; see :class:`omnigent.inner.acp_executor.AcpAgentConfig`).
:param session_id_mode: ``"server"`` (default) or ``"client"``.
:param send_model: Send the model in ``session/new`` (Qwen-shaped agents).
"""
slug: str
name: str
command: str
model: str | None = None
session_id_mode: str = "server"
send_model: bool = False
def slugify(name: str) -> str:
"""Derive a stable, URL-safe slug from an agent name.
Lowercases, replaces runs of non-alphanumerics with a single ``-``, and
trims leading/trailing ``-``. Empty results fall back to ``"agent"`` so a
name of only punctuation still yields an addressable slug.
:param name: The agent's display name, e.g. ``"Gemini CLI"``.
:returns: The slug, e.g. ``"gemini-cli"``.
"""
slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-")
return slug or "agent"
def acp_agents(config: dict[str, object] | None = None) -> list[AcpAgentEntry]:
"""Return the configured ACP agents, each with a unique derived slug.
Reads the ``acp:`` block's ``agents`` list. Malformed entries (not a dict,
or missing ``name`` / ``command``) are skipped. Slugs are assigned in list
order; a collision (two names slugifying the same) gets a ``-2`` / ``-3`` …
suffix so every returned entry is uniquely addressable.
:param config: A pre-loaded config mapping; ``None`` loads
``~/.omnigent/config.yaml`` via :func:`load_config`.
:returns: The configured agents (possibly empty).
"""
cfg = load_config() if config is None else config
block = cfg.get(ACP_CONFIG_KEY)
if not isinstance(block, dict):
return []
raw_agents = block.get(_AGENTS_FIELD)
if not isinstance(raw_agents, list):
return []
entries: list[AcpAgentEntry] = []
seen: dict[str, int] = {}
for raw in raw_agents:
if not isinstance(raw, dict):
continue
name = raw.get("name")
command = raw.get("command")
if not isinstance(name, str) or not name.strip():
continue
if not isinstance(command, str) or not command.strip():
continue
base = slugify(name)
count = seen.get(base, 0) + 1
seen[base] = count
slug = base if count == 1 else f"{base}-{count}"
model = raw.get("model")
mode = raw.get("session_id_mode")
entries.append(
AcpAgentEntry(
slug=slug,
name=name.strip(),
command=command.strip(),
model=model.strip() if isinstance(model, str) and model.strip() else None,
session_id_mode=mode if mode in ("server", "client") else "server",
send_model=bool(raw.get("send_model", False)),
)
)
return entries
def resolve_acp_agent(slug: str, config: dict[str, object] | None = None) -> AcpAgentEntry | None:
"""Return the configured agent for *slug*, or ``None`` if not found.
:param slug: The slug half of an ``acp:<slug>`` harness id.
:param config: A pre-loaded config mapping; ``None`` loads the global config.
:returns: The matching :class:`AcpAgentEntry`, or ``None``.
"""
for entry in acp_agents(config):
if entry.slug == slug:
return entry
return None
def acp_agents_settings(entries: list[AcpAgentEntry]) -> dict[str, object]:
"""Build the ``{"acp": {"agents": [...]}}`` settings dict for persistence.
Handed to :func:`omnigent.cli._save_global_config` (a shallow update, so it
replaces the whole ``acp:`` block). Only the user-authored fields are
written back — the derived ``slug`` is not persisted.
:param entries: The full desired agent list (after an add/remove).
:returns: The settings dict to save.
"""
agents: list[dict[str, object]] = []
for e in entries:
item: dict[str, object] = {"name": e.name, "command": e.command}
if e.model:
item["model"] = e.model
if e.session_id_mode != "server":
item["session_id_mode"] = e.session_id_mode
if e.send_model:
item["send_model"] = True
agents.append(item)
return {ACP_CONFIG_KEY: {_AGENTS_FIELD: agents}}
def command_binary_on_path(command: str) -> bool:
"""Return whether a command's first token resolves on ``PATH``.
A soft check for the setup readout / readiness — the agent owns its own
install, so a missing binary is a *hint*, never a hard gate. Absolute /
relative paths are checked for existence directly.
:param command: The configured command, e.g. ``"gemini --experimental-acp"``.
:returns: ``True`` when the first token is runnable.
"""
import shlex
try:
parts = shlex.split(command)
except ValueError:
return False
if not parts:
return False
return shutil.which(parts[0]) is not None
@dataclass(frozen=True)
class AcpConfigSummary:
"""Readiness view of the ``acp:`` block for the setup readout."""
configured: bool
agents: tuple[AcpAgentEntry, ...]
@property
def count(self) -> int:
return len(self.agents)
def acp_config_summary(config: dict[str, object] | None = None) -> AcpConfigSummary:
"""Summarize the configured ACP agents for ``omnigent setup``.
:param config: A pre-loaded config mapping; ``None`` loads the global config.
:returns: An :class:`AcpConfigSummary` — ``configured`` is ``True`` iff at
least one agent is registered.
"""
entries = acp_agents(config)
return AcpConfigSummary(configured=bool(entries), agents=tuple(entries))