-
Notifications
You must be signed in to change notification settings - Fork 584
Expand file tree
/
Copy pathevolve_skill.py
More file actions
367 lines (320 loc) · 16.1 KB
/
Copy pathevolve_skill.py
File metadata and controls
367 lines (320 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
"""Evolve a Hermes Agent skill using DSPy + GEPA.
Usage:
python -m evolution.skills.evolve_skill --skill github-code-review --iterations 10
python -m evolution.skills.evolve_skill --skill arxiv --eval-source golden --dataset datasets/skills/arxiv/
"""
import json
import sys
import time
from pathlib import Path
from datetime import datetime
from typing import Optional
import click
import dspy
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from evolution.core.config import EvolutionConfig, resolve_hermes_agent_path
from evolution.core.dataset_builder import SyntheticDatasetBuilder, EvalDataset, GoldenDatasetLoader
from evolution.core.external_importers import build_dataset_from_external
from evolution.core.fitness import skill_fitness_metric, LLMJudge, FitnessScore
from evolution.core.constraints import ConstraintValidator
from evolution.skills.skill_module import (
SkillModule,
load_skill,
find_skill,
reassemble_skill,
)
console = Console()
def evolve(
skill_name: str,
iterations: int = 10,
eval_source: str = "synthetic",
dataset_path: Optional[str] = None,
optimizer_model: str = "openai/gpt-4.1",
eval_model: str = "openai/gpt-4.1-mini",
hermes_repo: Optional[str] = None,
run_tests: bool = False,
dry_run: bool = False,
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
num_threads: Optional[int] = None,
lm_timeout: Optional[float] = None,
lm_retries: Optional[int] = None,
):
"""Main evolution function — orchestrates the full optimization loop."""
config = EvolutionConfig(
hermes_agent_path=resolve_hermes_agent_path(hermes_repo),
iterations=iterations,
optimizer_model=optimizer_model,
eval_model=eval_model,
judge_model=eval_model, # Use same model for dataset generation
run_pytest=run_tests,
)
# ── 1. Find and load the skill ──────────────────────────────────────
console.print(f"\n[bold cyan]🧬 Hermes Agent Self-Evolution[/bold cyan] — Evolving skill: [bold]{skill_name}[/bold]\n")
skill_path = find_skill(skill_name, config.hermes_agent_path)
if not skill_path:
console.print(f"[red]✗ Skill '{skill_name}' not found in {config.hermes_agent_path / 'skills'}[/red]")
sys.exit(1)
skill = load_skill(skill_path)
console.print(f" Loaded: {skill_path.relative_to(config.hermes_agent_path)}")
console.print(f" Name: {skill['name']}")
console.print(f" Size: {len(skill['raw']):,} chars")
console.print(f" Description: {skill['description'][:80]}...")
if dry_run:
console.print(f"\n[bold green]DRY RUN — setup validated successfully.[/bold green]")
console.print(f" Would generate eval dataset (source: {eval_source})")
console.print(f" Would run GEPA optimization ({iterations} iterations)")
console.print(f" Would validate constraints and create PR")
return
# ── 2. Build or load evaluation dataset ─────────────────────────────
console.print(f"\n[bold]Building evaluation dataset[/bold] (source: {eval_source})")
if eval_source == "golden" and dataset_path:
dataset = GoldenDatasetLoader.load(Path(dataset_path))
console.print(f" Loaded golden dataset: {len(dataset.all_examples)} examples")
elif eval_source == "sessiondb":
save_path = Path(dataset_path) if dataset_path else Path("datasets") / "skills" / skill_name
dataset = build_dataset_from_external(
skill_name=skill_name,
skill_text=skill["raw"],
sources=["claude-code", "copilot", "hermes"],
output_path=save_path,
model=eval_model,
)
if not dataset.all_examples:
console.print("[red]✗ No relevant examples found from session history[/red]")
sys.exit(1)
console.print(f" Mined {len(dataset.all_examples)} examples from session history")
elif eval_source == "synthetic":
builder = SyntheticDatasetBuilder(config)
dataset = builder.generate(
artifact_text=skill["raw"],
artifact_type="skill",
)
# Save for reuse
save_path = Path("datasets") / "skills" / skill_name
dataset.save(save_path)
console.print(f" Generated {len(dataset.all_examples)} synthetic examples")
console.print(f" Saved to {save_path}/")
elif dataset_path:
dataset = EvalDataset.load(Path(dataset_path))
console.print(f" Loaded dataset: {len(dataset.all_examples)} examples")
else:
console.print("[red]✗ Specify --dataset-path or use --eval-source synthetic[/red]")
sys.exit(1)
console.print(f" Split: {len(dataset.train)} train / {len(dataset.val)} val / {len(dataset.holdout)} holdout")
# ── 3. Validate constraints on baseline ─────────────────────────────
console.print(f"\n[bold]Validating baseline constraints[/bold]")
validator = ConstraintValidator(config)
# Validate the full file (frontmatter + body): skill_structure checks
# frontmatter, which load_skill strips from `body`.
baseline_constraints = validator.validate_all(skill["raw"], "skill")
all_pass = True
for c in baseline_constraints:
icon = "✓" if c.passed else "✗"
color = "green" if c.passed else "red"
console.print(f" [{color}]{icon} {c.constraint_name}[/{color}]: {c.message}")
if not c.passed:
all_pass = False
if not all_pass:
console.print("[yellow]⚠ Baseline skill has constraint violations — proceeding anyway[/yellow]")
# ── 4. Set up DSPy + GEPA optimizer ─────────────────────────────────
console.print(f"\n[bold]Configuring optimizer[/bold]")
console.print(f" Optimizer: GEPA ({iterations} iterations)")
console.print(f" Optimizer model: {optimizer_model}")
console.print(f" Eval model: {eval_model}")
# Configure DSPy. Generation kwargs are only passed when explicitly set,
# so the default behavior is unchanged. Reasoning models served by local
# OpenAI-compatible endpoints (low server-side max_tokens defaults) need
# an explicit budget or the thinking phase consumes it and content comes
# back empty.
lm_kwargs = {}
if max_tokens is not None:
lm_kwargs["max_tokens"] = max_tokens
if temperature is not None:
lm_kwargs["temperature"] = temperature
if lm_timeout is not None:
lm_kwargs["timeout"] = lm_timeout
if lm_retries is not None:
lm_kwargs["num_retries"] = lm_retries
lm = dspy.LM(eval_model, **lm_kwargs)
dspy.configure(lm=lm)
# Create the baseline skill module
baseline_module = SkillModule(skill["body"])
# Prepare DSPy examples
trainset = dataset.to_dspy_examples("train")
valset = dataset.to_dspy_examples("val")
# ── 5. Run GEPA optimization ────────────────────────────────────────
console.print(f"\n[bold cyan]Running GEPA optimization ({iterations} iterations)...[/bold cyan]\n")
start_time = time.time()
try:
# dspy.GEPA requires exactly one budget parameter (auto /
# max_full_evals / max_metric_calls) — there is no `max_steps` —
# and a reflection LM for proposing mutations.
# num_threads matters on serial local endpoints: parallel rollouts
# queue behind each other and time out in cascade once queue depth
# times per-request latency exceeds the client timeout.
gepa_kwargs = {}
if num_threads is not None:
gepa_kwargs["num_threads"] = num_threads
optimizer = dspy.GEPA(
metric=skill_fitness_metric,
max_full_evals=iterations,
reflection_lm=dspy.LM(optimizer_model, **lm_kwargs),
**gepa_kwargs,
)
optimized_module = optimizer.compile(
baseline_module,
trainset=trainset,
valset=valset,
)
except Exception as e:
# Fall back to MIPROv2 if GEPA isn't available in this DSPy version
console.print(f"[yellow]GEPA not available ({e}), falling back to MIPROv2[/yellow]")
optimizer = dspy.MIPROv2(
metric=skill_fitness_metric,
auto="light",
)
optimized_module = optimizer.compile(
baseline_module,
trainset=trainset,
)
elapsed = time.time() - start_time
console.print(f"\n Optimization completed in {elapsed:.1f}s")
# ── 6. Extract evolved skill text ───────────────────────────────────
# The optimized module's instructions contain the evolved skill text
evolved_body = optimized_module.skill_text
evolved_full = reassemble_skill(skill["frontmatter"], evolved_body)
# ── 7. Validate evolved skill ───────────────────────────────────────
console.print(f"\n[bold]Validating evolved skill[/bold]")
# Same rule as the baseline: validate the reassembled file, not the bare
# body — otherwise skill_structure always fails and nothing ever deploys.
evolved_constraints = validator.validate_all(evolved_full, "skill", baseline_text=skill["raw"])
all_pass = True
for c in evolved_constraints:
icon = "✓" if c.passed else "✗"
color = "green" if c.passed else "red"
console.print(f" [{color}]{icon} {c.constraint_name}[/{color}]: {c.message}")
if not c.passed:
all_pass = False
if not all_pass:
console.print("[red]✗ Evolved skill FAILED constraints — not deploying[/red]")
# Still save for inspection
output_path = Path("output") / skill_name / "evolved_FAILED.md"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(evolved_full)
console.print(f" Saved failed variant to {output_path}")
return
# ── 8. Evaluate on holdout set ──────────────────────────────────────
console.print(f"\n[bold]Evaluating on holdout set ({len(dataset.holdout)} examples)[/bold]")
holdout_examples = dataset.to_dspy_examples("holdout")
baseline_scores = []
evolved_scores = []
for ex in holdout_examples:
# Score baseline
with dspy.context(lm=lm):
baseline_pred = baseline_module(task_input=ex.task_input)
baseline_score = skill_fitness_metric(ex, baseline_pred)
baseline_scores.append(baseline_score)
evolved_pred = optimized_module(task_input=ex.task_input)
evolved_score = skill_fitness_metric(ex, evolved_pred)
evolved_scores.append(evolved_score)
avg_baseline = sum(baseline_scores) / max(1, len(baseline_scores))
avg_evolved = sum(evolved_scores) / max(1, len(evolved_scores))
improvement = avg_evolved - avg_baseline
# ── 9. Report results ───────────────────────────────────────────────
table = Table(title="Evolution Results")
table.add_column("Metric", style="bold")
table.add_column("Baseline", justify="right")
table.add_column("Evolved", justify="right")
table.add_column("Change", justify="right")
change_color = "green" if improvement > 0 else "red"
table.add_row(
"Holdout Score",
f"{avg_baseline:.3f}",
f"{avg_evolved:.3f}",
f"[{change_color}]{improvement:+.3f}[/{change_color}]",
)
table.add_row(
"Skill Size",
f"{len(skill['body']):,} chars",
f"{len(evolved_body):,} chars",
f"{len(evolved_body) - len(skill['body']):+,} chars",
)
table.add_row("Time", "", f"{elapsed:.1f}s", "")
table.add_row("Iterations", "", str(iterations), "")
console.print()
console.print(table)
# ── 10. Save output ─────────────────────────────────────────────────
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_dir = Path("output") / skill_name / timestamp
output_dir.mkdir(parents=True, exist_ok=True)
# Save evolved skill
(output_dir / "evolved_skill.md").write_text(evolved_full)
# Save baseline for comparison
(output_dir / "baseline_skill.md").write_text(skill["raw"])
# Save metrics
metrics = {
"skill_name": skill_name,
"timestamp": timestamp,
"iterations": iterations,
"optimizer_model": optimizer_model,
"eval_model": eval_model,
"baseline_score": avg_baseline,
"evolved_score": avg_evolved,
"improvement": improvement,
"baseline_size": len(skill["body"]),
"evolved_size": len(evolved_body),
"train_examples": len(dataset.train),
"val_examples": len(dataset.val),
"holdout_examples": len(dataset.holdout),
"elapsed_seconds": elapsed,
"constraints_passed": all_pass,
}
(output_dir / "metrics.json").write_text(json.dumps(metrics, indent=2))
console.print(f"\n Output saved to {output_dir}/")
if improvement > 0:
console.print(f"\n[bold green]✓ Evolution improved skill by {improvement:+.3f} ({improvement/max(0.001, avg_baseline)*100:+.1f}%)[/bold green]")
console.print(f" Review the diff: diff {output_dir}/baseline_skill.md {output_dir}/evolved_skill.md")
else:
console.print(f"\n[yellow]⚠ Evolution did not improve skill (change: {improvement:+.3f})[/yellow]")
console.print(" Try: more iterations, better eval dataset, or different optimizer model")
@click.command()
@click.option("--skill", required=True, help="Name of the skill to evolve")
@click.option("--iterations", default=10, help="Number of GEPA iterations")
@click.option("--eval-source", default="synthetic", type=click.Choice(["synthetic", "golden", "sessiondb"]),
help="Source for evaluation dataset")
@click.option("--dataset-path", default=None, help="Path to existing eval dataset (JSONL)")
@click.option("--optimizer-model", default="openai/gpt-4.1", help="Model for GEPA reflections")
@click.option("--eval-model", default="openai/gpt-4.1-mini", help="Model for evaluations")
@click.option("--hermes-repo", default=None, help="Path to hermes-agent repo")
@click.option("--run-tests", is_flag=True, help="Run full pytest suite as constraint gate")
@click.option("--dry-run", is_flag=True, help="Validate setup without running optimization")
@click.option("--max-tokens", default=None, type=int,
help="Generation budget per LM call (needed for reasoning models on local endpoints)")
@click.option("--temperature", default=None, type=float, help="Sampling temperature for LM calls")
@click.option("--num-threads", default=None, type=int,
help="Parallel rollouts for GEPA evaluation (use 1 for serial local endpoints)")
@click.option("--lm-timeout", default=None, type=float, help="Per-request LM timeout in seconds")
@click.option("--lm-retries", default=None, type=int, help="LM retry count on failures")
def main(skill, iterations, eval_source, dataset_path, optimizer_model, eval_model, hermes_repo, run_tests, dry_run, max_tokens, temperature, num_threads, lm_timeout, lm_retries):
"""Evolve a Hermes Agent skill using DSPy + GEPA optimization."""
evolve(
skill_name=skill,
iterations=iterations,
eval_source=eval_source,
dataset_path=dataset_path,
optimizer_model=optimizer_model,
eval_model=eval_model,
hermes_repo=hermes_repo,
run_tests=run_tests,
dry_run=dry_run,
max_tokens=max_tokens,
temperature=temperature,
num_threads=num_threads,
lm_timeout=lm_timeout,
lm_retries=lm_retries,
)
if __name__ == "__main__":
main()