Skip to content

feat: PDF-only evolution infrastructure + evolver prompt overhaul#23

Open
dastin-sentient wants to merge 35 commits into
sentient-agi:mainfrom
dastin-sentient:integrate-learning
Open

feat: PDF-only evolution infrastructure + evolver prompt overhaul#23
dastin-sentient wants to merge 35 commits into
sentient-agi:mainfrom
dastin-sentient:integrate-learning

Conversation

@dastin-sentient

@dastin-sentient dastin-sentient commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • PDF-only evolution infrastructure validated via 4-iteration opus experiment on OfficeQA Pro strict-94
  • Evolver prompt overhauled: compact failure index table, CREATE-only skill policy, clean trace files
  • Cache correctness fixes: skills-only tree_hash, effort in key, cached_transcript for replays
  • Agent trace improvements: partial messages on timeout, no Phoenix truncation, turn span labels

Experiment result: baseline 37.5% → 68.75% on val-16 (+31pp), 92.6% on holdout-68 (5 regressions from skill overhead on easy questions).

New mechanisms introduced

Mid-gate mean-score policy (src/loop/config.py, runner.py)

New mid_gate_policy config field with two modes:

  • "counts" (default, backward-compatible): requires fixed ≥ N AND regressed ≤ M per-sample binary pass/fail
  • "mean": passes if mean(post_scores) ≥ mean(pre_scores) across re-evaluated samples

The mean policy tolerates a single noise-driven regression as long as the net average improves — better signal when LLM scoring is noisy on borderline answers (chart reads, multi-component answers). Set via LoopConfig(mid_gate_policy="mean").

Skills-only cache tree_hash (src/cache/run_cache.py)

_git_behavior_token() now hashes ONLY the .claude/skills/ tree SHA — not the workspace root tree. Previously, non-behavior-affecting mutations (e.g., ProgramManager committing "Update score" to .claude/program.yaml) shifted the tree_hash and caused spurious cache misses between consecutive runs with identical agent config.

Cached transcript at cache-write time (src/cache/run_cache.py, src/harness/agent.py)

SDK message objects don't roundtrip through Pydantic JSON serialization. New approach: _render_turn_transcript() is called at cache.set() time (while SDK objects are still in memory) and the rendered string is stored as cached_transcript. On cache replay, AgentTrace.summarize() falls back to this field — so failure-trace files for the evolver contain actual tool-call history instead of empty transcripts.

Partial messages on timeout (src/harness/agent.py, src/harness/claude/executor.py)

New _partial_messages contextvar accumulates every SDK message as it streams. On TimeoutError, the AgentTrace is built with these partial messages instead of messages=[]. The evolver now sees what the solver was actually attempting before it ran out of time.

Compact evolver prompt (src/loop/helpers.py)

Replaced verbose inline failure sections (~12KB for 6 failures) with a compact markdown index table (~800 bytes). The evolver sees answer/GT/trace-ref at a glance and chooses which trace files to Read for diagnosis. Trace files contain: question, ground truth, solver answer, full reasoning (no truncation), and complete turn-by-turn tool-call transcript.

CREATE-only skill policy (src/agent_profiles/skill_evolver/prompt.md)

The evolver always creates a new skill with a new name — no editing existing skills. Each name = one version = one iteration. The loop manages version history. This gives clean attribution in the iteration history (score changes are tied to named skills).

PDF-only workspace (src/officeqa/workspace.py)

build_pdf_only_workspace() creates a clean workspace with:

  • treasury_bulletin_pdfs/ symlink to a read-only APFS clone (chmod 555)
  • .claude/skills/ for skill loading/writing
  • .cache/scratch/ for intermediate files
  • Per-run PDF clone refresh (agent sees latest user edits)

The APFS clone is instant + zero extra disk (copy-on-write). The chmod prevents agent writes to the corpus.

env parameter for Claude Agent SDK (src/harness/claude/options.py, utils.py)

New env: dict[str, str] parameter on build_options() → forwarded to ClaudeAgentOptions(env=...). Injects environment variables into the agent's Bash runtime without exposing literal paths in the system prompt.

Test plan

  • 4-iteration opus evo experiment completed (baseline 37.5% → 68.75% val)
  • Holdout-68 regression check (92.6% pass, 5 regressions from skill overhead)
  • Cache hits verified on iter-0 baseline replay
  • Mid-gate mean policy correctly passed 3 iters, rejected 0
  • Partial messages visible in timeout failure-trace files
  • cached_transcript populates on cache replay
  • Phoenix spans show full content (no truncation)
  • Turn spans include question tag ([val N/M]/turn.N)
  • append_feedback survives branch switches (.claude/.keep committed)
  • manager.py NameError on merge-conflict path fixed

🤖 Generated with Claude Code

dastin and others added 30 commits April 16, 2026 15:03
…ules

- src/loop/trace_db.py: SQLite-backed trace storage with progressive disclosure
  (writes individual .md files per trace, generates lightweight index)
- src/loop/reviewer.py: Background reviewer using harness.Agent with Haiku,
  extracts insights from successful solver traces into runtime_proposals table
- src/loop/constraints.py: Skill constraint gating (size, growth, YAML, binary,
  answer leakage) — runs before expensive evaluation to discard bad mutations
- src/tracing.py: Phoenix OTel tracing init (re-added from prior branch)
The unified evolver combines proposer + generator into a single Opus pass
(cheaper than the split, and implementation is informed by full trace context).

- src/schemas/skill_evolver.py: SkillEvolverResponse (action, skill_name, description, justification)
- src/agent_profiles/skill_evolver/: uses build_options for harness-agnostic SDK support
- Schemas and agent_profiles __init__ export the new names
…fields

- src/harness/agent.py: Agent class now takes name: str = "agent" for clearer OTel
  span labels and downstream logging
- src/schemas/agent.py: AgentResponse.final_answer Field description enforces
  clean atomic values (number/name/date) — prevents "approximately 18" leaking
  into scoring
- src/loop/config.py: adds skill_unified evolution mode, cost_metric type,
  accuracy_threshold, and reviewer_enabled
- src/loop/helpers.py: failures are now 5-tuples including the question text.
  build_proposer_query accepts past_traces_index and runtime_proposals sections,
  and tells the evolver to Read specific trace files on demand.
- src/loop/runner.py:
  * init TraceDB and BackgroundReviewer (auto-disabled on error)
  * detect validation-train overlap (tiny datasets) and defer base eval
  * failures carry the question for DB queries (5-tuple)
  * persist every solver trace to DB + individual .md file
  * fire reviewer fire-and-forget only when no failures (evolver handles failures)
  * inject lightweight index + unconsumed runtime proposals into evolver query
  * add skill_unified branch using SkillEvolverResponse
  * phase 2 efficiency: once accuracy >= threshold and all pass, treat successful
    traces as "expensive successes" and run evolver on them
  * run constraint gate on all non-meta skills before commit
New flags:
- --fresh: wipe program branches, frontier tags, feedback, checkpoint, trace DB
- --reviewer_enabled: toggle background reviewer (default True)
- --evolver_model: separate model for evolver (default opus)
- --accuracy_threshold: enable phase 2 efficiency optimization
- --data_root: configurable agent cwd/add_dirs (replaces previous hardcoding)
- --mode skill_unified: opt into the unified evolver

Also:
- Agent instances get explicit name= for better span labels
- skill_evolver agent built conditionally when mode == skill_unified
- Phoenix tracing initialized at entry (init_tracing)
- Fixed imports: Agent and set_sdk from src.harness (canonical path)
- tests/test_loop.py: all build_proposer_query calls updated to 5-tuple format
  (trace, agent_answer, ground_truth, category, question)
- tests/test_opencode_skill_context.py: same 5-tuple migration
- tests/test_trace_db.py: covers insert, progressive disclosure file write,
  index generation with cross-question context
- tests/test_constraints.py: covers size/growth/frontmatter/binary/leakage gates

All 468 tests pass (excluding 2 pre-existing failures on opencode_fix_clean
unrelated to this integration, and test_opencode_runtime which has a
pre-existing import error).
Previously Phoenix auto-instrumentation failures (e.g., wrapt/openinference
version mismatch) would raise at entry and block the entire run. Now we:
1. Try full auto_instrument mode (default)
2. Fall back to manual-spans-only mode on failure
3. Silently continue with no tracing if Phoenix itself is broken

The loop must run even without observability.
Previously the executor silently collected messages — no Phoenix spans beyond
auto-instrumentation (which is fragile across wrapt versions) and no terminal
progress. Added manual instrumentation so observability works regardless:

- agent.run:<name> parent span wrapping the full query
- <name>/turn.<N> spans per AssistantMessage
- <name>/tool.<toolname> spans per ToolUseBlock
- Prints "turn.N [model]: ToolName" or text preview to stdout for live feedback

Agent now threads its .name attribute into the Claude executor so spans and
logs are labeled (e.g., base/turn.5, skill_evolver/tool.Edit).

Other executors (OpenCode, OpenHands, Codex, Goose) unchanged — they don't
receive the agent_name kwarg.
…atch/

Previously --data_root was passed as the agent's cwd, so any Write/Bash
from the solver polluted the officeqa data folder (e.g., scratch/debug_*.png).

Fix:
- cwd stays at EvoSkill project root (owns skills, cache, scratch)
- --data_root is threaded into add_dirs, giving READ access without making
  it the working directory
- System prompt explicitly tells the solver: data dirs are read-only, scratch
  goes into .cache/scratch/ within cwd
Previously all spans (turns + tools) appeared as flat siblings under
agent.run:<name> because start_span() without explicit context picks up
the outermost active span.

Now we explicitly thread context: turn spans are children of run_span,
and tool spans are children of their turn_span. Phoenix tree view now
shows the expected hierarchy.
Previously tool inputs and text were capped at 300 chars via _preview, so
long Bash scripts and Read-tool outputs were truncated in the Phoenix UI.

Now:
- tool.input contains the complete JSON-serialized input
- text contains the full assistant text block
- Raised OTel attribute length limit to 1MB (default was ~12KB)
- Terminal print still uses short preview for at-a-glance visibility
1. All improver agents (skill_proposer, skill_generator, prompt_proposer,
   prompt_generator, skill_evolver) now default to opus. The --evolver_model
   flag overrides this single knob for all of them. --model only affects the
   solver/base agent.
   Rationale: the improvers benefit from stronger reasoning; the solver's
   model choice (sonnet/haiku) shouldn't drag them down to the same tier.

2. agent.run span captures the full query (agent.query attribute) instead
   of a 200-char preview. Evolver queries include failure traces + past
   trace index + runtime proposals — truncating them in Phoenix hid the
   actual context the evolver sees.
Phoenix displays input/output columns based on OpenInference semantic
conventions. Our custom agent.query attribute wasn't picked up:

- agent.run:* spans: openinference.span.kind=AGENT, input.value=<full query>,
  output.value=<final result string>, agent.total_cost_usd
- turn spans: openinference.span.kind=CHAIN
- tool spans: openinference.span.kind=TOOL, input.value=<full tool input JSON>

Phoenix table and detail views now show the complete evolver prompt.

Also defaulted --mode to skill_unified so the combined evolver runs out of the
box (previously defaulted to skill_only which fires proposer + generator).
Previously only ToolUseBlock and TextBlock were handled — turns containing
only extended thinking appeared empty in Phoenix (just model + turn attrs).

Now:
- ThinkingBlock: logged to 'thinking' span attribute + printed to terminal
  with "(thinking)" marker
- Unknown block types: stored as 'unknown_block.<TypeName>' attribute
  (capped at 1000 chars) and printed with type name

ThinkingBlock import is guarded (ImportError -> None) for older SDK versions.
Previously, individual turn spans showed no input/output (only model/turn
attrs). Tool spans closed immediately with no output. You had to click the
parent agent.run span to see the query, and tool results were invisible.

Now each span shows what it did:
- agent.run:<name>: input.value = full query, output.value = final result
- <name>/turn.1: input.value = query (so drilling into turn.1 shows the question)
- <name>/turn.N: output.value = concatenated text + tool call summary
- <name>/tool.X: output.value = the tool result string, tool.is_error flag

Tool spans now stay OPEN until the matching ToolResultBlock arrives (tracked
by tool_use_id -> span map), so latency reflects actual tool execution time.
Defensive cleanup at end closes any spans that didn't receive a result.
Phoenix hides OpenInference semantic attributes (input.value, output.value)
from the "All Attributes" pane — they render in the dedicated Input/Output
section instead. When users look at "All Attributes" they don't see them.

Set the same values under plain names ('query', 'input', 'output') that
will always appear in "All Attributes" regardless of Phoenix's filtering.
The semantic names stay for proper Input/Output pane rendering.
The skill evolver mutates files in-place during its run (Edit/Write tools
with acceptEdits permission). Reading .claude/program.yaml afterward can
fail if the working tree shifted.

Fix: read get_current() BEFORE the evolver runs, stash it, then use it to
create the child branch after the evolver completes its edits.

Before this fix, skill_unified runs crashed with:
    FileNotFoundError: .claude/program.yaml
    at manager.get_current() after evolver_trace completed.
Tool spans for the Skill tool were appearing empty in Phoenix. Making the
tool-span path robust:

- Handle missing/None block.name and block.input gracefully
- Serialize block.input with default=str so unusual types don't fail silently
- Always emit block.type and block.repr for debugging when a tool's shape
  is unexpected
- Cast tool_use_id to str on both emit and lookup sides (so dict keys match)
- When no id is available, set tool.output to an explanatory marker instead
  of leaving it blank
Previously summarize() only rendered the final ResultMessage.result string
(the last assistant text). Thinking, tool calls, tool results, and
intermediate reasoning were lost when the trace was persisted to the DB or
the per-question trace .md files.

Now summarize() walks self.messages and renders every block type per turn:

  --- Turn 1 ---
  (thinking): let me check the ToC first
  (tool.Read): {"file_path": "...", "pages": "6-9"}
  (tool.result): PDF pages extracted...

  --- Turn 2 ---
  text: Report page 5 is at PDF page 19
  (tool.Read): {...}
  (tool.result): ...

Tool results correlate with tool uses by tool_use_id (first pass builds map,
second pass renders inline). Per-result cap (default 4KB) prevents context
blowup on large file reads.

Fallback to the old "Full Result" view if messages are empty or the SDK's
block types can't be imported (non-Claude harnesses).

This finally makes the trace DB useful to the evolver — it can see what
the solver actually did on past iterations, not just its final answer.
wrapt 2.x removed the `module=` keyword from wrap_function_wrapper()
(breaking API change). openinference-instrumentation-anthropic 1.0.0
still uses the old call form, so auto-instrumentation crashed with:

  TypeError: wrap_function_wrapper() got an unexpected keyword argument 'module'

Our tracing.py catches this and falls back to manual-spans-only mode, but
that loses all the LLM-level spans from auto-instrumentation. Pinning
wrapt<2.0 restores the old API and lets auto-instrumentation work again.

Can be removed once openinference publishes a release compatible with
wrapt 2.x.
to current failures (prevents context bloat on larger datasets)

Bug fix
-------
_evaluate() passed result.ground_truth directly to the scorer. When the
CSV's ground_truth column is numeric, pandas hands us int64/float64, not
str. The scorer's fuzzy_match_answer then crashes on
`text.replace(...)` with AttributeError. Wrap in str() like the training
path already does.

Context bloat fix
-----------------
The evolver query inlined the full turn-by-turn transcript for each
current failure (~60KB/failure, dominated by thinking blocks). At
failure_samples=3 that's ~180KB per call, and grows with dataset size.

Now the evolver query contains, per failure:
  - Question, agent answer, ground truth (short)
  - Tool-call skeleton: just tool names in turn order, no thinking/
    results (few dozen bytes)
  - Reference to .cache/current_failures/<file> for the full transcript

Evolver reads specific transcripts on demand via the Read tool (same
pattern as past traces). Task instructions updated to point at the new
files and to prefer Write over Bash mkdir.

_fresh_reset also wipes .cache/current_failures/ now.
Two related gaps this closes:

1. Temporal causality for skills
   Skills evolve across iterations. When the evolver later reads a past
   trace that says "(tool.Skill): chart-counting", it had no way to know
   what that skill's content WAS at the moment the solver invoked it —
   only what the skill looks like NOW, possibly 5 edits later.

   Now every trace persisted to the DB also embeds the exact contents
   of each active SKILL.md at that iteration. The trace .md file has a
   new "Active Skill Snapshots" section with the full markdown each
   skill had when the solver ran.

   This lets the evolver answer "what guidance was the agent following
   when it failed iter-3?" vs "what changed that made iter-5 succeed?"
   from evidence, not guesswork.

2. Comprehensive past-traces index
   generate_index() previously prioritized failed_questions only and
   sampled 5 "other" traces. Now it shows ALL persisted traces (up to
   limit=200 by default), grouped by question, sorted chronologically
   within each question.

   The index is still compact — just metadata and file paths. The full
   transcripts and skill snapshots live in the per-trace .md files that
   the evolver reads on demand via the Read tool.

New in runner:
  - _snapshot_active_skills() reads current SKILL.md contents and hands
    them to trace_db.insert()
  - insert() now accepts active_skill_contents and embeds them

Index column format:
  | Iteration | Score | Turns | Active Skills | File |
…edup

Three related improvements to keep the evolver's context from exploding
as training-set size and iteration count grow.

A. Priority selection (per_question_cap=5)
   When a question has more than K iterations, show only representative
   rows: first (baseline), latest, best score, worst score, biggest score
   change. Gaps are then filled to reach the cap. All rows stay in
   chronological order. This caps per-question table size regardless of
   iteration count.

B. Score trajectory line
   Before each table, a compact one-liner showing ALL iterations of that
   question:
     Trajectory (10 iters): iter-1:0.00 → iter-2:0.10 → ... → iter-9:0.85
     — best: iter-9 @ 0.85
   Gives the evolver the full arc at a glance so it can decide which
   specific iterations warrant a deeper Read.

C. Content-addressed skill snapshot dedup
   Previously every trace embedded the full content of every active
   skill, duplicating bytes across iterations where the skill was
   unchanged. Now snapshots are stored by content hash at
   .cache/skill_snapshots/<skill>_<hash>.md — 10 iterations using 3
   distinct skill versions produces 3 snapshot files, not 30.
   Trace files reference snapshots by path; evolver Reads them on
   demand.

Result for 13 traces / 2 questions / 3 skill versions: index is 2.2KB
total, with full trajectory visible and 5 representative rows per
question. Scales sub-linearly with iteration count.

Side effects:
  - _fresh_reset wipes the new .cache/skill_snapshots/ directory
  - generate_index() new params: per_question_cap=5, limit=500
  - New module helpers: _iter_num(), _select_representative_rows()
When the solver reads the same file multiple times in one run (or any
tool returns identical output across turns), the transcript previously
stored each copy in full — 4KB × N duplication per trace. With large
PDFs being re-read 5-10 times this easily hit 40KB of redundant bytes
in a single trace file.

Now tool results are hashed (sha256 of the rendered text) as they're
rendered. On first sight, content goes in as before. On subsequent
matches the entry becomes:

  (tool.result): [identical to turn K's <ToolName> result — N chars omitted]

Pointing at the earliest occurrence preserves the evidence that "the
solver re-read the same file" — a diagnostic signal the evolver can
use — without paying for duplicate bytes.

Threshold: only dedup results ≥ 120 chars. Tiny results (single-line
"hi", "ok", short error strings) aren't worth the indirection noise.

Tested: 3 identical 542-char PDF reads compressed from ~1.5KB to ~1KB,
while a trivial "hi" result passed through untouched.
…ties

Documents the production launch incantation (env wipe, .env sourcing,
nohup detach, key flags) so external users can reproduce experiments
without reverse-engineering scripts/run_loop.py. Adds the inference-cache
key explanation, workspace-separation rationale, Phoenix setup, and the
.dataset/ helper scripts (audit compilation, hard/baby pool builders).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "base agent" terminology was overloaded: it referred to both the
agent that solves benchmark questions AND the unevolved starting program
("base program") in the evolution loop. Renames the agent-related
identifiers to "solver" so traces, logs, and future code unambiguously
distinguish the two concepts. Span names already say "solver" — this
brings the module/function/symbol layer into alignment.

  * Moved src/agent_profiles/base_agent/ → src/agent_profiles/solver/
  * Renamed base_agent.py → solver.py
  * Renamed BASE_AGENT_TOOLS → SOLVER_TOOLS,
    {get,make,_build}_base_agent_options{,_from_task} → *_solver_options*,
    base_agent_options → solver_options

The "base program" terminology in log strings (e.g., "Base score:",
parent="base", frontier="base") is unchanged because it refers to the
unevolved starting program — a distinct concept that should stay.

Imports updated across src/, scripts/, tests/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base_agent → solver rename in 929e3f5 lost prompt.txt because
.gitignore line 27 (*.txt) hid the renamed file from `git status`.
The original base_agent/prompt.txt was force-added long ago, so the
rename split into a deletion + an ignored add — only the deletion
got staged. Force-add the file so the solver agent can read its
system prompt at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… cleanup

Three cache-correctness bugs that caused stale or wrong results:

1. **Tree-hash didn't include the LIVE skills directory.** When workspace
   ≠ project source root, the cache key hashed the workspace's
   .claude/skills/ (which has only stub .disabled files) instead of the
   project's. Result: same cache key across iterations even as evolved
   skills changed → cache returned responses generated under DIFFERENT
   skill conditions → mid-gate produced bogus "fixed=0, regressed=0,
   same=N" artifacts. Adds `live_skills_dir` and `project_source_root`
   to CacheConfig and threads them through.

2. **`program.yaml` embedded a wall-clock timestamp.** Per-run
   `created_at` field made every fresh run produce a different yaml
   blob, busting the run-cache key on every `--fresh`. Drop `created_at`
   from base_metadata in options_to_config + ProgramConfig.child.
   (Same fix as PR sentient-agi#32, intentionally duplicated here so this bundle
   stands alone if sentient-agi#32 is merged separately.)

3. **Workspace branches left over from GATE-rejected mutations.** When
   mid-gate or val GATE discarded an iter, the workspace's
   program/iter-skill-N branch remained, crashing the next iter's
   `git checkout -b program/iter-skill-N` with "branch already exists".
   Adds defensive cleanup in ProgramManager._git_checkout_new plus
   skill-tree snapshot/restore so historical iters reproduce.

Files: src/cache/run_cache.py, src/registry/{models,sdk_utils,manager}.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles cohesive runtime improvements to the self-improving loop. Hard
to split further because most changes touch runner.py + executor.py +
agent.py simultaneously to wire context across modules.

**Mid-gate (cheap pre-val verification)** — re-runs the iter's train
samples on the newly-evolved program before paying for full val. If the
mutation didn't fix at least min_fixed train samples or regresses more
than max_regressions, discard without running val. Saves ~$25 of val
cost per failed mutation. Configurable via --mid_gate / --mid_gate_min_fixed
/ --mid_gate_max_regressions. Files: src/loop/{runner,config}.py,
scripts/run_loop.py.

**Pause/resume + clock-freeze** — `touch /tmp/evoskill_pause` halts the
loop at the next turn boundary; removing the file resumes within ~1s.
On resume, the wall-clock budget consumed during the pause is REFUNDED
to the active asyncio.timeout via tm.reschedule(when + elapsed), so a
30-min coffee break doesn't make a healthy run trip the 12-min cap.
New file: src/harness/pause.py. Wired via active_timeout contextvar in
src/harness/agent.py.

**Adaptive timeout extension** — the bundled CLI's API client retries
hung HTTP requests internally (API_TIMEOUT_MS=120s ×
CLAUDE_CODE_MAX_RETRIES=5). When the executor's stderr observer sees
"(attempt N/K)" banners, it extends the per-agent asyncio.timeout by
+120s, refunding the time the retry consumed. Closure-captured tm +
max_seen via ContextVar means each concurrent agent's extensions only
apply to itself. Worst-case effective budget: 12 + 2×5 = 22 min.
Files: src/harness/agent.py, src/harness/claude/executor.py.

**Phoenix span improvements** — per-iter prefix on every span
(`iter 3 | solver [val 12/15] [FAIL 0.79]`), score stamped onto run
spans post-trace via `eval_score_callback` contextvar, dropped the
noisy `agent.run:` prefix and the auto-instrumented `messages.create`
spans (auto_instrument=False). Per-turn spans now include the iter
prefix too. New contextvars in src/harness/utils.py:
eval_iter_label, eval_score_callback. Files: src/tracing.py,
src/harness/claude/executor.py, src/loop/runner.py (sets iter label).

**Cost tracking fix** — when all 3 train samples passed, the loop's
"continue" before accumulating _iter_cost into _total_cost made the
final report show "Total cost: $0.0000" after a real $30 run. Cost
now accumulated in the early-return branch.

**Train-time cache wrapper** — _run_one_train called agent.run()
directly instead of through the cache wrapper. Same UID re-evaluated
against the same parent program in subsequent runs paid full price.
Wrapped with the same cache.get/set pattern as evaluate_agent_parallel.

**Auto-cache for one-shot eval scripts** — evaluate_agent_parallel's
`cache` parameter now defaults to a sentinel `_AUTO_CACHE` that
auto-constructs a default RunCache. Existing callers passing
`cache=None` explicitly preserve no-cache behavior. Makes ad-hoc eval
scripts cache-by-default without each one having to remember to
construct a RunCache.

**Live tqdm postfix with running mean** — replaces tqdm_asyncio.gather
with manual asyncio.as_completed + tqdm + set_postfix, so the eval bar
shows `avg=0.XXX pass=N/M` updating per sample. Reads scorer from the
eval_score_callback contextvar set by the runner.

Files: src/loop/{runner,config,helpers,reviewer,trace_db}.py,
src/harness/{agent,utils,pause,claude/executor,claude/options}.py,
src/tracing.py, src/evaluation/{evaluate,eval_full}.py,
scripts/run_loop.py, src/cli/{commands/run,commands/init,config}.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes:

1. **Prompt moved from prompt.py to prompt.md.** Editing a 2KB skill
   spec inside a Python triple-quoted string was awkward — no markdown
   preview, no syntax highlighting on the spec itself, and every edit
   produced a Python diff. The .md file is read at agent-construction
   time (same pattern as src/agent_profiles/solver/prompt.txt).

2. **Required YAML frontmatter for emitted SKILL.md.** The evolver
   sometimes emitted SKILL.md without the `--- name/description ---`
   header, which the GATE rejected → loop retried 3x with no fix →
   wasted iterations. Added an explicit "REQUIRED file format" block
   to the prompt so the model produces frontmatter on the first
   attempt.

Files: src/agent_profiles/skill_evolver/{prompt.md,prompt.py,skill_evolver.py}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dastin and others added 5 commits May 7, 2026 10:47
Adds the OfficeQA Pro benchmark integration on top of the
task-universal evolution loop:

- **src/agent_profiles/officeqa_agent/** — domain-specific solver
  prompt (PDF parsing, Treasury Bulletin context). Used by
  scripts/run_loop.py when running on OfficeQA. The generic
  src/agent_profiles/solver/ remains task-universal.

- **src/evaluation/officeqa_judge.py** — LLM-as-judge scorer for
  OfficeQA's open-ended numeric/text answers. Handles unit
  conversion, rounding tolerance, and equivalent phrasings.

- **scripts/eval_oneshot_unseen.py** — one-shot solver evaluation
  over unseen UIDs. Writes per-question scores to oneshot_scores.csv
  and failures to oneshot_failures.csv. Populates the run cache so
  a subsequent evolution launch hits cache for these UIDs.

- **scripts/analyze_failures.py** — post-hoc failure analysis on a
  saved eval pkl: groups by error pattern, surfaces the most common
  failure modes for the evolver to target.

- **scripts/build_pro20_split.py / build_pro20_split_manual.py** —
  build train/val splits from the OfficeQA Pro 20-question subset
  (the 85 image-verified-correct UIDs).

- **scripts/llm_judge.py** — standalone CLI for running the LLM
  judge over a (predicted, ground_truth) CSV.

- **scripts/run_skill_check.py** — sanity-check harness that runs
  the solver agent on a single UID with a specific skill loaded,
  for debugging skill effectiveness in isolation.

These are deliberately OfficeQA-specific — kept out of the
task-universal evolver per project constraint that
agent_profiles/{solver,skill_evolver}/ remain benchmark-agnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenTelemetry instrumentation libraries (e.g., openinference-anthropic)
don't yet support wrapt 2.x, which broke Phoenix tracing on fresh
installs. Pin to wrapt < 2.0 in the lockfile until upstream catches
up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…kills

When `_restore_skills(name)` was called for a program whose snapshot
didn't exist (because `_snapshot_skills` early-returned when the
project skills dir was empty), it no-op'd and left the live skills
tree alone. Concretely: after iter N's child mutation was discarded,
the loop called `switch_to(parent)` to revert. If parent was `base`
(which has no skills), no snapshot existed, so the discarded child's
skill files persisted in `.claude/skills/` — and iter N+1's evolver
would see them and "edit" a skill that was supposed to be gone.

We observed this in a 4-iter run where iters 3 and 4 emitted
`edit:numerical-precision` even though their parent was `base`,
because `numerical-precision` from iter 2 (which had been discarded
at mid-gate) was still on disk.

Fix: `_snapshot_skills` always creates the snapshot directory, even
when empty. An empty snapshot is the correct record for a program
with no skills, and `_restore_skills` uses it to wipe the live tree
back to empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e sets

The round-robin sampler indexed pools linearly: for a pool of size P
sampled k-at-a-time, iters at the same offset within consecutive
cycles drew identical samples. With P=10 and k=5, iters 1+3 had the
same train set, and iters 2+4 had the same train set — the evolver
was re-attacking the same failures every other iteration.

Fix: maintain a shuffled order of pool indices per category, and
reshuffle each time `_per_cat_offset` wraps modulo pool size. Seed
the per-cycle RNG with `f"{cat}:{cycle}"` so the order is
deterministic across replays but different across cycles. Sampling
goes through a new `_next_train_sample(cat)` helper that handles
both the index lookup and the on-wrap reshuffle, so both the
proportional and round-robin paths share the logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validated via 4-iteration opus evo experiment on OfficeQA Pro strict-94:
baseline 37.5% → 68.75% on val-16 (+31pp), 92.6% on holdout-68 (5 regressions).

Cache correctness:
- Tree_hash uses skills-tree only (no root_tree drift from program.yaml mutations)
- Effort included in cache key (different effort levels don't collide)
- Null-output cache entries replay as-is (same Q-M-E-S-K = same outcome)
- cached_transcript rendered at cache.set time (replays preserve full tool-call history)

Agent trace improvements:
- Partial messages preserved on timeout via _partial_messages contextvar
- AgentTrace.cached_transcript field + summarize() fallback for cache replays
- Turn spans include question tag (e.g. [val 13/16]/turn.1) for Phoenix readability
- No Phoenix truncation on system prompt, user query, or any span attribute
- tqdm mininterval=10s for cleaner log files

Evolver prompt overhaul:
- Compact failure index table (replaces verbose inline multi-KB sections)
- Trace files: clean format (question → GT → answer → reasoning → transcript)
- Dynamic summary ("N of M failed: X timeouts, Y wrong") replaces static Phase label
- Suppress empty sections (skills, feedback hidden when iter-1)
- CREATE-only skill policy (new name per iteration for clear version tracking)
- Mid-gate explanation in system prompt
- Base prompt header cleaned (removed redundant instruction)
- Relative trace paths (not absolute)

Loop infrastructure:
- Mid-gate mean-score policy (tolerates noise-driven single regressions)
- append_feedback parent mkdir fix (survives git branch switches)
- .claude/.keep committed to workspace (persists across program branches)
- manager.py logger import fix (NameError on merge-conflict cleanup path)
- env parameter wired through build_options → ClaudeAgentOptions

PDF-only workspace:
- build_pdf_only_workspace() with read-only APFS clone of PDFs
- Per-run PDF clone refresh (agent always sees latest user edits)
- Dedicated eval + evo runner scripts for sonnet and opus experiments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dastin-sentient dastin-sentient changed the title Integrate learning loop: skill_unified evolver, cosine scorer, Phoenix observability, cost tracking feat: PDF-only evolution infrastructure + evolver prompt overhaul May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant