Skip to content

feat: full feature to agent self evolution - #182

Open
numandev1 wants to merge 29 commits into
NousResearch:mainfrom
numandev1:main
Open

feat: full feature to agent self evolution#182
numandev1 wants to merge 29 commits into
NousResearch:mainfrom
numandev1:main

Conversation

@numandev1

@numandev1 numandev1 commented Aug 27, 2026

Copy link
Copy Markdown

If someone wanna use it as an experiment, then use it

trippyogi and others added 29 commits August 21, 2026 16:41
…steps

dspy.GEPA expects max_full_evals to control the number of evaluation cycles,
not max_steps. The incorrect kwarg caused a TypeError, which was caught and
silently fell back to MIPROv2 without notification to the user.

This fix allows GEPA to engage as intended for skill text mutation and
optimization.
dspy.GEPA requires an explicit reflection_lm to generate program mutations.
Without it, DSPy silently falls back to MIPROv2, which only tunes the DSPy
instruction wrapper and never mutates the skill body — defeating the purpose
of skill evolution.

With this fix, GEPA uses the optimizer_model (e.g., Claude Opus) to reflect
on failed examples and generate coherent skill text mutations with higher
temperature and adequate token budget.
dspy.GEPA passes pred_name and pred_trace to the metric function per the
GEPAFeedbackMetric protocol. The original signature did not accept these
parameters, causing a TypeError when GEPA tried to call the metric.

Add optional pred_name and pred_trace parameters to match the protocol.
These are accepted but not used in the basic heuristic metric.
GEPA uses optuna for hyperparameter search. The dependency was missing from
pyproject.toml, causing ImportError on fresh installs. Add optuna>=3.0.0 to
the main dependencies.
…e attr

SkillModule passed skill text as a runtime InputField, but DSPy
optimizers (GEPA, MIPROv2) only mutate signature instructions — making
the entire optimization pipeline silently return the original baseline.

Embed skill text as the signature's instructions parameter so optimizers
can actually evolve it, and read back via predictor.predict.signature
instead of the unchanged instance attribute.
…lone

The constraint validator in evolution/skills/evolve_skill.py:189 was
called with evolved_body (the markdown body slice with YAML frontmatter
already stripped by load_skill()) instead of evolved_full (the
reassembled text produced by reassemble_skill(frontmatter, body)).

Result: every successful evolution run was rejected by the
skill_structure constraint, the file was written to evolved_FAILED.md,
and metrics.json was never produced. The self-evolution pipeline
has been silently broken in this way since the constraint was added.

The fix passes the reassembled text to validate_all, which is what
the function would actually deploy.

Adds two regression tests:
- test_evolved_full_reassembled_text_passes_skill_structure: pins the
  contract that the call site depends on (reassembled text passes).
- test_evolved_body_alone_fails_skill_structure: confirms the root
  cause (body alone cannot pass), so a future refactor that
  reintroduces validate_all(evolved_body, ...) fails loudly.

Refs NousResearch#74 (and NousResearch#11, NousResearch#93) — the same bug filed by three other people
before this fix.
…re the LLM-scoring cap

filter_and_score capped heuristic candidates at max_examples*3 WITHOUT
ranking them, so the LLM relevance judge spent its whole budget on the
chronologically-first N messages while stronger matches beyond the cap
were never scored. Observed on a real session store: 321 heuristic hits
-> capped to the first 150 -> only 5 survived LLM scoring -> a 2-example
holdout whose verdicts are pure noise.

_is_relevant_to_skill is now a thin boolean wrapper over a new
_relevance_score (int strength: +10 exact skill-name phrase, +3 per
skill-name word >3 chars, +N keyword overlap past the existing 2-keyword
floor), and filter_and_score sorts candidates by that score descending
before applying the cap. Boolean semantics (score > 0) are unchanged, so
existing callers/tests are unaffected.

Attributed-to: Diego + Hermes
Two unrelated stock-install bugs that together prevent the optimization
loop from completing on a fresh clone:

1. **Missing optuna dependency.** `evolve_skill.py` falls back to MIPROv2
   automatically when GEPA fails to initialize (which it currently always
   does on DSPy >=3.0 — see NousResearch#14, NousResearch#35, NousResearch#39). MIPROv2 imports `optuna` at
   `_optimize_prompt_parameters` time, so the fallback crashes with
   `ModuleNotFoundError: No module named 'optuna'` immediately after
   Step 2 finishes proposing instruction candidates. Switching the
   declared dependency from `dspy>=3.0.0` to `dspy[optuna]>=3.0.0` lets
   DSPy itself manage the version pin.

2. **No request timeout on LLM calls.** litellm's default request_timeout
   is unset, so any silent connection drop from the upstream provider
   (we hit this on a corporate/proxy gateway that drops long-lived
   POSTs without a TCP RST) hangs the optimization loop indefinitely
   with the python process holding an established but dead TCP socket
   at 0% CPU. We saw the entire 10-iteration loop block for 14+ minutes
   on a single hung call before manual intervention.

   Setting `litellm.request_timeout` at module import time gives every
   DSPy LM call a per-request deadline. Default 90s (generous for
   sonnet/opus reasoning tokens, short enough to detect a dead socket).
   Override via `LITELLM_REQUEST_TIMEOUT` env var.

Verification: 143 tests pass (139 existing + 4 new tests covering the
timeout default, env override, float parsing, and fail-fast on bad
input). End-to-end run of `evolve_skill --skill llm-wiki-extract
--eval-source synthetic --iterations 3` against a flaky upstream
gateway now completes (before this fix it hung indefinitely).
- Add api_base and api_key parameters to EvolutionConfig
- Update all DSPy LM initializations to use custom endpoints
- Add --api-base and --api-key CLI options to external_importers and evolve_skill
- Enable vLLM, Ollama, and other local model endpoints
- Update documentation for local model configuration

This allows the self-evolution tools to work with:
- Local vLLM instances
- Ollama endpoints
- Any LiteLLM-compatible API
- Cost savings on expensive cloud APIs
…uilt filters

The local-model support added api_base/api_key as instance attributes set in
RelevanceFilter.__init__. Tests construct the filter via __new__ to skip DSPy
setup, so filter_and_score hit AttributeError. Declare both as class-level
defaults so instances built without __init__ still resolve them.
load_skill() splits YAML frontmatter into skill["frontmatter"], so the
baseline pass over skill["body"] could never satisfy skill_structure and
printed a false 'baseline has constraint violations' warning on every run.
Mirrors the evolved-side fix that validates the reassembled file.
dspy.GEPA() raised on every run, and the surrounding try/except caught it
and dropped to MIPROv2 — so runs reported "Running GEPA optimization" and
completed successfully while never using GEPA at all. Three causes:

1. `max_steps` is not a DSPy >=3.1 argument; the budget is now expressed as
   `max_metric_calls` (or `auto`/`max_full_evals`).

2. GEPA requires an explicit `reflection_lm` and none was passed, so it
   raised even once the budget argument was correct. `--optimizer-model` was
   documented as "Model for GEPA reflections" but never used for anything —
   it was printed to the console and then dropped. It now drives the
   reflection LM, which is what the flag describes.

3. GEPA requires a five-argument metric returning a score *and* textual
   feedback. `skill_fitness_metric` has a three-argument signature and
   returns a bare float derived from keyword overlap, leaving GEPA with no
   trace-level signal to reflect on.

For (3), route the metric through the existing LLMJudge, whose `feedback`
field is already documented as "Textual feedback for GEPA's reflective
analysis" but was not wired to the optimizer. The heuristic metric remains
as a fallback when a judge call fails, so a flaky judge degrades the score
rather than killing the run.

Verified end-to-end against a local model: GEPA now engages
("Running GEPA for approx 20 metric calls") instead of falling through to
MIPROv2. Existing test suite (145 tests) still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the vendor-specific patch note from the cherry-picked validator fix and
state the actual invariant: validate the file we would deploy, and compare
growth full-file against full-file.
Extracts the Codex slice of upstream PR NousResearch#92 (stephenschoettler) — the
hermes_codex helper, the CodexOAuthLM DSPy backend, and its tests — and
skips that PR's cron/policy/golden-dataset changes.

Any model string prefixed openai-codex/ now resolves through Hermes' own
Codex OAuth store and CodexAuxiliaryClient instead of LiteLLM, so evolution
can run on a Codex subscription. All four LM construction sites (optimizer,
reflection, judge, importer) go through make_dspy_lm, which drops
api_base/api_key for Codex models since those credentials come from Hermes.

Verified against the local hermes-agent checkout: resolve_codex_runtime_
credentials and CodexAuxiliaryClient signatures match, and source discovery
resolves ~/.hermes/hermes-agent automatically.
…me signals

Replaces the parts of the evolution loop that never connected to Hermes.

Objectives (evolution/core/objectives.py)
  Size pressure now derives from the candidate. It was computed from the
  baseline body, so the penalty evaluated to 0.000 for every variant, the
  search grew the artifact unopposed, and a post-hoc gate rejected the
  winner. Adds Pareto dominance/selection so GEPA's trade-off structure
  survives instead of collapsing to one scalar.

Hermes paths (evolution/core/hermes_paths.py)
  Data-directory and profile discovery via HERMES_DATA_DIR/HERMES_HOME
  instead of Path.home(). Importers resolved to nonexistent paths inside
  the runtime container and silently mined zero examples.

state.db reader (evolution/core/state_db.py)
  Reads the store Hermes actually keeps sessions in, with FTS5-backed
  relevance retrieval, tool histograms and system_prompt_hash grouping.

Outcome signals (evolution/core/outcome_signals.py)
  Ground truth from verification_evidence.db exit codes and cron
  executions, rolled up per skill via jobs.json.

Also: corpus-derived size budgets, skill bundles with reference-integrity
checks, agent-in-the-loop evaluation harness, candidate-accurate judging,
and a single metric shared by search and reporting.
…oyment

Session sourcing now reads state.db through HermesStateImporter and reports
each source individually. A source that is missing and a source that is
present-but-empty are different failures with different fixes; both used to
surface as the same empty dataset, which is how the weekly job stayed broken
without anyone being told.

Adds the deployment half of the product, which did not exist:
  - evolution/deploy/pr.py     branch, version-bumped commit, gh PR whose
                               body carries the constraint report and A/B
                               summary so a reviewer can judge a
                               machine-authored diff
  - evolution/deploy/canary.py rollout tracked against system_prompt_hash
                               and cron outcomes, with auto-rollback

Adds reporting held to the readtool SUMMARY.md standard: noise bands from
repetitions, deltas inside the band reported as noise, explicit SHIP/HOLD,
and caveats emitted automatically for unbalanced arms and excluded errors.

Adds layered notification that always leaves a local record and never lets
delivery status decide the run's exit code.
…gates

Rewrites the skill orchestrator so the thing optimized, the thing measured
and the thing reported are the same thing.

- make_fitness_metric drives both GEPA and the holdout comparison. They
  were different functions: GEPA optimized a judge composite while the
  reported delta came from keyword overlap, so the headline number was not
  the quantity being improved.
- Size budget is derived from the installed corpus and floored at the
  skill's own size, replacing a constant that disqualified 27 of 201
  shipped skills at their own baseline.
- Skill lookup searches every tree (profiles, user, repo, optional-skills)
  instead of only <repo>/skills.
- Bundles are loaded and gated: dropping a link to a supporting file, or
  inventing one, now fails validation. Nearly half the library ships
  reference files the optimizer previously could not see.
- The pytest gate is actually invoked; --run-tests was accepted and ignored.
- Evolved skills get a version bump, so they are distinguishable from what
  they replaced.
- Verdicts are stated against a noise band and a run that cannot evaluate
  exits non-zero with a reason.

Replaces the source-grep test on the orchestrator with behavioural tests,
and isolates the suite from the developer's live ~/.hermes install.
Phase 2 — tool descriptions (evolution/tools/)
  Optimizes against tool-selection accuracy rather than a rubric: ground
  truth is which tool the agent actually reached for after each real user
  message, so scoring is exact and needs no judge. Targets a measured cost —
  tool_search and tool_describe account for 1,594 calls in one profile,
  turns spent finding tools instead of using them. Catalog size is an
  objective, since every description is paid for on every request.

Phase 3 — system prompt sections (evolution/prompts/)
  Reads the prompt Hermes actually ran from sessions.system_prompt, ranked
  by how many sessions each variant carried, and optimizes one heading-
  delimited section at a time. Growth allowance is 5% rather than 20%: a
  skill loads when relevant, the system prompt is paid on every request.
  Splices by offset so an unchanged section round-trips byte-identical.

Phase 5 — continuous loop (evolution/monitor/)
  Moves the rotation driver in-tree, where it is tested and reviewed with
  the code it drives. Replaces alphabetical round-robin with evidence-based
  priority: skills failing in production first, then never-evolved, then
  stalest; recently-passed and repeatedly-held skills sink. Round-robin at
  the deployed cadence implied a 13.4-month cycle over 117 skills, which
  the tool now states outright. Adds a time budget and reports what was
  deferred, so a bounded sweep never reads as full coverage.

Phase 4 is left as a documented placeholder: darwinian_evolver is AGPL v3
and must stay an external CLI so its licence does not reach this MIT tree.
Adds regression tests for every defect the audit named, against fixtures
that mirror the production schema (real sessions/messages DDL, a real FTS5
index, verification and cron stores) — a convenient invented schema would
have let the original bug pass just as it passed in production.

Two real bugs surfaced while writing them:

- state_db conflated 'the FTS index cannot be joined' with 'the query
  matched nothing'. Both yield zero rows and need opposite responses, so an
  irrelevant skill would have silently mined the entire corpus. The join is
  now probed before the query runs.
- skill_bundle compared reference paths as raw strings, so rewriting
  references/api.md to api.md read as deleting the file. Both sides now
  resolve to the supporting file they identify.

Also: a SHIP verdict backed by one observation per arm now says its noise
band was assumed rather than measured.
Test coverage was inverted relative to risk: 116 of 163 tests sat on the
importer module that pointed at the wrong data store, while the 409-line
orchestrator holding three of the audited defects had two, one of which
asserted on the module's source text rather than its behaviour.

Now 475 tests, with the weight on the code that can actually be wrong:
objectives and fitness (the size and judging defects), state.db and paths
(the data-layer defects), deployment and canary (new, previously absent),
rotation priority, and both new phases.

Test fixtures are hermetic: git fixtures neutralize developer global config
(commit.gpgsign and core.hooksPath would otherwise fail commits for reasons
unrelated to the code), and the orchestrator suite is isolated from a real
~/.hermes so results do not depend on whose machine runs them.
The cron driver is now a thin wrapper over evolution.monitor.run_rotation
instead of ~120 lines of policy in bash outside the repository. It resolves
the environment, takes a lock, and hands off; rotation policy, failure
handling and notification live with the code they drive and are tested.

Verified end to end: a sweep that cannot find a Hermes data directory now
names the variable to set, writes a local status record, and exits 1. The
previous driver reported success and told no one.

Adds console entry points (evolve-skill, evolve-tool, evolve-prompt,
evolve-rotation), an integration suite covering the full chain and every
CLI, and rewrites README/PLAN to describe what the system does rather than
what it was intended to do.
…ssions

Verified against the live install, the first cut of FTS narrowing was not
narrowing. An OR of a dozen terms matched 1,804 of 2,127 pairs for one
skill, and three unrelated skills all came back within 1% of each other —
the signature of a filter that is not filtering. Capping to the top-ranked
sessions did not help, because sessions on a real install are long.

Three fixes:

- Rank with bm25 rather than taking anything that matched a term. bm25()
  is only legal in a query directly against the FTS table; SQLite flattens
  a ranking subquery back into the join and rejects it, and the previous
  except-clause was quietly turning that rejection into a full scan.
- Filter at message level, not session level, so the cap bounds what it is
  meant to bound. The rest of each matched session is still read, since the
  answer and the tools used are what give the matched ask its context.
- Cap the ranked pool near what the LLM relevance filter will actually
  score, so ranking chooses the candidates rather than arrival order.

Live result: 1,804 pairs (85% of corpus) -> 100 (5%) for the SEO skill,
73 for an unrelated one, 33% overlap between them.
Adopted from upstream review of NousResearch#178
and NousResearch#179, which independently found the same core defects this fork already
fixed but also caught data-quality problems it had not.

Hermes writes machine text into the `user` role, and mining it verbatim turns
agent plumbing into evaluation tasks. Four kinds found on the live install:

  - [CONTEXT COMPACTION - REFERENCE ONLY] summary blocks
  - [IMPORTANT: ...scheduled cron job...] delivery preambles wrapping the
    real instruction
  - [IMPORTANT: ...invoked the "x" skill] preambles followed by the entire
    SKILL.md - the worst case, since the optimizer would be trained on the
    artifact it is meant to improve, with the skill file as the request
  - [ASYNC DELEGATION COMPLETE] subagent notices

Peeling is iterative, because these layer: a preamble wraps a skill file, so
the checks have to re-run on whatever a strip exposes. Bracket matching is
nesting-aware, because the cron preamble quotes "[SILENT]" inside itself and
a scan for the first ] closed on that, leaving boilerplate behind as the task.

Deliberately NOT adopting NousResearch#178's filter on the `compacted` flag. That flag
means "rolled out of the active context window", not "machine-written": 900 of
1,290 user rows in one profile are compacted and nearly all are genuine asks
("audit this website"), so filtering on it discards most of the corpus while
still admitting the injected blocks. Content-based detection does the job the
flag was being used to approximate.

Also adopted:
  - UnicodeDecodeError handling in the legacy JSON importer (NousResearch#179) - it is
    neither JSONDecodeError nor OSError, so one non-UTF8 file aborted the
    whole import
  - platform-aware install discovery: %LOCALAPPDATA%, XDG_DATA_HOME, macOS
    Application Support (NousResearch#178)
  - USERPROFILE alongside HOME in path tests; Path.expanduser() reads the
    former on Windows, so these failed there regardless of the code (NousResearch#178)

Live result: 2,127 -> 1,989 mined pairs, with zero scaffolding remaining.
The three container failures were not environmental. collect_external_messages
probed each importer's default path *before* calling it, which made a guess
about where data lives authoritative over the importer itself. Two consequences:
a source that could actually produce messages was skipped whenever the guess
was wrong, and any test substituting an importer was skipped with it.

That is why the suite read 504/0 on a laptop with ~/.claude/history.jsonl and
501/3 in the container without it. Reproduced locally by emptying HOME.

Extraction now runs first and the path is consulted only to explain an empty
result, so 'the source is not here' and 'it is here and idle' stay
distinguishable without the probe being able to veto real data. An importer
that raises is reported rather than ending the run.

508 tests, identical under both host conditions.
Implements Phase 4, and corrects the plan it was written against.

The plan said darwinian_evolver would be driven as an external CLI so its
AGPL-3.0 licence never touched this MIT tree. It cannot be: problems/
registry.py is a hardcoded dict and __main__.py restricts --problem to its
keys. There is no plugin path or entry point, so defining a Hermes problem
means subclassing its classes and importing AGPL code.

The AGPL-linked code now lives in a separate package, hermes-evolver-problems,
which imports both the engine and this package. This package reaches it only
over a subprocess boundary (evolution/code/sidecar.py), and a test asserts
that nothing under evolution/ imports darwinian_evolver — a stray import would
relicense the project and would not otherwise fail anything.

What is MIT and engine-independent:

  admission.py  Sandboxed gate a candidate must pass before it is scored.
                Visible failures feed the mutator; the full suite and replayed
                recorded commands are held out, gating admission while leaking
                neither names nor output — otherwise an optimizer learns to
                satisfy the checks it can see. Credentials are stripped from
                check environments; pytest exit 5 is a failure, so deleting the
                tests cannot pass.
  targets.py    Target resolution that refuses tests, packaging and __init__
                files, refuses paths outside the repo, and caps target size.
                Turns verification_evidence.db rows into replayable checks,
                skipping anything state-changing or networked.
  evolve_code.py  Orchestrator. Verifies the baseline passes its own gate
                before evolving — otherwise every candidate scores against a
                broken reference. Deploys to a draft PR and nothing else: code
                cannot be canaried, because a bad tool implementation is
                already executing before any outcome signal exists.

Also repoints the [darwinian] extra at its git source. It named a PyPI package
that does not exist, so the extra had never been installable.

591 tests.
Two reproduced correctness bugs, two subsystems reported as shipped that
nothing could reach, and the smaller items behind them.

Correctness
  evolve_code published multi-file changes one file at a time, and each call
  cut its own branch. A two-file target produced two branches holding one
  file each, both titled '2 file(s)', only the first pushed, and the branch
  reported back was the one that never left the machine. PRPublisher gains
  publish_many; publish() delegates to it. Verified: one branch, one commit,
  both files.

  The sidecar's size objective was inert. baseline_chars was stamped on the
  seed organism and never propagated, so every mutator-produced child divided
  its size by its own size and scored exactly 0.500 no matter how much code
  it added — the same defect as the original finding 01. It is a declared
  field now, carried by the mutator, and the arithmetic moved to
  hermes_problems.scoring so it can be tested without the AGPL engine.

Reachability
  The agent-in-the-loop harness (~500 lines, 37 tests) was imported by
  nothing, and its three config fields were never read. Now behind
  --agent-eval / --agent-eval-reps, failing loudly when the agent cannot be
  reached rather than quietly scoring completions instead.

  Canary deployment had no caller and no CLI. Now --canary on evolve_skill,
  plus evolution.deploy.canary_cli to list, evaluate, promote and roll back.

  tests/test_wiring.py guards the whole class: every config field must be
  read, named subsystems must have a caller, flags must exist, console
  scripts must resolve, and every click CLI must be declared as one. It
  immediately found two more — external_importers had no console script, and
  holdout_ratio was a knob that silently did nothing, since the split takes
  the holdout as the remainder.

Also
  Phase 4's A/B scored a hardcoded 0.0 baseline, making SHIP automatic; both
  arms now go through the same function. One sandbox per run instead of one
  per candidate (164 MB x 9,019 files was being re-copied ~20 times);
  hardlinking would be faster and is unsafe here, since checks execute the
  candidate's code. The sandbox is documented as a safety boundary, not a
  security one — verified that candidate code keeps $HOME and network access.
  The sweep can cover phases 2 and 3 via --phases; code is never swept.
  Lint 28 -> 5 (the rest are style preferences). Stale docstring in
  hermes_paths corrected.

621 tests, identical under both host conditions; 27 in the sidecar.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants