Topology optimization¶
GEPA evolves the prompts inside a fixed topology (Optimization). This battery evolves the topology itself — discovers Pipeline / Parallel / Router compositions from real traces against a small gold set. Same Pareto frontier contract; different mutation engine.
The implementation is ADAS-inspired (Hu, Lu, Clune 2024, arXiv 2408.08435) — Meta Agent Search with archive-based reflective evolution — but diverges deliberately on the search surface: the meta agent emits typed TopologySpec JSON, not raw Python. There is no exec(), no sandbox, no code-execution failure modes. Expressivity is capped to compositions of registered primitives; the cap is the deliberate price of safety.
What problem does this solve?¶
Two complementary failure modes in the GEPA-only world:
- Wrong structure. A single CoT agent can't compete with a CoT → Verifier Pipeline on inferential tasks; no amount of prompt evolution recovers what a missing structural step costs.
- Wrong specialization. A Router with an abstain-classifier in front handles off-topic inputs cleanly; a single agent with "abstain when unsure" baked into its prompt is unreliable at it.
This battery surfaces those structural axes as evolvable. The meta agent designs combinations of AgentStep / FunctionStep over Pipeline / Parallel / Router / RefinementLoop, evaluates each candidate against your gold set, and keeps the top-K on a Pareto frontier of accuracy / cost / latency / n_agents / depth.
The two-layer split¶
| Layer | Files | Responsibility |
|---|---|---|
| Orchestration IR | orqest/orchestration/spec.py, orqest/orchestration/hydrate.py |
Pydantic models + spec→runtime hydration. Independently useful (closes the audit-named "LLM cannot emit topology at runtime" gap regardless of search). |
| Search engine | orqest/optimization/topology.py, orqest/optimization/meta_agent.py |
TopologyGene + TopologyEvaluator + MetaAgentSearch loop (design → reflexion → evaluate → archive). |
Both ship together but the IR is the load-bearing piece — once TopologySpec exists, any search engine (MetaAgentSearch today, MCTS / GA / RL tomorrow) is a plug-in.
TopologySpec instead of raw Python — the deliberate cap¶
ADAS's public repo (ShengranHu/ADAS) calls exec(forward_str, globals(), namespace) directly in the search process — the paper claims "containerized execution," but the public code is in-process exec with try/except. This is fine for research, unacceptable for a library shipped to consumers.
Orqest takes the AFlow approach instead: meta agent emits a typed JSON document validated by Pydantic. The hydrator looks up agent and callable references against explicit registries — there is no eval, no exec, no name forgery. The emission surface is names from a user-controlled allowlist, nothing more.
The cap: candidates are restricted to compositions of registered primitives. Novel reasoning blocks (the kind ADAS occasionally discovers) are not expressible. We earn that expressivity back later (W3.C — orqest/sandbox/ + raw-Python codegen behind a Sandbox Protocol) only if users prove they need it.
The CallableRegistry contract¶
from orqest.orchestration.hydrate import CallableRegistry
cr = CallableRegistry()
cr.register("is_long", lambda x: len(str(x)) > 200)
cr.register("first_wins_priority", custom_merge)
cr.register("propose_next", state_updater)
print(cr.names()) # ['first_wins_priority', 'is_long', 'propose_next']
The meta agent receives cr.names() in its design prompt as the only legal callable references. Hydrating a spec that names "unknown_fn" raises KeyError — the search loop catches that, feeds the error back to the meta agent as debug feedback, and retries up to MetaAgentConfig.debug_max times.
The same pattern applies to the agent registry — a dict[str, Callable[[], BaseAgent]] of factories (not instances; factories avoid cached-_agent problems when the same agent appears in multiple candidates).
The MetaAgentSearch loop¶
from orqest.optimization import (
MetaAgentConfig, MetaAgentSearch, TopologyGene, TopologyEvaluator,
)
from orqest.orchestration.spec import PipelineSpec, AgentStepSpec, PipelineStepEntry
seed = PipelineSpec(steps=[PipelineStepEntry(operation=AgentStepSpec(agent_name="cot"))])
gene = TopologyGene(
name="main",
initial=seed,
constraints="Must end with an agent capable of abstaining for off-topic inputs.",
)
search = MetaAgentSearch(
MetaAgentConfig(n_generations=10, archive_strategy="top_k", archive_size=5),
gene=gene,
evaluator=TopologyEvaluator(
score_fn=score_fn,
callable_registry=callable_registry,
agent_registry=agent_registry,
),
meta_agent_model="openai:gpt-4.1",
api_key=api_key,
)
result = await search.run(trainset=gold_set)
Each generation:
- Design — the meta agent emits a
TopologyDesign(thought, spec)given the archive view (strategy-dependent), the TopologySpec JSON schema, the agent / callable allowlists, and anyTopologyGene.constraints. - Reflexion —
MetaAgentConfig.reflexion_passesrounds of "critique your own design and revise" (ADAS uses 2 by default). - Evaluate — hydrate the spec, run on a deterministically-sampled
minibatch_size-sized subset of the valset, score withTopologyEvaluator. On Pydantic ValidationError or hydration KeyError, the error is fed back as debug feedback (analogue of ADAS's traceback retry) and the design step retries. - Archive — append the
(spec, bundles, aggregate_score, thought)entry. The next generation sees a strategy-dependent archive view in its prompt.
After n_generations, the loop returns an OptimizationResult shaped identically to the GEPA path — same best_candidate / best_decoded / pareto_candidates / history / raw fields. Downstream consumers (apply_result, notebook visualizations) work without dispatch.
Archive strategies¶
| Strategy | What the meta agent sees on the next design | Use case |
|---|---|---|
top_k (default) |
Top archive_size entries by aggregate score |
Strongest signal-to-noise per the 2510.06711 critique. Recommended. |
cumulative |
Every entry seen so far | ADAS-paper-faithful. Tends to overflow context after ~20 entries and dilute strong signals. Use for reproducing the original paper. |
parallel |
Nothing | Each generation designs from scratch; selection is end-only. Surprisingly competitive per the same critique. Useful when archive context is biasing the meta agent toward incremental tweaks. |
All entries are retained in storage (so pareto() and best() see the full history); the strategy only affects what's shown to the next design step.
Two-phase composition with GEPA — never nest¶
Naive nesting (running GEPA inside each MetaAgentSearch iteration) compounds cost multiplicatively: O(N_topology × N_prompts × N_eval × |val_set|) LLM calls — four-to-five-figure dollar bills per discovery. Worse, topology mutations invalidate GEPA-learned prompts (a prompt evolved for LLM_debate(roles=[A,B,C]) does not transfer to Router(abstain | CoT-then-Verifier)), so the inner GEPA run wastes compute on a structure about to change.
Always two-phase: find topology with a fixed strong-prompt template; tune prompts on the winner.
# Phase 1: topology
topology_result = await MetaAgentSearch(...).run(gold_set)
winning_topology = topology_result.best_decoded["main"]
# Phase 2: prompts on the winner
prompt_genes = [PromptGene(name=f"{a}.system_prompt", initial=SEED[a]) for a in agents_in(winning_topology)]
prompt_runner = OptimizationRunner(
OptimizationConfig(...),
genome=Genome(genes=prompt_genes),
evaluator=Evaluator(agent_factory=evolved_topology_factory(winning_topology), score_fn=score_fn),
api_key=api_key,
)
final_result = await prompt_runner.optimize(trainset=gold_set)
Notebook 09_topology_with_gepa.ipynb demonstrates the full ablation with concrete numbers.
MetricBundle dimensions for topology¶
TopologyEvaluator reuses the standard MetricBundle with two extra structural dimensions in bundle.raw:
| Dimension | What it measures | Direction |
|---|---|---|
n_agents |
Total AgentStepSpec count in the hydrated topology |
lower is better (cost ceiling proxy) |
depth |
Maximum nesting depth | lower is better (latency ceiling proxy) |
These flow through to MetricBundle.to_per_instance_scores() automatically when surfaced; the optimizer's Pareto frontier prefers smaller / shallower topologies on accuracy ties without any further wiring.
Cost limitation (honest). Today, cost_usd=0.0 for topology evaluations because we don't aggregate Usage across multiple agents in the hydrated topology — each agent's Usage is local to its own BaseAgent.call_model invocation. Consumers wanting cost-as-fitness should pass a cost_estimator callable that walks the topology and sums per-agent token usage; the MetricBundle.raw extension point is the seam. This is a known limitation; per-step cost capture is on the future seams list below.
Apply: dry-run-by-default again¶
The same apply_result from the GEPA path handles topology values via the dict-target case:
from orqest.optimization import apply_result
topology_registry = {"main": seed_topology}
diffs = apply_result(result, target=topology_registry, dry_run=True)
for d in diffs:
if d.changed:
print(d.unified) # JSON pretty-printed diff for Pydantic values
# When ready, commit:
apply_result(result, target=topology_registry, dry_run=False)
# topology_registry["main"] is now the evolved TopologySpec
Dry-run is the default; commit is opt-in. Object-target and dict-target paths both work.
Cost reasoning¶
total_cost ≈ (n_generations × (1 + reflexion_passes) × meta_call_cost) # meta agent calls
+ (n_generations × minibatch_size × candidate_eval_cost) # candidate evaluations
+ (full_valset × candidate_eval_cost) # seed + final winner re-eval
Worked example with gpt-4o-mini ($0.15 per 1M input tokens, $0.60 per 1M output tokens), 10 generations, 2 reflexion passes, 5-example minibatch, 9-example full set, ~3 agents per candidate, ~500 tokens per call:
- Meta-agent design + reflexion: 10 × 3 × ~$0.005 ≈ $0.15
- Candidate evaluation (each runs ~3 agents × 500 tokens): 10 × 5 × ~$0.0075 ≈ $0.40
- Seed + winner full-set eval: 2 × 9 × ~$0.0075 ≈ $0.15
- Total: ~$0.70 per search.
Notebook 08_topology_search_basic.ipynb runs at this scale (~$1–2 with gpt-4.1).
Runtime topology design¶
MetaAgentSearch discovers topologies offline against a fixed gold set. RuntimeTopologyDesigner + TopologyOrchestrator complete the picture: the LLM designs a topology per request, hydrates it on the fly, runs it. Same TopologySpec IR, same topology_from_spec() hydrator — the only difference is when synthesis happens.
The framework already does runtime LLM-driven planning via MetaOrchestrator, but that planner emits a flat TaskDecomposition (a sequential list of SubTasks) — it can't produce branching, parallelism, refinement loops, or routing. TopologyOrchestrator is the topology-shaped sibling.
When to use which¶
| Setting | Use search-time MetaAgentSearch |
Use runtime TopologyOrchestrator |
|---|---|---|
| Workload shape | Stable — the same kind of task over and over | Variable — every request is meaningfully different |
| Latency budget | Generous (offline batch) | Tight (each request waits for synthesis or cache) |
| Compute per design | High (~\$1–5 per discovery) | Low (single LLM call + cache hit) |
| Output | One winning topology to deploy | Per-request topology |
Output shape vs. MetaOrchestrator |
(n/a) | Richer (full topology IR vs. flat subtask list) |
The two compose: offline search produces a Pareto-front library of validated topologies; runtime designer is seeded with that library and primarily picks or specializes from it, falling back to fresh design only on novel requests.
When NOT to use RuntimeTopologyDesigner — just hand-write a Pipeline¶
Worth saying out loud: the runtime designer adds a per-request LLM call. That cost is real (~1-3 s + a few cents). It's only worth paying when there's genuine structural variability across requests that the designer can exploit. If every request takes the same shape — same agents in the same order, same refinement loop, same routing logic — hand-writing the Pipeline is the right answer.
Three honest tests for "should I reach for this primitive":
| Test | Hand-write a Pipeline | Use RuntimeTopologyDesigner |
|---|---|---|
| Shape variability: how often does the optimal structure differ between requests? | Rarely; one structure handles 90%+ of the workload | Often — short questions want one shape, complex multi-step asks want another |
| Cache hit ratio (target): what fraction of requests will be similar enough to reuse a cached design? | N/A (no design) | >50% (otherwise the designer LLM call dominates latency) |
| Cost per request budget: how many cents can you spend on orchestration overhead? | ~free | ~$0.001–0.01 in extra LLM cost per cold request |
A simple rule: MetaOrchestrator's flat decomposition + a hand-written Pipeline covers most production workloads. Only escalate to RuntimeTopologyDesigner when you have measured evidence (or strong prior) that different request shapes need different orchestration shapes. The notebook 10 demo is deliberately a domain (mixed factual-QA + summarisation + verification requests) where this variability is real; not every consumer is in that situation.
Don't reach for RuntimeTopologyDesigner because it's the most sophisticated tool in the box. Reach for it because your workload has measured structural variability and you've ruled out simpler answers.
Where it lives — and an honest framing note¶
RuntimeTopologyDesigner lives in orqest.autonomy.runtime, alongside MetaOrchestrator (orqest.autonomy.meta) and TopologyOrchestrator (orqest.autonomy.topology_orchestrator). All three are runtime planners — they decide what to do for each incoming request.
The runtime designer is not an optimizer in the classical sense. There's no loss function, no per-request scoring, no Pareto archive — the cache's "online learning" is exception-driven invalidation (a topology that crashes during execution decays in reliability), not optimization over a quality signal. It shares the TopologySpec IR with MetaAgentSearch (which is an optimizer) because the IR is provenance-agnostic — but the relationship is shared infrastructure, not shared algorithm. Closing the loop into real online optimization requires the deferred W3.E (output-quality reliability signal) item below.
The four design calls¶
-
Designer-as-agent. The designer is a user-provided
BaseAgent[GlobalState, TopologyDesign], not a class that constructs its ownpydantic_ai.Agent. MirrorsMetaOrchestrator(planner: BaseAgent, ...). Lets you plug in your preferred model / hooks / reasoning effort. Pydantic-AI handles theTopologySpecJSON schema automatically via the structured-output mechanism — saves ~2-3k tokens per design call vs. the search-time path (which embeds the schema in the prompt). -
Cache pluggable behind a Protocol.
TopologyCacheis a Protocol with three concrete implementations:NoCache(default, zero state),InMemoryLRU(exact-match goal string, demos and tests), andMemoryStoreCache(production — backed byLocalMemoryStorewith semantic recall + reliability decay). -
TopologyOrchestratoris parallel toMetaOrchestrator, not a mode flag.MetaOrchestrator's flat-subtask shape is well-tested, has live consumers, and maps cleanly toExecutionPlanUI. New sibling class for the topology-shaped runtime; both ship. -
verify_on_hit=Trueby default; runtime design failure raises (no debug-retry). Cache hits validate the cached spec against current registries before returning (catches stale agent / callable references). Design failures raise immediately — runtime is latency-sensitive; the search-time debug-retry pattern is wrong here. Optionalfallback_speclets you provide a safe default.
MemoryStoreCache — online learning over topologies¶
The interesting piece. Discovered topologies become MemoryEntry(memory_type="semantic", source_agent="topology_cache") rows — one per goal — with the TopologySpec JSON in structured_content. Lookup uses LocalMemoryStore.recall() semantic similarity on the goal embedding; hit returns the prior spec.
from orqest.memory import LocalMemoryStore
from orqest.autonomy.runtime import MemoryStoreCache
store = LocalMemoryStore("topology_cache.db", embedder=my_embedder)
cache = MemoryStoreCache(store, threshold=0.85, namespace="topology_cache")
Embedder requirement (loud). Without an embedder configured on the backing store, recall falls back to FTS5 / LIKE — brittle for free-text goal similarity. The class still constructs and works (lookups just return None more often) so test wiring without an embedder doesn't crash, but production deployments should configure one.
Online learning is automatic. On TopologyOrchestrator.execute(goal):
- On success →
cache.store(goal, spec, success=True)adds (or refreshes) the entry. - On execution exception →
cache.store(goal, spec, success=False)callsMemoryStore.update_reliability(entry_id, success=False), which decays the reliability score via the existingPerKindConfig.decay_on_failuremachinery. WithMemoryStoreCache(min_reliability=0.3), chronically-failing entries drop below the floor and stop surfacing.
Why semantic memory_type and not procedural? Procedural memory's _validate_procedural_shape validator requires structured_content to be Skill-shaped — TopologySpec doesn't fit that shape and we'd be abusing the schema. Semantic with a namespaced source_agent keeps things clean; reliability decay still applies. If a future need surfaces (versioning topology library entries), revisit by relaxing the validator.
Seed-library bootstrap¶
Tying offline + runtime together:
# Once, offline (notebook 08):
search_result = await MetaAgentSearch(...).run(gold_set)
seed_library = [entry.spec for entry in search_result.raw.pareto()]
# Persist seed_library to disk: json.dump([s.model_dump() for s in seed_library], ...)
# At deploy time:
designer = RuntimeTopologyDesigner(
designer_agent=designer_agent,
callable_registry=callable_registry,
agent_registry=agent_registry,
seed_library=seed_library,
cache=MemoryStoreCache(memory_store, ...),
)
orchestrator = TopologyOrchestrator(designer, ...)
# Per request:
result = await orchestrator.execute(goal=incoming_goal)
The seed library is rendered into the design prompt as: "Library of validated topologies — prefer composing or specializing one of these; only design from scratch when none fit." That gives the designer a strong inductive bias toward proven structures while still allowing novel composition for genuinely-different requests. YAGNI today is "list of TopologySpec inline in the prompt"; when libraries grow past ~20 entries, a RetrievalPolicy Protocol layer (W3.D, deferred) becomes worthwhile.
Honest tradeoffs¶
- Per-request latency tax. A cold design call adds the meta-agent LLM round-trip (typically 1-3 s) before execution.
MemoryStoreCacheis the production answer — embedded recall is fast (single cosine pass over a few hundred entries) and a hit short-circuits the design call entirely. The break-even is roughly: "if cache hit ratio > 50%, runtime topology design beats fresh-design-every-time on average latency." - Cost compounds. Designer cost stacks on top of execution cost. For workloads where every request is similar (e.g., the same kind of QA over a fresh paragraph),
MetaOrchestrator's flat-subtask path is cheaper and good enough. UseTopologyOrchestratorwhen the structural variation across requests is real enough to pay for the design cost. - Decay only fires on exceptions, not bad outputs. Today reliability decay is exception-driven. A topology that runs cleanly but produces a wrong answer won't decay automatically. Wiring an
output_judgecallable intoTopologyOrchestrator.executeis the W3.E item that fixes this. - Asymmetry with
MetaAgentSearch. Search-time usespydantic_ai.Agentdirectly; runtime usesBaseAgent. Acceptable because search-time is internal-only (no consumer-side composition) while runtime is consumer-facing.
Notebook 10_runtime_topology.ipynb walks through cold design, cache hit reuse, the TopologyOrchestrator loop, seed-library bootstrap, and the online-learning failure decay end-to-end.
Caveat: LLM-generated tests as evaluator feedback¶
A pattern that looks attractive but reliably misfires on imperfect models: have an LLM generate additional test cases for the candidate code at evaluation time, then score the candidate against both the gold tests AND the LLM-generated ones. The intuition is appealing — "the designer agent knows the spec, it should be able to write good edge-case tests" — and we tried exactly this in V2 of the coding benchmark (benchmarks/coding/). It made the combo WORSE than the baseline.
Failure mode: the test-designer LLM hallucinates wrong expected values for its proposed tests. Whatever the test designer's confidence, on weaker models (gpt-4o-mini-tier) ~10–20% of generated tests have an incorrect expected output. The downstream fixer agent — which can't tell hallucinated tests from real ones — then "fixes" code that was actually correct, rewriting it to pass the wrong tests. The aggregate accuracy regresses.
This is the Unsupervised Evaluation Paradox applied to a runtime evaluator: if the LLM is competent enough to generate reliable test labels, it's competent enough to solve the task one-shot — so the test generation adds noise without adding signal. If the LLM isn't competent enough to solve the task one-shot, then any test labels it generates are equally unreliable, and using them as ground truth poisons the loop.
Where LLM-generated tests can work:
- As advisory feedback that doesn't gate exit. Surface them in the fixer's prompt as "consider these potential cases" without scoring against them — the fixer can take them as hints rather than ground truth.
- With cross-validation across diverse models. Generate tests with model A, score with B, only trust agreed-upon tests. Adds significant cost; only worth it for high-stakes domains.
- With explicit per-test confidence + filtering. Designer outputs a confidence; only tests above a high threshold (>0.9) are scored against. Discards most generated tests.
- When the candidate must produce a verifiable artifact (executable code, proof, schema) — the verifier IS the ground truth, the LLM-generated "tests" are just inputs to it.
What worked in benchmarks/coding/:
- Drop the test designer entirely.
- Score the candidate only against the visible examples shipped with the problem (the first
kreal test cases — labels the user wrote, not the LLM). - Use keep-best (
RefinementLoop(keep_best=True)) so a regression on visible tests never makes the final answer worse.
This is the conservative-but-correct shape. The combo wins because the agent gets to iterate using real-but-limited test feedback — not because new feedback got synthesized. If you find yourself reaching for "let an LLM generate the test labels," step back: you're probably dressing up unsupervised evaluation in a different costume.
What's next (deferred)¶
- W3.C —
orqest/sandbox/. ASandboxProtocol (subprocess + e2b implementations) so the meta agent can emit raw Pythonforward()for tasks where compositions of registered primitives are too constrained. This is the original ADAS surface; we ship it later only if users prove the expressivity ceiling matters. - MCTS search. AFlow-style MCTS over the same
TopologySpecIR — a one-strategy swap insideMetaAgentSearchonce aSearchStrategyProtocol abstraction lands. - Per-step cost capture. Walk the topology, hook
BaseAgent.call_model, sumUsageacross all agents. Plumbscost_usdthrough to the optimizer's Pareto front. Pipeline.to_spec()round-trip. The inverse oftopology_from_spec— lets users round-trip a hand-builtPipelineinto the IR for evolution.- Cross-task topology transfer. Discovered topologies as warm-starts for new tasks; requires a topology-similarity metric.
Runnable demos¶
notebooks/08_topology_search_basic.ipynb— ADAS-style offline topology searchnotebooks/09_topology_with_gepa.ipynb— two-phase: discover topology, then evolve promptsnotebooks/10_runtime_topology.ipynb— per-request synthesis with semantic cache