Skip to content

msradam/semley

Repository files navigation

Semley

      ┓
┏┏┓┏┳┓┃┏┓┓┏
┛┗ ┛┗┗┗┗ ┗┫
          ┛

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.

Install

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).

Quick start

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

How it works

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 step tool 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: conclude must 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 step tool 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 step and reset_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.

Driving the agent

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: confirmed or inconclusive. 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.

Prerequisites for the demos

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.

Demos

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.sh ensures an OrbStack systemd machine (web1) running nginx; make inject stops it (enabled-but-stopped), make heal restores it. Inventory: inventory/hosts.ini.
  • cluster (control plane): scripts/cluster-up.sh stands up a kind cluster, the kubernetes.core collection, and a workload stuck in ImagePullBackOff in the shop namespace; make cluster-down removes it. The script switches your kubectl context to kind-semley.
  • telemetry (observability plane): scripts/telemetry-up.sh deploys Prometheus in the kind cluster, scraping itself and a checkout job whose target is down (up == 0), and port-forwards it to localhost:9090; the forward runs in the background, so if it dies (laptop sleep, cluster restart) re-run make telemetry-up. make telemetry-down stops it. Ansible has no read-only module that queries Prometheus (the observability collections are all deploy roles and CRUD management), so this surface reads over ansible.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.

Surfaces

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)

Configuration

  • --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 to gpt-5.4.
  • THEODOSIA_LEDGER_KEY (hex) switches the audit ledger from unkeyed SHA-256 to an HMAC-keyed chain.

Auditability

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.

Limitations

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.

Naming

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.

License

MIT

About

Autonomous SRE investigation agent: a governed state machine driven by a language model, acting through reflected Ansible tools

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors