How specialists are chosen¶
"Which agent handles this?" is answered at two different times, by two different mechanisms:
- Which specialists exist for this deployment, decided before any question is asked: by what is installed, what the operator allows, and what an admin switched on in the console.
- Which specialist answers a given request, decided per turn by the coordinator model, choosing among the specialists from step 1.
This page describes both, what each guarantees, and where each stops. The decision behind it, and the alternatives, are in ADR-005. How to write a specialist is in Adding a New Agent.
What a specialist declares¶
A specialist is a package with one entry point. It names a SpecialistSpec: the agent, and what it contributes to the rest of the platform.
specialist = SpecialistSpec(
agent=root_agent,
routing_hint="Kubernetes cluster info, nodes, pods, deployments, ...",
health_check=HealthCheck(
system="Kubernetes",
checks="...",
criteria="...",
tools=(get_cluster_info, get_nodes, get_events, list_pods),
),
remediation=Remediation(
actions=(restart_deployment, scale_deployment, rollback_deployment),
playbook="...",
verify=(get_deployment_status, list_pods, ...),
),
journal=None, # the ops journal declares Journal(save=save_note, log=log_operation)
setup_check=SetupCheck(label="Kubernetes", tool=get_cluster_info, hint="..."),
)
| Contribution | What uses it |
|---|---|
routing_hint | The coordinator's routing section: one line per enabled specialist |
health_check | Both sweeps: the chat incident_triage_agent and the batch workflow's <key>_health_checker |
remediation | The batch workflow's act → verify loop, which exists only if one is enabled |
journal | The coordinator's "want me to save this?" offer, and the batch workflow's journal step |
The coordinator imports no specialist. Everything it says about one, and every tool it reaches through one outside an AgentTool, comes from these declarations.
The mechanism, end to end¶
flowchart TB
subgraph Install["1. Installed: entry points (orrery.specialists)"]
S1[kafka: SpecialistSpec]
S2[k8s: SpecialistSpec]
S3[acme-redis: SpecialistSpec<br/>third party]
end
Install -->|"read metadata: keys unique, identifiers"| Env
Env["2. Allowed: ORRERY_SPECIALISTS / ORRERY_DISABLED_SPECIALISTS<br/>(operator, the ceiling; a plugin only if named)"] -->|"load + validate each spec"| Cat
Cat["3. Enabled: admin catalog<br/>(console: switch off, add declarative agents)"] -->|"build_chat_agent(enabled)"| Tree
subgraph Tree["Coordinator tree (immutable, rebuilt per catalog version)"]
R["routing section: one line per enabled routing_hint"]
T["incident_triage_agent: union of enabled HealthChecks"]
J["journal offer: only if a Journal is enabled"]
A["AgentTool per enabled specialist"]
end
Env -->|"at process start (not the catalog)"| Batch
subgraph Batch["Batch workflow (make run-triage)"]
C["one checker per HealthCheck"]
L["remediation loop: only the enabled Remediations"]
W["journal step: the enabled Journal's tools"]
end
Tree -->|"atomic swap; in-flight turns finish on the old tree"| Gateway
Gateway -->|"per turn: the model picks an AgentTool"| Answer Step 1: which specialists exist¶
| Layer | Who decides | How | Can it widen the one above? |
|---|---|---|---|
| Installed | Every distribution in the Python environment, transitive dependencies included | Entry points, group orrery.specialists | n/a |
| Allowed | The operator | ORRERY_SPECIALISTS / ORRERY_DISABLED_SPECIALISTS. Built-ins are allowed by default; a third-party specialist only when ORRERY_SPECIALISTS names it | No: an unknown name stops startup |
| Allowed tools | The operator | ORRERY_DISABLED_TOOLS: qualified key.tool names taken out of a running specialist | No: it only removes, and an unknown name stops startup |
| Enabled | An admin, at run time | PUT /admin/catalog (console Agents tab) | No: switching on an env-excluded specialist is refused |
The layers narrow. Two details matter:
- "Switched off" in the catalog removes the route, not the tools. The coordinator no longer delegates to that specialist and the sweep no longer checks it, but a declarative agent may still use its tools, each with its own guard level. This is deliberate: a narrow agent over a broad specialist's tools is a common reason to switch the broad one off. To take tools away entirely, exclude the specialist in the env, or a single tool with
ORRERY_DISABLED_TOOLS. - A removed tool is gone from everything the orchestrator builds.
ORRERY_DISABLED_TOOLSclones the specialist's agent without the tool before anything is built from it, so the coordinator, declarative agents, the admin catalog, the Studio and the triage sweep never see it. Another specialist holding the same function (a plugin re-exporting a built-in tool) loses it too.GuardrailsPluginrefuses a call to it (BLOCKED, which never opens a circuit) by function identity in case one is reached another way. Two limits: a specialist package run on its own (its ownadk web) never goes through discovery, so the tool is still there; and the specialist's instruction is not rewritten, so a prompt that names the removed tool still does. The model then gets ADK's "tool not found" for it, so prefer removing tools the instruction doesn't name. A tool the specialist's health check, remediation, journal or self-test uses cannot be removed: startup fails instead of silently shrinking the sweep. For example,kafka.get_consumer_lagfeeds the Kafka health check, so it stays. Naming a tool of a specialist that is not running also stops startup. - Startup fails closed. A stored catalog that no longer builds is served narrowed: its switch-offs that still apply, without its additions. A store that cannot be read at startup stops startup, rather than serving the env's defaults and quietly switching back on what an admin switched off.
Step 2: which specialist answers¶
The coordinator is a chat-mode LlmAgent. Each enabled specialist is wrapped as an AgentTool (agents-as-tools; see ADR-002/003), and the instruction's routing section lists one line per specialist: its routing hint. The model picks the tool. This step is probabilistic by design. What bounds it:
- a closed world: the routing section, the sweep and the journal offer are pure functions of the enabled set, so the coordinator is never told about a specialist it cannot call;
- one owner per description: the line the model routes on is written next to the specialist's tools, by the people who know them;
- one line each: a hint enters the prompt collapsed to a single line of at most 200 characters, so it cannot open a section of its own.
There is no coordinator routing eval yet: make eval scores each specialist on its own questions, not the coordinator's choice between them. Since routing hints are now written by each specialist's authors, including third parties, a routing eval generated from the enabled set (with a hint-injection case) is the missing check.
The two sweeps share declarations, not behaviour¶
A broad "is everything healthy?" in chat goes to incident_triage_agent; a scheduled sweep runs the batch workflow. Both are built from the same HealthChecks, so they ask the same questions with the same rubric. They are not the same machine:
| Chat sweep | Batch sweep | |
|---|---|---|
| Follows | The admin catalog (rebuilt on every edit) | The env, at process start |
| Shape | One agent holding every enabled check's tools, called in sequence in one context | One checker per system, in parallel, each in its own context |
| Budget | The turn's run budget (50 tool calls; about 20 today) | Per checker |
| Verdict | record_triage_verdict | The same, plus a text fallback and a per-system coverage record |
So switching Kubernetes off in the console stops the chat sweep from checking it, but a cron sweep keeps checking it until its env changes. And the chat sweep will meet its call budget at roughly ten sweepable specialists.
What the batch workflow may do¶
The workflow (ADR-003) is deterministic in structure, not in outcome: its graph follows what is installed and enabled, and both the decision to remediate and the target come from model output.
- Its checkers are one per enabled
HealthCheck. - Its remediation actor holds the enabled
Remediation.actions, guided by their playbooks, behind the confirmation gate; its verifier holds theirverifytools plus every enabled health-check tool. With noRemediationenabled (the Tirraflow lab runs Kafka and observability only) there is no loop in the graph, and the report says a human must act. - Its journal step uses the enabled
Journal's tools, and exists only if one is enabled. - Its outcome (
run_triage.py's exit code and JSON line) records, per system, whether its checker reported at all. A silent checker, a refused read, an inferred verdict, or a run with no system to check is "incomplete" (exit 2), never "complete" (0).
The console's first-run self-test¶
POST /onboarding/selftest answers "is each integration wired at all?" with one row per specialist the deployment runs (the env set) that declares a SetupCheck. The env set, not the catalog: a console switch-off removes a route, not the integration, whose tools declarative agents may still use. A specialist the env excludes is not probed, and its module is not imported for it. The self-test does not consult RBAC, which is why SetupCheck.tool is held to the same read-only rule as a health check's tools.
Trust model for third-party specialists¶
Entry points come from every distribution in the environment, so "installed" is not a decision anyone made about this deployment: a transitive dependency can register one. The rules:
- Installed is not trusted. A third-party specialist is listed (from its metadata, without importing it) but not loaded until
ORRERY_SPECIALISTSnames it. - No silent replacement. Two packages registering one key, or two specialists with one agent name, stop startup. A malformed key from a package nobody named is logged and ignored: metadata alone cannot stop startup.
- The built-ins' invariants, at load time. This repository's tests check that every agent holding a guarded tool has a confirmation gate, and that no tool named as a write (
looks_mutating) is undecorated. A plugin never meets those tests, so both checks run on every agent it can reach when it loads. An agent holding a toolset (MCP, OpenAPI) needs the gate too, since its tools are only known at run time. A plugin'sJournaltools get no exemption: the batch run calls them unattended, so if they are named as writes they must be guarded. - Contributions are checked like the built-ins'. Health-check and verify tools must be reads; remediation actions must be guarded; every listed tool must be the specialist's own.
What this does not do: naming a plugin runs its code, with the process's permissions. The checks catch the mistakes a well-meaning author makes (a forgotten decorator, a bare LlmAgent), not a malicious package, which could do harm at import time. Supply-chain controls (a pinned lockfile, a signed image) are what stand between a deployment and a malicious package.
"Built-in" is decided by distribution name (the workspace packages'). A package published under one of those names would be labelled built-in, but it would have to replace the workspace package in the environment to be installed at all, which the lockfile and image signing are there to prevent.
Security properties¶
- Read-only sweeps, enforced at startup. A
HealthCheck(and aRemediation.verify) may only list the specialist's own tools, none guarded and none named as a write. Sweeps run unattended: "take no action" in an instruction is not a control. - Every unattended action is gated. Remediation actions must be guarded, and the actor carries the confirmation gate, so on a run with nobody to answer each action stops at "confirmation required".
- No privilege from the browser. The catalog only narrows the env and composes reviewed tools. Editing needs the admin role and
ORRERY_ADMIN_EDITING(off by default). - Guards travel with tools. RBAC, the confirmation gate and autonomy levels read the tool's own decorator wherever it ends up: specialist, declarative agent, sweep or remediation loop.
- Audited. Every catalog save emits
agent_catalog_updatedwith the actor, version and resulting set.
Reliability properties¶
- Fail fast on bad configuration. An unknown specialist name, a duplicate or non-identifier key, a duplicate agent name, an entry point that is not an agent or spec, an invalid contribution, or two different tools under one name in an assembled agent stops startup with a message naming it.
- The served tree is always one that built. Build, then save, then swap.
- No lost edits. Concurrent admins get a conflict, not last-write-wins.
- No torn turns. Swapping the root never changes a turn already running, and a thread's session survives the swap (
core/tests/test_session_continuity.py). - Replicas converge by polling the catalog version (
ORRERY_CATALOG_POLL_SECONDS, default 15) when the store is Postgres. That is slow for an incident kill-switch; push notification is a possible improvement.
Failure modes¶
| What goes wrong | What happens |
|---|---|
| A specialist key is mistyped in an env list | Startup stops, naming the key and what is installed |
| A dependency installs an unexpected specialist | Listed as a plugin, not loaded |
| A plugin claims a built-in's key | Startup stops |
| A plugin's agent holds a guarded tool with no gate, or an undecorated write | Startup stops when it is named |
| A health check lists a write | Startup stops |
| The stored catalog names an uninstalled specialist or tool | Served narrowed: its switch-offs, without its additions |
| The catalog store is unreachable at startup | Startup stops |
| …or during a poll | The replica keeps its current tree and retries |
| A checker reports nothing, or a read is refused | The batch run exits 2 (incomplete) |
| The summarizer records no verdict | Severity inferred from the checkers' text, flagged; the run exits 2 |
| No enabled specialist can remediate | No loop; the report says a human must act |
| The coordinator routes to the wrong specialist | Not caught automatically yet (no routing eval) |
Patterns¶
| Pattern | Where |
|---|---|
| Plugin architecture (as pytest plugins) | Entry points: a specialist is a pip install, not a fork |
| Self-describing components (cf. A2A Agent Cards) | SpecialistSpec |
| Dependency inversion | The coordinator depends on the orrery_core.registry contract, never on a specialist package |
| Typed capabilities | Journal, Remediation, SetupCheck: a capability carries the tools that implement it |
| Default deny | A plugin is off until named; the loop holds only enabled, guarded actions |
| Validate by construction | A catalog's tree is built before it is saved |
| Optimistic concurrency | A stale catalog save gets 409 (like Kubernetes resourceVersion) |
| Immutable snapshot + atomic swap | Turns in flight finish on the tree they started on |