SkillOpt v0.1.0: initial release

- Skill optimization framework with training loop analogy
- 11 benchmarks, 4 model backends (Azure OpenAI, Claude, Codex, Qwen)
- WebUI for browser-based training control
- Pluggable architecture for extending benchmarks and backends
This commit is contained in:
CharlesYang030
2026-05-21 17:22:04 +00:00
commit 244e346b83
237 changed files with 30248 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
"""ReflACT Optimizer -- skill update operations.
Analogous to the optimizer in neural network training: applies the computed
"gradient" (patches) to the current skill document to produce an updated
candidate skill.
Modules
-------
- skill: edit application (optimizer.step() / parameter update)
- clip: edit ranking and selection (gradient clipping)
- meta_reflect: epoch-level macro refinement (momentum)
- slow_update: longitudinal comparison and guidance (EMA / regularization)
"""
from skillopt.optimizer.skill import apply_edit, apply_patch # noqa: F401
from skillopt.optimizer.clip import rank_and_select # noqa: F401
+109
View File
@@ -0,0 +1,109 @@
"""ReflACT gradient clipping — LLM-driven edit ranking and selection.
Analogous to gradient clipping in neural network training: ranks candidate
edits by importance and selects the top-L to apply, controlling the
effective step size. Previously core/select.py.
"""
from __future__ import annotations
from skillopt.model import chat_teacher
from skillopt.optimizer.meta_skill import format_meta_skill_context
from skillopt.optimizer.update_modes import (
describe_item,
get_payload_items,
is_rewrite_mode,
normalize_update_mode,
payload_key,
payload_label,
)
from skillopt.prompts import load_prompt
from skillopt.utils import extract_json
# ── Public API ────────────────────────────────────────────────────────────────
def rank_and_select(
skill_content: str,
patch: dict,
max_edits: int,
meta_skill_context: str = "",
update_mode: str = "patch",
) -> dict:
"""Use a teacher LLM to rank edits by importance, then keep top-L.
If the edit pool is within budget, returns the patch unchanged.
Otherwise, calls the teacher to rank and select the most impactful edits.
Parameters
----------
skill_content : str
Current skill document.
patch : dict
Merged :class:`~skillopt.types.Patch` dict with ``edits`` list.
max_edits : int
Maximum number of edits to keep (the "edit budget").
Returns
-------
dict
:class:`~skillopt.types.Patch` dict with selected edits and
optional ``ranking_details``.
"""
update_mode = normalize_update_mode(update_mode)
edits = get_payload_items(patch, update_mode)
if len(edits) <= max_edits:
return patch
# Build the edit pool description for the teacher
edits_desc = []
for i, edit in enumerate(edits):
edits_desc.append(f"[{i}] {describe_item(edit, update_mode, max_chars=500)}")
user = (
f"## Current Skill\n{skill_content}\n\n"
f"## {payload_label(update_mode, title=True)} Pool ({len(edits)} {payload_label(update_mode)}, budget={max_edits})\n"
+ "\n".join(edits_desc)
+ f"\n\nSelect the {max_edits} most important {payload_label(update_mode)}. "
f"Return their 0-based indices in priority order."
)
teacher_ctx = format_meta_skill_context(meta_skill_context)
if teacher_ctx:
user = f"{teacher_ctx}\n\n{user}"
prompt_name = "ranking_rewrite" if is_rewrite_mode(update_mode) else "ranking"
try:
response, _ = chat_teacher(
system=load_prompt(prompt_name), user=user,
max_completion_tokens=2048, retries=3, stage="ranking",
)
result = extract_json(response)
if result and "selected_indices" in result:
indices = result["selected_indices"]
selected = []
seen: set[int] = set()
for idx in indices:
if (
isinstance(idx, int)
and 0 <= idx < len(edits)
and idx not in seen
):
selected.append(edits[idx])
seen.add(idx)
if len(selected) >= max_edits:
break
if selected:
return {
"reasoning": patch.get("reasoning", "")
+ f" [teacher-ranked: selected {len(selected)}/{len(edits)} {payload_label(update_mode)}]",
payload_key(update_mode): selected,
"ranking_details": result,
}
except Exception: # noqa: BLE001
pass
# Fallback: simple truncation
return {
"reasoning": patch.get("reasoning", "")
+ f" [fallback truncated {len(edits)}->{max_edits} {payload_label(update_mode)}]",
payload_key(update_mode): edits[:max_edits],
}
+108
View File
@@ -0,0 +1,108 @@
"""Teacher-driven autonomous update-size decisions."""
from __future__ import annotations
import json
import re
from typing import Any
from skillopt.model import chat_teacher
from skillopt.optimizer.meta_skill import format_meta_skill_context
from skillopt.optimizer.update_modes import describe_item, get_payload_items, payload_label
from skillopt.prompts import load_prompt
from skillopt.utils import extract_json
def _coerce_nonnegative_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return max(0, value)
if isinstance(value, float) and value.is_integer():
return max(0, int(value))
text = str(value or "").strip()
if not text:
return None
match = re.search(r"-?\d+", text)
if not match:
return None
return max(0, int(match.group(0)))
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 teacher 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.
"""
items = get_payload_items(merged_patch, update_mode)
available = len(items)
item_lines = [
f"[{idx}] {describe_item(item, update_mode, max_chars=700)}"
for idx, item in enumerate(items)
]
user = (
f"## Current Skill\n{skill_content}\n\n"
f"## Current Step Evidence\n"
f"rollout_n={rollout_n}\n"
f"rollout_hard={rollout_hard:.6f}\n"
f"rollout_soft={rollout_soft:.6f}\n"
f"proposed_update_items={available}\n"
f"update_item_type={payload_label(update_mode)}\n\n"
f"## Proposed Update Items\n"
+ "\n".join(item_lines)
+ "\n\nDecide how many proposed update items should be applied now."
)
if step_buffer_context.strip():
user += f"\n\n## Previous Steps in This Epoch\n{step_buffer_context}"
teacher_ctx = format_meta_skill_context(meta_skill_context)
if teacher_ctx:
user = f"{teacher_ctx}\n\n{user}"
response = ""
parsed: dict | None = None
decision: int | None = None
try:
response, _ = chat_teacher(
system=load_prompt("lr_autonomous"),
user=user,
max_completion_tokens=2048,
retries=3,
stage="lr_autonomous",
)
parsed = extract_json(response)
if parsed:
decision = _coerce_nonnegative_int(parsed.get("learning_rate"))
except Exception as exc: # noqa: BLE001
parsed = {"error": str(exc)}
fallback = False
if decision is None:
decision = 0
fallback = True
chosen = min(decision, available)
record = {
"learning_rate": chosen,
"raw_learning_rate": decision,
"available_update_items": available,
"clamped": chosen != decision,
"fallback": fallback,
"reasoning": (parsed or {}).get("reasoning", ""),
"confidence": (parsed or {}).get("confidence", ""),
"risk_notes": (parsed or {}).get("risk_notes", []),
"raw_response": response,
}
if parsed and "error" in parsed:
record["error"] = parsed["error"]
return record
+198
View File
@@ -0,0 +1,198 @@
"""ReflACT Meta-Reflect — epoch-level skill refinement with momentum.
After each epoch, the meta-reflect stage reviews the epoch's step history
(applied edits + gate scores) and performs high-level skill edits:
merging redundant rules, removing ineffective ones, and distilling
cross-step strategic patterns.
This is analogous to momentum in neural network optimization:
- Fast update (per step): analyst edits fix local issues from current batch
- Slow update (per epoch): meta-reflect refines the skill based on what
worked and what didn't across the full epoch
The meta-reflect also maintains a ``meta_summary`` — a compact memory
passed between epochs that captures directional insights (which editing
directions are effective, which are not). This is the "momentum buffer".
Public API
----------
- :func:`build_epoch_history` — format an epoch's step records for meta-reflect
- :func:`run_meta_reflect` — one teacher call to produce high-level edits + meta_summary
"""
from __future__ import annotations
import json
import os
import traceback
from skillopt.model import chat_teacher
from skillopt.optimizer.update_modes import (
describe_item,
get_payload_items,
normalize_update_mode,
payload_label,
truncate_payload,
)
from skillopt.prompts import load_prompt
from skillopt.utils import extract_json
# ── Epoch history formatting ─────────────────────────────────────────────────
def build_epoch_history(
epoch_step_records: list[dict],
out_root: str,
*,
update_mode: str = "patch",
) -> str:
"""Format an epoch's step records into text for the meta-reflect teacher.
For each step, includes the exact edits applied (read from
``ranked_edits.json``) and the gate evaluation result.
Parameters
----------
epoch_step_records : list[dict]
Step record dicts from ``history.json`` belonging to this epoch.
out_root : str
Training output root directory (to locate ``ranked_edits.json``).
Returns
-------
str
Formatted epoch history text.
"""
update_mode = normalize_update_mode(update_mode)
parts: list[str] = []
for rec in epoch_step_records:
step = rec["step"]
action = rec.get("action", "unknown")
gate_score = rec.get("selection_hard", rec.get("current_score", "?"))
best_score = rec.get("best_score", "?")
header = (
f"### Step {step}"
f"gate: {gate_score}, {action.upper()}, "
f"best_so_far: {best_score}"
)
# Read the actual applied edits
ranked_path = os.path.join(
out_root, "steps", f"step_{step:04d}", "ranked_edits.json",
)
edits_text = ""
if os.path.exists(ranked_path):
try:
with open(ranked_path) as f:
ranked = json.load(f)
edits = get_payload_items(ranked, update_mode)
if edits:
lines = [f"Selected {payload_label(update_mode)}:"]
for i, edit in enumerate(edits, 1):
lines.append(f" {i}. {describe_item(edit, update_mode, max_chars=220)}")
edits_text = "\n".join(lines)
else:
edits_text = f"Selected {payload_label(update_mode)}: (none)"
except Exception:
edits_text = f"Selected {payload_label(update_mode)}: (could not read)"
else:
# Step may have been skipped
if "skip" in action:
edits_text = f"Selected {payload_label(update_mode)}: (skipped)"
else:
edits_text = f"Selected {payload_label(update_mode)}: (file not found)"
parts.append(f"{header}\n{edits_text}")
# Append trajectory failure digest if available
digest_path = os.path.join(
out_root, "steps", f"step_{step:04d}", "trajectory_digest.json",
)
if os.path.exists(digest_path):
try:
with open(digest_path) as f:
digest = json.load(f)
patterns = digest.get("failure_patterns", [])
if patterns:
n_fail = digest.get("n_fail", "?")
n_total = digest.get("n_total", "?")
lines = [f"Failure patterns ({n_fail}/{n_total} tasks failed):"]
for p in patterns:
lines.append(
f' - "{p["pattern"]}" (×{p["count"]})'
)
parts[-1] += "\n" + "\n".join(lines)
except Exception:
pass
return "\n\n".join(parts)
# ── Meta-reflect teacher call ────────────────────────────────────────────────
def run_meta_reflect(
skill_content: str,
epoch_history_text: str,
prev_meta_summary: str,
meta_edit_budget: int = 4,
*,
system_prompt: str | None = None,
update_mode: str = "patch",
) -> dict | None:
"""Run one meta-reflect teacher call for an epoch.
Parameters
----------
skill_content : str
Current skill document (after the epoch's fast updates).
epoch_history_text : str
Formatted epoch history from :func:`build_epoch_history`.
prev_meta_summary : str
Meta summary from the previous epoch ("" if first epoch).
meta_edit_budget : int
Maximum number of high-level edits.
system_prompt : str | None
Custom system prompt. ``None`` = use generic default.
Returns
-------
dict | None
Conforms to :class:`~skillopt.types.MetaReflectResult`:
``"meta_summary"`` (str) and ``"patch"`` (:class:`~skillopt.types.Patch`
dict), or ``None`` on failure.
"""
mode = normalize_update_mode(update_mode)
actual_system = system_prompt if system_prompt is not None else load_prompt(
"meta_reflect_rewrite" if mode == "rewrite_from_suggestions" else "meta_reflect"
)
prev_section = prev_meta_summary.strip() if prev_meta_summary else "(First epoch — no previous summary)"
user = (
f"## Previous Meta Summary\n{prev_section}\n\n"
f"## Current Skill Document\n{skill_content}\n\n"
f"## {payload_label(mode, title=True)} Budget\n"
f"Produce at most {meta_edit_budget} high-level {payload_label(mode)}.\n\n"
f"## This Epoch's Step History\n{epoch_history_text}"
)
try:
response, _ = chat_teacher(
system=actual_system,
user=user,
max_completion_tokens=4096,
retries=3,
stage="meta_reflect",
)
result = extract_json(response)
if result and "patch" in result:
truncate_payload(result["patch"], meta_edit_budget, mode)
if "meta_summary" not in result:
result["meta_summary"] = ""
return result
except Exception: # noqa: BLE001
traceback.print_exc()
return None
+87
View File
@@ -0,0 +1,87 @@
"""Teacher-side meta skill memory for cross-epoch optimization guidance.
This module maintains a compact teacher-facing memory distilled from
adjacent-epoch skill comparisons. Unlike ``slow_update``, it does not
modify the student skill document. Instead, it produces guidance meant to
improve future teacher behavior when proposing, merging, and ranking edits.
"""
from __future__ import annotations
import traceback
from skillopt.model import chat_teacher
from skillopt.optimizer.slow_update import format_comparison_text
from skillopt.prompts import load_prompt
from skillopt.utils import extract_json
def format_meta_skill_context(meta_skill_content: str) -> str:
"""Render teacher memory into a prompt-ready context block."""
content = (meta_skill_content or "").strip()
if not content:
return ""
return (
"## Teacher Meta Skill\n"
"This is teacher-side memory distilled from prior epoch transitions in "
"this environment. Use it to improve how you propose, merge, and rank "
"skill edits. Prefer it when the current evidence is ambiguous, but do "
"not force it if the current trajectories clearly contradict it.\n\n"
f"{content}"
)
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 teacher-side meta skill from adjacent epochs."""
actual_system = system_prompt if system_prompt is not None else load_prompt("meta_skill")
prev_skill_display = prev_skill
if len(prev_skill_display) > 6000:
prev_skill_display = prev_skill_display[:6000] + "\n...[truncated]..."
curr_skill_display = curr_skill
if len(curr_skill_display) > 6000:
curr_skill_display = curr_skill_display[:6000] + "\n...[truncated]..."
prev_meta_section = (
prev_meta_skill_content.strip()
if prev_meta_skill_content and prev_meta_skill_content.strip()
else "(No previous teacher meta skill — this is the first update.)"
)
comparison_text = format_comparison_text(comparison_pairs)
user = (
f"## Previous Epoch Last-Step Skill\n{prev_skill_display}\n\n"
f"## Current Epoch Last-Step Skill\n{curr_skill_display}\n\n"
f"## Previous Teacher Meta Skill\n"
f"The following teacher memory was available during the current epoch. "
f"Reflect on whether it improved or harmed the quality of edits.\n\n"
f"{prev_meta_section}\n\n"
f"## Longitudinal Comparison (same tasks, two last-step skills)\n"
f"{comparison_text}"
)
try:
response, _ = chat_teacher(
system=actual_system,
user=user,
max_completion_tokens=3072,
retries=3,
stage="meta_skill",
)
result = extract_json(response)
if result and result.get("meta_skill_content"):
return {
"reasoning": str(result.get("reasoning", "")).strip(),
"meta_skill_content": str(result["meta_skill_content"]).strip(),
}
except Exception: # noqa: BLE001
traceback.print_exc()
return None
+59
View File
@@ -0,0 +1,59 @@
"""Teacher-driven full skill rewrite from selected revise_suggestions."""
from __future__ import annotations
import json
from skillopt.model import chat_teacher
from skillopt.prompts import load_prompt
from skillopt.optimizer.update_modes import get_payload_items
from skillopt.utils import extract_json
def rewrite_skill_from_suggestions(
skill_content: str,
patch: dict,
*,
system_prompt: str | None = None,
step_buffer_context: str = "",
env: str | None = None,
reasoning_effort: str | None = "high",
max_completion_tokens: int = 64000,
) -> dict | None:
suggestions = get_payload_items(patch, "rewrite_from_suggestions")
if not suggestions:
return None
user = (
f"## Current Skill\n{skill_content}\n\n"
f"## Selected Revise Suggestions ({len(suggestions)} total)\n"
f"{json.dumps(suggestions, ensure_ascii=False, indent=2)}\n\n"
)
if step_buffer_context.strip():
user += f"## Previous Steps in This Epoch\n{step_buffer_context}\n\n"
user += (
"Rewrite the full skill document so it integrates the selected suggestions. "
"Return the complete new skill in `new_skill`."
)
actual_system = system_prompt if system_prompt is not None else load_prompt(
"rewrite_skill", env=env,
)
try:
response, _ = chat_teacher(
system=actual_system,
user=user,
max_completion_tokens=max_completion_tokens,
retries=3,
stage="rewrite",
reasoning_effort=reasoning_effort,
)
result = extract_json(response)
if result and str(result.get("new_skill", "")).strip():
result["new_skill"] = str(result["new_skill"]).rstrip() + "\n"
if "change_summary" not in result or not isinstance(result["change_summary"], list):
result["change_summary"] = []
return result
except Exception: # noqa: BLE001
return None
return None
+127
View File
@@ -0,0 +1,127 @@
"""Learning-rate (edit budget) schedulers for ReflACT.
The "learning rate" in ReflACT is the maximum number of skill edits allowed
per optimization step. A scheduler controls how this budget changes over
the course of training.
Supported modes
---------------
- ``constant`` : Fixed budget throughout training.
- ``linear`` : Linear decay from ``max_lr`` to ``min_lr``.
- ``cosine`` : Cosine annealing from ``max_lr`` to ``min_lr``.
- ``autonomous`` : No limit — the model decides how many edits to make.
Usage::
scheduler = build_scheduler(cfg)
for step in range(1, total_steps + 1):
lr = scheduler.step() # returns edit budget for this step
# ... use lr as max_edits ...
"""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
class LRScheduler(ABC):
"""Base class for edit-budget schedulers."""
def __init__(self, max_lr: int, min_lr: int, total_steps: int) -> None:
self.max_lr = max_lr
self.min_lr = min_lr
self.total_steps = total_steps
self._current_step = 0
@abstractmethod
def _compute_lr(self, step: int) -> int:
"""Return the edit budget for the given 1-indexed step."""
def step(self) -> int:
"""Advance one step and return the edit budget."""
self._current_step += 1
return self._compute_lr(self._current_step)
def get_lr(self, step: int) -> int:
"""Return the edit budget for an arbitrary step (1-indexed)."""
return self._compute_lr(step)
def state_dict(self) -> dict:
return {"current_step": self._current_step}
def load_state_dict(self, state: dict) -> None:
self._current_step = state.get("current_step", 0)
class ConstantScheduler(LRScheduler):
"""Fixed edit budget throughout training."""
def _compute_lr(self, step: int) -> int:
return self.max_lr
class LinearScheduler(LRScheduler):
"""Linear decay from ``max_lr`` to ``min_lr`` over ``total_steps``."""
def _compute_lr(self, step: int) -> int:
if self.total_steps <= 1:
return self.max_lr
t = min(step, self.total_steps) / self.total_steps
lr = self.max_lr + (self.min_lr - self.max_lr) * t
return max(self.min_lr, round(lr))
class CosineScheduler(LRScheduler):
"""Cosine annealing from ``max_lr`` to ``min_lr`` over ``total_steps``."""
def _compute_lr(self, step: int) -> int:
if self.total_steps <= 1:
return self.max_lr
t = min(step, self.total_steps) / self.total_steps
lr = self.min_lr + 0.5 * (self.max_lr - self.min_lr) * (1 + math.cos(math.pi * t))
return max(self.min_lr, round(lr))
class AutonomousScheduler(LRScheduler):
"""No edit limit — the model decides freely."""
NO_LIMIT = 999
def _compute_lr(self, step: int) -> int:
return self.NO_LIMIT
# ── Factory ──────────────────────────────────────────────────────────────
_REGISTRY: dict[str, type[LRScheduler]] = {
"constant": ConstantScheduler,
"linear": LinearScheduler,
"cosine": CosineScheduler,
"autonomous": AutonomousScheduler,
}
def build_scheduler(
mode: str = "constant",
max_lr: int = 8,
min_lr: int = 2,
total_steps: int = 8,
) -> LRScheduler:
"""Build a scheduler from config parameters.
Parameters
----------
mode : str
One of ``constant``, ``linear``, ``cosine``, ``autonomous``.
max_lr : int
Initial / maximum edit budget.
min_lr : int
Minimum edit budget (for decay modes).
total_steps : int
Total number of optimization steps in training.
"""
if mode not in _REGISTRY:
raise ValueError(
f"Unknown scheduler mode '{mode}'. Available: {list(_REGISTRY.keys())}"
)
return _REGISTRY[mode](max_lr=max_lr, min_lr=min_lr, total_steps=total_steps)
+4
View File
@@ -0,0 +1,4 @@
"""Backward-compat stub — moved to skillopt.optimizer.clip."""
from skillopt.optimizer.clip import rank_and_select # noqa: F401
__all__ = ["rank_and_select"]
+154
View File
@@ -0,0 +1,154 @@
"""ReflACT skill operations — edit application and patch processing.
The Update stage (⑤) of the ReflACT pipeline: apply a ranked set of
edits to the current skill document, producing an updated candidate.
Analogous to optimizer.step() in neural network training.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from skillopt.types import Edit as EditType, Patch as PatchType
SLOW_UPDATE_START = "<!-- SLOW_UPDATE_START -->"
SLOW_UPDATE_END = "<!-- SLOW_UPDATE_END -->"
def _is_in_slow_update_region(skill: str, target: str) -> bool:
"""Check if *target* text falls within the protected slow update region."""
start_idx = skill.find(SLOW_UPDATE_START)
end_idx = skill.find(SLOW_UPDATE_END)
if start_idx == -1 or end_idx == -1:
return False
target_idx = skill.find(target)
if target_idx == -1:
return False
region_end = end_idx + len(SLOW_UPDATE_END)
return start_idx <= target_idx < region_end
def _edit_fields(edit: EditType | dict) -> tuple[str, str, str]:
op = edit.op if hasattr(edit, "op") else edit.get("op", "")
content = (edit.content if hasattr(edit, "content") else edit.get("content", "")).strip()
target = edit.target if hasattr(edit, "target") else edit.get("target", "")
return op, content, target
def _apply_edit_with_report(skill: str, edit: EditType | dict) -> tuple[str, dict]:
op, content, target = _edit_fields(edit)
report = {
"op": op,
"target": target[:200],
"content_preview": content[:200],
"status": "unknown",
}
if target and _is_in_slow_update_region(skill, target):
report["status"] = "skipped_protected_slow_update_region"
return skill, report
if op == "append":
su_start = skill.find(SLOW_UPDATE_START)
if su_start != -1:
before = skill[:su_start].rstrip()
after = skill[su_start:]
report["status"] = "applied_append_before_slow_update"
return before + "\n\n" + content + "\n\n" + after, report
report["status"] = "applied_append"
return skill.rstrip() + "\n\n" + content + "\n", report
if op == "insert_after":
if not target or target not in skill:
su_start = skill.find(SLOW_UPDATE_START)
if su_start != -1:
before = skill[:su_start].rstrip()
after = skill[su_start:]
report["status"] = "applied_insert_after_fallback_before_slow_update"
return before + "\n\n" + content + "\n\n" + after, report
report["status"] = "applied_insert_after_fallback_append"
return skill.rstrip() + "\n\n" + content + "\n", report
idx = skill.index(target) + len(target)
newline = skill.find("\n", idx)
insert_at = newline + 1 if newline != -1 else len(skill)
report["status"] = "applied_insert_after"
return skill[:insert_at] + "\n" + content + "\n" + skill[insert_at:], report
if op == "replace":
if not target:
report["status"] = "skipped_replace_missing_target"
return skill, report
if target not in skill:
report["status"] = "skipped_replace_target_not_found"
return skill, report
report["status"] = "applied_replace"
return skill.replace(target, content, 1), report
if op == "delete":
if not target:
report["status"] = "skipped_delete_missing_target"
return skill, report
if target not in skill:
report["status"] = "skipped_delete_target_not_found"
return skill, report
report["status"] = "applied_delete"
return skill.replace(target, "", 1), report
report["status"] = "skipped_unknown_op"
return skill, report
def apply_edit(skill: str, edit: EditType | dict) -> str:
"""Apply a single edit operation to the skill document.
Parameters
----------
skill : str
Current skill document content.
edit : Edit | dict
An :class:`~skillopt.types.Edit` instance or a plain dict with
keys ``op``, ``content``, ``target``.
Edits targeting the protected slow-update region are silently skipped.
"""
updated_skill, _ = _apply_edit_with_report(skill, edit)
return updated_skill
def apply_patch_with_report(
skill: str,
patch: PatchType | dict,
) -> tuple[str, list[dict]]:
"""Apply a patch and return a per-edit report for observability."""
edits = patch.edits if hasattr(patch, "edits") else patch.get("edits", [])
reports: list[dict] = []
for idx, edit in enumerate(edits, 1):
try:
skill, report = _apply_edit_with_report(skill, edit)
report["index"] = idx
except Exception as exc: # noqa: BLE001
report = {
"index": idx,
"op": "",
"target": "",
"content_preview": "",
"status": "error",
"error": str(exc),
}
reports.append(report)
return skill, reports
def apply_patch(skill: str, patch: PatchType | dict) -> str:
"""Apply a patch (list of edits) to the skill document sequentially.
Parameters
----------
skill : str
Current skill document content.
patch : Patch | dict
A :class:`~skillopt.types.Patch` instance or a plain dict with
key ``edits`` containing a list of edit operations.
"""
updated_skill, _ = apply_patch_with_report(skill, patch)
return updated_skill
+374
View File
@@ -0,0 +1,374 @@
"""ReflACT Slow Update — epoch-level longitudinal skill refinement.
At the end of each epoch, the slow update compares rollout performance of the
same sample set under the previous epoch's skill vs. the current epoch's skill
(Markov: only adjacent epochs). A teacher analyzes regressions, improvements,
and persistent failures, then writes a free-form guidance block into a
**protected** section of the skill document. This section cannot be modified by
step-level analyst edits — only the slow update process overwrites it.
Public API
----------
- :func:`inject_empty_slow_update_field` — add empty placeholder (epoch 1)
- :func:`extract_slow_update_field` — read current content
- :func:`replace_slow_update_field` — overwrite content
- :func:`has_slow_update_field` — check if markers are present
- :func:`build_comparison_text` — format side-by-side rollout results
- :func:`run_slow_update` — teacher call to produce guidance
"""
from __future__ import annotations
import json
import os
import traceback
from skillopt.model import chat_teacher
from skillopt.prompts import load_prompt
from skillopt.utils import extract_json
# ── Protected field markers ─────────────────────────────────────────────────
SLOW_UPDATE_START = "<!-- SLOW_UPDATE_START -->"
SLOW_UPDATE_END = "<!-- SLOW_UPDATE_END -->"
# ── Field manipulation helpers ──────────────────────────────────────────────
def has_slow_update_field(skill: str) -> bool:
return SLOW_UPDATE_START in skill and SLOW_UPDATE_END in skill
def inject_empty_slow_update_field(skill: str) -> str:
if has_slow_update_field(skill):
return skill
block = (
f"\n\n{SLOW_UPDATE_START}\n"
f"{SLOW_UPDATE_END}\n"
)
return skill.rstrip() + block
def extract_slow_update_field(skill: str) -> str:
start = skill.find(SLOW_UPDATE_START)
end = skill.find(SLOW_UPDATE_END)
if start == -1 or end == -1:
return ""
inner_start = start + len(SLOW_UPDATE_START)
return skill[inner_start:end].strip()
def replace_slow_update_field(skill: str, new_content: str) -> str:
start = skill.find(SLOW_UPDATE_START)
end = skill.find(SLOW_UPDATE_END)
if start == -1 or end == -1:
skill = inject_empty_slow_update_field(skill)
start = skill.find(SLOW_UPDATE_START)
end = skill.find(SLOW_UPDATE_END)
before = skill[:start + len(SLOW_UPDATE_START)]
after = skill[end:]
return before + "\n" + new_content.strip() + "\n" + after
# ── Comparison text builder ─────────────────────────────────────────────────
_MAX_TRAJ_CHARS = 3000
def _clip_text(value, limit: int) -> str:
if value is None:
return ""
return str(value)[:limit]
def _read_trajectory(rollout_dir: str, task_id: str) -> str:
"""Read and format a single trajectory from a rollout directory."""
conv_path = os.path.join(rollout_dir, "predictions", task_id, "conversation.json")
if not os.path.exists(conv_path):
return "(trajectory not available)"
try:
with open(conv_path) as f:
conversation = json.load(f)
except Exception:
return "(trajectory read error)"
if not conversation:
return "(empty trajectory)"
lines: list[str] = []
for entry in conversation:
if not isinstance(entry, dict):
continue
if entry.get("type") == "tool_call":
cmd = _clip_text(entry.get("cmd"), 500)
obs = _clip_text(entry.get("obs"), 800)
lines.append(f"[action] {cmd}")
lines.append(f"[obs] {obs}")
elif "action" in entry and "env_feedback" in entry:
step = entry.get("step", "?")
reasoning = _clip_text(entry.get("reasoning"), 300)
action = _clip_text(entry.get("action"), 200)
feedback = _clip_text(entry.get("env_feedback"), 500)
if reasoning:
lines.append(f"[step {step} think] {reasoning}")
lines.append(f"[step {step} action] {action}")
lines.append(f"[step {step} obs] {feedback}")
elif entry.get("role") == "system":
msg = _clip_text(entry.get("content"), 1000)
lines.append(f"[verification] {msg}")
else:
msg = _clip_text(entry.get("content"), 500)
role = entry.get("role", "agent")
lines.append(f"[{role}] {msg}")
text = "\n".join(lines)
if len(text) > _MAX_TRAJ_CHARS:
half = _MAX_TRAJ_CHARS // 2
text = text[:half] + "\n...[truncated]...\n" + text[-half:]
return text
# ── Structured comparison pairs ─────────────────────────────────────────────
def build_comparison_pairs(
results_prev: list[dict],
results_curr: list[dict],
items: list[dict],
prev_rollout_dir: str = "",
curr_rollout_dir: str = "",
) -> list[dict]:
"""Build a structured list of per-sample comparison entries.
Each entry bundles the original item, both rollout results, the change
category, and both trajectories into one dict — the single source of
truth for this sample's longitudinal comparison.
Returns
-------
list[dict]
One dict per sample with keys:
``id, task, category, prev, curr, prev_trajectory, curr_trajectory``
"""
prev_by_id = {str(r["id"]): r for r in results_prev}
curr_by_id = {str(r["id"]): r for r in results_curr}
pairs: list[dict] = []
for item in items:
tid = str(item.get("id", ""))
prev = prev_by_id.get(tid, {})
curr = curr_by_id.get(tid, {})
prev_ok = bool(prev.get("hard", 0))
curr_ok = bool(curr.get("hard", 0))
if not prev_ok and curr_ok:
category = "improved"
elif prev_ok and not curr_ok:
category = "regressed"
elif not prev_ok and not curr_ok:
category = "persistent_fail"
else:
category = "stable_success"
pairs.append({
"id": tid,
"task": item.get("question", item.get("task_description", item.get("instruction", tid))),
"category": category,
"prev": {
"hard": int(prev_ok),
"soft": float(prev.get("soft", 0.0)),
"predicted_answer": prev.get("predicted_answer", prev.get("answer", "N/A")),
"fail_reason": prev.get("fail_reason", ""),
},
"curr": {
"hard": int(curr_ok),
"soft": float(curr.get("soft", 0.0)),
"predicted_answer": curr.get("predicted_answer", curr.get("answer", "N/A")),
"fail_reason": curr.get("fail_reason", ""),
},
"prev_trajectory": (
_read_trajectory(prev_rollout_dir, tid) if prev_rollout_dir else ""
),
"curr_trajectory": (
_read_trajectory(curr_rollout_dir, tid) if curr_rollout_dir else ""
),
})
return pairs
def save_comparison_pairs(pairs: list[dict], out_path: str) -> None:
"""Persist comparison pairs to JSON (without trajectory text to save space)."""
slim = []
for p in pairs:
slim.append({
"id": p["id"],
"task": p["task"][:300],
"category": p["category"],
"prev": p["prev"],
"curr": p["curr"],
})
with open(out_path, "w") as f:
json.dump(slim, f, ensure_ascii=False, indent=2)
def format_comparison_text(pairs: list[dict]) -> str:
"""Format structured comparison pairs into teacher-readable text."""
by_cat: dict[str, list[dict]] = {
"regressed": [],
"persistent_fail": [],
"improved": [],
"stable_success": [],
}
for p in pairs:
by_cat.setdefault(p["category"], []).append(p)
total = len(pairs)
parts = [
f"## Longitudinal Comparison Summary\n"
f"Total samples: {total}\n"
f"- Improved (wrong→right): {len(by_cat['improved'])}\n"
f"- Regressed (right→wrong): {len(by_cat['regressed'])}\n"
f"- Persistent failures (wrong→wrong): {len(by_cat['persistent_fail'])}\n"
f"- Stable successes (right→right): {len(by_cat['stable_success'])}\n"
]
categories = [
("regressed", "Regressions (right→wrong) — HIGHEST PRIORITY", True),
("persistent_fail", "Persistent Failures (wrong→wrong)", True),
("improved", "Improvements (wrong→right)", True),
("stable_success", "Stable Successes (right→right)", False),
]
for cat_key, label, show_traj in categories:
entries = by_cat[cat_key]
if not entries:
parts.append(f"### {label}\n(none)\n")
continue
lines = [f"### {label}"]
for e in entries:
prev = e["prev"]
curr = e["curr"]
lines.append(
f"\n#### Task {e['id']}: {e['task'][:300]}\n"
f"- Prev epoch: {'PASS' if prev['hard'] else 'FAIL'} "
f"(soft={prev['soft']:.2f}) — answer: {str(prev['predicted_answer'])[:200]}\n"
f"- Curr epoch: {'PASS' if curr['hard'] else 'FAIL'} "
f"(soft={curr['soft']:.2f}) — answer: {str(curr['predicted_answer'])[:200]}"
)
if curr.get("fail_reason"):
lines.append(f"- Curr fail reason: {curr['fail_reason'][:300]}")
if prev.get("fail_reason") and not prev["hard"]:
lines.append(f"- Prev fail reason: {prev['fail_reason'][:300]}")
if show_traj:
if e.get("prev_trajectory"):
lines.append(
f"\n**Previous epoch trajectory:**\n```\n{e['prev_trajectory']}\n```"
)
if e.get("curr_trajectory"):
lines.append(
f"\n**Current epoch trajectory:**\n```\n{e['curr_trajectory']}\n```"
)
parts.append("\n".join(lines))
return "\n\n".join(parts)
# ── Teacher call ────────────────────────────────────────────────────────────
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:
"""Run the slow update teacher call for one epoch boundary.
Parameters
----------
skill_content : str
Current epoch's skill (after fast updates).
results_prev : list[dict]
Rollout results of the 20 samples under previous epoch's skill.
results_curr : list[dict]
Rollout results of the 20 samples under current epoch's skill.
items : list[dict]
The 20 sample items used for comparison.
prev_skill : str
Previous epoch's skill content.
prev_slow_update_content : str
The slow update guidance from the previous epoch (to reflect on).
prev_rollout_dir : str
Path to previous epoch rollout output (contains predictions/).
curr_rollout_dir : str
Path to current epoch rollout output (contains predictions/).
system_prompt : str | None
Custom system prompt override.
Returns
-------
dict | None
Conforms to :class:`~skillopt.types.SlowUpdateResult`:
``{"reasoning": str, "slow_update_content": str}`` or ``None``.
"""
actual_system = system_prompt if system_prompt is not None else load_prompt("slow_update")
pairs = comparison_pairs
if pairs is None:
pairs = build_comparison_pairs(
results_prev, results_curr, items,
prev_rollout_dir=prev_rollout_dir,
curr_rollout_dir=curr_rollout_dir,
)
comparison_text = format_comparison_text(pairs)
prev_skill_display = prev_skill
if len(prev_skill_display) > 6000:
prev_skill_display = prev_skill_display[:6000] + "\n...[truncated]..."
prev_guidance_section = (
prev_slow_update_content.strip()
if prev_slow_update_content and prev_slow_update_content.strip()
else "(No previous guidance — this is the first slow update.)"
)
user = (
f"## Previous Epoch's Skill\n{prev_skill_display}\n\n"
f"## Current Epoch's Skill\n{skill_content}\n\n"
f"## Previous Slow Update Guidance\n"
f"The following guidance was active during the current epoch. "
f"Reflect on its effectiveness before writing the new version.\n\n"
f"{prev_guidance_section}\n\n"
f"## Longitudinal Comparison (same 20 tasks, two skill versions)\n"
f"{comparison_text}"
)
try:
response, _ = chat_teacher(
system=actual_system,
user=user,
max_completion_tokens=4096,
retries=3,
stage="slow_update",
)
result = extract_json(response)
if result and result.get("slow_update_content"):
return {
"reasoning": str(result.get("reasoning", "")).strip(),
"slow_update_content": str(result["slow_update_content"]).strip(),
}
except Exception: # noqa: BLE001
traceback.print_exc()
return None
+136
View File
@@ -0,0 +1,136 @@
"""Helpers for switching between patch edits and rewrite-from-suggestions."""
from __future__ import annotations
from typing import Any
PATCH_MODE = "patch"
REWRITE_MODE = "rewrite_from_suggestions"
FULL_REWRITE_MINIBATCH_MODE = "full_rewrite_minibatch"
def normalize_update_mode(mode: str | None) -> str:
raw = str(mode or PATCH_MODE).strip().lower()
aliases = {
"patch": PATCH_MODE,
"edits": PATCH_MODE,
"rewrite": REWRITE_MODE,
"rewrite_from_suggestions": REWRITE_MODE,
"suggestions": REWRITE_MODE,
"rewrite_suggestions": REWRITE_MODE,
"full_rewrite": FULL_REWRITE_MINIBATCH_MODE,
"full_rewrite_minibatch": FULL_REWRITE_MINIBATCH_MODE,
"minibatch_full_rewrite": FULL_REWRITE_MINIBATCH_MODE,
"skill_rewrite_minibatch": FULL_REWRITE_MINIBATCH_MODE,
}
return aliases.get(raw, PATCH_MODE)
def is_rewrite_mode(mode: str | None) -> bool:
return normalize_update_mode(mode) == REWRITE_MODE
def is_full_rewrite_minibatch_mode(mode: str | None) -> bool:
return normalize_update_mode(mode) == FULL_REWRITE_MINIBATCH_MODE
def payload_key(mode: str | None) -> str:
if is_full_rewrite_minibatch_mode(mode):
return "skill_candidates"
return "revise_suggestions" if is_rewrite_mode(mode) else "edits"
def payload_label(mode: str | None, *, singular: bool = False, title: bool = False) -> str:
if is_full_rewrite_minibatch_mode(mode):
word = "skill candidate" if singular else "skill candidates"
elif is_rewrite_mode(mode):
word = "suggestion" if singular else "suggestions"
else:
word = "edit" if singular else "edits"
return word.title() if title else word
def get_payload_items(container: dict | None, mode: str | None) -> list[dict]:
if not isinstance(container, dict):
return []
items = container.get(payload_key(mode), [])
return items if isinstance(items, list) else []
def set_payload_items(container: dict, items: list[dict], mode: str | None) -> dict:
container[payload_key(mode)] = items
return container
def truncate_payload(container: dict, max_items: int, mode: str | None) -> dict:
if max_items < 0:
return container
items = get_payload_items(container, mode)
if len(items) > max_items:
set_payload_items(container, items[:max_items], mode)
return container
def describe_item(item: dict, mode: str | None, *, max_chars: int = 240) -> str:
if not isinstance(item, dict):
return ""
if is_full_rewrite_minibatch_mode(mode):
parts = [
f"title={item.get('title', '')!r}",
f"change_summary={item.get('change_summary', [])!r}",
]
if item.get("source_type"):
parts.append(f"source={item.get('source_type')}")
if item.get("support_count") is not None:
parts.append(f"support={item.get('support_count')}")
new_skill = str(item.get("new_skill", "")).strip()
if new_skill:
parts.append(f"new_skill_preview={new_skill[:120]!r}")
text = " ".join(parts)
elif is_rewrite_mode(mode):
parts = [
f"type={item.get('type', '?')}",
f"title={item.get('title', '')!r}",
f"instruction={item.get('instruction', '')!r}",
]
if item.get("priority_hint"):
parts.append(f"priority={item.get('priority_hint')}")
if item.get("support_count") is not None:
parts.append(f"support={item.get('support_count')}")
text = " ".join(parts)
else:
op = item.get("op", "?")
target = item.get("target", "")
content = item.get("content", "")
parts = [f"op={op}"]
if target:
parts.append(f"target={target!r}")
if content:
parts.append(f"content={content!r}")
if item.get("support_count") is not None:
parts.append(f"support={item.get('support_count')}")
text = " ".join(parts)
if len(text) <= max_chars:
return text
return text[: max_chars - 3].rstrip() + "..."
def short_item_summary(item: dict, mode: str | None, *, max_chars: int = 200) -> dict[str, Any]:
if is_full_rewrite_minibatch_mode(mode):
return {
"title": str(item.get("title", ""))[:max_chars],
"change_summary": [
str(x)[:max_chars] for x in item.get("change_summary", [])[:3]
] if isinstance(item.get("change_summary"), list) else [],
"source_type": item.get("source_type", ""),
}
if is_rewrite_mode(mode):
return {
"type": item.get("type", "?"),
"title": str(item.get("title", ""))[:max_chars],
"instruction": str(item.get("instruction", ""))[:max_chars],
}
return {
"op": item.get("op", "?"),
"content": str(item.get("content", ""))[:max_chars],
"target": item.get("target", ""),
}