A two-part document. Section 1 dissects Hu et al. 2024
"Automated Design of Agentic Systems" — how
search.py's outer loop, archive injection, and debug retry
actually work, with the original source annotated.
Section 2 documents the AgentCanvas port
(adas-subagent): one main Claude conversation envelope
plus one Agent sub-agent per Reflexion round, giving 3
independent tool-augmented samples per propose.
section 1 upstream paper analysis section 2 adas-subagent (reasoning module = sub-agent) scope coding-agent-era extension, no AAS additions
Framing note. Upstream ADAS lives in the
reasoning module = stateless LLM call paradigm — the only
option in 2024. adas-subagent proposes
reasoning module = tool-augmented Claude conversation with
context management as the coding-agent-era replacement.
This is the primary contribution; "faithful 1:1 reproduction" is
not the goal. Several paradigm-independent elements (3-call
Reflexion structure, Reflexion_prompt_1 / 2 text,
bootstrap_CI algorithm, archive contract) are
preserved verbatim — the "verbatim" labels on individual tables
below are accurate at that per-element level. Any residual
“faithful port”-style language elsewhere should be read as
“structurally accurate translation of paradigm-independent
elements,” not as a 1:1 fidelity claim.
How the original ADAS paper's search() function drives one
generation (source:
third_party/ADAS/_mmlu/search.py:146–242).
The orange 3-call Reflexion sequence is the load-bearing piece — our
adas-subagent proposer skill (Section 2) preserves it
verbatim — three independent Agent sub-agent spawns,
one per Reflexion round. Annotations below the SVG link each diagram
region to the exact upstream line.
Below is the verbatim search() function from
third_party/ADAS/_mmlu/search.py:146–241,
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.
Diagram ↔ code map:
156, 159–172, 178 — init archive + seed scoring + persist.180.188, 194, 198 — each is one get_json_response_from_gpt_reflect sharing the same msg_list.199–203.206.208–212.215–218 — push err to msg_list, re-ask LLM.224–226.228 and 236.
146 def search(args):
147 file_path = os.path.join(args.save_dir, f"{args.expr_name}_run_archive.json")
148 if os.path.exists(file_path):
149 with open(file_path, 'r') as json_file:
150 archive = json.load(json_file) # resume from disk if exists
151 if "generation" in archive[-1] and isinstance(archive[-1]['generation'], int):
152 start = archive[-1]['generation']
153 else:
154 start = 0
155 else:
156 archive = get_init_archive() ◀── BOX: get_init_archive (7 baseline agents)
157 start = 0
158
159 for solution in archive: ◀── BOX: evaluate + bootstrap_CI (score each seed)
160 if 'fitness' in solution:
161 continue
162
163 solution['generation'] = "initial"
164 print(f"============Initial Archive: {solution['name']}=================")
165 try:
166 acc_list = evaluate_forward_fn(args, solution["code"])
167 except Exception as e:
168 print("During evaluating initial archive:")
169 print(e)
170 continue
171
172 fitness_str = bootstrap_confidence_interval(acc_list)
173 solution['fitness'] = fitness_str
174
175 # save results
176 os.makedirs(os.path.dirname(file_path), exist_ok=True)
177 with open(file_path, 'w') as json_file:
178 json.dump(archive, json_file, indent=4) ◀── BOX: archive.json (seeded archive persisted)
179
180 for n in range(start, args.n_generation): ◀── OUTER DASHED BOX (for n in range(n_generation))
181 print(f"============Generation {n + 1}=================")
182 system_prompt, prompt = get_prompt(archive) ◀── BOX: get_prompt(archive)
183 msg_list = [
184 {"role": "system", "content": system_prompt},
185 {"role": "user", "content": prompt},
186 ]
187 try:
188 next_solution = get_json_response_from_gpt_reflect(msg_list, args.model)
◀── BOX (orange): LLM #1 propose
temp=0.8 / JSON mode / max_tokens=4096 (defaults in search.py:53–67)
189
190 Reflexion_prompt_1, Reflexion_prompt_2 = get_reflexion_prompt(archive[-1] if n > 0 else None)
191 # Reflexion 1
192 msg_list.append({"role": "assistant", "content": str(next_solution)})
193 msg_list.append({"role": "user", "content": Reflexion_prompt_1})
194 next_solution = get_json_response_from_gpt_reflect(msg_list, args.model)
◀── BOX (orange): LLM #2 Reflexion_prompt_1
195 # Reflexion 2
196 msg_list.append({"role": "assistant", "content": str(next_solution)})
197 msg_list.append({"role": "user", "content": Reflexion_prompt_2})
198 next_solution = get_json_response_from_gpt_reflect(msg_list, args.model)
◀── BOX (orange): LLM #3 Reflexion_prompt_2
next_solution = {thought, name, code}
199 except Exception as e:
200 print("During LLM generate new solution:")
201 print(e)
202 n -= 1 ◀── RED SKIP: any of LLM #1/#2/#3 raised
203 continue n -= 1; continue (generation NOT archived)
204
205 acc_list = []
206 for _ in range(args.debug_max): ◀── INNER ORANGE DASHED BOX (debug_max=3)
207 try:
208 acc_list = evaluate_forward_fn(args, next_solution["code"])
◀── BOX: evaluate_forward_fn
209 if np.mean(acc_list) < 0.01 and SEARCHING_MODE: ◀── DIAMOND: mean(acc)<0.01? → "yes" path
210 raise Exception("All 0 accuracy")
211 break ◀── DIAMOND: no → leave debug loop, success
212 except Exception as e: ◀── DIAMOND: yes → catch & retry
213 print("During evaluation:")
214 print(e)
215 msg_list.append({"role": "assistant", "content": str(next_solution)})
216 msg_list.append({"role": "user", "content": ◀── YES branch arrow: push err to msg_list
f"Error during evaluation:\n{e}\nCarefully consider where you went wrong in your latest implementation. "
f"Using insights from previous attempts, try to debug the current code to implement the same thought. "
f"Repeat your previous thought in 'thought', and put your thinking for debugging in 'debug_thought'"})
217 try: ◀── ask LLM (with debug_thought field) → new code
218 next_solution = get_json_response_from_gpt_reflect(msg_list, args.model)
219 except Exception as e:
220 print("During LLM generate new solution:")
221 print(e)
222 continue
223 continue
224 if not acc_list: ◀── RED SKIP: 3 retries exhausted
225 n -= 1 n -= 1
226 continue continue (generation NOT archived)
227
228 fitness_str = bootstrap_confidence_interval(acc_list) ◀── BOX: bootstrap_CI → fitness_str
229 next_solution['fitness'] = fitness_str
230 next_solution['generation'] = n + 1
231
232 if 'debug_thought' in next_solution:
233 del next_solution['debug_thought'] # clean scratch field before archiving
234 if 'reflection' in next_solution:
235 del next_solution['reflection']
236 archive.append(next_solution) ◀── BOX: archive.append(next_solution)
237
238 # save results
239 os.makedirs(os.path.dirname(file_path), exist_ok=True)
240 with open(file_path, 'w') as json_file:
241 json.dump(archive, json_file, indent=4) # archive grows iff success
Upstream invariants worth marking — these are the paradigm-independent structural anchors adas-subagent preserves verbatim, even as the reasoning module mechanism itself is upgraded:
msg_list — R1
sees the assistant's propose response; R2 sees both. adas-subagent's
proposer § 6 preserves this verbatim across the three
Agent sub-agent spawns.n -= 1; continue.
adas-subagent commits only via the loop skill's Atomic Writer,
which fires only on full success; SKIP paths do rm -rf
.staging/iter_n/ with no archive append. Section 2 documents
this contract."95% Bootstrap CI: (52.3%, 73.1%), Median: 63.0%".
adas-subagent's loop computes this verbatim in the Atomic Writer
enrichment step; the string is stored at the top of
metrics.json AND injected into every
archive.jsonl entry so meta-LLM sees uncertainty, not just
point estimates.COT, COT_SC, Reflexion, LLM_debate, Take_a_step_back, QD,
Role_Assignment), each scored before generation 0 starts.
adas-subagent keeps the 7-pattern injection but as
text-only seed entries (fitness: null)
— pre-evaluating 7 graph baselines at 100 ep each would cost ~14
h, infeasible at our scale. Meta-LLM sees the patterns as "design
palette", not benchmarked alternatives.mean(acc_list) < 0.01 is an upstream debug
trigger, same as a Python crash. adas-subagent's implementer
intentionally drops this trigger — low / zero metric values on
a clean run are real data ("this approach scored 0%"), not a
broken-code signal. See Section 2 bucket D (intentional divergences)
for rationale.Section 2 below documents how adas-subagent implements each of these invariants concretely.
.claude/commands/architect/adas-subagent/ is the
AgentCanvas port of upstream ADAS. Structural delta vs
upstream: each meta-LLM call from search.py (the
3 Reflexion rounds in proposer and the retry-on-failure call in
implementer) becomes one
Agent({subagent_type:"general-purpose", ...}) invocation
— a fully tool-augmented independent Claude conversation, not a
stateless OpenAI API call. The 5-skill layout, archive contract,
two-tier evaluation, atomic writer, and vN/iter_M run-dir
are kept; bootstrap_CI + fitness_str +
Reflexion prompt text are byte-for-byte verbatim.
mean(acc)<0.01. The smoke gate now judges
runtime correctness only (exit=0 + all eps completed +
step_count>0 + valid numeric metric). Low or zero
scores are real data for archive, not retry triggers.msg_list. Each retry is a fresh
coding-agent debugging task that sees the proposal + accumulated
failure trace as read-only text and emits {thought, name,
patch}. The Reflexion debug_thought field is
gone from the schema.retry_max is a config knob (default
3); was hard-coded debug_max=3 verbatim upstream.bootstrap_CI + fitness_str
enrichment moved into loop's Atomic Writer step 1.This section documents:
Agent spawns) → implementer (native
edit + smoke + runtime-fail retry) → evaluator → atomic writerReflexion_prompt_1 / 2 text and the
cross-spawn msg_list rendering
The diagram below is the main per-iter flow. The proposer panel
contains three Agent sub-agent rectangles (orange-bordered)
for R0 / R1 / R2; the implementer panel is method-free, with one fresh
retry sub-agent spawned per smoke runtime fail (no
mean<0.01 trigger, no msg_list inheritance from
proposer, retry_max is a config knob).
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
(R0/R1/R2 sub-agent spawns inside proposer, smoke retry
inside implementer, Atomic Writer); this view is the
10-second summary.
Every Agent(...) spawn in adas-subagent obeys three rules:
Agent spawns exist at
all.subagent_type:
"general-purpose", so the sub-agent has Read / Grep / Bash /
WebSearch / nested Agent. It can ground its proposal in
log.jsonl, archive entries, source under
workspace/nodesets/, related literature, etc. This is
what the "Claude conversation as modernized meta-LLM" framing
delivers — without tool access, sub-agents collapse back to
stateless LLM calls.```json block as the final visible chunk of
its return message. Proposer's R0 / R1 / R2 spawns
share the same msg_list — each parsed response is appended as
{"role":"assistant", "content":json.dumps(parsed)} and
fed to the next Agent spawn as text. Implementer's
retry spawns do NOT inherit this msg_list; each retry is a fresh
coding-agent debugging task that sees the proposal + accumulated
failure trace as read-only text, edits the active_workspace/
overlay natively with Edit / Write, and emits
{edit_summary, extra_targets} (no Reflexion-style
debug_thought field). Proposer's sub-agents MUST NOT edit
files — they only produce the change spec; the implementer
sub-agent is the one that edits, and only ever under
active_workspace/, never frozen workspace/*.
Reflexion_prompt_1 and Reflexion_prompt_2 are
copied character-for-character from upstream
third_party/ADAS/_mmlu/mmlu_prompt.py:497–528
into adas-subagent/proposer.md § 6. The only character-level
adaptation is "code" → "patch" in the
response-schema description (our artifact is a {intent, targets}
change spec, not a Python function string).
The [EXAMPLE] placeholder in Reflexion_prompt_1
follows upstream's get_reflexion_prompt formula verbatim:
filled with "Here is the previous agent you tried:\n" +
json.dumps(archive[-1]) + "\n\n" for generation ≥ 1, empty
string for iter_1 (when there is no prior evolved generation).
Upstream search.py threads one Python list of
{role, content} dicts through three sequential API calls.
adas-subagent keeps the textual content of this list identical, with one
unavoidable adaptation: the Agent tool accepts only a
single user-string prompt, so prior role: assistant
outputs are serialized as text inside subsequent sub-agents' prompts
(rendered with [role] block markers). From the
sub-agent's perspective this is the same information; the only loss is
the literal role: assistant tagging, which the
cumulative-prompt-text form approximates.
=== msg_list so far (each [role] block is one conversation turn) ===
[system]
You are a helpful assistant. Make sure to return in a WELL-FORMED JSON object.
[user]
{base prompt: # Overview ... # Task ... # The utility code ... # Discovered architecture archive [ARCHIVE] ... # Output Instruction ...}
[assistant] ⇐ R0's response, present from R1 onward
{"thought": "...", "name": "...", "patch": {...}}
[user] ⇐ Reflexion_prompt_1 (verbatim), present from R1 onward
"[EXAMPLE]Carefully review the proposed new architecture and reflect on the following points: ..."
[assistant] ⇐ R1's response, present from R2 onward
{"reflection": "...", "thought": "...", "name": "...", "patch": {...}}
[user] ⇐ Reflexion_prompt_2 (verbatim), present at R2 only
"Using the tips in '## WRONG Implementation examples' section, revise the patch further..."
=== end msg_list ===
Against the original ADAS paper (Hu et al. 2024, MMLU codebase): what do we keep verbatim, what's forced by the new substrate, and what's an intentional design choice. Four buckets:
These items survive the port unchanged at the structural or byte-for-byte level. Reasoning module no longer being a stateless API call doesn't disturb them.
| Element | Upstream anchor | How preserved |
|---|---|---|
| 3-call Reflexion sequence (R0 / R1 / R2) | search.py:188, 194, 198 |
Call sequence verbatim; implemented as 3 independent
Agent(...) spawns instead of 3 stateless API calls |
Reflexion_prompt_1 + Reflexion_prompt_2 |
mmlu_prompt.py:497, 525 |
Character-for-character verbatim; only "code" →
"patch" renamed in the response-schema description |
get_prompt(archive) injects whole archive into
[ARCHIVE] slot of base prompt |
mmlu_prompt.py:535–541 |
Same shape; archive entries use graph_summary
where upstream had Python code string |
get_reflexion_prompt(archive[-1]) — separate
echo of most-recent archive entry |
mmlu_prompt.py:544–547 |
Verbatim semantics |
bootstrap_confidence_interval(acc_list, 100000, 0.95) |
utils.py:31 |
Verbatim algorithm; lives in
adas-subagent/lib/helpers.py; invoked from loop's
Atomic Writer (moved here from evaluator in the 2026-05 evaluator
refactor — the algorithm itself is unchanged) |
fitness_str = "95% Bootstrap CI: (lo%, hi%), Median:
x%" |
utils.py:76 |
Verbatim format string (downstream [ARCHIVE]
injection matches it literally) |
del next_solution['reflection'] before archive
append |
search.py:232–233 |
Verbatim semantics for reflection; the companion
debug_thought stripping is no longer applicable (field
removed from schema — see bucket D) |
Failed iter not archived (n -= 1; continue) |
search.py:202, 225 |
Verbatim semantics |
| 7 reference seeds pre-loaded into archive at init | search.py:154–172 + mmlu_prompt.py
archive constants (CoT / CoT-SC / Self-Refine / LLM Debate /
Step-back / QD / Role Assignment) |
Same 7 patterns, same archive injection |
| Single-population hill-climb (no tournament / parallel candidates) | search.py overall search shape |
Same — archive grows linearly, one new entry per successful iter |
These items change because the meta-LLM is no longer a stateless
OpenAI API call. The reasoning module is now a tool-augmented Claude
sub-agent (Agent({subagent_type: "general-purpose", ...}));
the upstream knobs that control stateless sampling don't have a
meaningful home in this paradigm.
| Upstream | adas-subagent | Why forced |
|---|---|---|
Meta-LLM call =
get_json_response_from_gpt_reflect(msg_list, ...)
stateless API |
Meta-LLM call =
Agent({subagent_type:"general-purpose", ...}) spawn with
full tool access (Read / Grep / Bash / WebSearch / nested Agent) |
Claude isn't a stateless API; "the LLM" is replaced by a coding-agent session. Tools let each propose ground itself in the codebase, eval logs, archive entries, etc. |
Sampling diversity = 3 calls at temperature=0.8 |
Sampling diversity = 3 fresh Agent spawns (each is
one independent Claude sample) |
Stateless sampling-knob equivalent doesn't exist for sub-agents; fresh-spawn-per-call restores independence at the spawn boundary instead |
Pinned API params: temperature=0.8,
response_format=json_object, max_tokens=4096,
model=gpt-4o-2024-05-13 |
N/A — not applicable to sub-agent spawns | These are stateless-API-call knobs. Sub-agents don't expose them. JSON output is enforced by prompt instruction + parser; max_tokens is the sub-agent's own context budget |
role: assistant history (native API message
role) |
Prior assistant outputs serialized as text inside the next
sub-agent's user prompt (rendered with [role] block
markers) |
Agent tool takes a single user-string prompt;
semantic role-tagging is approximated by textual rendering. Content
identical |
These items change because the target environment (Habitat VLN episodes) is fundamentally different from upstream's target (MMLU multiple-choice). VLN episodes are expensive (~1–2 h for 100 ep) and the artifact is a multi-file graph + nodeset spec, not a Python function string.
| Upstream (MMLU) | adas-subagent (VLN) | Cost / substrate reason |
|---|---|---|
Artifact = Python code string (one function body) |
Artifact = a prose {intent, targets} change spec;
the implementer sub-agent realizes it by editing
{graph}.json + nodeset .py files natively |
AgentCanvas agents are graph + nodesets, not a Python function. A code string can't express the artifact; the typed op DSL was retired 2026-05-20 once the implementer became an editing agent |
| Archive entry "code" slot = full Python source | Archive entry "code" slot = graph_summary
(rendered structural snapshot) + diff_narrative
(plain-text What-changed) |
Full graph.json blows the context budget on
[ARCHIVE] injection; the patch alone isn't
self-contained (relies on parent state). Need a compact structural
indicator |
7-pattern reference seed evaluated upfront
(search.py:159–172) |
7 seeds entered as text-only descriptors with
fitness: null — no upfront eval |
Upstream ~seconds per seed; ours would be 7×~2 h = 14 h upfront before any new search iter |
Evaluation = 1 tier
(evaluate_forward_fn(acc_list, ...), ~seconds) |
Evaluation = 2 tiers: Smoke (5 ep, ~10 min, inside implementer) + Performance (100 ep, ~1–2 h, separate evaluator skill) | Need a cheap patch-correctness gate before paying for full perf eval; conflated would mean every broken patch costs ~2 h |
| Low-fitness or all-zero result triggers debug retry (3×) | Performance tier has NO retry on low fitness; low scores enter archive as data | 3×~2 h = ~6 h per gen infeasible. (For Smoke tier specifically, low-fitness retry was also removed in the 2026-05 refactor — see bucket D — for a different reason) |
State management = stateless exec(code_str);
archive is the only persistent state |
Full vN/iter_M/active_workspace/ overlay system
+ atomic .staging/iter_n/ → iter_n/ commit + edit
whitelist enforcement (per files-contract) |
VLN agents touch multiple files (graphs / nodesets / policies / hooks). Need an isolated mutation set per iter with rollback semantics |
Run-dir = just archive.json |
Full outputs/design_runs/adas-subagent/{graph}/v{N}/iter_M/
tree with per-iter graph snapshot, eval profile copy, metrics,
summary CSV, export JSON, proposal.md, debug_log.md |
Each VLN iter has ~MB of forensic data worth keeping (per-ep log.jsonl, per-step traces); flat archive can't hold it |
These changes were not forced by substrate or cost — they're judgement calls about what coding-agent capability changes mean for the search loop. Modern Claude sub-agents are strong enough debuggers that the Reflexion-style retry chain in upstream's debug loop offered little marginal value over fresh-spawn-with-failure-trace. The refactor below removes those ADAS-fidelity-but-not-quality elements from the implementer; the proposer's Reflexion chain (bucket A) is preserved because that's where the quality-diversity actually lives.
| Upstream | adas-subagent (post-refactor) | Rationale |
|---|---|---|
debug_max = 3 hard-coded
(search.py:206) |
retry_max from config.yaml (default 3) |
Same value by default; config knob future-proofs the cap without semantic change |
Retry trigger = exception OR
mean(acc_list) < 0.01
(search.py:209–210, raise "All 0 accuracy") |
Retry trigger = runtime correctness only
(exit≠0 OR not all eps completed OR step_count == 0
OR primary_metric not numeric) |
Low/zero metric values on a clean run are data ("this approach scored 0%"), not a broken-code signal. Conflating the two cost us a retry on every "patch ran but didn't help" outcome. Modern coding agents only need a retry to fix actual runtime failures |
Retry mechanism: push {err, debug_feedback} to
shared msg_list, ask the meta-LLM for a corrected
patch with a debug_thought field; same Reflexion thread
continues (search.py:215–218,
mmlu_prompt.py debug-feedback wording) |
Retry mechanism: spawn a fresh
Agent per retry; it sees the
proposal + accumulated failure trace as
read-only text, edits the active_workspace/ overlay
natively, and emits {edit_summary, extra_targets}. No
msg_list inheritance from proposer; no Reflexion-style
debug_thought field |
Implementer's job is "make the patch run"; the proposal's
strategic intent is read-only context. Reflexion-style
chain-of-reasoning isn't load-bearing for debugging when the
sub-agent has full tool access (Read / Grep / Bash on
log.jsonl, source, etc.). Decoupling implementer from
proposer's msg_list also lets implementer be method-free —
reusable across variants |
del next_solution['debug_thought'] before archive
append (search.py:232–235) |
No-op — debug_thought field doesn't exist
in the schema |
Companion of the row above; nothing to strip if nothing produces it |
Evaluator computes fitness_str + bootstrap CI as
part of the eval call |
Evaluator writes neutral metrics only
(acc_list, primary_metric_value);
bootstrap CI + fitness_str enrichment moved to loop's
Atomic Writer step 1 |
Evaluator is method-free now — same skill works for any variant. Method-specific metrics enrichment is the variant's loop job. Algorithm body unchanged (bucket A) |