Dataset → evolved skill¶
There is one entry point, evolve. Evolving a skill from a
dataset needs three things that are genuinely yours:
- your data,
- how to score an answer,
- which model.
Everything else — wrapping rows as Tasks, the line that puts the skill in front
of the question, the scorer, the proposer — is a public building block, so the
whole program is a dozen lines and every one of them is an ordinary evolve()
argument:
from agentdescent import SingleSlot, evolve, openai_compatible, reflector, scorer, tasks_from
from agentdescent.dataloader import hf_rows
rows = hf_rows("hotpotqa/hotpot_qa", "validation", config="distractor", limit=40)
model = openai_compatible(model="deepseek-v4-flash")
tasks = tasks_from(rows, prompt="question", gold="answer") # rows -> Task objects
run = lambda skill, task: model(f"{skill}\n\n{task.prompt}") # the skill meets the question
result = evolve(tasks, scorer("exact"), run=run, propose=reflector(model),
strategy=SingleSlot(initial_value="You are a helpful assistant."),
rounds=8, n_workers=8, max_concurrency=8, held_out_frac=0.3,
patience=3, target_reward=0.98)
print(result.rendered) # the skill it learned
print(result.final_reward) # held-out reward
print(result.outcomes()) # why it went that way
What that call actually does — measured¶
The block above, run as written on 40 real HotpotQA items with
deepseek-v4-flash (12 held out):
| held-out exact match | |
|---|---|
starting instruction ("You are a helpful assistant.") |
2/12 = 0.167 |
| after evolution | 7/12 = 0.583 |
Four rounds, stopped by patience; 338 model calls, ~25 min wall-clock. The skill
it wrote:
"Respond with only the requested answer, omitting any extra explanation or restatement."
Which is exactly the failure it was looking at — asked for a short span, the model was answering with a paragraph:
gold='Arena of Khazan' got='In *Tunnels and Trolls*, an adventure is called a **tunnel** - a playf...'
result.outcomes() was {'committed': 1, 'below-threshold': 3}: one proposal
cleared the gate and three were rejected for not beating it — the gate doing its
job, not a stuck run.
The pieces¶
tasks_from(rows, prompt=, gold=) |
rows (dicts) from anywhere → Tasks; the gold lands in meta, where the reflector reads it. Ready-made Tasks need no wrapping. |
scorer(name_or_callable) |
a name from SCORERS below, or your own (task, output) -> float |
run= |
how the skill meets the question. The lambda above is the whole default; put the skill after the question, or inside a scaffold, by writing a different one |
propose=reflector(model) |
any completion as the proposer. A cheap model here behind an expensive one in run= is a fine trade |
strategy=SingleSlot(initial_value=…) |
the artifact is one instruction, and each accepted proposal replaces it. AppendRules / KeyedRules are the other text strategies |
The scorers¶
agentdescent.rewards covers the common cases, and gets the details right that
are easy to get wrong:
scorer(…) |
matches when | notes |
|---|---|---|
"last_number" |
the last number in the output equals the gold number | the default for arithmetic — models show their working, so the answer is the last number |
"exact" |
output equals the gold | casefolds, collapses whitespace, strips trailing punctuation |
"contains" |
the gold appears anywhere in the output | forgiving, and the easiest to fool: gold "2" is inside "12" |
"numeric_close" |
last number within a relative tolerance | for rounded answers |
A dataset's answer column is often not just the answer
GSM8K's answer is the whole worked solution, ending in #### 72.
last_number reads the gold the same way it reads the output, so "72",
"#### 72" and "The answer is 72." all match. A gold with no number in it
raises, rather than scoring every item zero.
The knobs worth choosing¶
The values in the block above are the ones a dataset run wants, and none of them
is a default of evolve() itself:
n_workers = max_concurrency = min(8, train tasks)— one worker per training task is the useful ceilingrounds = 8,patience = 3,target_reward = 0.98— early stopping, so a small dataset does not buy eight rounds of nothingheld_out_frac = 0.3; andshuffle=Trueis worth knowing about — rows arrive in dataset order and the train/held-out split is positional, so grouped data otherwise holds out one end of the file
Everything else — a barrier-free run (asynchronous=True, max_seconds=600), a
custom optimizer (aggregator_factory=), a multi-step agent as run= — is
a further argument to the same call.
Next¶
- Have a folder rather than a dataset? Quickstart — evolve a directory does the same thing for a skill folder, an agent folder, or its code, with a real agent reading the files off disk.
- Want the knobs? The
evolvemethod. - Want to know why it works? Concepts, then the aggregator.