Skip to content

Latest commit

 

History

History
574 lines (401 loc) · 32.4 KB

File metadata and controls

574 lines (401 loc) · 32.4 KB

ACE Architecture

Architecture for ACE, a pipeline-based framework for building self-improving AI agents. Roles are backed by PydanticAI agents; the pipeline engine handles composition and concurrency.

For full code examples and API reference, see ACE_REFERENCE.md. For design decisions and rejected alternatives, see ACE_DECISIONS.md. For the pipeline engine, see PIPELINE_DESIGN.md.


Overview

ACE (Agentic Context Engine) builds AI agents that learn from their own executions. It combines:

  • A pipeline engine (pipeline/) with typed step contracts, concurrent execution, and structured error handling
  • Roles (Agent, Reflector, SkillManager) backed by PydanticAI agents for structured LLM interactions
  • A Skillbook — an evolving knowledge base of strategies that agents read from and learning loops write to
  • Integration steps for external frameworks (browser-use, LangChain, Claude Code, Anthropic SDK)
  • Observability via Logfire auto-instrumentation of all PydanticAI agent calls

The LLM interaction layer uses PydanticAI exclusively. Three legacy hand-rolled LLM clients (LiteLLM, Instructor, ClaudeCode) were replaced — PydanticAI handles structured output, retries with error feedback, and multi-provider support as maintained infrastructure. The pipeline engine and skillbook/learning loop are untouched.

Kept (core IP) Replaced (commodity plumbing)
Pipeline engine (requires/provides, async_boundary, max_workers) LLM client abstraction (3 implementations → PydanticAI agents)
Skillbook & learning loop (Reflect → Update → Apply) Structured output parsing + retries (→ PydanticAI native validation)
Step composition (learning_tail, pipeline nesting) RR iteration loop, code extraction, budget tracking (~2,500 lines → PydanticAI agent + tools)
Domain-specific prompts Sub-agent call management (CallBudget → UsageLimits)

Naming

Legacy Current What it does
OfflineACE TraceAnalyser Analyse pre-recorded traces → evolve a skillbook
OnlineACE ACE Live execution → feedback → learning loop
ACEBase ACERunner Shared runner infrastructure (composition, not inheritance from Pipeline)
ACEStepResult Removed — use SampleResult from the pipeline engine Unified result type

Architecture Layers

The framework separates concerns into four layers:

Layer Location Responsibility Example
Protocols ace/protocols/ Interface contracts ReflectorLike.reflect()
Roles ace/implementations/ Business logic (LLM calls) Reflector, RRStep
Steps ace/steps/ Context plumbing (extract → call role → put back) ReflectStep
Runners ace/runners/ Orchestration (sample loop, epoch management) ACELiteLLM

Protocols define what a role must look like. Roles implement the logic. Steps adapt between the pipeline's context-based data flow and the role's parameter-based API. Runners compose steps into pipelines and iterate over inputs.

Roles are interchangeable anywhere their protocol is expected — both Reflector (simple single-pass) and RRStep (recursive multi-iteration) satisfy ReflectorLike. The runner and pipeline don't know or care which one is in use.


Core Concepts

Sample

The input unit for ACE. A question with optional context and ground truth:

@dataclass
class Sample:
    question: str
    context: str = ""
    ground_truth: str | None = None
    metadata: dict = field(default_factory=dict)
    id: str | None = None

ACESample — protocol for step access

Steps access ctx.sample.question uniformly. A Protocol makes this duck typing explicit and type-safe. Sample satisfies it structurally — no inheritance required.

SkillbookView — read-only projection

The Skillbook is mutable — steps add, update, and remove skills. Placing it directly on a frozen=True context would allow mutation through the reference, breaking the immutability guarantee.

SkillbookView wraps a Skillbook and exposes only read methods (as_prompt(), get_skill(), skills(), stats()). Write methods don't exist on the class — calling them raises AttributeError at runtime and a type error at check time.

Enforcement:

  • Type checker — mypy/pyright flags ctx.skillbook.add_skill(...) because SkillbookView has no such method.
  • RuntimeAttributeError if someone calls a write method anyway.
  • Convention — the underlying _sb is underscore-prefixed. Accessing it is a deliberate violation.

Steps that only read the skillbook (ReflectStep) access ctx.skillbook — the view. Steps that write the skillbook (AgentStep, UpdateStep, DeduplicateStep, CheckpointStep) receive the real Skillbook via constructor injection and use self.skillbook. AgentStep bumps used_count; UpdateStep invokes the agentic SkillManager whose tools apply ADD / UPDATE / REMOVE / TAG directly.

ACEStepContext — immutable step-to-step data

Subclass of the pipeline engine's StepContext. Carries all step-to-step data for the ACE pipeline. The pipeline engine only knows about sample and metadata; all ACE-specific fields live here.

Key fields:

Field Type Source
mode Literal["online", "offline"] "online" (default) — reserved for downstream steps
sample ACESample | None Set by runner's _build_context()
skillbook SkillbookView | None Read-only projection of the real Skillbook
trace object | None Raw execution record — any type, no enforced schema
agent_output AgentOutput | None Produced by AgentStep
reflections tuple[ReflectorOutput, ...] Produced by ReflectStep / RRStep
skill_manager_output UpdateBatch | None Produced by UpdateStep (audit log of mutations the SM already applied)
injected_skill_ids tuple[str, ...] Produced by AgentStep — skill IDs rendered into the agent prompt; downstream attribution scope
epoch, total_epochs int Runner bookkeeping
step_index, total_steps int Runner bookkeeping
global_sample_index int Runner bookkeeping (used by interval steps)

The trace field holds the raw execution record from any external system — a browser-use AgentHistoryList, a LangChain result dict, a Claude Code transcript, or any arbitrary Python object. The Reflector receives the raw trace and is responsible for making sense of it.

The reflections field is a tuple. In single-trace mode, it's a 1-tuple. In batch mode, it holds one ReflectorOutput per trace. Downstream steps iterate uniformly — no special-casing.

Context vs constructor injection

On the context Injected via constructor
Nature Step-to-step data + read-only dependencies Mutable shared state
Lifetime Per-sample (born in _build_context, dies after pipeline) Per-runner (created once, shared across samples)
Immutable? Yes — frozen fields, read-only views No — mutable by design
Examples agent_output, reflections, skillbook (view) skillbook (real), environment, dedup_manager
Validated by engine? Yes — requires/provides No — runtime error if missing

Protocols

Steps depend on protocols, not concrete classes. Each protocol defines the minimal interface a step needs. Concrete implementations satisfy them structurally — no inheritance required.

Protocol Method Used by Satisfied by
AgentLike generate(question, context, skillbook, reflection, **kwargs) → AgentOutput AgentStep Agent
ReflectorLike reflect(question, agent_output, skillbook, ground_truth, feedback, **kwargs) → ReflectorOutput ReflectStep Reflector, RRStep
SkillManagerLike update_skills(reflections, skillbook, question_context, progress, **kwargs) → SkillManagerOutput UpdateStep SkillManager
DeduplicationManagerLike get_similarity_report(skillbook) → str | None DeduplicateStep DeduplicationManager

Roles take a model string directly (e.g. Agent("gpt-4o-mini")). Internally each role creates a PydanticAI agent that handles structured output natively — no separate LLM client protocol is needed.

Why protocols, not ABC: Protocols use structural typing (duck typing checked by mypy). A class satisfies a protocol if it has the right methods — no class Agent(AgentLike) inheritance needed. Users can pass any object with a matching method, mocks satisfy protocols without ceremony, and steps are decoupled from implementations at the type level.


Roles (Implementations)

Concrete LLM-based implementations of the role protocols. Live in ace/implementations/ — fully self-contained.

Class Protocol Method What it does
Agent AgentLike generate() Produces answers using the current skillbook of strategies
Reflector ReflectorLike reflect() Single-pass analysis of agent outputs to extract lessons
RRStep ReflectorLike + StepProtocol reflect() / __call__() Recursive multi-iteration reflection via PydanticAI agent with tools
SkillManager SkillManagerLike update_skills() Transforms reflections into actionable skillbook updates

All three share the same constructor pattern: __init__(self, model: str, *, prompt_template=..., max_retries=3). The model parameter is resolved via resolve_model() to a PydanticAI agent.

RRStep is both a StepProtocol[ACEStepContext] (composable in any pipeline) and ReflectorLike (usable as a drop-in reflector). It is a subclass of RecursiveAgent with execute_code and recurse tools, plus two-tier compaction and depth-based recursion. See RR_DESIGN.md for the full Recursive Reflector architecture.


Steps

Reusable step implementations in ace/steps/. Each satisfies StepProtocol[ACEStepContext]. Each step does exactly one thing.

Design principle: steps are stateless. A step's __call__ is a pure function of its constructor arguments and the incoming ACEStepContext. No internal counters, no accumulated state between invocations. Run-scoped information (like a global sample index for interval logic) comes from the context.

Step summary

Step Requires Provides Side effects max_workers
AgentStep sample, skillbook agent_output None 1
EvaluateStep sample, agent_output trace None 1
ReflectStep trace, skillbook reflections None 3; async_boundary = True
UpdateStep reflections, skillbook skill_manager_output Agentic SkillManager mutates skillbook directly via ADD / UPDATE / REMOVE / TAG tools; output is an audit log 1
DeduplicateStep global_sample_index Consolidates similar skills 1
CheckpointStep global_sample_index Saves skillbook to disk 1
LoadTracesStep sample trace None 1
PersistStep skillbook Writes skillbook to external file 1
ExportSkillbookMarkdownStep skillbook Exports skillbook as markdown 1

Requires vs Injected: Requires lists context fields (validated by the pipeline engine at construction time). The skillbook on the context is a SkillbookView (read-only). Steps that write to the skillbook receive the real Skillbook via constructor injection.

trace as the universal learning input: The learning tail's entry point (ReflectStep) requires only trace and skillbook. In the standard ACE pipeline, EvaluateStep bundles structured fields into a trace dict. In TraceAnalyser, _build_context places the raw trace directly. In integrations, the execute step provides trace from its framework's native output. The learning tail is agnostic to trace format.

Steps with empty provides are pure side-effect steps — they mutate shared state (skillbook) or write to external systems (disk) but add no new fields to the context.


Runners

Class hierarchy

ACERunner (shared infrastructure: epoch loop, delegates to Pipeline.run())
├── TraceAnalyser       — [Reflect → Update → Apply]
├── ACE                 — [Agent → Evaluate → Reflect → Update → Apply]
├── BrowserUse          — [BrowserExecute → BrowserToTrace → learning_tail]
├── LangChain           — [LangChainExecute → LangChainToTrace → learning_tail]
├── ClaudeCode          — [ClaudeCodeExecute → ClaudeCodeToTrace → learning_tail]
└── OpenClaw (script)   — [LoadTraces → OpenClawToTrace → learning_tail]

ACELiteLLM (standalone convenience wrapper — not an ACERunner subclass)
├── ask()               — direct Agent call, no pipeline
├── learn()             — delegates to lazy-init ACE runner
├── learn_from_traces() — delegates to lazy-init TraceAnalyser
└── learn_from_feedback()— runs learning_tail from last ask()

RRStep (RecursiveAgent subclass — composable iterative step)
├── __call__()          — StepProtocol entry; usable in any runner's pipeline
├── reflect()           — ReflectorLike entry; drop-in reflector for runners
└── _run_reflection()   — PydanticAI agent with execute_code and recurse tools

All runners compose a Pipeline rather than extending it.

ACERunner — shared base

Encapsulates everything runners have in common: the epoch loop and Iterable validation. Per-sample iteration, error handling, background execution, and checkpoints are all delegated to Pipeline.run().

Subclasses only override run() (public signature) and _build_context() (input mapping).

Responsibilities:

Concern Owner
Epoch loop + Iterable validation ACERunner._run()
Per-sample iteration + error isolation Pipeline.run()
Foreground/background split Pipeline.run() (via async_boundary)
Concurrent workers Pipeline.run(workers=N)
Checkpoints CheckpointStep (in the pipeline)
Background drain ACERunner.wait_for_background()Pipeline.wait_for_background()
Skillbook I/O save(path) on the runner

Each sample is independent — no state persists across samples. The skillbook is the only cross-sample coupling.

Eventual consistency: SkillbookView is a thin delegation wrapper, not a snapshot — it reads from the live Skillbook at call time. When background learning is active, concurrent samples may observe partially-updated skillbook state. This is by design: steps see a best-effort view rather than a point-in-time snapshot. The trade-off is acceptable because (1) the skillbook is LLM prompt context where a few missing or extra skills have negligible impact, (2) serialising reads would eliminate the concurrency benefit, and (3) write steps already run with max_workers = 1.

TraceAnalyser

Analyses pre-recorded traces without executing an agent. Runs the learning tail only. Accepts raw trace objects of any type.

When to use: You have execution logs from an external system and want to build or refine a skillbook from historical data. Multi-epoch mode re-processes all traces with the evolving skillbook.

Pipeline:

[ReflectStep] → [UpdateStep]   (SkillManager mutates the skillbook directly)

No AgentStep, no EvaluateStep. The trace already contains the agent's output and the evaluation feedback.

Multi-epoch semantics: Each epoch re-processes all traces with the current skillbook. Early epochs extract obvious patterns; later epochs refine and consolidate.

ACE

The full live adaptive pipeline. An agent executes, the reflector analyses, the skill manager updates. Optionally evaluates against a TaskEnvironment for feedback-driven learning.

When to use: Building a new agent, or running closed-loop learning where the agent improves in real time.

Pipeline:

[AgentStep] → [EvaluateStep] → [ReflectStep] → [UpdateStep]   (SkillManager mutates the skillbook directly)

A single class handles both single-pass (epochs=1) and multi-epoch batch training (epochs > 1). The environment is optional — when provided, EvaluateStep generates feedback. When omitted, the Reflector learns from ground-truth comparison or the agent's reasoning alone.

ACELiteLLM — standalone convenience wrapper

ACELiteLLM is not an ACERunner subclass. It wraps two different runners (ACE and TraceAnalyser) and exposes a fundamentally different API:

Method What it does
ask(question, context) Direct Agent call — no pipeline. Stores interaction for learn_from_feedback()
learn(samples, environment, epochs) Delegates to lazy-init ACE runner
learn_from_traces(traces, epochs) Delegates to lazy-init TraceAnalyser
learn_from_feedback(feedback, ground_truth) Manual single-shot learning from last ask() call

Runners are cached and invalidated on load() (new skillbook object means stale references).

Factory methods

All runners provide a from_roles factory that takes pre-built role instances. Integration runners also provide from_model() that auto-builds PydanticAI-backed roles from a model string.

Common parameters on from_roles:

Parameter Default Description
skillbook Skillbook() Starting skillbook
dedup_manager None Appends a DeduplicateStep
dedup_interval 10 Deduplication frequency
checkpoint_dir None Appends a CheckpointStep
checkpoint_interval 10 Checkpoint frequency
extra_steps None Additional steps appended after the learning tail

learning_tail() — reusable learning steps

Every integration assembles the same [Reflect → Update → Apply] suffix. learning_tail() returns this standard step list, with optional dedup and checkpoint steps. If the provided reflector already exposes provides = {'reflections'} (e.g. RRStep), it's inserted directly instead of being wrapped in ReflectStep.


Integration Pattern

External frameworks integrate via composable pipeline steps in ace/integrations/. Each integration provides:

  1. Result type — an integration-specific dataclass (e.g. BrowserResult, ClaudeCodeResult)
  2. Execute step — INJECT skillbook context + EXECUTE the framework, writes to ctx.trace
  3. ToTrace step — converts the integration-specific result into the standardised trace dict

Execute → Convert → Learn

Standard ACE:      [Agent → Evaluate]                          → [Reflect → Update → Apply]
                    ╰── execute (built-in) ──╯                    ╰──────── learn (shared) ──────╯
                         provides: trace (dict) ─────────────────► requires: trace

Browser-use:       [BrowserExecute] → [BrowserToTrace]         → [Reflect → Update → Apply]
                    ╰── execute ────╯   ╰── convert ──╯           ╰──────── learn (shared) ──────╯
                    provides: trace      rewrites trace             requires: trace
                    (BrowserResult)      (BrowserResult → dict)

TraceAnalyser:     [_build_context]                            → [Reflect → Update → Apply]
                    ╰── sets ctx.trace (raw object) ───────╯      ╰──────── learn (shared) ──────╯

The standardised trace dict keys match what ReflectStep expects: question, reasoning, answer, skill_ids, feedback, ground_truth.

Result types

Integration Result type Key fields
Browser-use BrowserResult task, success, output, error, steps_count, duration_seconds, cited_skill_ids, chronological_steps, raw_history
Claude Code ClaudeCodeResult task, success, output, execution_trace, returncode, error
Claude SDK ClaudeSDKResult task, success, output, error, model, stop_reason, input_tokens, output_tokens, tool_calls, cited_skill_ids
LangChain LangChainResult task, output, result_type, success, error, intermediate_steps, messages, raw_result

Why two steps instead of one

Splitting execute from trace conversion gives independent testability, reusability (execute step usable standalone), and separation of concerns (framework interaction vs trace formatting).

Live vs offline

Integration Runner TraceAnalyser
When Live execution Post-hoc analysis
Agent Framework runs it Already ran
Feedback Generated live Baked into trace
Use case Production deployment Historical batch learning, debugging

Both update the same skillbook. A common workflow: TraceAnalyser builds an initial skillbook from historical data, then an integration runner refines it during live deployment.

MCP Server is a different pattern. It does not add pipeline steps — it's a thin async layer over ACELiteLLM that exposes ACE as an MCP tool provider. See MCP Server docs.


Configuration & Providers

Principles

  1. API keys never appear in ACE APIs — no api_key parameter anywhere. Keys are resolved from the environment by LiteLLM at call time.
  2. Per-role model selection — Agent, Reflector, and SkillManager can each use different models.
  3. Validate before runningace setup and validate_connection() make a tiny LLM call to verify auth before writing code.

Config types

  • ModelConfig — which model to use for a role (model string, temperature, max_tokens). No secrets.
  • ACEModelConfig — model selection per ACE role. Serialises to/from ace.toml (committable, no secrets).

Construction paths

Constructor Input Use case
ACELiteLLM.from_setup() ace.toml + .env Teams, CI, guided setup
ACELiteLLM.from_config(config) ACEModelConfig object Per-role model selection in code
ACELiteLLM.from_model("gpt-4o") Model string Quick start, single model
ACELiteLLM("gpt-4o-mini", ...) Model string + overrides Full control

CLI

Command What it does
ace setup Interactive wizard: model name, API key, validate, assign per-role models. Saves .env + ace.toml.
ace models [query] Search LiteLLM's model registry (2,600+ models). Filter by --provider.
ace providers List providers with env var names and key status.
ace validate <model> Test a model connection with a tiny LLM call.

File layout

File Secrets? Committable? Purpose
.env Yes No (gitignored) API keys only
ace.toml No Yes Model names + parameters per role

Key resolution flow

API Key:  .env → os.environ → LiteLLM reads OPENAI_API_KEY / ANTHROPIC_API_KEY / etc.
Model:    ace.toml → ACEModelConfig.for_role("agent") → resolve_model(model) → PydanticAI agent

Provider resolution

ACE model strings follow LiteLLM convention (provider/model). The resolver in ace/providers/pydantic_ai.py routes them through three paths:

  1. PydanticAI-native prefix — strings like openai:gpt-4o pass through unchanged
  2. LiteLLM prefix → native provider — when the first path segment matches a PydanticAI native provider, / is rewritten to : (e.g. bedrock/modelbedrock:model)
  3. Fallback — everything else is prefixed with litellm: for the proxy provider
LiteLLM string                                  → PydanticAI string
─────────────────────────────────────────────────  ──────────────────────────────────────────
gpt-4o-mini                                      → litellm:gpt-4o-mini
bedrock/eu.anthropic.claude-haiku-4-5-v1:0       → bedrock:eu.anthropic.claude-haiku-4-5-v1:0
groq/llama-3.1-70b-versatile                     → groq:llama-3.1-70b-versatile
openrouter/anthropic/claude-3.5-sonnet           → openrouter:anthropic/claude-3.5-sonnet
anthropic/claude-3-5-sonnet-20241022             → anthropic:claude-3-5-sonnet-20241022
ollama/llama3                                    → litellm:ollama/llama3
together_ai/meta-llama/Llama-3-70b               → litellm:together_ai/meta-llama/Llama-3-70b

Mapped LiteLLM prefixes: anthropic, azure, azure_ai, bedrock, cohere, deepseek, groq, mistral, openrouter, vertex_ai. All others fall through to litellm:.

Native providers are faster (no proxy hop) and use the provider's own API key env vars directly. Install with extras: uv add "pydantic-ai-slim[anthropic,openai,bedrock]".


Deduplication

Skill deduplication subsystem in ace/deduplication/ — fully self-contained.

Class Role
SimilarityDetector Computes embeddings, detects similar pairs via cosine similarity
DeduplicationManager Coordinates detection and consolidation

Embedding providers: LiteLLM (remote) or sentence-transformers (local, lazy-loaded).

Consolidation operations:

Operation Effect
MergeOp Combine skills — accumulate counters, soft-delete others
DeleteOp Soft-delete a redundant skill
KeepOp Store a similarity decision so the pair is not flagged again
UpdateOp Refine content to differentiate, clear embedding

Pipeline integration: Deduplication runs as a separate DeduplicateStep at a configurable interval, not inside the SkillManager. Appended by factory methods when a DeduplicationManagerLike is provided.


Observability

PydanticAI has first-class Logfire integration. One call auto-instruments everything:

logfire.configure()
logfire.instrument_pydantic_ai()

This automatically captures agent runs, tool calls, model requests, structured output validation, and sub-agent delegation — the entire RR execution appears as a structured trace. No custom span-building code.

Pipeline integration: Logfire is OpenTelemetry-based, so pipeline-level spans coexist. Steps that don't use PydanticAI can use logfire.span() / logfire.info() directly.

Setup: ace/observability/configure_logfire() auto-instruments all PydanticAI agents. Opt-in via ACELiteLLM(logfire=True). Config is purely env-based (LOGFIRE_TOKEN), no changes to ace.toml.


Concurrency

Both TraceAnalyser and ACE inherit async capabilities from the pipeline engine. No custom async machinery is needed.

ReflectStep as async boundary

ReflectStep.async_boundary = True means everything before it (Agent, Evaluate) runs in the foreground, and everything from ReflectStep onwards runs in a background thread pool:

sample 1:  [AgentStep] [EvaluateStep] ──fire──► [ReflectStep] [UpdateStep]
sample 2:  [AgentStep] [EvaluateStep] ──fire──► ...
                                       ↑
                                 async_boundary

Concurrency knobs

Knob Where Effect
ReflectStep.max_workers = 3 Step class attribute Up to 3 reflections in parallel
UpdateStep.max_workers = 1 Step class attribute Serialises skill manager LLM calls AND skillbook writes (SM tools mutate in place)
wait_for_background(timeout) Runner method Blocks until background threads drain

Cancellation

ACE inherits cancellation from the pipeline engine. Pass a CancellationToken to run(). The pipeline checks it before each foreground step. Within a step, PydanticAI's async runtime handles cancellation natively. The token flows via contextvars.ContextVar — no parameter changes needed across layers. See PIPELINE_DESIGN.md § Cancellation.


Error Handling

Follows the pipeline engine's error model without additions.

  • Per-sample isolation: A failing sample does not abort the run. The exception is recorded in SampleResult.error and SampleResult.failed_at.
  • Background failures: Captured and attached to SampleResult by the pipeline engine.
  • No retry logic in the runner. Retries are the responsibility of individual steps (e.g., PydanticAI's built-in retry with error feedback).

Directory Structure

ace/
  __init__.py               ← Public API re-exports
  core/
    context.py              ← ACEStepContext, SkillbookView, ACESample
    insight_source.py       ← TraceIdentity, TraceReference, InsightSource
    outputs.py              ← AgentOutput, ReflectorOutput, SkillManagerOutput
    skillbook.py            ← Skill, Skillbook, SimilarityDecision
    environments.py         ← Sample, TaskEnvironment, SimpleEnvironment
  protocols/                ← Role protocols (one file per protocol)
    agent.py, reflector.py, skill_manager.py, deduplication.py
  implementations/          ← PydanticAI-backed role implementations
    agent.py, reflector.py, skill_manager.py, helpers.py, prompts.py
  steps/                    ← Pipeline steps (one file per class)
    __init__.py             ← learning_tail() helper
    agent.py, evaluate.py, reflect.py, update.py,
    apply.py, deduplicate.py, checkpoint.py,
    load_traces.py, persist.py, export_markdown.py, observability.py
  runners/                  ← Runner classes
    base.py                 ← ACERunner
    trace_analyser.py, ace.py, browser_use.py, langchain.py,
    claude_code.py, litellm.py
  integrations/             ← Integration steps (execute + result + converter)
    browser_use.py, langchain.py, claude_code.py, claude_sdk.py
    openclaw/               ← OpenClaw trace converter
    mcp/                    ← Optional MCP server
  providers/                ← PydanticAI model resolution
    pydantic_ai.py, config.py, registry.py
  deduplication/            ← Skill deduplication subsystem
    detector.py, manager.py, operations.py, prompts.py
  rr/                       ← Recursive Reflector (PydanticAI agent)
  observability/            ← Logfire configuration

Key modules

Module Contents
ace/core/ ACEStepContext, SkillbookView, Skillbook, AgentOutput, ReflectorOutput, InsightSource
ace/protocols/ AgentLike, ReflectorLike, SkillManagerLike protocols
ace/implementations/ PydanticAI-backed Agent, Reflector, SkillManager
ace/steps/ All pipeline steps + learning_tail()
ace/runners/ ACERunner, TraceAnalyser, ACE, BrowserUse, LangChain, ClaudeCode, ACELiteLLM
ace/providers/ resolve_model, ACEModelConfig, validate_connection
ace/steps/rr_step.py RRStep (RecursiveAgent subclass), RRConfig, TraceSandbox
ace/core/recursive_agent.py RecursiveAgent, AgenticConfig, AgenticDeps, compaction, recursion, usage_callback hook
ace/core/metered_model.py MeteredModel — pydantic-ai WrapperModel that fires the usage_callback once per request
ace/integrations/ Execute steps, result types, ToTrace converters; MCP server
ace/deduplication/ Dedup subsystem (detector, manager, operations)
ace/observability/ Logfire configuration (configure_logfire())

Future Directions

Issues acknowledged but deferred.

Streaming / lazy iteration: _run() eagerly materializes the full iterable before passing to Pipeline.run(). True streaming would require the pipeline to accept an iterator. Deliberate simplification — revisit if memory pressure from large single-pass runs becomes real.

Builder API for custom pipelines: The current API offers two extremes: factory methods that hide the pipeline, and manual construction that requires understanding step contracts. A builder could bridge this gap, but learning_tail() covers the most common customisation (custom execute step + standard learning). Worth pursuing when users hit friction with manual wiring.

Skillbook rollback and versioning: Currently the skillbook is mutated in place with no undo. A lightweight versioning mechanism (snapshotting at epoch boundaries, rollback(to_version)) would enable automatic revert when metrics degrade. Deferred because checkpoints cover the common recovery scenario.

LiteLLM proxy base URL support: Users running a LiteLLM proxy may need api_base configuration. Deferred because all current users connect directly to providers.