0025 — Unified delegate registry + hot-swappable management panel
Status: Accepted (sliced; PR1 = backend registry)
Context
protoAgent can already hand work to other agents three different ways, but they're split across three surfaces with three mental models:
- ACP coding agents —
code_with(thecoding_agentplugin, ADR 0024), configured incoding_agent.agentsYAML. - A2A peers —
peer_consult(tools/peer_tools.py), configured byPEER_<HANDLE>_URLenv vars only (no UI, no per-call auth beyond a token). - Another model/endpoint — not available as a delegate at all; the agent only has its own gateway model.
ORBIS (the protoLabs voice companion) solved this with a single delegate_to(target, query) tool over a unified delegate registry with three types — a2a / openai / acp — and a React management panel to add / edit / test / remove delegates live. Each type is an adapter exposing a field schema (drives a generic form), a probe() (the "Test" button), and a dispatch(). Changes write delegates.yaml atomically, the registry reloads, and live sessions re-register the tool — hot-swap, no restart.
The operator ask: "I want to hot-swap the agents and endpoints my agent can talk to." — i.e. bring ORBIS's delegate registry + panel to protoAgent.
What we already have that makes this cheap
protoAgent's _reload_langgraph_agent (Save & Reload / /api/config/reload) already re-runs every plugin's register() with the new config and rebuilds the graph's tools (server/agent_init.py). So config-driven tool config already hot-swaps without a restart — that's the engine ORBIS's registry.reload() + session-refresh provides. What's missing is the unified registry, a CRUD API, and the panel on top of it.
Decision
Build a unified delegate registry exposed as one delegate_to(target, query) tool, plus a hot-swappable management panel in the operator console.
Placement
- Backend — a first-party plugin
plugins/delegates(claims thedelegatesconfig section, registers thedelegate_totool, and mounts the CRUD REST router viaregister_router). Keeps core stable (operator-fork contract) and rides the existing plugin hot-reload. - Panel — lives in the core React console (
apps/web), because a custom panel (type picker, Test button, health badges) is richer than the generic settings-schema form and must be part of the React build. It talks to the plugin's REST routes.
This supersedes the separate coding_agent plugin (acp) and the env-based peer_tools (a2a): the acp adapter absorbs code_with's AcpClient; the a2a adapter absorbs peer_consult. code_with / peer_consult stay as thin, deprecated back-compat shims for one release, then are removed.
The model
delegates:
- name: helm # what the LLM passes to delegate_to(target=…)
type: a2a # a2a | openai | acp
description: Chief of staff — planning, fleet coordination.
url: https://helm.example/a2a
auth: { scheme: bearer } # secret value lives in secrets.yaml (below)
- name: opus
type: openai
description: Heavy reasoning model for deep analysis.
url: https://api.proto-labs.ai/v1
model: protolabs/reasoning
system_prompt: "Answer thoroughly but concisely."
- name: proto
type: acp
description: Terminal coding agent for this repo.
command: proto
args: ["--acp"]
workdir: ~/dev/my-repo
permissions: allowlist # carried over from ADR 0024One adapter per type, each providing:
config_schema()→ field specs (key/label/kind/required/help) that drive the generic panel form and server-side validation.parse(raw) → Delegate/validate(raw)— normalize + check.probe(delegate) → {ok, latency_ms, error}— the panel's Test button (a2a: GET agent-card; openai:/v1/modelsor a 1-token ping; acp: binary on PATH + workdir exists).dispatch(delegate, query) → str— the actual delegation (a2a: thepeer_toolsJSON-RPC+SSE path; openai: a/v1/chat/completionscall; acp: the ADR 0024AcpClient).
Secrets (decision: inline → secrets.yaml)
The panel takes a secret value (bearer token, API key) in a field and routes it to the gitignored config/secrets.yaml — the same handling as the Discord / Google plugin tokens (ADR 0019). Keyed per delegate (delegates.<name>.<field>), merged into the delegate config at load by the existing config_io secret overlay. Secrets are never returned in API responses or written to the tracked langgraph-config.yaml.
Hot-swap
Reuses protoAgent's native path: a CRUD write updates langgraph-config.yaml (delegates section) + secrets.yaml, then calls the existing reload (_reload_langgraph_agent) — which re-runs the plugin's register() with the new delegates config and rebuilds delegate_to. Next turn uses the new roster. No restart. (Routes/surfaces still mount once — but the CRUD router is static; only the registry contents change, which is config, not routes.)
REST API (PR2)
Mounted by the plugin router:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/delegates | list (+ configured flag + health) |
| POST | /api/delegates | create → write + reload |
| PUT | /api/delegates/{name} | update → write + reload |
| DELETE | /api/delegates/{name} | remove → write + reload |
| POST | /api/delegates/test | probe a (possibly unsaved) entry |
| GET | /api/delegate-types | type list + capabilities + field schema |
Slices
- PR1 (this ADR): backend registry +
delegate_to.delegatesconfig section, the registry, the three adapters (a2a/openai/acp — acp reusing the ADR 0024AcpClient), thedelegate_totool, config-driven hot-reload, tests. No API/panel yet; configured via YAML.code_with/peer_consultuntouched this slice. - PR2 (shipped): CRUD REST API — the endpoints above + config/secrets writes (inline secrets →
secrets.yamldelegate_secretsoverlay) + reload trigger +probe()for Test +/delegate-typesschema. Mounted by the plugin router at/api/delegates*(operator-console posture). - PR3 (shipped): React panel —
DelegatesSectioninapps/webunder Settings → Integrations (list + type picker + schema-driven form + Test button + secret handling). Surfaces the Integrations tab whenever the plugin is reachable even with no schema-driven integration enabled. e2e:delegates.spec.ts. - PR4 (shipped): health prober — a background surface probes every delegate periodically (fixed interval + initial delay) into a cache that
GET /api/delegatesmerges in; the panel shows a live health dot.code_with/peer_consultdeprecated (docstrings) in favor ofdelegate_to— still functional, removed in a future release.
Consequences
- One mental model — "manage the agents and endpoints my agent can talk to" — with a live panel, replacing three disjoint mechanisms.
- A real openai-endpoint delegate (ask another model) that didn't exist before.
- Migration:
coding_agent.agents→delegates: [{type: acp, …}];PEER_*_URLenv →delegates: [{type: a2a, …}]. Documented; shims ease the transition. - The one core touch is the React panel (PR3); the rest is a plugin.
Alternatives considered
- Extend
coding_agentonly / agents+a2a only — rejected by the operator in favor of full ORBIS parity (the panel should manage endpoints too). - Env-var-names-only secrets (ORBIS model) — rejected in favor of inline →
secrets.yamlfor parity with the existing Discord/Google token UX. - All-in-core — rejected; the backend fits the plugin seam (config + router + secrets), keeping core edits to just the React panel.