logic-engine / docs /design /ACE_ARCHITECTURE.md
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
|
Raw
History Blame Contribute Delete
33.8 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](ACE_REFERENCE.md).
For design decisions and rejected alternatives, see [ACE_DECISIONS.md](ACE_DECISIONS.md).
For the pipeline engine, see [PIPELINE_DESIGN.md](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:
```python
@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.
- **Runtime** β€” `AttributeError` 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](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](../integrations/mcp.md).
---
## 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 running** β€” `ace 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/model` β†’ `bedrock: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:
```python
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](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.