18df2fe7b4
Standalone Python stdlib pipeline that reads an agent's past sessions, compares them against installed skills, and generates structured improvement proposals gated by an evaluation framework before anything mutates. Host-agnostic via HostAdapter (Hermes, Claude Code). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
155 lines
5.8 KiB
Python
155 lines
5.8 KiB
Python
"""Tests that SKILL_EVOLUTION_HISTORY_RETENTION now applies automatically, not just via a
|
|
manually-run `evaluate.py --prune`.
|
|
|
|
Before this, prune_history()'s only caller was the --prune CLI flag, so an unattended
|
|
deployment with a retention policy configured still grew the file forever unless someone
|
|
remembered to run --prune. evaluate_and_record() now prunes after every append by default
|
|
(a no-op unless retention is configured, so nothing changes for anyone who hasn't opted
|
|
in) -- except inside retroactive_reevaluate()'s batch loop, which prunes once after the
|
|
whole batch instead of once per proposal, since prune_history() rewrites the *shared*
|
|
history file (every target), not just the one being re-evaluated.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
|
|
import pytest
|
|
|
|
import evaluate
|
|
import proposal as proposal_module
|
|
from evaluate import EvalResult, evaluate_and_record, retroactive_reevaluate
|
|
from proposal import ProposalStatus, ProposalType, ProposedChange, SkillEvolutionProposal, save_proposal
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_dirs(tmp_path, monkeypatch):
|
|
history_path = str(tmp_path / "eval_history.jsonl")
|
|
proposals_dir = str(tmp_path / "proposals")
|
|
os.makedirs(proposals_dir, exist_ok=True)
|
|
monkeypatch.setattr(evaluate, "get_history_path", lambda: history_path)
|
|
return {"history_path": history_path, "proposals_dir": proposals_dir}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def stub_evaluator(monkeypatch):
|
|
monkeypatch.setattr(
|
|
evaluate, "run_evaluators",
|
|
lambda content, target, context=None: [
|
|
EvalResult(score=0.9, passed=True, feedback="stub", evaluator_name="stub"),
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_retention_env(monkeypatch):
|
|
monkeypatch.delenv(evaluate.HISTORY_RETENTION_ENV_VAR, raising=False)
|
|
|
|
|
|
def _proposal(target_skill, proposal_id):
|
|
return SkillEvolutionProposal(
|
|
proposal_id=proposal_id,
|
|
type=ProposalType.IMPROVE_EXISTING,
|
|
target_skill=target_skill,
|
|
confidence=0.9,
|
|
summary=f"Improve {target_skill}",
|
|
rationale="Fixture rationale.",
|
|
status=ProposalStatus.PROPOSED,
|
|
proposed_changes=[ProposedChange(field="body", new_value="Body content.")],
|
|
)
|
|
|
|
|
|
def test_evaluate_and_record_prunes_automatically_when_retention_configured(isolated_dirs, monkeypatch):
|
|
monkeypatch.setenv(evaluate.HISTORY_RETENTION_ENV_VAR, "1")
|
|
target = "skill:demo"
|
|
proposal = _proposal("demo", "p1")
|
|
|
|
for _ in range(4):
|
|
evaluate.append_history(target, EvalResult(score=0.5, passed=False, feedback="old", evaluator_name="gate"))
|
|
evaluate_and_record(proposal)
|
|
|
|
entries = evaluate.read_history(target)
|
|
# count=1 window (the just-written entry) + earliest-entry anchor (the first old one) --
|
|
# not all 5, proving auto-prune actually ran without a manual --prune.
|
|
assert len(entries) == 2
|
|
|
|
|
|
def test_evaluate_and_record_is_noop_prune_when_retention_unset(isolated_dirs):
|
|
"""Explicit regression lock: the default must not touch anything for callers that
|
|
haven't opted into a retention policy."""
|
|
target = "skill:demo"
|
|
proposal = _proposal("demo", "p1")
|
|
|
|
for _ in range(10):
|
|
evaluate_and_record(proposal)
|
|
|
|
assert len(evaluate.read_history(target)) == 10
|
|
assert not os.path.exists(evaluate.get_history_archive_path(isolated_dirs["history_path"]))
|
|
|
|
|
|
def test_retroactive_batch_prunes_once_not_per_proposal(isolated_dirs, monkeypatch):
|
|
monkeypatch.setenv(evaluate.HISTORY_RETENTION_ENV_VAR, "1")
|
|
for i in range(4):
|
|
proposal = _proposal(f"skill-{i}", f"p{i}")
|
|
save_proposal(proposal, directory=isolated_dirs["proposals_dir"])
|
|
|
|
calls = []
|
|
original = evaluate.prune_history
|
|
|
|
def spy(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return original(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(evaluate, "prune_history", spy)
|
|
|
|
retroactive_reevaluate(proposals_dir=isolated_dirs["proposals_dir"])
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_apply_proposal_prunes_after_each_call(isolated_dirs, monkeypatch):
|
|
"""apply_proposal() calls evaluate_and_record(proposal) with no extra kwargs, so it
|
|
must get auto_prune=True for free via the new default -- no changes needed in
|
|
proposal.py itself."""
|
|
monkeypatch.setenv(evaluate.HISTORY_RETENTION_ENV_VAR, "1")
|
|
target = "skill:demo"
|
|
|
|
for i in range(3):
|
|
proposal = _proposal("demo", f"p{i}")
|
|
proposal_module.apply_proposal(proposal, min_confidence=0.0, directory=isolated_dirs["proposals_dir"])
|
|
|
|
entries = evaluate.read_history(target)
|
|
# count=1 window + earliest-entry anchor: at most 2 survive despite 3 calls, proving
|
|
# each apply_proposal() call pruned on its own rather than only at the very end.
|
|
assert len(entries) <= 2
|
|
|
|
|
|
def test_evaluate_and_record_propagates_transport_failure_flag(isolated_dirs, monkeypatch):
|
|
"""A transport failure in any evaluator must surface on the combined history entry,
|
|
so a reviewer (or find_low_scoring_targets) can tell an outage-driven 0.0 from a real
|
|
regression."""
|
|
monkeypatch.setattr(
|
|
evaluate, "run_evaluators",
|
|
lambda content, target, context=None: [
|
|
EvalResult(score=0.0, passed=False, feedback="503 from provider", evaluator_name="llm_judge",
|
|
transport_failure=True),
|
|
],
|
|
)
|
|
|
|
proposal = _proposal("demo", "p1")
|
|
evaluate_and_record(proposal)
|
|
|
|
entries = evaluate.read_history("skill:demo")
|
|
assert len(entries) == 1
|
|
assert entries[0]["transport_failure"] is True
|
|
|
|
|
|
def test_evaluate_and_record_leaves_flag_unset_when_no_transport_failure(isolated_dirs):
|
|
proposal = _proposal("demo", "p1")
|
|
evaluate_and_record(proposal)
|
|
|
|
entries = evaluate.read_history("skill:demo")
|
|
assert len(entries) == 1
|
|
assert "transport_failure" not in entries[0]
|