Prior Work Deep-Dive · grounded in source code
Paper: “SkillOpt: Executive Strategy for Self-Evolving Agent Skills” (arXiv:2605.23904, microsoft/SkillOpt).
Every signature and quote on this page is taken verbatim from the cloned implementation at
/home/v-seungplee/SkillOpt (skillopt/engine/trainer.py, skillopt/evaluation/gate.py,
skillopt/optimizer/*, skillopt/prompts/*, skillopt_sleep/*). The function cards and pipeline stages are collapsed
toggles — click a title to unfold the original code and input/output examples.
A system that freezes the model weights and optimizes one skill document (.md) as the “trainable state,” under deep-learning training discipline: epochs · minibatches · a learning rate · a validation gate. A separate optimizer LLM turns scored rollouts into bounded add / delete / replace edits, and a candidate edit is accepted only when it strictly raises the held-out validation score. README self-description (verbatim): “SkillOpt treats the skill document as the trainable state of a frozen agent … A textual learning-rate budget, a rejected-edit buffer, and an epoch-wise slow / meta update make skill training stable while adding zero inference-time model calls at deployment.”
The deployment artifact is a compressed best_skill.md (typically 300–2,000 tokens)
that attaches as-is to the unchanged target model — zero extra model calls at inference time.
The cloned repo carries two copies of the loop: the paper’s main skillopt package with
ReflACTTrainer (the benchmark training loop — internally named ReflACT), and
skillopt_sleep, the same machinery moved to nightly offline self-evolution
(harvest → mine → replay → consolidate(gate) → stage → adopt).
The paper’s four phases (Rollout → Reflect → Edit → Gate) are implemented as six stages in code: Edit unfolds into Aggregate + Select + Update, and Gate into the Evaluate stage. The flowchart below folds the six code stages back onto the four paper phases.
skillopt/engine/trainer.py — module docstring, verbatim
"""ReflACT Trainer — the main training loop. Orchestrates the 6-stage ReflACT pipeline: 1. Rollout — execute episodes with current skill 2. Reflect — analyze trajectories, generate patches 3. Aggregate — hierarchical merge of patches 4. Select — rank and select top edits 5. Update — apply edits to skill document 6. Evaluate — validate candidate skill, accept/reject """
accumulation batches: adapter.rollout(train_env, current_skill, rollout_dir, use_eval_feedback=True).
Each result dict carries a hard (exact-match 0/1) and soft (F1/partial) score,
a fail_reason, and the full trajectory. Batches shuffle each epoch from a fixed seed pool
(base_seeds — the data-loader counterpart of neural training).run_minibatch_reflect groups failure and success trajectories into separate
minibatches (default 8) and calls the analyst prompts (analyst_error.md / analyst_success.md) in parallel.
Each minibatch returns a patch (a list of proposed edits), and _normalise_patches tags each with
source_type: failure|success and a support_count (how many trajectories back it).
Earlier steps’ failure patterns + rejected-edit summaries (step_buffer_context) are injected alongside.merge_patches fuses the patches with a failure-first
hierarchical merge. Select: fix the edit budget (= the learning rate) — with
lr_control_mode=fixed a scheduler decides (edit_budget max, min_edit_budget min, constant/cosine, …);
with autonomous the optimizer LLM picks the count itself (the textual learning rate).
Then rank_and_select keeps only the top L by importance.
Update: build the candidate skill document with apply_patch_with_report (patch mode)
or rewrite_skill_from_suggestions (rewrite mode).valid_seen split;
a hash cache sel_cache prevents duplicate evaluation), and the pure decision function
evaluate_gate rules accept_new_best | accept | reject.
Acceptance is a strict inequality: anything short of cand_score > current_score is REJECT
and the skill rolls back to its previous state. On rejection, every attempted edit and the score drop are loaded
into the step buffer as “negative feedback” (the rejected-edit buffer).slow_update.md) writes epoch-level guidance into the skill document’s protected
region (between the SLOW_UPDATE markers). The prompt has a built-in step of reflecting on whether the guidance it
wrote last epoch actually helped.
Meta skill: run_meta_skill distills optimizer-side meta-knowledge
from the same pairs and injects it as context into every Reflect/Select call of the next epoch (the optimizer
learns, not the target skill).out_root/best_skill.md every step, and
runtime_state.json records current/best scores · origin · paths to support resume.
Force-accept-mode slow-update guidance is injected only into current_skill; best_skill is
protected as “a faithful snapshot of the val-best step” (source comment, verbatim:
“best_skill must remain a faithful snapshot of the val-best step”).run_sleep_cycle() docstring, verbatim: “harvest -> mine -> replay ->
consolidate(gate) -> stage (-> optional adopt)”. Instead of benchmarks, it mines recurring tasks from
the user’s past Claude Code / Codex session transcripts as training data, and stages only what passes the gate.
harvest_for_config normalizes transcripts into SessionDigests
(only since the last harvest; first run uses lookback_hours=72), and
mine extracts scoreable TaskRecords (intent + judging criterion,
train/val split at holdout_fraction=0.34). An LLM miner on a real backend, heuristics otherwise.consolidate() docstring, verbatim: “Run one consolidation epoch:
reflect -> bounded edit -> gate.” Train tasks drive reflect; val tasks drive the gate.
Optional dream rollouts (rolling the same task K times for good-vs-bad contrast) and associative recall
(summoning K similar past tasks from the archive) augment only the train side — val is never contaminated.write_staging writes proposed_SKILL.md / proposed_CLAUDE.md / report.md / diagnostics.json
into .skillopt-sleep/staging/<ts>/ (secrets redacted), and only a separate, explicit
adopt step copies over the live files — after taking a backup. Edits happen only inside the
<!-- SKILLOPT-SLEEP:LEARNED START/END --> protected region, never trespassing on the
user’s hand-written content.skillopt/gradient/reflect.py — signature, verbatim
def run_minibatch_reflect( results: list[dict], skill_content: str, prediction_dir: str, patches_dir: str, workers: int, failure_only: bool, minibatch_size: int = 8, edit_budget: int = 4, random_seed: int | None = None, *, error_system: str | None = None, success_system: str | None = None, rejection_context: str = "", trajectory_memory_context: str = "", step_buffer_context: str = "", meta_skill_context: str = "", update_mode: str = "patch", skill_aware_reflection: bool | None = None, skill_aware_appendix_source: str | None = None, ) -> list[dict | None]: """Full minibatch reflect stage: group → parallel optimizer calls → patches. Separates failure and success trajectories, splits each into minibatches of size M, runs all minibatches in parallel, and saves patch files."""
Minibatch-analyzes trajectories split into failures and successes, each under a different analyst prompt — failures yield what to fix, successes what to preserve/generalize. Each returned element is a RawPatch.
input → output example (the shape of one RawPatch)
run_minibatch_reflect(rollout_results, current_skill, pred_dir, patches_dir, workers=16, failure_only=False) # -> [ {"patch": {"edits": [ # {"op": "add", "content": "When the sheet has merged cells, unmerge before ...", # "source_type": "failure", "support_count": 3}, ...]}, # "source_type": "failure", "batch_size": 8, "failure_summary": [...]}, ... ]
skillopt/gradient/aggregate.py — signature + docstring, verbatim
def merge_patches( skill_content: str, failure_patches: list[dict], success_patches: list[dict], batch_size: int = 8, verbose: bool = True, workers: int = 16, update_mode: str = "patch", meta_skill_context: str = "", ) -> dict: """Failure-first hierarchical merge with support count tracking. 1. Merge failure patches independently (parallel) 2. Merge success patches independently (parallel) 3. Final merge: combine both groups with failure priority"""
The counterpart of gradient accumulation. Hierarchically merges edit proposals from multiple
minibatches, giving failure-derived edits priority and preserving how many trajectories back each edit
(support_count) — the ranking evidence for the Select stage.
skillopt/optimizer/lr_autonomous.py + skillopt/prompts/lr_autonomous.md
def decide_autonomous_learning_rate(*, skill_content: str, merged_patch: dict, update_mode: str, rollout_hard: float, rollout_soft: float, rollout_n: int, step_buffer_context: str = "", meta_skill_context: str = "", ) -> dict: """Ask the optimizer to choose the number of update items for this step. The prompt intentionally avoids default budgets, candidate budget lists, or scheduler history. The only hard post-processing is validity: the returned integer is clamped to the available item count."""
The “learning rate” is not a real-valued step size but the number of edits
applied this step. In fixed mode a scheduler decides: build_scheduler(mode=cfg["lr_scheduler"],
max_lr=cfg["edit_budget"], min_lr=cfg.get("min_edit_budget", 2), total_steps=total_steps), then
edit_budget = scheduler.step() every step. In autonomous mode the optimizer LLM picks the
count from evidence alone. The prompt’s response contract, verbatim:
Respond ONLY with a valid JSON object:
{
"learning_rate": <non-negative integer>,
"reasoning": "<brief evidence-based reason>",
"confidence": "low|medium|high",
"risk_notes": ["<short note>", ...]
}
input → output example (the record saved to lr_decision.json)
decide_autonomous_learning_rate(skill_content=skill, merged_patch=merged, update_mode="patch", rollout_hard=0.4375, rollout_soft=0.5211, rollout_n=16) # -> {"learning_rate": 3, "raw_learning_rate": 3, "available_update_items": 9, # "clamped": false, "fallback": false, "confidence": "medium", # "reasoning": "...", "risk_notes": [...], "raw_response": "..."} # on parse failure learning_rate=0 (fallback=true) — this step safely falls back to zero edits
skillopt/optimizer/clip.py — signature + docstring, verbatim
def rank_and_select( skill_content: str, patch: dict, max_edits: int, meta_skill_context: str = "", update_mode: str = "patch", ) -> dict: """Use a optimizer LLM to rank edits by importance, then keep top-L. If the edit pool is within budget, returns the patch unchanged. Otherwise, calls the optimizer to rank and select the most impactful edits."""
The counterpart of gradient clipping. When the merged edit pool exceeds the budget (the learning
rate of the card above), the optimizer ranks by importance and passes only the top L. The result persists as the
step directory’s ranked_edits.json.
skillopt/evaluation/gate.py — dataclass + decision logic, verbatim
@dataclass(frozen=True) class GateResult: action: GateAction # "accept_new_best" | "accept" | "reject" current_skill: str; current_score: float best_skill: str; best_score: float; best_step: int def evaluate_gate(candidate_skill, cand_hard, current_skill, current_score, best_skill, best_score, best_step, global_step, *, cand_soft=0.0, metric="hard", mixed_weight=0.5) -> GateResult: cand_score = select_gate_score(cand_hard, cand_soft, metric, mixed_weight) if cand_score > current_score: # strict inequality — even a tie is rejected if cand_score > best_score: return GateResult(action="accept_new_best", ...) return GateResult(action="accept", ...) return GateResult(action="reject", ...) # current/best unchanged = rollback
Module docstring, verbatim: “Analogous to validation-based early stopping and model
selection in neural network training … This module is the pure decision function.”
All side effects (cache lookups, rollouts, state mutation) belong to the trainer; the gate is a pure function.
metric is one of hard | soft | mixed (weighted mean (1-w)·hard + w·soft).
input → output example
evaluate_gate(candidate_skill=cand, cand_hard=0.6250, current_skill=cur, current_score=0.5625,
best_skill=best, best_score=0.6000, best_step=7, global_step=12)
# -> GateResult(action="accept_new_best", current_score=0.625, best_score=0.625, best_step=12)
# had cand_hard been 0.5625 -> GateResult(action="reject", ...) # ties do not pass
skillopt/engine/trainer.py — buf_entry construction (excerpt) + _format_step_buffer
# right after EVALUATE, every step (trainer.py): buf_entry = {"step": global_step, "action": action, "n_total": n_total, "n_fail": n_fail, "failure_patterns": failure_patterns} if "reject" in action and ranked_patch: # only rejected steps attach their edit details buf_entry["score_before"] = current_score buf_entry["score_after"] = cand_gate_score buf_entry["rejected_edits"] = rejected_edits step_buffer.append(buf_entry)
A buffer accumulating every earlier step’s failure patterns plus, for rejected steps, the
“attempted edits + score drop.” _format_step_buffer renders it into one context
block injected into all of the next step’s Reflect · LR-decision · Rewrite prompts. The rendered
preamble, verbatim: “Below is a summary of previous steps in this epoch. Use it to avoid repeating
ineffective edits and to prioritise failure patterns that remain unsolved.”
rendered output example (format exactly as in source strings)
### Step 4 — REJECT (5/16 failed)
- "wrong_tool: used pandas where openpyxl is required" (×3, tasks: t12, t18, t27)
Rejected edits (score 0.5625 → 0.5):
1. [add] "Always coerce date columns with pd.to_datetime before comparison"
2. [replace] target="..." → "..."
skillopt/optimizer/slow_update.py + skillopt/prompts/slow_update.md
def run_slow_update( skill_content: str, results_prev: list[dict], results_curr: list[dict], items: list[dict], *, prev_skill: str = "", prev_slow_update_content: str = "", prev_rollout_dir: str = "", curr_rollout_dir: str = "", comparison_pairs: list[dict] | None = None, system_prompt: str | None = None, ) -> dict | None: ... def build_comparison_pairs(results_prev, results_curr, items, ...) -> list[dict]: # category ∈ {improved, regressed, persistent_fail, stable_success}
At epoch end, the same 20 tasks (slow_update_samples) are rolled with both
the previous and current skills to build longitudinal comparison pairs, targeting the systemic drift that
step-level edits cannot catch. Prompt, verbatim: “The per-step analyst sees individual trajectories and
proposes local patches. YOU see how the skill has evolved across an entire epoch … identify systemic drift,
regressions, and persistent blind spots that step-level edits cannot catch.” The output guidance goes
into the skill document’s SLOW_UPDATE protected region, with mandatory self-reflection on whether last
epoch’s guidance helped. Two acceptance modes: by default it is force-injected into
current_skill only (best_skill inviolate); with
slow_update_gate_with_selection=true it must pass the same validation gate as a step.
skillopt/optimizer/meta_skill.py — signature, verbatim
def run_meta_skill( prev_skill: str, curr_skill: str, comparison_pairs: list[dict], *, prev_meta_skill_content: str = "", system_prompt: str | None = None, ) -> dict | None: """Produce updated optimizer-side meta skill from adjacent epochs."""
Consumes the same longitudinal pairs as slow update, but points the other way: it is
the optimizer itself, not the target skill, that learns “which edits work in this
environment.” The result is saved to meta_skill/epoch_NN/meta_skill_result.json and
injected as context into every Reflect · Aggregate · Select · LR-decision call of the next
epoch (active_meta_skill = _load_meta_skill_content(out_root, epoch - 1)).
skillopt/engine/trainer.py — per-step save paths (excerpt)
_save_skill(out_root, global_step, current_skill) # skills/skill_v0012.md — the full trajectory is preserved with open(os.path.join(out_root, "best_skill.md"), "w") as f: f.write(best_skill) # the validation-best skill = the deployment artifact _persist_runtime_state(global_step) # runtime_state.json: current/best scores·origin·paths
Every step’s skill version survives as skills/skill_v<NNNN>.md,
while the gate-best copy keeps overwriting best_skill.md. Per the README this file is the
final deployment artifact of “typically 300–2,000 tokens,” reporting best or tied-best in all 52
cells of 6 benchmarks × 7 target models × 3 harnesses, and on GPT-5.5 gains over no-skill of
+23.5 (direct chat) / +24.8 (Codex loop) / +19.1 (Claude Code).
runtime_state.json records last_completed_step and
current_origin/best_origin (e.g. step_0012,
slow_update_epoch_02) to support interrupt-resume.
skillopt_sleep/cycle.py + consolidate.py + dream.py — signatures, verbatim
def run_sleep_cycle(cfg=None, *, seed_tasks=None, dry_run=False, clock=None) -> CycleOutcome # CycleOutcome: (report: SleepReport, staging_dir: str, adopted: bool, adopted_paths: list[str]) def consolidate(backend, tasks, skill, memory, *, edit_budget: int = 4, gate_metric: str = "mixed", gate_mixed_weight: float = 0.5, gate_mode: str = "on", rollouts_k: int = 1, ...) -> ConsolidationResult """Run one consolidation epoch: reflect -> bounded edit -> gate.""" def dream_consolidate(backend, tasks, skill, memory, *, history_tasks=None, recall_k=0, dream_rollouts=1, dream_factor=0, ...) -> ConsolidationResult
One SkillOpt epoch that runs once a night. ConsolidationResult comes back
holding applied_edits / rejected_edits (EditRecord lists) plus
holdout baseline/candidate scores, and the report surfaces rejections as-is under a
“Rejected by gate (kept as negative feedback)” section.
dream (K synthetic variants) / recall (summoning similar past tasks) default to OFF and augment only the train split.
output example (report.md rendering, exactly the _render_report_md format)
# SkillOpt-Sleep — night 7 report - held-out score: 0.500 -> 0.667 - gate: **accept_new_best** (accepted=True) ## Accepted edits - [skill/add] Use uv, not pip, in this repo _why: 3 sessions re-derived this_ ## Rejected by gate (kept as negative feedback) - [memory/add] Always run the full test suite before every commit
skillopt_sleep/memory.py + types.py — verbatim
LEARNED_START = "<!-- SKILLOPT-SLEEP:LEARNED START -->" LEARNED_END = "<!-- SKILLOPT-SLEEP:LEARNED END -->" @dataclass class EditRecord: """One bounded edit proposed/applied to skill or memory.""" target: str # "skill" | "memory" op: str # add | delete | replace content: str = "" anchor: str = "" # for replace/delete: text being changed rationale: str = "" def apply_edits(doc: str, edits: List[EditRecord]) -> Tuple[str, List[EditRecord]]: """Apply add/delete/replace edits to the protected learned region. Returns (new_doc, applied_edits). Dedups: an `add` whose content already exists (normalized) is skipped."""
The edit operations are only add / delete / replace, and they work only inside the marker-fenced protected region (module docstring, verbatim: “the sleep cycle never clobbers the user's hand-written content”). A normalized-duplicate add is silently skipped; delete/replace match by normalized anchor substring. The main trainer’s patch mode uses the same three-operation contract.
input → output example
apply_edits(claude_md, [EditRecord(target="memory", op="add", content="Use uv, not pip, in this repo")]) # -> (a new doc with a "- Use uv, not pip, in this repo" bullet added in the LEARNED region, [the applied EditRecord])
skillopt_sleep/state.py + scheduler.py — verbatim
DEFAULT_STATE = {
"version": 1,
"night": 0,
"last_harvest": {}, # project -> iso timestamp of last harvested record
"slow_memory": "", # cross-night consolidated lessons (meta-skill analogue)
"history": [], # list of per-night summaries
"task_archive": [], # capped(300) past mined tasks (for associative recall)
}
state.json lives in ~/.skillopt-sleep and bridges nightly episodes into
long-term capability (docstring, verbatim: “the ‘long-term’ store that turns nightly episodes
into durable competence”). The source itself comments that the slow_memory field
is the counterpart of the main loop’s meta-skill. The scheduler plants a managed block in the crontab, firing
at 03:17 by default — the comment explains why it avoids the top of the hour: “so many users don't
all hit the API at the same instant.” Cron only goes as far as staging: “it only STAGES a
proposal — adopt is still manual.”
SkillOpt optimizes a textual skill document; we optimize an executable world model + a probability-carrying skill library. Yet the grammar of the loop overlaps exactly — each row below is a 1:1 correspondence.
| SkillOpt (code-grounded) | our engine | the correspondence |
|---|---|---|
Rollout — adapter.rollout(train_env, current_skill, …) runs the training batch with the current skill, harvesting trajectories + scores |
wake OBSERVED log | For both, “the record of acting on current knowledge” is the sole raw material of learning. For us that batch is the wake-phase observation log (action → frame change). |
Reflect — run_minibatch_reflect splits failure and success trajectories and analyzes each under a different analyst prompt |
passing / failing split | The dichotomy of extracting repair signals from failures and preservation signals from successes. Our sleep’s observation split (rounds where a skill held / rounds where it broke) is isomorphic. |
Edit — edit_budget (a scheduler) or decide_autonomous_learning_rate bounds the number of edits this step |
EDIT MAGNITUDE | In textual learning, step size = permitted edit volume. Our prompt’s EDIT MAGNITUDE directive (edit breadth proportional to the residual) is the same lever. |
Gate — evaluate_gate: accept only when cand_score > current_score on the held-out selection set, else roll back |
advantage + unseen confirm | “Never trust the generator; judge on held-out data.” For us that gate is the judge’s advantage scoring plus confirmation on unseen states (unseen confirm). |
| rejected-edit buffer — every rejected step’s edits and score drop re-enter the next prompt via the step buffer | hypothesis ledger REFUTED | Failed attempts are not discarded but recirculated as explicit “do not retry this” context. Our hypothesis ledger’s REFUTED entries play the same role. |
slow / meta update — run_slow_update/run_meta_skill distill systemic lessons from end-of-epoch comparisons |
reasoning_log distillation | A slower-timescale layer of summary learning on top of per-step edits. We periodically distill the reasoning_log, producing the same two-tier timescale. |
| best_skill.md — one validation-best skill document is the deployment artifact (300–2,000 tokens) | living note | Learning condenses into “a single continuously-updated document.” Our living note is that deployment surface. |
SkillOpt’s validation signal is ultimately one scalar score (hard/soft/mixed); its learning target is non-executable text; its data is a fixed benchmark batch. We extended each of the three.
Instead of the gate’s binary accept/reject, every skill carries a Beta-style posterior
(confirm_n, falsify_n) that evidence updates cumulatively. In SkillOpt, rejected knowledge
survives only as text in a buffer; with us, a refuted skill survives quarantined together with its posterior, so
even “how wrong it was” carries forward quantitatively.
SkillOpt’s skill document is text that takes effect only when the target model
reads and interprets it. Our world model is executed: miner-authored
predict_next code emits predicted changed cells that are checked cell-by-cell against the
engine’s own logged transitions (Game.fork sealed, fork_dependence = 0)
— the gate directly asks “was the prediction right?”, not “did the score go up?”.
A far cheaper and more local signal (down to which cell was wrong) than re-rollout evaluation.
SkillOpt never decides what to roll next — the dataset hands it the batch. We choose the next action itself through expected-information-gain (EIG) and value-of-information (VoI) gates: which probe splits the competing hypotheses hardest, and is that observation worth enough to change the current plan? Turning passive data consumption into active experiment design is the third extension.
A fair note in the other direction: SkillOpt is more refined than us in places — the learning rate’s autonomous mode (the optimizer chooses its own step size), the failure-first hierarchical merge’s support counts, and the slow update’s “did my last guidance actually help” self-reflection protocol are disciplines our sleep loop does not yet have.