File size: 10,965 Bytes
116524e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# Design Decisions

> What was considered and rejected for ACE and the PydanticAI migration β€” and why.

For architecture and concepts, see [ACE_ARCHITECTURE.md](ACE_ARCHITECTURE.md).
For code reference and examples, see [ACE_REFERENCE.md](ACE_REFERENCE.md).

---

## PydanticAI Migration

### What we replaced and why

ACE had three hand-rolled LLM client implementations (LiteLLMClient, InstructorClient, ClaudeCodeLLMClient) with inconsistent retry/validation behavior, ~3,500 lines of custom agent-loop plumbing in the Recursive Reflector, and manual code extraction via regex. PydanticAI handles all of this as maintained infrastructure.

| Before | After |
|---|---|
| 3 LLM client implementations | PydanticAI agents inside roles |
| Manual JSON extraction + Pydantic parse | PydanticAI validates via tool-call schema, retries with error feedback |
| 3 blind retries or Instructor | PydanticAI native (configurable, with error context) |
| LiteLLM wrapper for provider support | PydanticAI (native support for 15+ providers, wraps LiteLLM internally) |
| Custom SubRunner loop (~400 lines) | PydanticAI's agent loop β€” LLM calls tools until it produces structured output |
| 200 lines of regex code extraction | Tool args are pre-parsed (code arrives as `execute_code` parameter) |
| Inner pipeline steps (~500 lines) | ~50 lines of tool definitions |
| CallBudget + SubAgentLLM (~200 lines) | `ctx.usage` shared budget + delegate agent |
| Custom `RRIterationContext` | PydanticAI manages message state internally |
| Manual Opik span building (~356 lines) | `logfire.instrument_pydantic_ai()` auto-instruments everything |

**Net result for RR:** ~3,500 lines β†’ ~1,000 lines (sandbox + prompts + trimming + agent definition). ~2,500 lines of loop/extraction/budget/context plumbing deleted.

### What we kept

- **Pipeline engine** (`pipeline/`) β€” `requires`/`provides` contracts, `async_boundary`, per-step `max_workers`, `SampleResult` error isolation. No framework offers this combination.
- **Skillbook & learning loop** β€” Reflect β†’ Update β†’ Apply β†’ Deduplicate. This is core IP.
- **Step composition** β€” `learning_tail()`, pipeline-as-step nesting, `SkillbookView` read/write split.
- **Domain-specific prompts** β€” tightly coupled to skillbook format and ACE's reflection strategy.
- **All pipeline steps** β€” they depend on protocols, not implementations. Completely unchanged.

### Provider resolution design

The resolver routes LiteLLM model strings to PydanticAI through three paths:

1. **PydanticAI-native prefix** β€” pass through unchanged
2. **LiteLLM prefix β†’ native provider** β€” rewrite `/` to `:` when the prefix matches a native provider. This is necessary because PydanticAI's `litellm` provider uses an OpenAI-compatible HTTP client under the hood, which doesn't work for providers with non-OpenAI APIs (Bedrock via SigV4, Anthropic's native API, etc.).
3. **Fallback** β€” prefix with `litellm:` for the proxy provider

User-facing API is unchanged β€” same LiteLLM model strings as before.

---

## ACE Architecture Decisions

**Runner extends Pipeline:**
Making TraceAnalyser and ACE subclasses of `Pipeline` was considered. Rejected β€” the runner is not a pipeline. It owns the epoch loop. Composition (`self.pipeline`) keeps responsibilities separate.

**Cross-sample state (reflection window):**
A rolling window of recent reflections that persists across samples was considered, with variants: on the runner, on `StepContext`, on step instances, via a shared mediator object. All rejected β€” each sample should be independent. The only cross-sample coupling is the skillbook itself. Adding a reflection window complicates the model (reset between epochs, eventual consistency with background steps, ordering issues with concurrent workers) for marginal benefit.

**Separate Online and Offline classes:**
Keeping two runner classes for single-pass and multi-epoch was considered. Rejected β€” the only difference is `epochs=1` vs `epochs > 1`, which is a parameter, not a class distinction. ACE handles both. TraceAnalyser is a separate class because its input type is fundamentally different (raw traces vs `Sample + Environment`).

**Structured Trace dataclass:**
A `@dataclass Trace` with typed fields (`task`, `output`, `feedback`, `reasoning`, etc.) was considered. Rejected β€” it imposes a schema on trace data that doesn't match reality. External frameworks produce wildly different trace shapes (browser-use `AgentHistoryList`, LangChain result dicts, Claude Code transcripts). Forcing them through a common dataclass means either losing information or adding catch-all `metadata` buckets that defeat the purpose of typing. Instead, `ctx.trace` is `object | None` and the Reflector makes sense of whatever it receives.

**Steps that accept both traces and samples:**
Making ReflectStep and UpdateStep polymorphic over input type was considered. Rejected β€” steps always receive `StepContext` with the same named fields. The runner (`_build_context`) is responsible for building the context correctly.

**Observability in the runner:**
Keeping observability logic in `ACERunner._track_observability_data()` was considered. Rejected β€” it mixes concerns. Observability is handled by Logfire auto-instrumentation.

**Custom AsyncLearningPipeline:**
The legacy `ace/async_learning.py` implements a manual thread pool with reflector and skill manager queues. Rejected β€” the pipeline engine's `async_boundary` and `max_workers` provide the same functionality with less code and consistent semantics.

**Per-integration pipeline classes:**
Having each integration define its own pipeline class was considered. Rejected β€” every integration pipeline has the same learning tail; only the execute step differs. Instead, integrations provide execute steps that compose into an `ACERunner` subclass, reusing the shared `_run()` loop.

**Checkpoints in the runner:**
Having the runner own checkpoint logic (via `run()` parameters) was considered. Rejected β€” a `CheckpointStep` at the end of the pipeline tail keeps checkpointing within the pipeline formalism. Configuration belongs at construction time (factory methods), not at call time (`run()`).

**Mutable Skillbook directly on the context:**
Storing the real `Skillbook` as a field on `ACEStepContext` was the initial design. Rejected β€” `StepContext` is frozen, but `Skillbook` is mutable. Placing it on the context creates the illusion of immutability while allowing any step to mutate shared state through the reference. Instead, the context carries a `SkillbookView` (read-only projection). Write steps receive the real `Skillbook` via constructor injection.

**Injection is ground truth; citation dropped.**
Earlier the Agent "cited" skills by writing `[skill-id]` markers in its reasoning, the Reflector scanned the text to produce `skill_tags`, and the SkillManager consumed those tags. Rejected β€” citation scanning does not scale: it is fragile (regex over free-form text), biased (agents forget to cite skills they used), and asks the Reflector to dictate downstream state mutation. Replaced with injection-based attribution: the `AgentStep` records `ctx.injected_skill_ids` (the set of active skills rendered into the agent prompt) and bumps `used_count` on each skill. The SkillManager β€” not the Reflector β€” now decides helpful/harmful/neutral per injected skill, using atomic `tag_skill` / `remove_skill` tool calls against the real Skillbook. The Reflector produces pure analysis; `skill_tags` and the `SkillTag` output type were removed.

**SkillManager mutates directly; Reflector is analysis-only.**
The old SkillManager was a one-shot PydanticAI agent that emitted an `UpdateBatch` of planned operations, and a separate `ApplyStep` applied them to the skillbook. Rejected β€” two-phase (plan β†’ apply) forced the LLM to commit to decisions without inspecting the skillbook, and serialisation of operations meant the agent could not dedupe-before-ADD, inspect counters, or sandbox-verify candidate strategies. Replaced with an agentic SkillManager built on `RecursiveAgent` with atomic mutation tools (`add_skill`, `update_skill`, `remove_skill`, `tag_skill`) and read-only inspection tools (`search_skills`, `read_skill`). Tools operate on the real `Skillbook` immediately β€” there is no staging. `ApplyStep` was deleted; `UpdateStep` is the sole SM invocation and the skillbook is already mutated when it returns. `SkillManagerOutput` now carries a post-hoc audit trail of the operations the tools executed, not a plan to be applied. `UpdateStep.max_workers=1` is preserved and now guards the mutation critical section in addition to serialising LLM calls. `AgenticConfig.max_requests` controls loop budget; `max_requests=1` approximates the old one-shot behaviour. `UpdateBatch` / `apply_update` / `_apply_operation` are retained for offline reconstruction and tests but the online path no longer flows through them.

**Instructor auto-wrapping in implementations:**
The old `ace/roles.py` auto-wrapped LLM clients with Instructor if `complete_structured` was missing. Rejected β€” PydanticAI handles structured output natively via its `result_type` parameter.

**Recursive Reflector (initial rejection, now implemented):**
The old `ace/reflector/` subsystem supports recursive mode. Initially rejected for `ace` due to complexity. Now implemented as `RRStep` β€” a PydanticAI agent-based step that runs an iterative REPL loop. Satisfies both `StepProtocol[ACEStepContext]` and `ReflectorLike`.

**Observability decorator on implementations:**
The old `ace/roles.py` uses `@maybe_track()` decorators for Opik tracing on every role method. Rejected β€” Logfire auto-instrumentation handles observability. Per-method decorators would double-count and create coupling.

**Deduplication inside SkillManager:**
The old `ace/roles.py` SkillManager integrates with `DeduplicationManager` directly. Rejected β€” deduplication is now a separate `DeduplicateStep` in the pipeline. Cleaner separation: the SkillManager only produces output, deduplication runs at a configurable interval.

**Shared `ace/features.py` module:**
A centralized feature detection module was considered. Rejected β€” the only code that needs it is `deduplication/detector.py`, which uses a local `_has(module)` helper. A shared module would add a file for a single 4-line function.

**Separate wrapper classes for integration runners:**
Separate convenience classes (`ACEAgent`, `ACELangChain`, `ACEClaudeCode`) wrapping the runners were the initial design. Rejected β€” the wrappers only added `from_model()` and a few lifecycle helpers, which fit naturally on the runner class itself. Two classes for one concept forces users to choose between them. Exception: `ACELiteLLM`, which wraps two runners and exposes a fundamentally different API.