Spaces:
Sleeping
Sleeping
| # 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. | |