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