01Setup
One environment, two commands. The gateway serves the runtime; the Vite dev server serves the UI and reverse-proxies to the gateway, so only one port ever needs forwarding.
pip install -e ".[dev]"
# terminal 1 — the runtime
python -m agentevolver.cli serve --transport websocket --port 9876
# terminal 2 — the UI (proxies /ws to the gateway)
cd frontend && npm install && npm run dev
The UI stores its gateway address. Move the dev server to another port and the stored address still names the old one — both proxy to a gateway, so everything looks connected while you talk to the wrong backend. The symptom is one newer method reported as unknown. Loopback addresses now migrate automatically; the Connection dialog in the sidebar shows and clears the stored one.
02Repository layout
Every capability lives in its own top-level module under agentevolver/. There is no shared "core" that things get added to — a new capability is a new directory, and nothing else changes.
| Area | What it owns |
|---|---|
agent/ | The loop: steps, tool dispatch, hooks, plan mode, delegation. |
model/ | Provider adapters and one canonical stream vocabulary they all speak. |
tool/, skill/, environment/, connector/ | The four capability families a model can be given. |
trace/ | The append-only session log and the two readings of it. |
memory/ | What a run carries forward, and how it is compacted. |
gateway/ | The one door the browser talks through. |
extension/, scope/, dynamic/ | Components created at runtime, and how they are removed again. |
tests/ | Every gate. There is no separate CI configuration that checks something else. |
03The module contract
A module is four files, and the shape is uniform enough that finding your way around an unfamiliar one takes no reading.
agentevolver/<module>/
README.md # YAML frontmatter + what this module owns (gated)
types.py # the data and the contract — no I/O
server.py # the singleton manager: register / get / list / unregister
__init__.py # the public surface
Every manager spells removal the same way — unregister(name). That uniformity is not cosmetic: it is what lets one scope own registrations across several managers and take them all back out, which is what makes a contributor removable.
Registration is by class field
A tool, agent, or environment is discovered by its name class field, not by an argument passed to a constructor. Passing it any other way registers as None, and nothing complains until the model cannot find the capability.
04Adding a capability
A tool is one thing an agent can do. The procedure below is the whole of it; agents, skills, and environments follow the same three steps against their own manager.
class WordCountTool(Tool):
"""Count the words in a file."""
name: str = Field(default="word_count") # class field — see above
description: str = Field(default="Count words in a UTF-8 text file.")
mutates: bool = Field(default=False) # three-valued; see below
async def __call__(self, path: str, ctx=None, **kwargs) -> Response:
with open(path, encoding="utf-8") as handle:
count = len(handle.read().split())
return Response(type=ResponseType.TOOL, success=True,
message=f"{count} words", data={"words": count})
- Write the class in
agentevolver/tool/default/. - Export it from that package's
__init__.py. Discovery is by import; a tool that is written and not exported is invisible and nothing reports it. - Test it, covering the failure path — a model cannot tell "this file does not exist" from "this tool is broken" unless the message says so.
mutates is three-valued and the third value matters. True changes state, False only reports, and None means depends on the arguments — which is what a shell tool declares, and a shell command is the most likely way an agent destroys something. Anything but an explicit False makes the runtime flush its log before dispatch.
The description is a model-facing contract, not a label. "Count the words in a UTF-8 text file" tells a model when to reach for it; "Word counting utility" does not.
05Gateway and frontend
The browser reaches the runtime through one WebSocket and nothing else. A command is {id, method, params, protocol_version}; the server answers with a response of the same id, and pushes ordered events alongside.
Adding a method is one function. The dispatcher maps method to _command_<name>, so docs.list is served by _command_docs_list. No table to register in.
The frontend declares the same wire types by hand, in another language and another build. Drift is silent in both directions — a field the client declares and the server never sends arrives as undefined with the type checker satisfied. A gate compares the two field sets on every run; it found three fields that had been sent for months and declared nowhere.
06Extensions at runtime
Components can be created, versioned, promoted, and rolled back while the system runs. Two mechanisms make that safe rather than merely possible.
Promotion — turning a file an agent wrote into a file the next session loads as code — checks containment after resolving both paths, so a traversal cannot be spelled with .. or hidden behind a symlink.
07Verification
Two lanes. The ordinary one is fast; the measured one costs about 30% more wall-clock and answers a question no individual test can.
pytest # the ordinary run
pytest --cov # measures, then applies the coverage gate
The gate is a dark-file register, not a percentage
The rule is not the number. It is the set of files the whole suite never executes a line of — a set that holds two things wearing the same clothes: code nothing tests, and code nothing calls. Every file at zero must be named in a register with the reason a test cannot reach it.
The register is enforced in both directions. A file that goes dark without an entry fails the run; a registered file that starts being covered also fails, until its entry is deleted. Without the second direction the list only grows, and a file whose tests were written stays permanently exempt from the check it just started passing.
A check that cannot fail is worse than no check
Consistency checks are themselves guarded: a suite reintroduces each real defect in a subprocess and requires the check to go red. A check that always passes reports the invariant as held, silently, forever.
08Decisions and postmortems
Code says what happens; tests say what must keep happening. Neither says why the third option lost, and that is what gets re-litigated.
| Record | Answers | Rule |
|---|---|---|
| Decision | Why is it like this, and what did we give up? | Alternatives are mandatory and are recorded, never invented. Present tense only. |
| Postmortem | Why did the process let this through? | Subtle, systemic, and costly to rediscover — all three, or it does not belong. |
The test for writing one is simple: would someone reasonably ask "why was this done this way?" in six months? That is a lower bar than "was this hard" and a much higher one than "did I change something".
Both trees live beside the code and are checked by the same gates as everything else: decisions/ · postmortems/ · cookbook/ · DOC-STANDARD.md
09Invariants that were paid for
Each of these is a rule that exists because its absence produced a defect. They are collected here because every one of them is easy to undo by accident.
Disposal must reach quiescence, not request it. Sending a signal and immediately marking something stopped reports a process that ignores the signal as dead while it is still running. Wait, escalate, wait again, and say so if it survives both.
The log reaches disk before a mutation. Events are queued, not written, so the log trails the run. That is free until the process dies inside the lag — and then nobody can tell whether the destructive command executed.
Retries happen in one place, and every attempt is recorded. The product here is trajectories. A call that failed twice and succeeded on the third try must not be indistinguishable from one that got it right immediately — that is a training sample that lies.
The model's history is not the human transcript. Compaction shadows the range it replaces, which is correct for a model request and deletes conversation a person has already read. Two readings of one log; picking the wrong one renders perfectly until the first compaction.
Enforce a decision in the operation that makes it. Omitting something from a schema, filtering a prompt, or wrapping a facade is not enforcement when another caller can bypass it. Test the denial through the executor.
An action's result must be text the model can read. Returning a bare object leaves the agent holding None, and the run spins with no observation and no error.
A gate is verified by its exit code, not its output. A check can print a red banner and still exit zero, in which case CI walks straight past it. This one cost a full round of debugging; it has its own postmortem.