Skip to main content
The agent crew is the labor force that keeps the knowledge base full without proportional human time. It sweeps unnamed functions bottom-up through the call graph, proposes names and summaries grounded in hard facts, gates every proposal through a verifier, and writes back under the KB’s provenance/confidence economy. The result: you can re-run the crew on every vendor update without clobbering Oracle matches, exports, or anything you’ve verified by hand.
Alpha status. The propose → verify → write-back loop, the offline, OpenAI/Codex, and Anthropic backends, and the verifier gate are implemented and running. The concurrency and type/struct roles are also implemented as deterministic, zero-dependency analyzer passes (see Specialized analyzers below). The remaining specialized agent roles (Oracle adjudicator, diff, behavioral verifier) are the intended next steps; the naming crew currently runs a single generic pass that routes through whichever backend is selected.

The core loop: propose → verify → write-back

run_agent_pass in src/warden/agents/crew.py drives one full sweep over a module version.
1

Gather hard facts

For every defined function in the version, gather_facts assembles a FunctionFacts object (the hallucination constraint). Every field is derived mechanically from the binary and the KB; the backend sees only what is actually in the binary. See FunctionFacts below.
2

Sort bottom-up

Functions are sorted by number of call targets, ascending. Leaf functions run first. As callees acquire names, the next caller’s context is richer when the backend sees it.
3

Skip already-confident entries

If a function already has a symbol with confidence >= 0.5 (the SKIP_CONFIDENCE constant in crew.py), or if the symbol is locked (human or Oracle), the function is skipped. This is the mechanism that makes re-running safe: the crew never overwrites high-confidence or locked work.
4

Backend proposes

The selected backend receives the FunctionFacts and returns a Proposal (a name, summary, confidence score, and optional refined type signature), or None to abstain.
5

Verifier gate

verify_proposal runs cheap sanity checks before anything reaches the KB. It rejects proposals with invalid identifiers, names shorter than two characters, confidence outside [0, 1], or string-xref claims that aren’t backed by actual referenced strings. See the verifier gate for detail.
6

Write-back under the economy

Accepted proposals are submitted to kb.upsert_symbol with provenance="agent". The KB’s economy decides whether to actually write: an agent proposal may only overwrite a lower-confidence prior agent entry. Human, Oracle, and higher-confidence agent entries are never touched. Each write stores the provenance trail and evidence list alongside the symbol.
After the sweep, run_agent_pass returns an AgentRunResult with counters for considered, proposed, written, rejected_by_verifier, rejected_by_economy, and skipped_existing.

FunctionFacts: the hallucination constraint

@dataclass
class FunctionFacts:
    func_index: int
    stable_id: str
    type_signature: str
    call_targets: list[str]
    referenced_strings: list[str]
    raw_name: str | None
    instruction_mnemonics: list[str]
    is_exported: bool
Every field is derived mechanically from the binary and the KB. There is no inference, no LLM input. Backends receive only this struct. This is the contract that constrains hallucination: a backend cannot claim a function references a string that referenced_strings does not contain.
  • referenced_strings is built by walking the function’s i32.const instructions and looking up each immediate in the module’s data-section string map.
  • call_targets contains direct call names (with imports resolved to their import names) and <indirect> for call_indirect sites.
  • type_signature is the WASM type section entry. It is exact, not guessed.
  • instruction_mnemonics contains up to all opcodes in the function body; LLM backends truncate to the first 40 when building the user message.

The backends

Offline heuristic backend

The zero-dependency default. Deterministic, no API key, no network. Runs immediately after pip install -e . with no extras. Applies three heuristics in priority order:
PriorityHeuristicConfidenceTrigger
1String xref0.45referenced_strings is non-empty
2Call-neighborhood0.30Function makes at least one direct call
3Placeholder0.12Neither heuristic fires
The string xref heuristic is the strongest cheap signal because Emscripten modules are full of format strings, error messages, and symbol names. The placeholder heuristic ensures nothing stays anonymous; 100% symbol coverage is achievable offline. Even if an LLM backend is selected but fails at construction time (missing key, import error), make_backend silently falls back to the offline backend.

OpenAI / Codex backend

Selected automatically when OPENAI_API_KEY is set and the openai package is installed (pip install -e '.[agents]'). Uses the OpenAI Responses API with structured JSON output so the model returns {name, summary, confidence}.
  • Default model: gpt-5.3-codex (override with WARDEN_OPENAI_MODEL or WARDEN_AGENT_MODEL).
  • Aliases: --backend openai, --backend codex, and --backend oai all select this backend.
  • Reasoning effort: defaults to medium and can be changed with WARDEN_OPENAI_REASONING_EFFORT.
  • System prompt: the same RE prompt used by the Anthropic backend.
  • User message: contains only fields from FunctionFacts: function index, type signature, export status, call targets, referenced strings, raw name hint, and up to 40 opcode mnemonics.
  • Output: the response is validated and clamped. name is run through slugify; confidence is clamped to [0.0, 1.0].

Anthropic backend

Selected automatically when ANTHROPIC_API_KEY is set, the anthropic package is installed (pip install -e '.[agents]'), and the OpenAI backend is not available. Uses the Anthropic Messages API with structured JSON output via a JSON schema constraint so the model always returns {name, summary, confidence} and nothing else.
  • Default model: claude-opus-4-8 (override with the WARDEN_AGENT_MODEL environment variable).
  • System prompt: instructs the model to act as a RE assistant, propose a concise snake_case C-style identifier, write a one-sentence purpose, and emit a calibrated confidence in [0, 1]. The model is told explicitly to prefer low confidence when evidence is thin and never invent behavior unsupported by the facts.
  • User message: contains only fields from FunctionFacts: function index, type signature, export status, call targets, referenced strings, raw name hint, and up to 40 opcode mnemonics. No content outside these facts is sent.
  • Output: the response is validated and clamped. name is run through slugify to guarantee a valid identifier; confidence is clamped to [0.0, 1.0].
The JSON schema constraint used for structured output:
{
  "type": "object",
  "properties": {
    "name":       { "type": "string" },
    "summary":    { "type": "string" },
    "confidence": { "type": "number" }
  },
  "required": ["name", "summary", "confidence"],
  "additionalProperties": false
}

Backend selection

make_backend(prefer) in backends.py resolves which backend runs:
ConditionBackend chosen
--backend offlineOfflineHeuristicBackend
--backend openai, --backend codex, or --backend oaiOpenAIBackend (falls back to offline if unavailable)
--backend anthropicAnthropicBackend (falls back to offline if unavailable)
No flag, OPENAI_API_KEY set, openai installedOpenAIBackend
No flag, OpenAI unavailable, ANTHROPIC_API_KEY set, anthropic installedAnthropicBackend
No flag, key missing or package absentOfflineHeuristicBackend
An explicit --backend flag always beats auto-detection.

Running the agent crew

# Auto-detect backend (OpenAI, then Anthropic, then offline):
warden agent v1

# Force the offline heuristic backend (no API key needed):
warden agent v1 --backend offline

# Force the OpenAI/Codex backend:
OPENAI_API_KEY=sk-... warden agent v1 --backend codex

# Force the Anthropic backend:
ANTHROPIC_API_KEY=sk-ant-... warden agent v1 --backend anthropic

# Use a different model:
OPENAI_API_KEY=sk-... WARDEN_OPENAI_MODEL=gpt-5.3-codex warden agent v1
ANTHROPIC_API_KEY=sk-ant-... WARDEN_AGENT_MODEL=claude-opus-4-8 warden agent v1

# Check provider availability:
warden agent-backends

# Point at a non-default database:
warden agent v1 --db /path/to/project.db
The command prints a summary table on completion:
Agent pass: v1 (anthropic)
considered                  42
proposed                    38
written                     31
skipped (already confident)  9
rejected by verifier         2
rejected by economy          5
Re-running the command at any time is safe. Already-confident and locked entries are skipped before the backend is even called, and the economy rejects any proposal that would overwrite a stronger entry.
After running the crew, use warden coverage v1 to see how symbol coverage is split between oracle, export, agent, and human sources. Use warden funcs v1 --unnamed to find functions the crew could not name (or named only at very low confidence).

Call-graph strategy

By default, run_agent_pass walks the call graph bottom-up instead of running a single flat sweep. Pass --strategy flat to get the original behavior.
# Default: bottom-up call-graph walk (recommended)
warden agent v1 --strategy call-graph

# Original flat pass (leaves-first ordering, no concurrency within a layer)
warden agent v1 --strategy flat
The concurrency parameter (default 8) caps how many proposals are in-flight at once within a single layer. Set it programmatically via run_agent_pass(..., concurrency=N).

How the call-graph walk works

1

Build the call graph

build_call_graph(module) in warden.analysis.callgraph constructs a CallGraph with direct and indirect edges for every defined function. Direct call and return_call instructions are exact. call_indirect and return_call_indirect instructions carry only a type index at the static level, so their targets are over-approximated: every defined function in the module’s element table whose type matches the call’s type index is included as a potential callee. The resulting graph is a conservative static skeleton.
2

Condense recursion into layers

strongly_connected_components (iterative Tarjan) groups mutually recursive functions into SCCs. layered_schedule then condenses the SCC graph into a DAG and assigns a depth to each component: layer 0 holds leaves, and every later layer holds functions whose defined callees all appear in earlier layers. Mutual recursion lands in the same layer and is treated as a single unit. All traversals are sorted, so the schedule is deterministic.
3

Route to specialists

Before processing any layer, _specialist_notes runs the concurrency and struct analyzers (the same passes that warden analyze runs). Their findings are written to the KB and also routed into per-function hint lists:
  • Atomic sites from the concurrency analyzer produce notes such as "atomic i32.atomic.rmw.add at offset 8; likely a synchronization primitive".
  • Struct layouts from the struct analyzer produce notes describing which field offsets the function accesses through a base pointer.
These notes appear in FunctionFacts.notes so the backend sees them when proposing a name.
4

Enrich each function with callee names

When a function is about to be processed, _enrich looks up the KB names of all its direct defined callees and attaches them as FunctionFacts.callee_names. Because layers are processed bottom-up, the callees have already been named (or skipped) before the caller is reached. A backend that sees callee_names=["parse_header", "validate_checksum"] has far richer context than one that sees only raw opcodes.
5

Propose each layer concurrently

All functions in a layer are independent (no intra-layer edges by construction), so their proposals can safely run in parallel. _propose_concurrently uses asyncio.gather with a semaphore capped at concurrency. Backends that block (every current backend) are dispatched via asyncio.to_thread so the event loop stays responsive. A single-function layer skips the async path entirely and calls backend.propose directly.
6

Write back under the economy

Proposals from each layer go through the same verify_proposal gate and kb.upsert_symbol call as the flat pass. Because functions in the same layer cannot be each other’s callees, concurrent branches in one layer never share a callee that is being written at the same time. The KB’s provenance/confidence economy rejects any write that would overwrite a higher-confidence or locked entry, so concurrent branches are safe.

FunctionFacts fields added by the call-graph strategy

The call-graph strategy attaches two fields that the flat pass leaves empty:
FieldTypeSource
callee_nameslist[str]KB names of defined callees, looked up after each prior layer is written
noteslist[str]Per-function hints from the concurrency and struct analyzers
Both fields are part of the FunctionFacts dataclass and are forwarded to the backend as additional context in the user message.

When to use each strategy

Use --strategy call-graph (the default) for any module where naming quality matters. The bottom-up order means callers are named in light of what their callees do, which is the main quality improvement over a flat pass. Use --strategy flat when you want a quick, fully sequential sweep, for example in CI environments where deterministic single-threaded output is easier to diff, or when debugging the backend in isolation.

The verifier gate

verify_proposal(proposal, facts) in crew.py sits between the backend’s output and the KB. It returns (accepted, reason). Currently it performs cheap structural checks:
  • The name must match ^[A-Za-z_][A-Za-z0-9_]*$ (valid C identifier).
  • The name must be at least two characters.
  • Confidence must be in [0.0, 1.0].
  • A summary that claims string evidence must be backed by non-empty facts.referenced_strings.
This is intentionally minimal: it catches obviously broken output without requiring a toolchain. The function’s signature is the plug-in point for the full behavioral verifier described in the design. That verifier uses differential re-execution via wasm2c, where a lifted C reconstruction is recompiled and executed against the original WASM under a fuzzer corpus. warden verify <wasm> reports whether the current environment has the toolchain needed to activate it.
The behavioral verifier (wasm2c differential re-execution) is scaffolded but not yet active. The verify_proposal call site is where it plugs in when a C toolchain is available.

The provenance/confidence economy

Every write to the KB carries three fields that together make re-running the entire crew safe. The full economy is explained in core concepts; here is how the agent crew interacts with it.
  • provenance is set to "agent" for every crew write. This places agent output at the lowest authority tier, below human, Oracle, export, and string-xref entries.
  • confidence is the calibrated score returned by the backend. The offline backend emits 0.45, 0.30, or 0.12 depending on which heuristic fired. LLM backends are instructed to self-calibrate and their output is clamped to [0.0, 1.0].
  • locked is never set by the crew. Only warden set-name (human writes) sets locked=True, which makes an entry immutable to every automated actor.
The crew enforces the economy at two points:
  1. Before the backend is called: entries at confidence >= 0.5 or marked locked are skipped. The threshold SKIP_CONFIDENCE = 0.5 is the boundary between “confident enough to leave alone” and “fair game.”
  2. After the verifier passes: kb.upsert_symbol enforces that an agent proposal may only land if no higher-confidence agent entry (or any higher-authority entry) already exists. The result is counted as rejected_by_economy and no write happens.
The practical effect: running warden agent on a module that already has Oracle matches and a prior agent pass at confidence 0.45 will re-propose only the functions that are still below threshold, and only overwrite those where the new proposal is stronger.

Specialized analyzers

Beyond the naming crew, WARDEN ships two deterministic analyzers that populate first-class KB facts with no LLM and no API key. They cover the concurrency and type/struct roles from the intended crew architecture and run together under a single command:
warden analyze <label>
Both passes persist their findings to the KB immediately. Re-running is idempotent: the KB upsert semantics apply the same provenance/confidence economy as any other write.

Concurrency analyzer

warden.analysis.concurrency.analyze_concurrency(module, kb, version_id) recovers the thread model from three byte-level fossils that survive Emscripten stripping:
SignalWhat it means
Shared memory flagThe WASM limits field has the shared bit set (atomics require it)
Atomic opcodes (0xFE family)Every rmw, cmpxchg, wait, notify, or fence instruction is an atomic site
pthread-named imports/exportspthread_*, emscripten_thread*, _emscripten_proxy*, atomic-tagged helpers surviving in the symbol table
The pass returns a ConcurrencyReport with .shared_memory, .atomic_sites, .pthread_markers, and .facts. When a KB and version ID are supplied, each atomic site is written to the thread_model table via kb.add_thread_fact as kind='atomic', with the memarg offset as the best-effort “guarded data” pointer and a confidence of 0.6. This is high enough to be a fact but below the human/Oracle tier, because the exact guarded data is a best-effort guess.
A module is considered multithreaded when any of the three signals is present. Shared memory or atomic opcodes are conclusive; pthread-named symbols are a weaker hint (a module may import them without actually spawning threads), but they are still recorded.

Struct-layout analyzer

warden.analysis.structs.analyze_structs(module, kb, version_id) reconstructs candidate struct shapes from memory-access patterns. Emscripten compiles a C struct field access into a recognizable two-instruction sequence:
local.get N          # push base pointer
i32.load offset=K    # dereference at fixed displacement K
The pass walks every defined function looking for exactly this adjacency. Each unique (base local, offset) pair is one candidate field; multiple accesses to the same offset are deduped. Fields are grouped by base local into a StructLayout named <func>_arg<N>_t, and the recovered fields are sorted by offset so the output is deterministic. Each StructLayout has a .name, .fields (a list of StructField(offset, size, type, name)), and .source_function. When a KB and version ID are supplied, every layout is persisted via kb.upsert_struct at provenance agent, confidence 0.5, so the recovered shapes become queryable KB facts and carry forward on the next ingest.
The struct analyzer is deliberately conservative: it only fires on the literal local.get → load/store adjacency. Non-trivial address arithmetic (pointer arithmetic, GEP chains) is not modeled. This keeps the results deterministic and avoids false positives at the cost of recall.

Running both passes

warden analyze <label> runs both analyzers in sequence and prints a summary:
Concurrency: shared_memory=True  atomic_sites=14  pthread_markers=6
Structs:     layouts=9
The KB is updated atomically. If either pass fails, no partial facts are written for that pass. Both sets of facts are then available to the naming crew (the next warden agent run sees thread_model and structs entries when assembling FunctionFacts) and to the HTML report generator.

Intended crew architecture

The current implementation runs a single generic naming pass. The target architecture from the design is a crew of specialized agents, each owning a distinct domain:
Adjudicates fuzzy Oracle matches and attaches upstream Emscripten/musl source links to matched symbols.
Owns atomic/lock/TLS analysis; labels lock-to-guarded-data relationships and worker entry points discovered via dynCall/elem tables. Implemented as a deterministic pass in warden.analysis.concurrency. warden analyze runs it and persists findings to the thread_model table.
Reconstructs struct layouts from memory access patterns and propagates types to callers. Implemented as a deterministic pass in warden.analysis.structs. warden analyze runs it and persists findings to the structs table.
Proposes human-readable names and writes pseudocode summaries. This is what the current implementation does.
On each new version, explains modified functions and writes the semantic changelog.
Builds differential test harnesses and triages mismatches between the WASM and its reconstruction.
Each specialized agent would expose the KB and verifier as MCP tools via warden mcp, so any MCP-capable model can drive the loop. The current warden agent command is the starting spine for this architecture.

Relation to other pipeline stages

  • Oracle identification runs before the agent crew and pre-populates the KB with high-confidence names for runtime/libc functions. In a real Emscripten module, 40–80% of functions may already be named by the time the crew runs. Those are skipped, so the crew concentrates effort on the application-specific remainder.
  • Diff carry-over runs on ingest of a new version and ports annotations from the previous version. After carry-over, only genuinely changed or new functions are below threshold, so the crew only touches what actually needs attention.
  • warden demo runs the full pipeline end-to-end offline and shows all three stages feeding each other (Oracle → agent (offline) → diff carry-over), with no API key.
Last modified on June 7, 2026