┓
┏┏┓┏┳┓┃┏┓┓┏
┛┗ ┛┗┗┗┗ ┗┫
┛
An autonomous SRE investigation agent, on rails.
A language model drives the investigation; a state machine governs what it may do; and every read runs through a typed, auditable tool surface the model cannot reach past. The thesis is "entrust, don't trust": the model decides where the investigation goes, while authority over its actions and verification of its conclusions stay outside it.
Describe an incident in plain language and watch it work:
semley › the web service on web1 is not responding
(proposes target=web1, scope="web service not responding", asks to confirm)
semley › yes
▸ step triage target=web1 scope=web service not responding hypothesis=...
▸ step read module=ansible.builtin.service_facts args={}
read e1 ansible.builtin.service_facts on web1
▸ step read module=ansible.builtin.listen_ports_facts args={}
read e2 ansible.builtin.listen_ports_facts on web1
▸ step conclude finding=... cited_evidence=['e1', 'e2']
A full session, captured live, is committed at recordings/host-investigation.txt.
Requires uv and Python 3.12+.
uv sync
Put an API key in a .env file at the repo root:
OPENAI_API_KEY=sk-... # the default model is gpt-5.4
The agent is vendor-agnostic: it speaks the OpenAI chat-completions protocol, so any
compatible endpoint works. OPENAI_API_KEY is the only required variable. For a
different vendor, also set OPENAI_BASE_URL (for example https://openrouter.ai/api/v1)
and SEMLEY_MODEL (for example anthropic/claude-sonnet-5).
The fastest check needs no model and no infrastructure:
make check
To see a real end-to-end investigation without standing up any target, read the committed recording. To run one live, stand up a demo target (see Demos) and run:
uv run semley --surface host
ARCHITECTURE.md covers the design in depth: the loop, the key decisions, and their
trade-offs. The short version.
Control and verification live outside the model:
- A Burr state machine is the investigation graph. The model advances it only by
calling one governed
steptool with an action name and inputs. An unreachable action is refused with the valid next actions, so a wrong move is recoverable. - The mount grounds every verdict:
concludemust cite a read that actually ran, or it is refused. The failing entity comes from the evidence, not from the code. - The durable record is a persisted state store, not the transcript. Cross-incident memory is a compact findings digest rendered from that store, so context stays flat across incidents instead of re-sending every prior investigation's raw evidence.
Reads are Ansible modules reflected into typed tools (via rocannon). The model picks
the module and writes its arguments, but only within the surface's curated read-only
set; the mount refuses anything outside it. The telemetry surface reads over uri, a
general HTTP module, kept read-only by allowing only GET and QUERY (see Demos).
The four pieces:
- Burr is the state-machine engine: the investigation graph, its actions, and the evidence-driven branching.
- Theodosia mounts that graph as a governed MCP server, exposing it through the
single
steptool and holding investigation state, with a SQLite state persister as the memory of record and a hash-chained trail as the audit ledger. - PydanticAI is the agent framework: model inference over the mounted server,
with the toolset filtered to exactly
stepandreset_session. - Rocannon reflects the curated Ansible modules into typed, read-only tools, attached in-process as the upstream tool server.
Theodosia is the only MCP server the model connects to, and it exposes exactly one
tool, step. Rocannon is not a second server the model picks tools from: it is mounted
in-process as an upstream that the graph's actions call. The model's action space is the
surface's read-only module set: it calls step(read, {module, args}), choosing the
module and filling in the arguments itself, and the mount refuses any module outside the
set. So the model drives the reads, but only within the modules the surface reflects.
Widening the action space means adding modules to a surface, not exposing a larger tool
menu.
The investigation is a short loop the model drives: triage fixes the target and states
the model's own hypothesis; read gathers evidence one module at a time, the model
choosing what to look at next from what it has already seen; and the model finishes with
conclude (a grounded verdict, citing the reads it relied on) or inconclusive (no
fault confirmed). A read that cannot dispatch is recorded uninvestigable, never a fault,
and an investigation that runs out of leads ends honestly rather than inventing one. The
loop is bounded by an iteration cap.
The banner lists the inventory hosts (or, on the cluster surface, the live namespaces) you can name. Describe an incident in plain language. The agent proposes a target and scope, waits for you to confirm, then drives the investigation to a conclusion.
Reading the stream:
▸ step <action>is one governed transition of the state machine.read <id> <module> on <target>is a real Ansible read that dispatched. The id (e1,e2, ...) tags the evidence the model reads and later cites.refused <reason>; valid: ...is the state machine rejecting an unreachable or ungrounded action and listing what is valid instead.- The closing panel is the model's grounded verdict:
confirmedorinconclusive. The evidence table lists every read, with a*on the ones the verdict cited.
After a conclusion, /playbook writes the recorded Ansible calls as a standard
playbook, /quit exits, and Tab completes the commands.
The fast checks need nothing but uv. The live agent needs an API key. Each demo target needs local infrastructure, which the bring-up scripts create if it is missing.
| To run | Requires |
|---|---|
make check (fast deterministic tests) |
uv and Python 3.12+ |
| the agent on any surface | the above, plus an API key for an OpenAI-compatible endpoint |
the host demo |
OrbStack (it provides the web1 Linux machine over SSH); macOS only |
the cluster and telemetry demos |
a running Docker engine (OrbStack provides one), kind, and kubectl |
On Linux, skip the host demo and use the cluster, telemetry, or localhost surfaces.
Ansible and the kubernetes client are installed by uv sync. The kubernetes.core
Ansible collection is installed by scripts/cluster-up.sh when you bring the cluster
up. You do not install them separately.
Each demo target has a bring-up script that provisions it and prints clear status, so the demo runs end to end from a clean machine:
make demo-host # host-up + inject a fault + investigate + restore the baseline
make demo-cluster # kind cluster + a faulted workload + investigate it
make demo-telemetry # Prometheus on the cluster + investigate a failing scrape target
make demo-localhost # investigate this control host
make inject # stop nginx on web1 (run the fault and the agent separately)
make heal # restore nginx on web1
make check # fast deterministic checks (no model, no infrastructure)
- host (primary, reliable):
scripts/host-up.shensures an OrbStack systemd machine (web1) running nginx;make injectstops it (enabled-but-stopped),make healrestores it. Inventory:inventory/hosts.ini. - cluster (control plane):
scripts/cluster-up.shstands up a kind cluster, thekubernetes.corecollection, and a workload stuck inImagePullBackOffin theshopnamespace;make cluster-downremoves it. The script switches your kubectl context tokind-semley. - telemetry (observability plane):
scripts/telemetry-up.shdeploys Prometheus in the kind cluster, scraping itself and acheckoutjob whose target is down (up == 0), and port-forwards it tolocalhost:9090; the forward runs in the background, so if it dies (laptop sleep, cluster restart) re-runmake telemetry-up.make telemetry-downstops it. Ansible has no read-only module that queries Prometheus (the observability collections are all deploy roles and CRUD management), so this surface reads overansible.builtin.uri, a general HTTP module: the model writes its own PromQL, and the action phase keeps the read a read by rejecting any method other than GET or QUERY. This is a deliberate workaround for the missing telemetry facts module. - localhost: this machine as a local target (no setup). On a non-systemd control
host the node reads cannot dispatch, so it returns
inconclusive, not a false all-clear.
Each surface binds one inventory and a curated, disjoint set of Ansible modules, so a session bound to one plane structurally cannot call another plane's modules.
| Surface | Plane | Investigates |
|---|---|---|
host |
node | service and resource health on a remote systemd host (the primary demo) |
localhost |
node | this machine, as a legitimate local target |
cluster |
control | a Kubernetes workload fault, scoped by namespace |
telemetry |
observability | Prometheus scrape health, read over a GET-only uri (see Demos) |
--surface {host,localhost,cluster,telemetry}selects the governed surface (required).OPENAI_API_KEY(required) authenticates the model endpoint.OPENAI_BASE_URL(optional) points at a non-OpenAI vendor; defaults to OpenAI.SEMLEY_MODEL(optional) selects the model slug; defaults togpt-5.4.THEODOSIA_LEDGER_KEY(hex) switches the audit ledger from unkeyed SHA-256 to an HMAC-keyed chain.
Every investigation writes a governed trail (under .semley/trail): a hash-chained log
of each transition and refusal, what happened, provably. Verify a session's chain with:
uv run theodosia verify --home .semley/trail
This checks the most recent session; pass -p <surface> for a specific one. Typing
/playbook after a conclusion writes the second artifact: the recorded playbook (under
.rocannon/playbooks/), a deterministic transcription of the real Ansible calls, tasks
matching the calls in order, credentials redacted at recording. The trail and the state
persister are separate stores, and neither is the model's.
The action space is the surface's read-only module set: the model reads and diagnoses
freely within it, but a fault that needs a module the surface does not reflect (or a
write) is out of scope by design, and the loop ends at inconclusive rather than
guessing. A session is bound to one surface at launch. Recorded playbooks are audit
records, not directly re-runnable: credentials are redacted and must be restored to
replay. The host surface is the most reliable; the cluster surface works but has more
moving parts (a kind cluster and the kubernetes client), so it needs make cluster-up
first.
Semley, Rocannon, and the ansible all come from Ursula K. Le Guin's Rocannon's World (1966). The ansible, her name for a device that communicates instantly across any distance, gave Red Hat's Ansible its name. Rocannon is the novel's protagonist, and the library that reflects Ansible modules into tools takes his name. Semley is the noblewoman whose journey opens the book.
MIT