Skip to content
Back to skills

Self Evolving

ASecurity

Build a self-evolving agent harness from a spec — run, evaluate, reflect, apply, gated by a test suite the agent cannot edit and a rollback it cannot skip

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 17, 2026
ai-agentspythongoreactgitapiperformance

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 17, 2026

npx -y skills add ArttuAn/agent-harness-skills --skill self-evolving --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Self Evolving?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Self Evolving
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/arttuan-self-evolving/badge)](https://www.skillsdirectory.com/skills/arttuan-self-evolving)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: harness-self-evolving
description: "Build a self-evolving agent harness from a spec — run, evaluate, reflect, apply, gated by a test suite the agent cannot edit and a rollback it cannot skip"
---

# Self-Evolving Agent Harness

The harness runs a task set, scores itself, proposes changes to its own prompts
and tools, applies them, and repeats. Each pass is a *generation*.

A frontier model will write the evolve loop from a spec. What it will not do is
**make the gate something the agent cannot reach**, and that single property is
the difference between a system that improves and one that learns to cheat. An
optimizer with write access to its own scoring function optimizes the scoring
function. Build the containment first; the loop is the easy part.

## Use this when

- You have a **fixed, external evaluation set** with known-good answers, or a
  test suite the agent does not author.
- The harness runs the same class of task repeatedly, so an improvement
  compounds.
- Someone will review the diffs. This pattern produces changes to your system;
  unreviewed, it produces drift.

**Don't use this when:**

- **You cannot write the evaluation set first.** This is disqualifying. No
  fixed metric means no gradient, and the loop will wander while producing
  confident generation logs.
- The task varies too much for one metric to represent quality.
- Nobody will read the diffs. → `harness-reflexion` gives most of the benefit
  with per-task memory and no permanent mutation.

## The containment comes first

Write these down before any code, and state them to the user:

1. **The agent may not edit the evaluation set, the scoring code, or the test
   suite.** Enforce with a path check in the applier, not a prompt instruction.
2. **The agent may not edit the applier, the gate, or this containment.**
3. **Every generation is a git commit**, so every change is reviewable and
   revertible by construction.
4. **A generation that does not improve the score is reverted**, automatically.
5. **Generations are bounded.** A default of 5, not "until it stops improving".

If any of these cannot hold in the target environment, say so plainly and
recommend `harness-reflexion` instead. A self-evolving harness without an
immutable gate is not a weaker version of this pattern — it is a different,
worse thing that looks identical in the logs.

## The decisions that matter

### 1. What is allowed to change

Narrow surface, widening only with evidence:

| Surface | Evolve it? | Why |
| --- | --- | --- |
| System prompts, tool descriptions | **Yes** | Highest value per risk; text only, trivially revertible |
| Few-shot examples | **Yes** | Easy to diff and review |
| Tool *parameters* (limits, thresholds) | Cautiously | Bounded ranges only, validated on apply |
| New tools | Rarely | Arbitrary new code; needs review before it runs |
| The loop itself | **No** | The thing being measured must stay fixed |
| Evaluator, tests, gate | **Never** | See containment |

Most of the available gain is in the first two rows. Start there and probably
stay there.

### 2. The gate is mechanical and runs after every apply

```python
def gate(workspace, baseline_score: float, new_score: float) -> tuple[bool, str]:
    """Accept a generation only if tests pass AND the score improved."""
    tests = run_command(workspace, ["pytest", "-q"], timeout=300)
    if "exit=0" not in tests:
        return False, f"tests failed:\n{tests}"
    if new_score <= baseline_score:
        return False, f"score did not improve ({new_score:.3f} <= {baseline_score:.3f})"
    return True, f"accepted: {baseline_score:.3f} -> {new_score:.3f}"
```

Both conditions. Tests alone let quality slide while staying green; score alone
lets the agent trade correctness for metric.

### 3. Rollback is `git`, and it is not optional

```python
def apply_generation(repo, generation: int, changes) -> bool:
    before = repo.rev_parse("HEAD")
    write(changes)
    repo.commit(f"gen {generation}: {summarize(changes)}")

    accepted, reason = gate(...)
    if not accepted:
        repo.reset_hard(before)          # the whole point
        log(generation, "reverted", reason)
        return False
    log(generation, "accepted", reason)
    return True
```

Refuse to start on a dirty tree. Refuse to start outside a git repo — offer to
`git init` instead. Without rollback this pattern cannot be run unattended, and
running it attended defeats the purpose.

### 4. Hold out an evaluation set the loop never sees

Scoring and optimizing on the same set is overfitting with extra steps: the
score climbs while real performance does not.

- **Dev set** — the loop scores against this and optimizes it.
- **Holdout set** — scored only at the *end*, or every N generations, and never
  used to choose a change.

If the dev score rises and the holdout score does not, the harness is fitting
the dev set. Report both numbers every run; a single rising number is the
easiest thing in this pattern to mistake for progress.

### 5. Assume the metric will be gamed

Not because the model is adversarial, but because optimizers find the cheapest
path. Seen in practice: prompts that make answers longer when the rubric
rewards detail; tools that return cached constants when the metric is latency;
"I cannot answer" when wrong answers are penalized more than refusals.

Guard: score more than one dimension (correctness *and* cost *and* refusal
rate), and read the actual diffs. A generation whose only change is "be more
thorough" is a smell.

### 6. Append-only, and cap it

Prompts grow every generation until they are 4k tokens of accreted advice.
Cap the evolved prompt's length and require the reflector to **replace**
guidance rather than stack it. Log the prompt size per generation — steady
growth with a flat score means it is padding.

## Build it

```text
<project>/
├── src/<package>/
│   ├── config.py        # max_generations, score threshold, protected paths
│   ├── llm.py           # ChatClient + FakeChat + adapter (references/llm-seam.md)
│   ├── agent.py         # the harness being evolved (a harness-react loop)
│   ├── evalset.py       # dev + holdout task sets; loaded read-only
│   ├── scorer.py        # task -> score; mechanical wherever possible
│   ├── reflector.py     # run logs -> structured change proposals
│   ├── applier.py       # validates and writes changes; enforces protected paths
│   ├── gate.py          # tests + score comparison
│   ├── evolve.py        # the generation loop
│   └── cli.py           # run, evolve --generations N, --dry-run
├── prompts/             # the evolvable surface, one file per prompt
├── generations/         # per-generation logs, diffs, scores
└── tests/               # PROTECTED — the applier refuses to touch this
```

Protected paths, enforced in code rather than requested in a prompt:

```python
PROTECTED = ("tests/", "src/<package>/gate.py", "src/<package>/applier.py",
             "src/<package>/scorer.py", "src/<package>/evalset.py", "evalset/")


def validate_change(path: str) -> None:
    normalized = str(Path(path).as_posix())
    if any(normalized.startswith(p) for p in PROTECTED):
        raise PermissionError(f"refusing to modify protected path: {path}")
```

Use the `Workspace.resolve` confinement from `harness-code-agent` as well —
a proposal writing to `../../.claude/` is exactly the case that check exists
for.

The generation loop:

```python
def evolve(self, generations: int) -> list[Generation]:
    baseline = self.score(self.evalset.dev)
    holdout = self.score(self.evalset.holdout)
    self._log(0, baseline, holdout, "baseline")
    history = []

    for number in range(1, generations + 1):
        runs = self.run_all(self.evalset.dev)          # transcripts + scores
        proposals = self.reflector.propose(runs)       # structured changes
        if not proposals:
            break                                       # nothing left to try

        applied = self.applier.apply(proposals)         # raises on protected paths
        new_score = self.score(self.evalset.dev)
        accepted, reason = self.gate(baseline, new_score)

        if accepted:
            baseline = new_score
        else:
            self.repo.reset_hard(self.repo.head_before(number))

        holdout = self.score(self.evalset.holdout)
        history.append(Generation(number, new_score, holdout, accepted, reason, applied))
        self._log(number, new_score, holdout, reason)

    return history
```

Report dev and holdout side by side. Diverging lines are the finding.

## Failure modes

Beyond `references/failure-modes.md`:

- **The agent edits its own gate.** The failure that invalidates everything.
  Guard: `PROTECTED` enforced in the applier, and a test that asserts a proposal
  touching `tests/` raises.
- **Metric gaming.** Guard: multi-dimensional scoring, read the diffs.
- **Dev/holdout divergence.** Guard: report both, always.
- **Prompt bloat.** Guard: cap length, log size per generation.
- **A regression ships because tests were green.** Guard: the gate requires
  improvement *and* green, never either alone.
- **Unbounded generations.** Guard: `max_generations`, default 5.
- **Non-reproducible scoring.** Temperature > 0 in the scorer means the score
  moves on its own and the gate accepts noise. Guard: `temperature=0` for all
  evaluation runs; score each task more than once if it is still noisy.
- **Evolving on a dirty tree.** The user's uncommitted work gets committed as
  "gen 1" and reverted away. Guard: refuse to start.

## Required tests

These are the tests the agent must not be able to edit — which makes them the
most important in the repo:

```python
def test_proposal_touching_tests_is_refused():
    with pytest.raises(PermissionError):
        validate_change("tests/test_scorer.py")


def test_proposal_touching_the_gate_is_refused():
    with pytest.raises(PermissionError):
        validate_change("src/pkg/gate.py")


def test_proposal_escaping_the_repo_is_refused(workspace):
    with pytest.raises(PermissionError):
        workspace.resolve("../../.claude/settings.json")


def test_failing_tests_reject_the_generation():
    accepted, reason = gate(FailingTests(), baseline_score=0.5, new_score=0.9)
    assert not accepted and "tests failed" in reason


def test_flat_score_rejects_the_generation():
    accepted, reason = gate(PassingTests(), baseline_score=0.8, new_score=0.8)
    assert not accepted and "did not improve" in reason


def test_rejected_generation_is_rolled_back(repo, config):
    before = repo.rev_parse("HEAD")
    Evolver(repo=repo, gate=AlwaysRejects(), config=config).evolve(generations=1)
    assert repo.rev_parse("HEAD") == before


def test_generations_are_bounded(config):
    config.max_generations = 3
    history = Evolver(gate=AlwaysAccepts(), config=config).evolve(generations=3)
    assert len(history) == 3


def test_holdout_is_never_used_to_accept(config):
    evolver = Evolver(gate=RecordingGate(), config=config)
    evolver.evolve(generations=1)
    assert evolver.gate.saw_only_dev_scores
```

## Verify

1. **Tier 0** — install, import, `--help`.
2. **Tier 1** — `pytest -q`. The containment tests must pass. If any is
   skipped, stop: the harness is not safe to run unattended.
3. **Dry run** — `evolve --generations 1 --dry-run`. Read the proposed diff.
   Nothing should touch protected paths. This is a human checkpoint; do not
   automate past it on the first run.
4. **One real generation** — confirm a commit was made, the gate ran, and a
   rejected generation actually reverted (force one by inverting the gate).
5. **Tier 2** — 3 generations; report dev and holdout side by side.

Report the score trajectory for both sets, which generations were accepted and
why the rest were rejected. A run where every generation was accepted deserves
suspicion, not celebration — check whether the metric is being gamed.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.