chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""SkillOpt 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)
|
||||
- slow_update: longitudinal comparison and guidance (EMA / regularization)
|
||||
- meta_skill: cross-epoch memory for optimizer context
|
||||
"""
|
||||
from skillopt.optimizer.skill import apply_edit, apply_patch # noqa: F401
|
||||
from skillopt.optimizer.clip import rank_and_select # noqa: F401
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Skill-Aware Reflection — protected appendix field (EmbodiSkill S_app).
|
||||
|
||||
EmbodiSkill (paper 2605.10332v1) splits a skill into ``S = (S_body, S_app)``:
|
||||
the body holds the main prescriptive rules; the appendix only *emphasizes*
|
||||
existing valid rules that the executor failed to follow (EXECUTION_LAPSE), and
|
||||
**never introduces new rules**.
|
||||
|
||||
This module owns the appendix region of the skill document. It mirrors the
|
||||
protected-field pattern of :mod:`skillopt.optimizer.slow_update`, with two
|
||||
differences:
|
||||
|
||||
1. **Append semantics** (not replace): execution-lapse reminders accumulate
|
||||
across steps within a run, so new notes are merged into the existing
|
||||
appendix rather than overwriting it.
|
||||
2. **Lightweight dedup**: near-duplicate reminders are collapsed (inspired by
|
||||
GMemory's ``_dedupe_preserve_order``) so the appendix stays compact.
|
||||
|
||||
The appendix lives **inside** the skill markdown, between dedicated markers, so
|
||||
it is persisted by the normal ``_save_skill`` path and is resume-safe. Step-level
|
||||
analyst edits cannot modify it (enforced by the shared protected-region check in
|
||||
:mod:`skillopt.optimizer.skill`).
|
||||
|
||||
Public API
|
||||
----------
|
||||
- :func:`has_appendix_field` — check if markers are present
|
||||
- :func:`inject_empty_appendix_field` — add empty placeholder (skill init)
|
||||
- :func:`extract_appendix_notes` — read current notes as a list
|
||||
- :func:`append_to_appendix_field` — merge new notes (dedup) into the region
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ── Protected field markers ─────────────────────────────────────────────────
|
||||
|
||||
APPENDIX_START = "<!-- APPENDIX_START -->"
|
||||
APPENDIX_END = "<!-- APPENDIX_END -->"
|
||||
|
||||
# Heading shown inside the rendered appendix block (human-readable only).
|
||||
APPENDIX_HEADING = "## Execution Notes Appendix"
|
||||
|
||||
# Each note is rendered as a markdown bullet so the target model reads it as
|
||||
# ordinary guidance.
|
||||
_NOTE_BULLET_PREFIX = "- "
|
||||
|
||||
|
||||
# ── Dedup helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _canonicalize(text: str) -> str:
|
||||
"""Normalize a note for duplicate detection (whitespace/punct/case-insensitive)."""
|
||||
normalized = re.sub(r"\s+", " ", str(text or "").strip())
|
||||
normalized = normalized.rstrip(" .;:,_-")
|
||||
return normalized.casefold()
|
||||
|
||||
|
||||
def _dedupe_preserve_order(notes: list[str]) -> list[str]:
|
||||
"""Drop blanks and near-duplicates, preserving first-seen order."""
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for note in notes:
|
||||
text = re.sub(r"\s+", " ", str(note).strip())
|
||||
if not text:
|
||||
continue
|
||||
key = _canonicalize(text)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(text)
|
||||
return deduped
|
||||
|
||||
|
||||
# ── Field manipulation ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def has_appendix_field(skill: str) -> bool:
|
||||
return APPENDIX_START in skill and APPENDIX_END in skill
|
||||
|
||||
|
||||
def _render_block(notes: list[str]) -> str:
|
||||
"""Render the full marker-delimited appendix block for *notes*."""
|
||||
lines = [APPENDIX_START, APPENDIX_HEADING]
|
||||
for note in notes:
|
||||
lines.append(f"{_NOTE_BULLET_PREFIX}{note}")
|
||||
lines.append(APPENDIX_END)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def inject_empty_appendix_field(skill: str) -> str:
|
||||
"""Add an empty appendix placeholder at the end of *skill* (idempotent).
|
||||
|
||||
Mirrors ``inject_empty_slow_update_field``: called once at skill init so the
|
||||
protected region exists before any note is written.
|
||||
"""
|
||||
if has_appendix_field(skill):
|
||||
return skill
|
||||
block = f"\n\n{APPENDIX_START}\n{APPENDIX_HEADING}\n{APPENDIX_END}\n"
|
||||
return skill.rstrip() + block
|
||||
|
||||
|
||||
def extract_appendix_notes(skill: str) -> list[str]:
|
||||
"""Return the current appendix notes as a list of strings (no markers/heading)."""
|
||||
start = skill.find(APPENDIX_START)
|
||||
end = skill.find(APPENDIX_END)
|
||||
if start == -1 or end == -1:
|
||||
return []
|
||||
inner = skill[start + len(APPENDIX_START):end].strip()
|
||||
notes: list[str] = []
|
||||
for raw_line in inner.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line == APPENDIX_HEADING or line.lstrip("#").strip() == APPENDIX_HEADING.lstrip("#").strip():
|
||||
continue
|
||||
if line.startswith(_NOTE_BULLET_PREFIX):
|
||||
line = line[len(_NOTE_BULLET_PREFIX):].strip()
|
||||
elif line.startswith("-") or line.startswith("*"):
|
||||
line = line[1:].strip()
|
||||
if line:
|
||||
notes.append(line)
|
||||
return notes
|
||||
|
||||
|
||||
def _strip_all_appendix_fields(skill: str) -> str:
|
||||
"""Remove every appendix marker pair (and content between) from *skill*."""
|
||||
while True:
|
||||
start = skill.find(APPENDIX_START)
|
||||
if start == -1:
|
||||
break
|
||||
end = skill.find(APPENDIX_END, start)
|
||||
if end == -1:
|
||||
skill = skill[:start] + skill[start + len(APPENDIX_START):]
|
||||
break
|
||||
skill = skill[:end + len(APPENDIX_END)].rsplit(APPENDIX_START, 1)[0] + skill[end + len(APPENDIX_END):]
|
||||
skill = skill.replace(APPENDIX_END, "")
|
||||
while "\n\n\n" in skill:
|
||||
skill = skill.replace("\n\n\n", "\n\n")
|
||||
return skill.rstrip()
|
||||
|
||||
|
||||
def append_to_appendix_field(skill: str, new_notes: list[str]) -> str:
|
||||
"""Merge *new_notes* into the appendix region (dedup), returning updated skill.
|
||||
|
||||
- If no appendix region exists yet, one is created.
|
||||
- Existing notes are preserved; new ones are appended after dedup against the
|
||||
combined set, so order is stable and duplicates are dropped.
|
||||
- Empty / whitespace-only notes are ignored. If the merged set is empty, an
|
||||
empty placeholder region is still ensured.
|
||||
"""
|
||||
incoming = _dedupe_preserve_order(list(new_notes or []))
|
||||
existing = extract_appendix_notes(skill)
|
||||
merged = _dedupe_preserve_order(existing + incoming)
|
||||
|
||||
base = _strip_all_appendix_fields(skill)
|
||||
block = _render_block(merged)
|
||||
return f"{base}\n\n{block}\n"
|
||||
@@ -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_optimizer
|
||||
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 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.
|
||||
|
||||
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 optimizer
|
||||
edits_desc = []
|
||||
for i, edit in enumerate(edits):
|
||||
edits_desc.append(f"[{i}] {describe_item(edit, update_mode)}")
|
||||
|
||||
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."
|
||||
)
|
||||
optimizer_ctx = format_meta_skill_context(meta_skill_context)
|
||||
if optimizer_ctx:
|
||||
user = f"{optimizer_ctx}\n\n{user}"
|
||||
prompt_name = "ranking_rewrite" if is_rewrite_mode(update_mode) else "ranking"
|
||||
|
||||
try:
|
||||
response, _ = chat_optimizer(
|
||||
system=load_prompt(prompt_name), user=user,
|
||||
max_completion_tokens=16384, 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" [optimizer-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],
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Optimizer-driven autonomous update-size decisions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from skillopt.model import chat_optimizer
|
||||
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 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.
|
||||
"""
|
||||
items = get_payload_items(merged_patch, update_mode)
|
||||
available = len(items)
|
||||
item_lines = [
|
||||
f"[{idx}] {describe_item(item, update_mode)}"
|
||||
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}"
|
||||
optimizer_ctx = format_meta_skill_context(meta_skill_context)
|
||||
if optimizer_ctx:
|
||||
user = f"{optimizer_ctx}\n\n{user}"
|
||||
|
||||
response = ""
|
||||
parsed: dict | None = None
|
||||
decision: int | None = None
|
||||
try:
|
||||
response, _ = chat_optimizer(
|
||||
system=load_prompt("lr_autonomous"),
|
||||
user=user,
|
||||
max_completion_tokens=16384,
|
||||
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
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Optimizer-side meta skill memory for cross-epoch optimization guidance.
|
||||
|
||||
This module maintains a compact optimizer-facing memory distilled from
|
||||
adjacent-epoch skill comparisons. Unlike ``slow_update``, it does not
|
||||
modify the target skill document. Instead, it produces guidance meant to
|
||||
improve future optimizer behavior when proposing, merging, and ranking edits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
|
||||
from skillopt.model import chat_optimizer
|
||||
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 optimizer memory into a prompt-ready context block."""
|
||||
content = (meta_skill_content or "").strip()
|
||||
if not content:
|
||||
return ""
|
||||
return (
|
||||
"## Optimizer Meta Skill\n"
|
||||
"This is optimizer-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 optimizer-side meta skill from adjacent epochs."""
|
||||
actual_system = system_prompt if system_prompt is not None else load_prompt("meta_skill")
|
||||
|
||||
prev_meta_section = (
|
||||
prev_meta_skill_content.strip()
|
||||
if prev_meta_skill_content and prev_meta_skill_content.strip()
|
||||
else "(No previous optimizer meta skill — this is the first update.)"
|
||||
)
|
||||
|
||||
comparison_text = format_comparison_text(comparison_pairs)
|
||||
user = (
|
||||
f"## Previous Epoch Last-Step Skill\n{prev_skill}\n\n"
|
||||
f"## Current Epoch Last-Step Skill\n{curr_skill}\n\n"
|
||||
f"## Previous Optimizer Meta Skill\n"
|
||||
f"The following optimizer 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_optimizer(
|
||||
system=actual_system,
|
||||
user=user,
|
||||
max_completion_tokens=16384,
|
||||
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
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Optimizer-driven full skill rewrite from selected revise_suggestions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from skillopt.model import chat_optimizer
|
||||
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_optimizer(
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,201 @@
|
||||
"""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 -->"
|
||||
|
||||
# Skill-aware reflection (EmbodiSkill S_app) appendix region. Like the slow
|
||||
# update region, it is protected: step-level analyst edits must not modify it.
|
||||
APPENDIX_START = "<!-- APPENDIX_START -->"
|
||||
APPENDIX_END = "<!-- APPENDIX_END -->"
|
||||
|
||||
# All protected (start, end) marker pairs. Step-level edits cannot target text
|
||||
# inside any of these regions, and `append` / `insert_after`-fallback ops are
|
||||
# inserted before the earliest-occurring region so protected blocks stay at the
|
||||
# document tail. With only the slow-update region present, every helper reduces
|
||||
# to the original slow-update-only behavior (byte-identical skill output).
|
||||
_PROTECTED_REGIONS: tuple[tuple[str, str], ...] = (
|
||||
(SLOW_UPDATE_START, SLOW_UPDATE_END),
|
||||
(APPENDIX_START, APPENDIX_END),
|
||||
)
|
||||
|
||||
|
||||
def _earliest_protected_start(skill: str) -> int:
|
||||
"""Index of the earliest protected-region start marker, or -1 if none."""
|
||||
positions = [
|
||||
idx
|
||||
for idx in (skill.find(start) for start, _ in _PROTECTED_REGIONS)
|
||||
if idx != -1
|
||||
]
|
||||
return min(positions) if positions else -1
|
||||
|
||||
|
||||
def _is_in_protected_region(skill: str, target: str) -> bool:
|
||||
"""Check if *target* text falls within any protected region."""
|
||||
if not target:
|
||||
return False
|
||||
target_idx = skill.find(target)
|
||||
if target_idx == -1:
|
||||
return False
|
||||
for start_marker, end_marker in _PROTECTED_REGIONS:
|
||||
start_idx = skill.find(start_marker)
|
||||
end_idx = skill.find(end_marker)
|
||||
if start_idx == -1 or end_idx == -1:
|
||||
continue
|
||||
region_end = end_idx + len(end_marker)
|
||||
if start_idx <= target_idx < region_end:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_in_slow_update_region(skill: str, target: str) -> bool:
|
||||
"""Backward-compatible alias kept for any external callers/tests."""
|
||||
return _is_in_protected_region(skill, target)
|
||||
|
||||
|
||||
def _strip_slow_update_markers(text: str) -> str:
|
||||
"""Remove any protected-region markers from edit content to prevent duplication."""
|
||||
return (
|
||||
text.replace(SLOW_UPDATE_START, "")
|
||||
.replace(SLOW_UPDATE_END, "")
|
||||
.replace(APPENDIX_START, "")
|
||||
.replace(APPENDIX_END, "")
|
||||
)
|
||||
|
||||
|
||||
def _edit_fields(edit: EditType | dict) -> tuple[str, str, str]:
|
||||
op = edit.op if hasattr(edit, "op") else edit.get("op", "")
|
||||
content = _strip_slow_update_markers(
|
||||
(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_protected_region(skill, target):
|
||||
report["status"] = "skipped_protected_region"
|
||||
return skill, report
|
||||
|
||||
if op == "append":
|
||||
prot_start = _earliest_protected_start(skill)
|
||||
if prot_start != -1:
|
||||
before = skill[:prot_start].rstrip()
|
||||
after = skill[prot_start:]
|
||||
report["status"] = "applied_append_before_protected_region"
|
||||
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:
|
||||
prot_start = _earliest_protected_start(skill)
|
||||
if prot_start != -1:
|
||||
before = skill[:prot_start].rstrip()
|
||||
after = skill[prot_start:]
|
||||
report["status"] = "applied_insert_after_fallback_before_protected_region"
|
||||
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
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Skill-Aware Reflection — analyst prompt augmentation (EmbodiSkill).
|
||||
|
||||
When ``use_skill_aware_reflection`` is enabled, the failure/success analysts are
|
||||
asked to additionally classify each reflection by EmbodiSkill type and to route
|
||||
**EXECUTION_LAPSE** reflections (the skill rule is correct, the executor just
|
||||
failed to follow it) into a separate ``appendix_notes`` list instead of the body
|
||||
patch. This module owns:
|
||||
|
||||
1. the instruction text appended to the resolved analyst system prompt, and
|
||||
2. extraction of ``appendix_notes`` from the analyst JSON response.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- The suffix is appended **at runtime, gated by the toggle**, so env-specific and
|
||||
generic analyst prompts are augmented uniformly and — when the toggle is off —
|
||||
remain byte-identical to baseline.
|
||||
- Discrimination follows the paper / GMemory: ``SKILL_DEFECT`` = the skill rule is
|
||||
wrong / missing / underspecified (→ body edit); ``EXECUTION_LAPSE`` = the rule
|
||||
is valid but the agent didn't follow it (→ appendix reminder, body untouched).
|
||||
**When unsure, default to EXECUTION_LAPSE** (protect the body — never delete a
|
||||
valid rule over a one-off execution slip).
|
||||
- Success reflections are labeled DISCOVERY / OPTIMIZATION for logging only; their
|
||||
edit behavior is unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# ── Runtime switch (config-driven, env-independent) ─────────────────────────
|
||||
#
|
||||
# The trainer calls :func:`configure_skill_aware_reflection` once at startup
|
||||
# from the resolved config. ``run_minibatch_reflect`` then picks these values
|
||||
# up automatically, so env adapters never need to thread the toggle through —
|
||||
# the feature is controlled purely by ``optimizer.use_skill_aware_reflection``
|
||||
# regardless of benchmark. Mirrors the ``configure_azure_openai`` pattern in
|
||||
# :mod:`skillopt.model`. Explicit kwargs at a call site still take precedence
|
||||
# (backward compatible).
|
||||
|
||||
_RUNTIME: dict = {"enabled": False, "appendix_source": "both"}
|
||||
|
||||
|
||||
def configure_skill_aware_reflection(
|
||||
enabled: bool,
|
||||
appendix_source: str = "both",
|
||||
) -> None:
|
||||
"""Set the process-wide skill-aware reflection switch from config."""
|
||||
_RUNTIME["enabled"] = bool(enabled)
|
||||
_RUNTIME["appendix_source"] = str(appendix_source or "both")
|
||||
|
||||
|
||||
def is_skill_aware_enabled() -> bool:
|
||||
return bool(_RUNTIME["enabled"])
|
||||
|
||||
|
||||
def get_skill_aware_appendix_source() -> str:
|
||||
return str(_RUNTIME["appendix_source"])
|
||||
|
||||
|
||||
# ── Prompt suffixes ─────────────────────────────────────────────────────────
|
||||
|
||||
# Appended to the FAILURE analyst system prompt when the toggle is on.
|
||||
ERROR_SUFFIX = """
|
||||
|
||||
## Skill-Aware Reflection (EmbodiSkill)
|
||||
|
||||
Before proposing body edits, classify EACH failure pattern as one of:
|
||||
|
||||
- **SKILL_DEFECT**: the current skill is wrong, missing, or underspecified for
|
||||
this situation — i.e. an agent that *followed the skill* would still fail, or
|
||||
the skill gives no relevant guidance. These become normal body `edits`.
|
||||
- **EXECUTION_LAPSE**: the skill ALREADY contains a relevant, correct rule that
|
||||
would have avoided the failure, but the agent did not follow it (e.g. ignored a
|
||||
rule, malformed output, copied the feedback text verbatim, emitted a non-action
|
||||
token like "stop", or otherwise broke execution unrelated to skill content).
|
||||
|
||||
Discrimination test: "Is there a rule in the current skill that, if followed,
|
||||
prevents this failure?" If yes → EXECUTION_LAPSE. If no (rule absent/wrong) →
|
||||
SKILL_DEFECT. **When genuinely unsure, choose EXECUTION_LAPSE** — do not edit or
|
||||
delete a valid rule over a one-off execution slip.
|
||||
|
||||
Routing:
|
||||
- SKILL_DEFECT → put the fix in `patch.edits` (body), as usual.
|
||||
- EXECUTION_LAPSE → put a concise reminder in `appendix_notes` (a flat list of
|
||||
strings). DO NOT add a body edit for it. Each note should re-emphasize the
|
||||
existing valid rule the agent failed to follow; it must NOT introduce a new
|
||||
rule. Keep notes short, concrete, and reusable.
|
||||
|
||||
Add `appendix_notes` as a TOP-LEVEL key of your JSON output (a sibling of
|
||||
`patch`), e.g. `"appendix_notes": ["Follow the existing X rule before Y."]`.
|
||||
Use `[]` when there is no execution lapse. Body edits and appendix notes are
|
||||
independent: a batch may yield only edits, only notes, both, or neither.
|
||||
"""
|
||||
|
||||
# Appended to the SUCCESS analyst system prompt when the toggle is on.
|
||||
SUCCESS_SUFFIX = """
|
||||
|
||||
## Skill-Aware Reflection (EmbodiSkill)
|
||||
|
||||
For each proposed edit, optionally label its `reflection_type` for logging:
|
||||
- **DISCOVERY**: a useful new rule not yet in the skill (typically an `append`).
|
||||
- **OPTIMIZATION**: a better way to perform an existing rule (typically a
|
||||
`replace` of that rule).
|
||||
|
||||
This labeling does not change edit behavior. You may also add a top-level
|
||||
`appendix_notes` list (flat strings) if a successful trajectory reveals an
|
||||
existing valid rule worth re-emphasizing; otherwise use `[]`.
|
||||
"""
|
||||
|
||||
|
||||
def augment_error_prompt(system_prompt: str) -> str:
|
||||
"""Append the failure-analyst skill-aware instruction."""
|
||||
return system_prompt.rstrip() + "\n" + ERROR_SUFFIX
|
||||
|
||||
|
||||
def augment_success_prompt(system_prompt: str) -> str:
|
||||
"""Append the success-analyst skill-aware instruction."""
|
||||
return system_prompt.rstrip() + "\n" + SUCCESS_SUFFIX
|
||||
|
||||
|
||||
# ── Response parsing ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_appendix_notes(result: dict | None) -> list[str]:
|
||||
"""Pull a clean list of appendix-note strings from an analyst JSON result.
|
||||
|
||||
Tolerant of shape: accepts a top-level ``appendix_notes`` list, a single
|
||||
string, or items wrapped in dicts with a ``note``/``content`` field. Returns
|
||||
``[]`` for anything missing or malformed (so a non-compliant model degrades
|
||||
gracefully to baseline body-only behavior).
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
raw = result.get("appendix_notes")
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, str):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
notes: list[str] = []
|
||||
for item in raw:
|
||||
if isinstance(item, str):
|
||||
text = item.strip()
|
||||
elif isinstance(item, dict):
|
||||
text = str(item.get("note") or item.get("content") or "").strip()
|
||||
else:
|
||||
text = ""
|
||||
if text:
|
||||
notes.append(text)
|
||||
return notes
|
||||
|
||||
|
||||
# ── Appendix consolidation (threshold-gated, paper Eq.11 UpdateSkillAppendix) ──
|
||||
|
||||
_CONSOLIDATE_SYSTEM = (
|
||||
"You compact the Execution Notes Appendix of an agent skill. Each note "
|
||||
"re-emphasizes an existing skill rule the agent failed to follow. Your job "
|
||||
"is a periodic compaction pass: remove duplicates and redundant overlap, "
|
||||
"merge near-identical reminders into one, and simplify phrasing while keeping "
|
||||
"each note concrete and operational. Do not invent new rules. Preserve the "
|
||||
"distinct actionable content. Return valid JSON only."
|
||||
)
|
||||
|
||||
|
||||
def consolidate_appendix_notes(
|
||||
notes: list[str],
|
||||
*,
|
||||
chat_fn,
|
||||
max_completion_tokens: int = 4096,
|
||||
) -> list[str]:
|
||||
"""LLM-consolidate appendix notes: dedupe / merge / compact.
|
||||
|
||||
Mirrors GMemory ``_maybe_refactor_execution_notes`` and paper Eq.11. ``chat_fn``
|
||||
is the optimizer chat callable ``(system, user, max_completion_tokens, retries,
|
||||
stage) -> (text, meta)``. On ANY failure (parse, empty, exception) the original
|
||||
notes are returned unchanged, so consolidation can never lose the appendix.
|
||||
"""
|
||||
from skillopt.utils import extract_json # local import to avoid cycles
|
||||
|
||||
clean = [str(n).strip() for n in (notes or []) if str(n).strip()]
|
||||
if len(clean) < 2:
|
||||
return clean
|
||||
|
||||
numbered = "\n".join(f"{i}. {n}" for i, n in enumerate(clean, 1))
|
||||
user = (
|
||||
f"## Current Execution Notes ({len(clean)} total)\n{numbered}\n\n"
|
||||
"Compact these into a shorter list without losing distinct actionable "
|
||||
"information. Merge duplicates and near-duplicates; keep each note short, "
|
||||
"concrete, and reusable. Return valid JSON only with this schema:\n"
|
||||
'{ "appendix_notes": ["compacted note 1", "compacted note 2"] }'
|
||||
)
|
||||
try:
|
||||
response, _ = chat_fn(
|
||||
system=_CONSOLIDATE_SYSTEM,
|
||||
user=user,
|
||||
max_completion_tokens=max_completion_tokens,
|
||||
retries=2,
|
||||
stage="appendix_consolidate",
|
||||
)
|
||||
result = extract_json(response)
|
||||
compacted = extract_appendix_notes(result)
|
||||
# Guard: only accept a non-empty result that actually shrinks the set.
|
||||
if compacted and len(compacted) <= len(clean):
|
||||
return compacted
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return clean
|
||||
@@ -0,0 +1,396 @@
|
||||
"""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 optimizer 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` — optimizer call to produce guidance
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from skillopt.model import chat_optimizer
|
||||
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 _strip_all_slow_update_fields(skill: str) -> str:
|
||||
"""Remove every SLOW_UPDATE_START/END pair (and content between) from *skill*."""
|
||||
while True:
|
||||
start = skill.find(SLOW_UPDATE_START)
|
||||
if start == -1:
|
||||
break
|
||||
end = skill.find(SLOW_UPDATE_END, start)
|
||||
if end == -1:
|
||||
# Orphan start marker — remove it
|
||||
skill = skill[:start] + skill[start + len(SLOW_UPDATE_START):]
|
||||
break
|
||||
skill = skill[:start] + skill[end + len(SLOW_UPDATE_END):]
|
||||
# Clean up stray end markers
|
||||
skill = skill.replace(SLOW_UPDATE_END, "")
|
||||
# Collapse excess blank lines left behind
|
||||
while "\n\n\n" in skill:
|
||||
skill = skill.replace("\n\n\n", "\n\n")
|
||||
return skill.rstrip()
|
||||
|
||||
|
||||
def replace_slow_update_field(skill: str, new_content: str) -> str:
|
||||
# Remove all existing slow update regions first to guarantee exactly one.
|
||||
skill = _strip_all_slow_update_fields(skill)
|
||||
block = (
|
||||
f"\n\n{SLOW_UPDATE_START}\n"
|
||||
f"{new_content.strip()}\n"
|
||||
f"{SLOW_UPDATE_END}\n"
|
||||
)
|
||||
return skill + block
|
||||
|
||||
|
||||
# ── Comparison text builder ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
# NOTE: Character-length limits on the comparison samples fed to the slow-update /
|
||||
# meta-skill optimizer have been REMOVED. Previously a whole-trajectory cap plus
|
||||
# per-field caps (cmd/obs/reasoning/etc.) and comparison-metadata caps
|
||||
# (task/answer/fail_reason) trimmed this context to save optimizer tokens and
|
||||
# speed up the call. They never affected what gets written into the skill — only
|
||||
# how much longitudinal context the optimizer sees. We now pass everything through
|
||||
# at full length: the comparison input is as long as the source data is.
|
||||
|
||||
|
||||
def _clip_text(value, limit: int | None = None) -> str:
|
||||
# Truncation disabled: return the full text. The `limit` argument is kept only
|
||||
# for call-site compatibility and is intentionally ignored (see NOTE above).
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
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
|
||||
# Per-field truncation removed: feed each step's full cmd/obs/reasoning/
|
||||
# action/feedback/content (see NOTE above).
|
||||
if entry.get("type") == "tool_call":
|
||||
cmd = _clip_text(entry.get("cmd"))
|
||||
obs = _clip_text(entry.get("obs"))
|
||||
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"))
|
||||
action = _clip_text(entry.get("action"))
|
||||
feedback = _clip_text(entry.get("env_feedback"))
|
||||
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"))
|
||||
lines.append(f"[verification] {msg}")
|
||||
else:
|
||||
msg = _clip_text(entry.get("content"))
|
||||
role = entry.get("role", "agent")
|
||||
lines.append(f"[{role}] {msg}")
|
||||
|
||||
# Whole-trajectory truncation removed: return the full formatted trajectory.
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── 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"],
|
||||
"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 optimizer-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']}\n"
|
||||
f"- Prev epoch: {'PASS' if prev['hard'] else 'FAIL'} "
|
||||
f"(soft={prev['soft']:.2f}) — answer: {str(prev['predicted_answer'])}\n"
|
||||
f"- Curr epoch: {'PASS' if curr['hard'] else 'FAIL'} "
|
||||
f"(soft={curr['soft']:.2f}) — answer: {str(curr['predicted_answer'])}"
|
||||
)
|
||||
if curr.get("fail_reason"):
|
||||
lines.append(f"- Curr fail reason: {curr['fail_reason']}")
|
||||
if prev.get("fail_reason") and not prev["hard"]:
|
||||
lines.append(f"- Prev fail reason: {prev['fail_reason']}")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
# ── Optimizer 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 optimizer 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_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}\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_optimizer(
|
||||
system=actual_system,
|
||||
user=user,
|
||||
max_completion_tokens=16384,
|
||||
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
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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 | None = None) -> 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!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)
|
||||
# Truncation disabled: the optimizer is given the full item description.
|
||||
return text
|
||||
|
||||
|
||||
def short_item_summary(item: dict, mode: str | None, *, max_chars: int | None = None) -> dict[str, Any]:
|
||||
if is_full_rewrite_minibatch_mode(mode):
|
||||
return {
|
||||
"title": str(item.get("title", "")),
|
||||
"change_summary": [
|
||||
str(x) for x in item.get("change_summary", [])
|
||||
] 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", "")),
|
||||
"instruction": str(item.get("instruction", "")),
|
||||
}
|
||||
return {
|
||||
"op": item.get("op", "?"),
|
||||
"content": str(item.get("content", "")),
|
||||
"target": item.get("target", ""),
|
||||
}
|
||||
Reference in New Issue
Block a user