This document explains how the system is designed, why it is designed that way, and everything you need to know to extend or rebuild it by hand.
There is one permanent agent (the "Main Agent"). It never answers the user's task itself. Its only job is to engineer other agents:
- decide what team of agents a task needs,
- write their Python code,
- run them,
- combine their results,
- read the answer back against the request, and try again if it falls short,
- then delete or archive them.
Think of it as a factory, not a worker. Generated agents are disposable, single-purpose programs that exist only for the lifetime of one task (unless the user saves them to inventory).
USER
│ task (string)
▼
┌─────────────────┐
│ main.py │ CLI loop, cleanup prompt
└────────┬────────┘
▼
┌─────────────────┐
│ orchestrator.py │ THE permanent Main Agent
└────────┬────────┘
┌──────────┬──────────┼──────────┬────────────┬────────────┐
▼ ▼ ▼ ▼ ▼ ▼
planner.py generator.py executor.py merger.py judgment.py inventory.py
(1 LLM call) (1+ calls (0 LLM calls,(1 LLM call)(1 LLM call,(0 LLM calls,
task → per agent, subprocess outputs → + a rerun file ops)
Plan) spec → per agent) final if the
.py file) answer answer is
│ short)
▼
codeguard.py (0 LLM calls - validates generated code)
Numbered lifecycle for one task:
| Step | Module | What happens | LLM used? |
|---|---|---|---|
| 1 | planner.py |
Task → Plan: a complexity grade and a dependency DAG of AgentSpecs |
✅ structured output |
| 1b | taskgraph.py |
Sanitise the declared graph; order it; derive waves and closures | ❌ |
| 2 | generator.py |
Each AgentSpec → Python source, all new agents generated in parallel |
✅ one call per agent (more if rejected) |
| 2b | codeguard.py |
Reject unsafe/malformed source before it lands | ❌ |
| 3 | executor.py |
Source → file in generated_agents/ |
❌ |
| 4 | executor.py |
Install vetted pip packages into an isolated venv | ❌ |
| 5 | executor.py |
Run the agents wave by wave - independent agents in parallel, dependent ones in sequence | ❌ (the agents call the LLM) |
| 6 | merger.py |
All outputs → one final response | ✅ always |
| 6b | council.py |
Deep tasks only: an adversarial critic challenges the answer; real faults drive one refinement | ✅ one call, +1 when flawed |
| 6c | judgment.py |
Answer checked against the request; agents rerun if short | ✅ one call, plus a full round per revision |
| 7 | library.py |
Remember each new agent; record each reused agent's win or loss | ❌ |
| 8 | runlog.py |
Archive the answer to runs/ |
❌ |
| 9 | inventory.py |
Clear the scratch copies | ❌ |
Every LLM call in the run - and every generated agent's own calls, through
LLM_EFFORT in its environment - runs at the effort the planner's grade
selected: simple drops to low, deep raises to at least high, and
standard runs at whatever the user configured (config.effort_for).
One module = one responsibility. If you're adding code and can't decide where it goes, that usually means it deserves a new module.
- Loads
.env, then owns the model name, the Anthropic endpoint, the directories, and every tunable limit (agent timeout, retry counts, plan size). get_client()is the only place an SDK client is constructed for the main agent, andcomplete()/complete_structured()are the only two ways to reach the model. There is no temperature: current models reject it, soLLM_EFFORT(low-max) is what paces a call instead.response_text()normalises a reply tostr. A reply is a list of content blocks and on a reasoning model the first one is the thinking, not the answer, so never touch.content[0]directly.Usageaccumulates tokens and estimates cost.
- Defines the two Pydantic models that are the system's most important data structures (see §4).
- Grades the task first (
Plan.complexity: simple / standard / deep) - declared beforeagentsso the model sizes the work before designing the team - and declares each agent'sdepends_on, which is what makes selective parallelism possible. plan_agents(task)makes one call throughcomplete_structured(...), which constrains the reply to thePlanschema withoutput_format. The shape is enforced by the API, so a malformed plan is a request error rather than something to salvage afterwards.- The plan is a trust boundary. An
AgentSpec.namebecomes a filename and a dict key, so it is sanitised to a safe snake_case identifier ("../../../pwned"→"pwned"), Windows device names are avoided, duplicates are disambiguated, and the agent count is bounded by the schema - not merely requested in the prompt.
- Pure functions over anything with
.nameand.depends_on. - Declared dependencies are untrusted like everything else an LLM writes: self and unknown references are dropped, a cycle is broken rather than obeyed, and a plan that declares nothing falls back to the sequential chain every plan had before dependencies existed - an empty graph means the planner never thought about it, not independence.
topological_ordermakes list order and graph order agree;wavesgroups the plan into rounds that may each run in parallel;dependency_closurecomputes exactly which upstream outputs one agent receives - its declared dependencies and theirs, never "whatever finished first", which would make runs unrepeatable.
AGENT_HEADERis a fixed, trusted runtime - imports, the Messages API call, the stdin/stdout contract. The LLM never writes it, so it cannot drift.- The LLM writes only
run()(plus small helpers).assemble_agent()splices that into the header. This is why generated agents are always structurally correct and always standard-library only. - The generator prompt states the exact
previous_outputskeys this agent will receive. Without that the model invents key names,.get()returns the default, and the agent silently runs on the task string alone. generate_agent_code()retries on rejection, feedingcodeguard's complaints back to the model. Malformed code never reaches disk.
ast.parsethe source; confirmrun(task, previous_outputs)exists with the right arity and is not async.- Imports are an allowlist, not a denylist - an unknown module is refused.
The standard library is allowed wholesale minus
BLOCKED_STDLIB, the dozen modules that would undo a check made elsewhere in the same file (subprocessand friends for shelling out,importlib/picklefor running code chosen at runtime,shutilfor the filesystem). A curated subset was the wrong shape: it refusedcsv,sqlite3andzipfilefor no reason but absence. ALLOWED_PACKAGES(pip name → import name) is the single source of truth for third-party code.executor.pyinstalls from it and the import check derives from it, so an installable-but-unimportable package cannot exist.- Refuses
eval/exec/compile/__import__, process and filesystem-mutating attribute calls (os.system,os.remove, …), andopen()in any write mode. - Fully unit-testable without an API key.
save_agent_file- writesgenerated_agents/<name>.py, asserting the resolved path is still inside that directory.install_dependencies- only packages incodeguard.ALLOWED_PACKAGESare installed, and they go into an isolated venv (.agent_venv/), never the interpreter running this program. Anything else is refused and reported. Installed distributions are probed viaimportlib.metadata, because pip names and import names differ (beautifulsoup4→bs4).execute_agent- runs ONE agent viasubprocess.run, feeding JSON on stdin. UTF-8 is forced in both directions (PYTHONUTF8/PYTHONIOENCODINGin the child,errors="replace"in the parent); without it a piped child on Windows writes cp1252, the parent decodes UTF-8, and the output is lost. Every failure mode - crash, hang, unstartable, silent - becomes anAgentResult, never an exception.execute_all- the pipeline loop: agent N gets the outputs of agents 1..N-1. Only successful outputs are forwarded.
clarifying_question(task)- the one question worth asking before anything is spent, or None. Called frommain.pybefore the live display goes up, and only when stdin is a person: a question nobody can answer is a stalled run, not a careful one. Biased hard toward silence - an agent that asks about every task is worse than one that never asks.judge(task, answer)- aVerdictofdoneplus, when it is not,missing: an instruction the next attempt can act on.missingis declared beforedoneso the model examines the answer before it rules on it.revision_task(task, missing)- the original wording with the gap appended. Replacing the task with the critique is how a second attempt drifts onto the complaint instead of onto what the user actually asked for.
- The judge checks compliance; the council checks quality, and only for tasks graded deep - exactly the tasks where "meets every stated demand and is still shallow" is the failure that matters.
- One critic call names concrete faults (unsupported claims, reasoning that does not carry its conclusion, the missing counter-case); if any are real, one refinement call fixes what was named and preserves everything else. Two calls at most, no agent reruns, and biased toward acquittal: a critic that always objects bills every deep run twice and teaches the user to ignore it. An empty refinement never replaces the answer it reviewed.
COUNCIL=auto|always|off;Challenge.weaknessesis declared beforeChallenge.flawedfor the same reasonmissingprecedesdonein the judge - the model must state what it found before it rules.
- One LLM call that receives the task + all labelled outputs and writes the final answer. It always runs, including for a single agent: it is the only stage that still holds the user's original wording, so it is what enforces the task's own constraints (word counts, format, tone).
lookup(name)returns stored source, or None. A hit skips the generator entirely, which is the single largest cost in a run.remember(name, role, source)stores an agent underinventory/agents/. The orchestrator never calls it for a newly built agent: those are held inTaskResult.pendinguntilmain.ask_keepgets a decision from the user. Repairing an already-kept agent is the one case that writes without asking.describe_for_planner()is injected into the planner prompt, so the model prefers an agent that already exists over inventing a near-duplicate.- The library curates itself.
record_outcomewrites a win or a loss against every agent handed back from the library;reliable()retires one that has failed at least three times and lost more than it won, so the next task rebuilds it fresh instead of billing for the same crash again. A repair that replaces a kept agent's source is an evolution (remember(..., evolved=True)): the generation counter advances and the record resets, because the code that earned those losses is gone. - The
.pyfiles are the source of truth;index.jsonis a rebuildable convenience, so a corrupt index never costs the user their library. - Reuse is only sound because the generator is forbidden from baking the
task's subject into an agent. See
generator.GENERATOR_PROMPT.
- Every completed task is written to
runs/<timestamp>_<slug>.md. - A failed write returns None rather than raising: the answer is already printed and paid for.
delete_agents- unlink the working copies, tolerating ones already gone. Keeping an agent islibrary.remember's job, not this module's.
handle_task(task)calls the six phases in order, reports each notable moment to aTaskEventsobject, and returns aTaskResult. It never prints: presentation is the caller's problem.- It owns retry policy (
_run_with_repair): a failed agent is regenerated from its own stderr, up toAGENT_REPAIR_ATTEMPTStimes. The generator already has the code and the traceback, so a crash is a repairable event rather than lost work. - It owns scheduling:
_for_each_in_wavesdrives generation-independent work and each execution wave through a thread pool (ceiling:MAX_PARALLEL_AGENTS), while results are absorbed on the caller's thread so shared state is mutated from exactly one place._SharedEventsserialises event emission, so two workers can never interleave inside one renderer. An agent'sprevious_outputsis always its dependency closure - parallel or not, a run is repeatable. - No other business logic of its own - it should read like the flow diagram.
TaskEventsnames every notable moment of a run (phase started, plan ready, agent building / running / repairing / finished, merge started) as a no-op hook. The pipeline emits; whoever is watching overrides.- The default instance shows nothing, so
handle_taskruns headless - in tests, in scripts, inside another program - at zero presentation cost.
- All presentation lives behind one surface.
PlainUI(inui.py) is the reference implementation: every visual the product can show exists as an aligned, colorless ASCII line, safe for pipes, CI and redirected output. - Three streams, one rule. The answer is stdout, always. Narration is
stdout only when a person is watching; into a pipe it moves to stderr.
Errors are stderr always, and survive
--quiet- suppressing an answer's decoration must never suppress the reason a run died. RichUI(inrichui.py) subclasses it and repaints the same surface withrich: the startup wordmark, a live phase rail, a per-agent status board with spinners, the answer panel, and the run transcript. Anything it does not override degrades to a plain line instead of an AttributeError.make_ui()chooses once per session:AGENTGOD_PLAIN/--plain, a missingrich, or a non-terminal stdout all selectPlainUI.richis a soft dependency on purpose - the product must run before its wardrobe arrives.- Neither module imports the rest of the project: every fact they display (model name, limits, results) arrives as an argument or through an event.
- One argparse surface: the positional task,
--task(REMAINDER, so task text may contain flag-shaped words),-for stdin, the output flags (--plain,--quiet,--json), the per-invocation overrides (--model,--effort,--council) and the keep policy. - Exit codes are the contract: 0 succeeded, 1 failed, 2 usage, 130 interrupt.
apply()writes overrides into the environment beforeconfigis imported, so a flag and a.envvalue take the same path - there is no second configuration system to keep in sync.- Imports nothing from the project, so
--versionand usage errors work even before dependencies are installed.__version__lives here andpyproject.tomlreads it, so one number is the truth.
explain(error) -> Problem(headline, advice, technical). Exceptions are matched by class name across the MRO rather than imported types, so the translator never fails on an import and survives an SDK version change.- Every branch names the thing the user can change: the key, the model, the
connection, the timeout.
_explain_every_agent_failedreads the shared cause out of the orchestrator's report - four identical timeouts are one story, not four. - The raw text is kept as
technicalso renderers can dim it. The headline is never a class name.
- Validates
ANTHROPIC_API_KEY, loops on the task prompt, hands each run's events to the active UI, then asks keep or discard?. - One failed task must not end the session:
handle_taskis wrapped, and cleanup runs in afinallyso a mid-run failure still offers to remove the files it wrote. EOF/Ctrl-C end the session cleanly instead of raising. - The only module that talks to a human - and it talks through
ui, so it stays about the conversation, not the paint.
These models are the "wire format" between planning and everything downstream.
class AgentSpec(BaseModel):
name: str # sanitised snake_case; becomes <name>.py and a dict key
role: str # one sentence, used in prompts and logs
instructions: str # detailed brief the generated agent must follow
dependencies: list[str] # extra pip packages (usually empty; allowlist-gated)
class Plan(BaseModel):
agents: list[AgentSpec] # IN EXECUTION ORDER, 1..MAX_AGENTS (schema-enforced)
reasoning: str # describes the list aboveagents is declared before reasoning on purpose: structured output is
generated in field order, so the model describes the team it actually emitted
instead of committing in prose to agents it then omits.
Why Pydantic + with_structured_output?
- The LLM's reply is validated against the schema; malformed output raises instead of silently corrupting the pipeline.
- Field descriptions (
Field(description=...)) are sent to the model as part of the schema - they are prompt engineering, not just docs. - Validators are where untrusted model output is made safe, once, for everyone downstream.
handle_task returns a TaskResult: the merged response, the agent_paths
(so the caller decides about cleanup), any failures, and token/cost totals.
This is the most important design decision in the project. Every generated agent - no matter what it does - obeys the same tiny interface:
stdin ──► JSON {"task": str, "previous_outputs": {agent_name: output, ...}}
stdout ──► plain-text result
stderr ──► diagnostics + a "__AGENT_USAGE__ {...}" token report
exit 0 ──► success exit != 0 ──► failure (stderr = reason)
The payload is written with json.dumps(..., ensure_ascii=True), so stdin is
pure ASCII on the wire and cannot be mangled by the child's encoding.
Structurally, every generated file is AGENT_HEADER + the model's run() +
AGENT_FOOTER. The header is standard library only (json, os, sys,
time, urllib, pathlib) and POSTs to the Messages API over plain HTTPS -
it does not even import the Anthropic SDK. Two consequences worth knowing:
- Startup is ~0.05 s, not ~6 s. Importing a framework inside every agent subprocess used to dominate wall-clock time.
- An archived agent runs on its own.
api_key()falls back to a.envfile up the directory tree, so an agent ininventory/works with nothing installed.
Why a subprocess contract instead of import-ing the generated module?
| Concern | Subprocess (chosen) | Dynamic import |
|---|---|---|
| A buggy agent crashes the app | ❌ isolated, becomes an AgentResult |
✅ can take the process down |
| Hangs / infinite loops | killed by timeout=, caught |
needs threads to interrupt |
| Dependency conflicts | contained per run | pollute the main process |
| Simplicity | one subprocess.run call |
importlib gymnastics |
The contract also means agents are language-agnostic in principle - a future version could generate a Node.js agent and the executor wouldn't care, as long as stdin/stdout behave the same.
Communication model: a sequential pipeline. Agent 3 sees
{"research_agent": "...", "analysis_agent": "..."} in previous_outputs, and
the generator was told those exact key names. There is no shared memory and no
message bus - just data passed forward.
For a task planned with N agents:
1 call planner (structured output; also grades the task)
N calls generator (one per agent; +1 per rejected or repaired attempt;
new agents are generated in parallel)
N calls inside agents (each generated agent calls the LLM itself at runtime;
independent agents run in parallel waves)
1 call merger (always - it enforces the task's own constraints)
1 call judge (unless TASK_REVISIONS=0; + a full round per revision)
+1..2 council (deep tasks only: challenge, + refine when flawed)
─────────────────────────
total: 2N + 3 calls, plus retries, revisions and the council's sitting
Every call is accounted for. The main agent's tokens come from
response.usage_metadata; each generated agent reports its own usage on stderr,
which the executor parses off. TaskResult.cost_summary() totals both.
- Planner/codegen failures (bad key, network, schema mismatch, code that
cannot be made valid) → raise.
main.pycatches, reports, and keeps the REPL alive; the files written so far are still offered for cleanup. - Agent runtime failures → soft-fail into an
AgentResult, then repair: the code and stderr go back to the generator for up toAGENT_REPAIR_ATTEMPTSregenerations. If it still fails, that agent is recorded inTaskResult.failuresand excluded - its stderr is never passed downstream or into the merger as if it were a result. - Every agent failed → raise, rather than merge nothing into a confident hallucination.
- Dependency problems → never fatal. Refused and failed packages are reported; the agent that needed them will fail and be repaired or excluded.
AgentGOD/
├── main.py # entry point - the conversation, not the paint
├── ui.py # presentation surface + PlainUI + renderer choice
├── richui.py # the rich renderer (loaded only when rich exists)
├── events.py # TaskEvents - the pipeline/presentation seam
├── orchestrator.py # sequences the pipeline + retry policy + scheduling
├── planner.py # LLM: task → Plan (grade + DAG; the trust boundary)
├── taskgraph.py # the plan's shape: waves and closures (no LLM)
├── generator.py # LLM: AgentSpec → source code (trusted header + run())
├── codeguard.py # static validation of generated code
├── executor.py # files, pip, subprocesses (no LLM)
├── merger.py # LLM: outputs → final answer
├── council.py # LLM: adversarial review of deep answers
├── library.py # remembers agents for reuse (no LLM)
├── runlog.py # archives each answer to runs/ (no LLM)
├── inventory.py # clears scratch copies (no LLM)
├── config.py # model, key, paths, limits - the only provider-aware file
├── tests/ # pytest suite; needs no API key
├── generated_agents/ # scratch space, contents are disposable
├── inventory/ # user-kept teams
├── .agent_venv/ # isolated interpreter, created only if a task needs pip
├── .env # your real key - gitignored, never committed
├── .env.example # committed template
├── pyproject.toml # pytest + ruff configuration
├── requirements.txt
├── README.md
└── ARCHITECTURE.md # this file
Conventions to keep while contributing:
- Agent name == filename ==
previous_outputskey. Sanitise it inplanner.safe_agent_name, nowhere else. config.complete()andconfig.complete_structured()are the only two ways to reach the model. Never construct ananthropic.Anthropicclient anywhere else in the main codebase.- Executor and codeguard stay AI-free. They deal in files, processes, strings and syntax trees only. That separation is what makes the test suite runnable without an API key.
- Prompts live as module-level constants (
PLANNER_PROMPT,GENERATOR_PROMPT,MERGER_PROMPT) - easy to find, easy to diff. - Never read
response.contentdirectly - useconfig.response_text(). orchestrator.handle_taskreturns aTaskResultand lets the caller decide about cleanup. Don't make the orchestrator ask questions; user interaction belongs inmain.py.- Anything an LLM produces is untrusted input until a validator has seen
it. Names go through
safe_agent_name, code throughcodeguard, packages throughALLOWED_PACKAGES.
Generated code is executed on your machine. The defences, in order:
- Names are sanitised, so a plan cannot write outside
generated_agents/. - Code is
ast-validated: allowlisted imports only, noeval/exec, no shelling out, no writing files. - Packages are allowlisted and installed into an isolated venv.
- Execution is a subprocess with a hard timeout.
What this does not do: it is static validation, not a sandbox. A determined prompt-injection payload that stays within the allowlist can still make network calls (agents need HTTPS to reach the API). Treat the task string as a trust boundary - the roadmap's Docker-per-agent item is the real fix.
Small (1 commit each)
-
--task "..."CLI argument so it runs non-interactively (argparse). - Log each run (task, plan, timings, cost) to a
runs.logor JSON file. - Colored terminal output for the five phases.
Medium (a few commits each)
- Inventory reuse: before planning, search
inventory/*/TASK.txtfor a similar past task and offer to rerun that saved team. - Parallel execution: the planner marks dependencies (
depends_on),taskgraph.pyderives waves, and independent agents run on a thread pool while dependent ones stay sequential. Silent data loss is designed out: each agent receives its dependency closure, and a plan that declares nothing falls back to the sequential chain. - Stream agent output instead of buffering it to completion.
Large (multi-day)
- Give generated agents tools (web search) instead of a single bare LLM call. File reading still pending.
- Sandbox execution (Docker container per agent) so generated code can't touch your filesystem or network freely.
- A planner that outputs a DAG instead of a list, with a topological-sort
executor (
taskgraph.py). - Simple web UI (FastAPI + one HTML page) replacing the CLI.
- LLM orchestration: prompt design, structured output, multi-step chains.
- Pydantic: schemas as validation, prompt engineering, and a security boundary.
- Metaprogramming: a program that writes, validates, saves and runs other programs.
ast: treating generated code as data before treating it as code.subprocess: stdin/stdout piping, exit codes, timeouts, encodings.- Filesystem hygiene:
pathlib, containment checks, scratch vs. archive. - Separation of concerns: UI / orchestration / AI calls / IO in separate
modules - only
main.pytalks to the user, onlyconfig.pyknows the provider, and onlyexecutor.pytouches the filesystem.