chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:24:16 +08:00
commit 4b92209caa
317 changed files with 67848 additions and 0 deletions
+15
View File
@@ -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
+156
View File
@@ -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"
+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_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],
}
+108
View File
@@ -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
+79
View File
@@ -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
+59
View File
@@ -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
+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"]
+201
View File
@@ -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
+206
View File
@@ -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
+396
View File
@@ -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
+135
View File
@@ -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", ""),
}