aflow port
A two-part document. Section 1 dissects Zhang et al. 2024
“AFlow: Automating Agentic Workflow Generation”
(ICLR 2025 Oral) — how optimizer.py's outer round
loop, score-softmax parent sampling, per-parent experience filtering, and
validation-rounds averaging actually work, with the original source annotated.
Section 2 documents the aflow AgentCanvas port
(namespace .claude/commands/architect/aflow/) — 5-skill
layout reusing most of adas-subagent's substrate plus the 4 algorithmic deltas
AFlow's MCTS-flavoured search demands.
section 1 upstream paper / code analysis
section 2 aflow port
scope AFlow-style — search policy ported, search space is free-form (no operator set)
optimizer.py is something narrower and worth naming
precisely: score-softmax + uniform-mix sampling over a flat
round list, with per-parent anti-replay filtering on modification
descriptions. There is no tree traversal, no UCB statistic, no
visit count, no backprop. The “tree” lives only in each
round's experience.json as a father_node field
the optimizer writes but never reads as a tree. Treat the MCTS framing as
rhetorical; treat the rest of this Section 1 as what AFlow really runs.
How the original AFlow paper's optimize() and
_optimize_graph() functions drive one round (source:
third_party/AFlow/scripts/optimizer.py:71–198
and the parent-sampling helper at
scripts/optimizer_utils/data_utils.py:61–109). The
orange parent-selection block + single LLM call is the load-bearing piece
— everything else is bookkeeping. Annotations below the SVG link
each diagram region to the exact upstream line.
Diagram ↔ code map:
optimizer.py:125–129 — first-round init: load
round_1/graph.py + prompt.py, evaluate (initial=True) so
the baseline has a score before any optimisation.optimizer.py:80
(for opt_round in range(self.max_rounds); default
max_rounds=20).optimizer.py:87–103 —
max_retries=1, the while retry_count < max_retries
loop runs at most once before score=None; this round produces no
child workflow.optimizer.py:132–183 — resample parent and re-call
LLM until the proposed modification is not a duplicate.optimizer.py:135 +
data_utils.py:40–59 — top-sample rounds by
mean score, with round 1 unconditionally included.data_utils.py:61–109
— p = λ⋅uniform + (1−λ)⋅softmax(α⋅score),
α=0.2, λ=0.3.optimizer.py:138–149 — reads parent's
graph.py + prompt.py + log.json
(3 random failure samples), plus the parent's
processed_experience.json.optimizer.py:156–160
— single optimize_llm.call_with_format(prompt,
XmlFormatter.from_model(GraphOptimize)).optimizer.py:164–174 + _extract_fields_from_response
— on schema-validation failure, raw response is regex-parsed for
<modification> / <graph> / <prompt> tags.optimizer.py:177–183 +
experience_utils.py:69–80 — if this exact
modification string already appears in the parent's success
OR failure history, regenerate.optimizer.py:186–190 — new
round_{N+1}/graph.py + prompt.py persisted; loaded
for the validation eval.optimizer.py:194 —
validation_rounds=5 separate evaluations are averaged into
avg_score.optimizer.py:196 +
experience_utils.py:91–95 —
experience.json = {father_node, modification, before, after, succeed=bool(after>before)}
written into the child round dir; the parent's anti-replay list grows by one entry.optimizer.py:108 +
convergence_utils.py:68–113 — top-3 average's
z⋅σ stability over consecutive_rounds=5; with
z=0 (the default), this fires only on exact equality and is
essentially advisory._optimize_graph()
Below is the verbatim _optimize_graph() function from
third_party/AFlow/scripts/optimizer.py:120–198,
plus the outer-loop frame from :71–118, with right-side
annotations tying each region back to a box / arrow in the diagram above.
Yes — the diagram is a faithful structural translation; every diagram
element has a corresponding code region marked here.
71 def optimize(self, mode: OptimizerType = "Graph"): 72 if mode == "Test": 73 ... 80 for opt_round in range(self.max_rounds): ◂— OUTER DASHED BOX (for opt_round in range(max_rounds)) 81 loop = asyncio.new_event_loop() 82 asyncio.set_event_loop(loop) 83 84 retry_count = 0 85 max_retries = 1 ◂— only 1 attempt; not a retry budget despite the variable name 86 87 while retry_count < max_retries: 88 try: 89 score = loop.run_until_complete(self._optimize_graph()) 90 break 91 except Exception as e: ◂— ANY Python exception during the round 92 retry_count += 1 93 logger.info(f"Error occurred: {e}. Retrying... ...") 94 if retry_count == max_retries: 95 logger.info("Max retries reached. Moving to next round.") 96 score = None score=None, round produces no child workflow 97 wait_time = 5 * retry_count 98 time.sleep(wait_time) 99 ... 105 self.round += 1 106 logger.info(f"Score for round {self.round}: {score}") 107 108 converged, convergence_round, final_round = self.convergence_utils.check_convergence(top_k=3) ◂— DIAMOND: check_convergence (top_k=3, z=0, consecutive_rounds=5) 109 110 if converged and self.check_convergence: 111 ... ◂— BOX: break outer loop (converged) 116 break 117 118 time.sleep(5)
120 async def _optimize_graph(self): 121 validation_n = self.validation_rounds # default 5 122 graph_path = f"{self.root_path}/workflows" 123 data = self.data_utils.load_results(graph_path) 124 125 if self.round == 1: ◂— TOP ROW: first-round init 126 directory = self.graph_utils.create_round_directory(graph_path, self.round) 127 self.graph = self.graph_utils.load_graph(self.round, graph_path) 128 avg_score = await self.evaluation_utils.evaluate_graph( baseline scored with validation_rounds=5 129 self, directory, validation_n, data, initial=True) 130 131 # Create a loop until the generated graph meets the check conditions 132 while True: ◂— INNER ORANGE DASHED BOX (anti-replay sampling loop) 133 directory = self.graph_utils.create_round_directory(graph_path, self.round + 1) 134 135 top_rounds = self.data_utils.get_top_rounds(self.sample) ◂— BOX: get_top_rounds (top-K by score) 136 sample = self.data_utils.select_round(top_rounds) ◂— BOX (orange): select_round softmax(α=0.2)+uniform(λ=0.3) mix; np.random.choice on top-K 137 138 prompt, graph_load = self.graph_utils.read_graph_files( ◂— BOX: build optimize_prompt (read parent files) 139 sample["round"], graph_path) 140 graph = self.graph_utils.extract_solve_graph(graph_load) 141 142 processed_experience = self.experience_utils.load_experience() 143 experience = self.experience_utils.format_experience( parent's success+failure history injected as natural language 144 processed_experience, sample["round"]) 145 146 operator_description = self.graph_utils.load_operators_description(self.operators) 147 log_data = self.data_utils.load_log(sample["round"]) 3 random failure samples from parent's log.json 148 149 graph_optimize_prompt = self.graph_utils.create_graph_optimize_prompt( 150 experience, sample["score"], graph[0], prompt, operator_description, self.type, log_data 151 ) 152 153 try: 154 graph_formatter = XmlFormatter.from_model(GraphOptimize) 155 156 response = await self.optimize_llm.call_with_format( ◂— BOX (orange): LLM single call 157 graph_optimize_prompt, XmlFormatter parses <modification> <graph> <prompt> tags 158 graph_formatter) 159 ... 163 logger.info(f"Graph optimization response received successfully") 164 except FormatError as e: ◂— RED FALLBACK: schema validation failed 165 logger.error(f"Format error in graph optimization: {str(e)}") 166 raw_response = await self.optimize_llm(graph_optimize_prompt) 167 168 response = self._extract_fields_from_response(raw_response) regex-extract <modification> <graph> <prompt> from raw text 172 if not response: 173 logger.error("Failed to extract fields from raw response, retrying...") 174 continue extraction failed → resample parent + re-call LLM 175 176 # Check if the modification meets the conditions 177 check = self.experience_utils.check_modification( ◂— DIAMOND: check_modification 178 processed_experience, response["modification"], duplicate of parent's success OR failure list? → False = regenerate 179 sample["round"] 180 ) 181 182 if check: ◂— DIAMOND NO branch: exit inner orange box 183 break 184 (DIAMOND YES branch: implicit `continue` — re-sample parent + re-call LLM) 185 186 # Save the graph and evaluate 187 self.graph_utils.write_graph_files(directory, response, self.round + 1, self.dataset) ◂— BOX: write_graph_files (round_{N+1} dir) 188 189 experience = self.experience_utils.create_experience_data(sample, response["modification"]) 190 191 self.graph = self.graph_utils.load_graph(self.round + 1, graph_path) 192 193 logger.info(directory) 194 195 avg_score = await self.evaluation_utils.evaluate_graph( ◂— BOX: evaluate_graph (validation_rounds=5, initial=False) 196 self, directory, validation_n, data, initial=False) 197 198 self.experience_utils.update_experience(directory, experience, avg_score) ◂— BOX: update_experience (experience.json: succeed=bool(after>before)) 199 200 return avg_score
_compute_probabilitiesThis is the algorithmic heart of AFlow's “tree” search and worth quoting in full. The function decides which prior round is the parent of the next child workflow.
14 class DataUtils: 15 16 DEFAULT_ALPHA = 0.2 # very flat softmax over scores 17 DEFAULT_LAMBDA = 0.3 # 30% pure uniform mix → forced exploration 18 DEFAULT_LOG_SAMPLES = 3 # 3 failure samples injected as context ... 61 def select_round(self, items: List[Dict]) -> Dict: ... 66 sorted_items = sorted(items, key=lambda x: x["score"], reverse=True) 67 scores = [item["score"] * 100 for item in sorted_items] 68 69 probabilities = self._compute_probabilities(scores) ... 73 selected_index = np.random.choice(len(sorted_items), p=probabilities) ... 78 def _compute_probabilities(self, scores, alpha=0.2, lambda_=0.3): ... 85 scores = np.array(scores, dtype=np.float64) 86 n = len(scores) ... 91 uniform_prob = np.full(n, 1.0 / n, dtype=np.float64) ◂— uniform component 92 93 max_score = np.max(scores) 94 shifted_scores = scores - max_score 95 exp_weights = np.exp(alpha * shifted_scores) ◂— softmax(α⋅score) numerator 96 97 sum_exp_weights = np.sum(exp_weights) ... 101 score_prob = exp_weights / sum_exp_weights ◂— score-weighted component 102 103 mixed_prob = lambda_ * uniform_prob + (1 - lambda_) * score_prob ◂— final mix: 30% uniform + 70% softmax 104 ... 109 return mixed_prob
Compare this against UCB1
(argmax_c [Q̂(c) + C⋅√(ln N(parent) / n(c))]):
no visit count, no exploration bonus that decays with visits, no parent
child statistic. AFlow's exploration term is a flat
λ⋅uniform baseline that does not shrink
— even after 100 rounds, every top-K round still has a 30/K floor on
selection probability. This is qualitatively different behaviour from
UCB, and the paper's MCTS framing should be read as analogical rather
than literal.
These are the fidelity anchors any AgentCanvas port would need to preserve.
p = λ⋅uniform + (1−λ)⋅softmax(α⋅score)
with α=0.2 (flat) and λ=0.3 (30% pure
uniform). Top-K candidate set defaults to self.sample from
the experiment config. round_1 is unconditionally included
in the top-K to keep the baseline reachable
(data_utils.py:53–55).while True loop. Each child round writes
experience.json = {father_node, modification, before, after, succeed}
into its own dir; on the next round, load_experience
aggregates these into a parent → {success: [...], failure: [...]}
map. The LLM is shown the parent's failure list (formatted as
“Absolutely prohibit X (Score: y)”) and the success list (also
prohibited — success means “already tried, don't repeat”).
If the LLM still returns a duplicate modification string,
the inner loop regenerates with a re-sampled parent
(experience_utils.py:69–80,
optimizer.py:177–183).msg_list across rounds. The prompt is single-turn
and stuffs everything (experience, parent's graph, parent's prompt, 3
random failure samples from parent's log, operator descriptions) into
one WORKFLOW_INPUT template. Output is parsed by
XmlFormatter.from_model(GraphOptimize) against
the pydantic schema {modification: str, graph: str, prompt: str}
— graph and prompt are full Python source
strings, not patches.XmlFormatter rejects the response, the optimizer
re-calls the LLM without the formatter and regex-extracts
<modification> / <graph> / <prompt> tags
from the raw text. If extraction also fails, the inner loop
continues — same loop body, different LLM sample
(optimizer.py:164–174). No err-pushed-to-msg-list,
no debug_thought field. This is qualitatively weaker failure handling
than ADAS's 3-retry reflection chain._optimize_graph() is caught at the outer
optimize() level; max_retries=1 means the
while retry_count < max_retries loop runs at most
once before score=None and the round is abandoned. The
flat results.json doesn't get a new entry, but
self.round += 1 still fires, so the round number
increments without a child workflow being persisted
(optimizer.py:87–105).validation_rounds = 5 separate evaluations
averaged into avg_score. There is no bootstrap-CI
or fitness-string equivalent — the score that lands in
results.json is a single float
(evaluation_utils.evaluate_graph; default
validation_rounds=5 on optimizer.py:45).ConvergenceUtils.check_convergence(top_k=3, z=0, consecutive_rounds=5)
watches the top-3 average's stability across 5 consecutive rounds, with
z=0 hard-coded on optimizer.py:108. With
z=0, the stop predicate fires only when
|y_current − y_previous| ≤ 0 — i.e. exact
equality of top-3 means for 5 rounds in a row, which is almost never
satisfied on continuous-score benchmarks. In practice the loop runs
for the full max_rounds (default 20) and convergence
detection is a no-op. Stronger still: Optimizer.__init__
defaults check_convergence=False, and
optimizer.py:110 gates the early-break on
converged and self.check_convergence —
so by default the convergence branch never fires regardless of
z. The port's loop.md runs the check as an
always-on advisory, a mild divergence from this upstream default.operators list in
run.py:21–58 (e.g.
Custom / AnswerGenerate / ScEnsemble / Programmer / CustomCodeGenerate / Test)
names the LLM-callable building blocks the workflow itself can compose.
The optimizer's action set is the set of all syntactically valid
Python programs over these operators — effectively unbounded.
The optimize prompt nudges the LLM toward review / revise /
ensemble / selfAsk as patterns to consider but doesn't gate on
any of them (prompts/optimize_prompt.py:WORKFLOW_OPTIMIZE_PROMPT).
This is the most important faithfulness anchor for AgentCanvas: the
port's mutation surface needs to be free-form
(graph + prompts in one shot), not constrained to a typed action
enum.round_1 is whatever workflow lives at
workspace/{dataset}/workflows/template/ when
self.round == 1 — typically a vanilla
“Custom + AnswerGenerate” chain. Unlike ADAS, there is no
pre-seeded design palette injected into the LLM context.
Section 2 below documents how the aflow AgentCanvas port
preserves each of these invariants. Three design forks raised in the
planning discussion are resolved inline: F1 mutation surface
= free-form change (graph + prompt in one shot, no typed action enum) —
realized 2026-05-20 by the implementer sub-agent editing the
active_workspace/ overlay natively; the proposer hands over a
prose {intent, targets} change spec (the earlier typed op DSL
was the compromise of routing edits through a deterministic replayer);
F2 parent-selection = faithful
score-softmax + uniform-mix with α=0.2 / λ=0.3
against archive.jsonl; F3 validation-rounds budget
= single-pass 100-ep eval with bootstrap-CI — the only forced adaptation,
since 5×100 ep at our cost would be ~5–10 h per round.
The bootstrap resampling captures the episode-sampling variance of
the mean — it does NOT capture the run-to-run stochasticity
(LLM temperature, nondeterministic agent behaviour across passes) that
upstream's 5 independent passes average out. These are different variance
components; the bootstrap is a cost-forced approximation, not a statistical
equivalent of the 5-repeat mean. This is not localized to
evaluation cost: the single-pass score feeds
select_round's softmax and check_convergence's
stability test, both of which assume score is comparable
across iters — so the unmodeled run-to-run variance can mis-rank a
parent into the top-K, and (because per-round σ is 0 under one
pass) collapses the convergence predicate to exact equality. F3 is
therefore a soundness caveat on parent selection, not merely an eval
shortcut — see Section 2 § “Two-tier evaluation”.
aflow implementation
A new namespace at .claude/commands/architect/aflow/ that
ports AFlow's optimize() + _optimize_graph()
control flow onto the AgentCanvas substrate. The cross-variant file/iter
contract (run-dir layout, resolve protocol, {graph}.yaml
schema, edit whitelist, eval-API endpoints) is shared with
adas-subagent, adas v1 and myloop via
../_common/files-contract.md;
aflow only declares what diverges from adas-subagent.
Five algorithmic changes vs adas-subagent — AFlow's per-parent-experience MCTS-flavoured search differs from ADAS's archive-injection meta-LLM in ways that affect the proposer's call shape, the loop's per-iter bookkeeping, the archive entry schema, and the evaluation tiering:
msg_list. aflow's proposer makes one
call_with_format(GraphOptimize) call per attempt; the
conversation thread is the anti-replay retry loop — if
check_modification rejects, the thread re-samples a parent
and re-calls the LLM with a fresh optimize_prompt. Verbatim
upstream optimizer.py:132–183.aflow runs select_round(top_K)
with p = λ⋅uniform + (1−λ)⋅softmax(α⋅score)
against archive.jsonl — any prior round can be
the parent, including iter_0. The chosen
parent_iter_id is recorded in proposal.md
frontmatter so loop can restore the parent's active_workspace/
into .staging/iter_n/ before invoking implementer.parent_iter_id +
modification for per-parent anti-replay. adas-subagent's
archive entry is {generation, iter_id, name, thought, graph_summary,
diff_narrative, fitness}. aflow adds
parent_iter_id (which round was the parent) and
modification (the verbatim natural-language description of
the change the LLM proposed) so the next iter can build a parent →
{success: [...], failure: [...]} lookup table for the
optimize_prompt's "Absolutely prohibit X (Score: y)" experience block.archive.jsonl with 7 ADAS reference patterns (text-only,
fitness:null) so the meta-LLM has a starting design palette.
aflow follows upstream — the only initial entry is
iter_0 (baseline workflow scored on the
perf_ tier + bootstrap_CI). The optimize_prompt has no
slot for ungrounded patterns.perf_<graph>
eval whose acc_list the Atomic Writer turns into the
archive's score. (The 2026-05-20 three-tier
smoke / search / perf design was reverted on 2026-05-25
after the v0 mapgpt_mp3d run showed its small frozen
search_ subset noise floor ≈ 9pp was larger than
the ∼5–10pp gains the search could detect; iter_6 won the
search by +13pp and lost perf by −4.2pp.) Every
archive.jsonl score is now a single-pass
perf_<graph> value; step 8 adds verification reruns
on top-1, top-2, and baseline to calibrate residual LLM run-to-run
stochasticity. See § “Two-tier evaluation” below.
Failed iters remain atomic per the shared files-contract:
no iter dir, no archive append. The
per-vN archive.jsonl, fitness_str in
metrics.json, and the .staging/iter_n/
pre-commit dir all carry over from adas-subagent unchanged.
Same shape as the myloop reference diagram: per-iter phases run
top-to-bottom on the left, the persistent archive lives on the right,
dashed lines show what each phase reads from or writes to it. The
large flow diagram immediately below preserves the per-skill mechanics
(anti-replay while True inside proposer,
parent-checkout inside implementer, Atomic Writer); this
view is the 10-second summary. The defining shape difference vs ADAS:
the proposer picks a parent from the archive each attempt
(any prior iter is eligible, weighted by score) and feeds that
parent's experience block into a single LLM call — no 3-call
Reflexion.
aflow reuses adas-subagent's 5-skill skeleton 1-for-1 —
understand / loop / proposer /
implementer / evaluator. The split is the same,
the conversation contract (one iter = one Claude conversation) is the
same, the staging-then-atomic-commit is the same. What differs is what
happens inside each skill, especially proposer (single-call +
anti-replay) and loop (parent selection + convergence check).
| Skill | Role in aflow | SVG region | Reads | Writes |
|---|---|---|---|---|
understand |
P0 bootstrap — loads files-contract + AgentCanvas
concept/contract docs + Section 1 invariants + graph state. Same
shape as adas-subagent:understand; forked only for namespace
isolation and to point at aflow-specific contract notes. |
(off-diagram; runs once before the loop starts) | files-contract, Section 1 invariants, {graph}.json,
{graph}.yaml, trace.md, latest iter |
(read-only) |
loop |
Orchestrator. Owns the conversation for an entire
iter. Drives the pre-seed perf_<graph> eval on
iter_0 (baseline only — no 7-seed palette), then for each
iter_n: invokes proposer → implementer → evaluator, decides
SKIP vs commit, calls Atomic Writer, appends to archive / trace /
lineage. Runs the check_convergence top-3-stability check
after each commit (advisory: under F3 every per-round σ is 0,
so the predicate fires only on exact top-3-mean equality regardless
of z — mostly a no-op, kept for upstream
faithfulness). Step 8 runs three verification reruns of
perf_<graph> (top-1, top-2, baseline) to calibrate
in-loop single-pass scores against LLM run-to-run stochasticity.
Terminates on max_rounds, STOP file, convergence, or
consecutive-SKIP ≥ K. |
Outer for opt_round dashed frame; iter_0 row; atomic
WRITE + archive.append row |
archive.jsonl, trace.md, prior iters' active_workspace/
(for parent checkout) |
iter dir (via Atomic Writer), archive.jsonl append, trace.md row,
lineage.md section, .staging/iter_n/active_workspace/
(parent checkout step) |
proposer |
Single-call + anti-replay. One LLM call per
attempt via call_with_format(GraphOptimize); on
FormatError, regex-extract fallback (verbatim
optimizer.py:164–174); on duplicate
modification, re-sample parent and re-call LLM in the same
conversation. The anti-replay while True is bounded by a
soft cap K (e.g. 5) to avoid infinite resampling on near-converged
archives. Output: proposal.md = {parent_iter_id,
modification, graph_patch, prompt_patch, thought, name}. Folds
the old analyze step inside its prompt-assembly Python
helper. |
Orange proposer box (the entire anti-replay while True
loop) |
archive.jsonl (full read for top-K + experience aggregation), prior
iters' log.jsonl (3 random failure samples from chosen
parent), {graph}.yaml |
staging proposal.md (only committed by Atomic Writer
on success) |
implementer |
Reuses adas-subagent's implementer with one preamble: copy parent's
active_workspace/ into .staging/iter_n/active_workspace/
before applying patch. Then: apply patch → Smoke (5 eps) →
classify (crash / step=0 / mean<0.01 / ok) → on failure:
revert to parent's checkpoint, push (err, run_summary) to msg_list,
ask LLM for a new patch with debug_thought, retry. Max 3
retries (debug_max=3). On 3-exhausted → SKIP iter.
Debug retry is an adas-subagent add-on, not in upstream AFlow
— AgentCanvas substrate forces it (see Open Q1 below). |
Implementer box (white) inside the orange envelope | proposal.md (from proposer's conversation state), parent's
active_workspace/ |
edits .staging/iter_n/active_workspace/{graphs,nodesets}/;
debug_log.md in staging; SKIP signal to loop on exhaustion |
evaluator |
Method-free per-iter perf eval: runs the
perf_<graph> profile (full paper-comparable set)
via experiment:run → writes a neutral
metrics.json (acc_list +
primary_metric_value + secondary_metrics).
It does not compute bootstrap_CI /
fitness_str / score — that enrichment
lives in loop's Atomic Writer. Invoked once per iter, after
implementer succeeds; also invoked by loop's step-8 verification
reruns (top-1, top-2, baseline). No retry on low value.
Every archive.jsonl score is a single-pass
perf_<graph> value (post 2026-05-25; see
§ “Two-tier evaluation”). |
Evaluator box (white) inside the orange envelope | .staging/iter_n/active_workspace/ (frozen by
implementer's success), exp.yaml |
staging metrics.json (neutral),
summary.csv, export.json;
fitness_str + score + iter dir + archive
append committed by loop's Atomic Writer |
Why no parent_selector as a separate skill?
Upstream's select_round is a 30-line numpy helper, not an
iter-level stage. It runs inside the proposer's while True
body (re-sampled on each anti-replay attempt). Promoting it to a skill
would force the anti-replay loop to cross skill boundaries, which would
break the “one iter = one Claude conversation” contract.
Decision: keep it as a Python helper inside proposer, mirror
upstream's data_utils.select_round structurally.
Same shape as adas-subagent — a cheap Smoke gate inside the
implementer's debug-retry loop + a per-iter Performance eval whose
acc_list the Atomic Writer turns into the archive's
score. Two {graph}.yaml blocks:
| Tier | Profile | When | Role |
|---|---|---|---|
| Smoke | smoke_<graph> (~5 ep) |
inside implementer's debug-retry loop | Cheap gate: “does the patched graph even run?” Runtime correctness only. |
| Perf | perf_<graph> (full paper-comparable set) |
every iter (the evaluator's
profile_key); plus step-8 verification reruns on top-1,
top-2, baseline |
The per-iter ranking eval AND the source of headline numbers.
loop's Atomic Writer turns its acc_list
into fitness_str + the bare score that
select_round's softmax consumes. Every
archive.jsonl score is a single-pass
perf_ value. Verification reruns live in
final_report.md, not archive.jsonl. |
Why two tiers (revert from 2026-05-20 three-tier →
2026-05-25): the 2026-05-20 design inserted a
search_<graph> middle tier — a small frozen
stratified subset (~30 ep) — for per-iter ranking, with
perf_ paid once post-loop. The
aflow/mapgpt_mp3d/v0 run exposed why this didn't work:
N=30 + validation_rounds=1 gives binomial SE ≈
9pp at p=0.5, while genuine gains in this regime are ∼5–10pp.
The loop ranked iters on a signal smaller than its noise floor.
iter_6 won the search at +13pp over baseline; on perf
it was −4.2pp. The whole archive was a noise gradient. Running
perf_<graph> every iter at full N (e.g. 216 for
mapgpt_mp3d) drops binomial SE to ≈ 3.4pp,
restoring SNR > 1 against the gains aflow tries to
detect. Cost goes up ∼5× $ per iter; wall is
similar (perf was paid 2× post-loop in the old design, plus
20 search runs at ∼the same wall as perf runs at the right
worker_count). See
outputs/design_runs/aflow/mapgpt_mp3d/v0/final_report.md
for the full v0 postmortem.
F3 deviation. Upstream averages
validation_rounds=5 separate passes per round;
aflow still runs 1 pass per iter. The two-tier revert
fixes episode-sampling noise (small-N → full-N) but not
LLM run-to-run stochasticity (temperature=1 nondeterminism
across passes). The bootstrap CI in fitness_str
resamples within the one pass — it captures the first
kind, not the second. This propagates into the search
policy: the single-pass score drives
select_round's softmax and
check_convergence's stability test, both of which
assume score is comparable across iters. A lucky single
pass can mis-rank a mediocre iter into the top-K; and because every
per-round σ is 0 under validation_rounds=1,
check_convergence's z·σ
tolerance collapses to 0 — the convergence_z
tunable is inert and convergence fires only on exact top-3-mean
equality. Mitigations in this port:
aflow.lambda_uniform if
select_round collapses onto one parent.| Aspect | adas-subagent | aflow |
|---|---|---|
| Per-attempt LLM calls | 3 (propose / Reflexion_1 / Reflexion_2) | 1 (single call_with_format(GraphOptimize)) —
verbatim optimizer.py:156–160 |
| Conversation thread role | Reflection chain (the LLM critiques + revises its own prior proposal) | Anti-replay retry chain (the LLM gets a fresh parent + fresh optimize_prompt on each retry; no cross-attempt reflection) |
| Parent selection | Implicit — always archive head | p = λ⋅uniform + (1−λ)⋅softmax(α⋅score)
over top-K of archive.jsonl; α=0.2,
λ=0.3; iter_0 unconditionally in top-K |
| Anti-replay | None — meta-LLM may propose duplicates; archive injection is expected to discourage them | Per-parent: LLM is shown parent's previous
{success, failure} modification strings as “Absolutely
prohibit X (Score: y)”; if response duplicates anyway, regenerate
(resample parent + re-call LLM) |
| FormatError handling | 3-call Reflexion chain mostly avoids it; on JSON parse failure, SKIP the iter | Regex-extract fallback for <modification> / <graph>
/ <prompt> tags from raw text; on extraction failure,
continue the anti-replay loop — verbatim
optimizer.py:164–174 |
| Per-iter eval | 1 pass × 100 eps (perf_<graph>) +
bootstrap_CI, every iter |
1 pass × full perf_<graph> every iter
+ bootstrap_CI [two-tier post 2026-05-25; F3 deviation
remains]. Step 8 adds verification reruns on top-1,
top-2, and baseline. Upstream averages
validation_rounds=5 passes; aflow runs 1 + bootstrap
resample-within-set |
| Initial archive | Baseline (scored) + 7 reference pattern descriptors (text only,
fitness:null) |
Baseline only — verbatim upstream
(round_1 = template/ workflow); no design-palette
pre-injection |
| Convergence detection | None — runs to max_iters or STOP or SKIP-cap |
Top-3-mean stability over 5 consecutive rounds — verbatim
convergence_utils.py:68–113. Under F3
(validation_rounds=1) per-round σ=0, so the
z·σ tolerance is 0 and it fires only on
exact equality regardless of z; advisory only |
| Workspace state model | Live workspace/ tracks archive head; implementer
edits in place |
Per-parent checkout: loop copies parent's
iter_X/active_workspace/ into
.staging/iter_n/active_workspace/ before implementer
applies patch. Live workspace/ stays at the user's
pre-loop state (advisory) |
| Archive entry shape | {generation, iter_id, name, thought, graph_summary,
diff_narrative, fitness} |
Adds parent_iter_id + modification +
bare numeric score (alongside the human-readable
fitness) so next iter can compute parent →
{success, failure} lookup + softmax weights |
| Meta-LLM params | Pinned: temperature=0.8,
response_format=json_object, max_tokens=4096,
model=gpt-4o-2024-05-13 |
Pinned upstream defaults from config2.yaml:
temperature=0.7, response_format=XmlFormatter
(against GraphOptimize pydantic schema),
max_tokens per provider, model=claude-3-5-sonnet
or gpt-4o per the run config |
Each component lives inside one of the 5 skills. “Owner” = which skill's code path instantiates the component. Components in the same skill share that skill's conversation context. The numbering follows adas-subagent's table for cross-doc comparability (some adas-subagent components are merged or absent here).
| # | Component | Owner skill | Role | Upstream anchor | Reused from adas-subagent? |
|---|---|---|---|---|---|
| 1 | Parent Selector | proposer |
get_top_rounds + select_round: top-K by
score (round_1 unconditionally included), then softmax-mix sample;
runs inside the anti-replay loop |
data_utils.py:40–109 |
New — adas-subagent has no parent selection |
| 2 | Experience Loader | proposer |
load_experience + format_experience:
aggregate per-parent {success, failure} from prior
archive entries' modification field; format as
“Absolutely prohibit X (Score: y)” |
experience_utils.py + optimizer.py:141–142 |
New |
| 3 | Optimize Prompt Builder | proposer |
create_graph_optimize_prompt: assemble {experience,
parent's score, parent's graph, parent's prompt, operator descriptions,
3 random failure samples from parent's log.jsonl} into
one WORKFLOW_INPUT template |
optimizer.py:149–151 +
prompts/optimize_prompt.py |
Replaces adas-subagent's get_prompt(archive) + analyze helper
(different prompt shape, no [ARCHIVE] dump) |
| 4 | Single-Call LLM + Format Fallback | proposer |
One call_with_format(GraphOptimize); on
FormatError, raw call + regex-extract
<modification> / <graph> / <prompt>; on
extraction failure, continue the anti-replay loop |
optimizer.py:153–174 |
New — adas-subagent has 3-call Reflexion + JSON schema, no regex fallback |
| 5 | Modification Anti-Replay | proposer |
check_modification: reject if response's
modification string is in parent's success OR failure
list (success means “already tried, don't repeat”); on
reject, continue the inner loop with re-sampled parent |
optimizer.py:177–183 +
experience_utils.py:69–80 |
New |
| 6 | Workspace Checkout | loop |
cp -r outputs/.../iter_{parent}/active_workspace/ → .staging/iter_n/active_workspace/
after reading parent_iter_id from proposal.md
frontmatter; runs once per iter, before invoking implementer |
(upstream is stateless —
write_graph_files writes a fresh round_{N+1}
dir from the LLM's full source dump) |
New — adas-subagent has no parent checkout (live workspace = archive head) |
| 7 | Native-edit implementer | implementer |
Spawns an editing sub-agent that realizes the proposer's
{intent, targets} change spec by editing
.staging/iter_n/active_workspace/{graphs,nodesets}/ with
native Edit/Write; JSON + Python validated after the edit |
upstream is wholesale rewrite via
write_graph_files |
Shared _common/implementer.md; seeding via
_common/lib/overlay.py (typed op DSL retired 2026-05-20) |
| 8 | Smoke Evaluator + Debug Retry | implementer |
5-ep smoke + outcome classification + ≤3 debug retries pushing
(err, run_summary) to msg_list; on exhaustion SKIP |
(upstream has no debug retry — FormatError fallback is proposer-side only) | Reused from adas-subagent — AgentCanvas-substrate add-on, not in upstream AFlow |
| 9 | Perf Evaluator (method-free) + Bootstrap-CI | evaluator / loop |
evaluator runs the perf_<graph>
full-set eval and writes neutral metrics; loop's Atomic
Writer does acc_list → bootstrap_CI → fitness_str
+ bare-numeric score (for parent-selection softmax).
validation_rounds=1 per F3. Step 8 adds verification
reruns of perf_<graph> on top-1, top-2, baseline |
evaluation_utils.evaluate_graph with
validation_rounds=5 |
Reused from adas-subagent — reps reduced 5→1
(F3); per-iter tier is perf_ (two-tier post 2026-05-25;
pre-revert this was search_) |
| 10 | Atomic Writer + Convergence Check | loop |
mv .staging/iter_n/ → iter_n/ + archive.jsonl append
+ trace.md row + lineage.md section, in one transaction. Then
check_convergence(top_k=3, z=0, consecutive_rounds=5)
decides whether to early-exit (advisory, rarely fires) |
convergence_utils.py:68–113 +
optimizer.py:108–116 |
Atomic Writer reused from adas-subagent; convergence check new |
Same role as adas-subagent's archive.jsonl (per-vN, JSONL,
append-only, atomic write paired with iter-dir mv) but with
two extra fields that the parent-selection algorithm
reads on every iter: parent_iter_id (so the experience
loader can group entries by parent) and modification (so
check_modification can detect duplicates). A bare numeric
score is also stored alongside the human-readable
fitness string, because select_round's softmax
needs a float, not the “95% Bootstrap CI: (lo%, hi%), Median: x%”
rendering.
| Aspect | Decision | Rationale |
|---|---|---|
| Path | outputs/design_runs/{graph}/v{N}/archive.jsonl | Per-vN. Major pivots (new vN) start a fresh archive; meta-LLM is not anchored to a dead-end design family. |
| Format | JSON Lines, append-only. | Crash-safe; each line independently parseable; identical to adas-subagent. |
| Writer | loop (Atomic Writer), in the same transaction as mv .staging/iter_n/ → iter_n/ | SKIP path does neither — archive only sees successful generations. |
| Reader (parent selection) | proposer reads the full file each attempt; runs get_top_rounds(K) + select_round(softmax-mix). | Cost is trivial up to ~hundreds of entries; deferred sliding-window pruning to when archive grows beyond that. |
| Reader (experience aggregation) | proposer aggregates entries by parent_iter_id into a {parent: {success: [modifications], failure: [modifications]}} dict; injects formatted “Absolutely prohibit” strings into the optimize_prompt. | Mirrors upstream load_experience + format_experience; the parent_iter_id field is what makes per-parent grouping possible at all. |
| Failure handling | Failed iters (proposer SKIP / implementer SKIP / evaluator infra fail) do not append. | Verbatim upstream semantics (the self.round += 1 orphan-round case from optimizer.py:96, 105). |
| Pre-append cleanup | Strip debug_thought field before append (kept in debug_log.md for forensics). | adas-subagent strips reflection + debug_thought; aflow only has the latter (no Reflexion chain). |
| Resume | wc -l archive.jsonl → current generation; full-file scan for parent selection. | Filesystem is the source of truth, no separate state file. |
{
"generation": 3, // int (1+ for evolved) | "initial" (baseline)
"iter_id": "iter_3", // soft pointer to evidence; stripped from optimize_prompt injection
"parent_iter_id": "iter_1", // chosen parent for this iter (NEW vs adas-subagent)
"name": "Self-Audit Chain-of-Thought via History Tracker",
"thought": "**Insights:** ... **Overall Idea:** ... **Implementation:** ...", // verbatim from LLM output
"modification": "Add a HistoryTrackerNode upstream of NavGPTReason; wire history.summary into navgpt.context; bump max_steps from 5 to 15.", // NEW vs adas-subagent — load-bearing for anti-replay
"graph_summary": {
"nodes": [
{"id": "env_mp3d__step", "type": "env_mp3d.step", "key_config": {"action_space": "discrete_4"}},
{"id": "navgpt__think", "type": "navgpt.think", "key_config": {"llm_model": "gpt-4", "system_prompt_first_200": "You are a navigation agent..."}},
{"id": "history__update", "type": "history.update", "key_config": {"max_steps": 15}}
],
"wires": [
["env_mp3d__step.observation", "history__update.obs"],
["history__update.summary", "navgpt__think.context"]
],
"loop": {"iter_in": ["controller__step_gate"], "iter_out": ["navgpt__think"]}
},
"diff_narrative": "Added history__update node before navgpt__think; wired its summary output into navgpt__think.context; bumped max_steps from 5 to 15.",
"fitness": "95% Bootstrap CI: (66.8%, 76.2%), Median: 71.5%", // human-readable, for archive viewing
"score": 0.715 // bootstrap median, fraction in [0,1], for select_round softmax (NEW vs adas-subagent)
}
{
"generation": "initial",
"iter_id": "iter_0",
"parent_iter_id": null,
"name": "",
"thought": "Baseline graph as provided by the user — design starting point.",
"modification": "(baseline)", // sentinel; never matched by check_modification
"graph_summary": ,
"diff_narrative": "Baseline (no parent).",
"fitness": "95% Bootstrap CI: (lo%, hi%), Median: x%",
"score":
}
parent_iter_id instead of an ancestor chain?
Upstream's experience.json only stores father_node
— the immediate parent. There is no grandparent traversal, no
visit-count backprop, no sibling statistic. AFlow's
“tree” lives only as a flat list of (parent, child) edges
written into experience.json files; the optimizer reads them
back as a {parent: {success: [...], failure: [...]}} dict
and never reconstructs the tree topology
(experience_utils.py:12–53). aflow
preserves this — one field parent_iter_id in each
archive entry is sufficient to rebuild the experience map; no recursion
needed.
This is also why the Section 1 callout warns against the
“MCTS” framing: a real MCTS implementation would maintain
per-node visit counts and backprop reward up the ancestor chain on each
evaluation. AFlow has neither — the anti-replay surface and
softmax-mix selection are the only mechanisms by which exploration
history feeds back into selection. The flat parent_iter_id
field is faithful to that.
iter_{parent}/active_workspace/ into
.staging/iter_n/active_workspace/ before implementer runs).
This requires implementer to take a --workspace-root arg
(small adas-subagent generalisation) and experiment:run to honour
the override (already supported via env var).
Alternative: keep live workspace/ mutable,
loop runs git stash-style snapshots before each iter and
restores parent's state into live before implementer. Live-workspace
approach minimises adas-subagent deltas but couples concurrent canvas use to
the search loop, which we want to avoid. Default: staging
approach.self.sample
from the experiment config — not a fixed default. For our
archive sizes (typically 20–30 evolved entries), what's the
reasonable K? K=5 matches AFlow's typical setting on small
benchmarks; K=10 would reduce the floor probability per
candidate from 0.3/5=6% to 0.3/10=3% —
more concentrated on top performers. Suggest declaring it in
{graph}.yaml alongside perf_<graph>.z=0 convergence detector almost never fires (top-3
means rarely match exactly across 5 rounds on continuous-score
benchmarks). Three options: (a) keep verbatim — faithful but
no-op; (b) raise z=0.005 (within-CI stability) — fires
when top-3 plateau; not in upstream so it's a deviation; (c) drop
entirely and rely on max_iters. Default: option
(a), document the z tunable in exp.yaml so users
can opt into (b) without code change.modification string format. Upstream
is free-form natural language from the LLM; check_modification
uses string equality on it (experience_utils.py:69–80).
This is fragile — a one-character whitespace change defeats the
anti-replay. Three options: (a) verbatim string equality —
faithful but fragile; (b) normalize whitespace + lowercase before
comparison — mild deviation, robust against trivial paraphrase
drift; (c) embedding-similarity threshold (cosine > 0.95) —
robust but introduces an extra LLM call per check. Default:
option (b) — cheap, near-faithful, addresses the
obvious failure mode.while True is unbounded — if every parent in the
top-K has been exhausted, the loop spins forever (in practice the LLM
generates novel modifications eventually). For us this is a hang risk.
Suggest a soft cap K (e.g., 5 attempts) before SKIP-ing the iter.
Document the cap in aflow/README.md's deltas table.debug_thought field (from implementer's debug retries)
before append, mirroring adas-subagent's reflection +
debug_thought strip. AFlow upstream has no equivalent
field (no debug retry) — the strip is purely an AgentCanvas-substrate
adapter. Confirm in implementation review.load_operators_description(self.operators)
enumerates the workflow-internal building blocks the LLM can compose
(Custom / AnswerGenerate / ScEnsemble / Programmer / etc.)
and stuffs their descriptions into the prompt. The AgentCanvas analogue
is the loaded NodeSets' canvas-node catalog, exposed via
GET /api/components/node-schemas. Decision needed: inject
the full schema (verbose), or filter to nodes actually used in the
parent's graph + a curated “commonly proposed” list?
Defer until we see prompt-budget pressure on a real run.