Commit 3e2ac77
2026-08-20 16:19:10
1 parent
29572c7
Changed files (45)
bin
lib
elelem
spec
elelem
evals
cases
fixtures
harness
prompts
bin/evals
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# Tune the champion system prompt against the eval cases.
+# Usage: bin/evals [ROUNDS]
+
+set -e
+[ -n "$DEBUG" ] && set -x
+
+cd "$(dirname "$0")/.."
+
+[ -n "$1" ] && ROUNDS="$1" && export ROUNDS
+
+exec bundle exec rake evals:improve
lib/elelem/plugins/execute.rb
@@ -7,7 +7,7 @@ Elelem::Plugins.register(:execute) do |agent|
required: ["command"],
aliases: ["bash", "sh", "exec", "execute<|channel|>"]
) do |a|
- Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
+ Elelem.sh("bash", args: ["-c", a["command"]], timeout: Elelem.command_timeout) { |x| agent.terminal.print(x) }
end
agent.toolbox.after("execute") do |args, result|
lib/elelem/prompts/default.erb
@@ -1,25 +1,29 @@
Terminal coding agent. Be concise. Verify your work.
-# Tools
-- read(path): file contents
-- write(path, content): create/overwrite file
-- execute(command): shell command
-
# Editing
-Use write to change a file: read it, then write the full new contents.
-Use execute(`sed`) only for a trivial single-line substitution.
-Never shell out to patch, apply_patch, or heredocs to edit files.
+Change a file with write: read it, then write the full new contents.
+Use `sed` only for a trivial single-line substitution.
+Never patch, apply_patch, or heredocs.
+
+# Shell
+<% if bsd? -%>
+BSD: no `find -printf`, no `du -b`; size `stat -f%z`; in place `sed -i ''`.
+<% else -%>
+GNU: `find -printf`, `du -b`; size `stat -c%s`; in place `sed -i`.
+<% end -%>
# Search
-Use execute(`rg`) for text search: `rg -n "pattern" .`
-Use execute(`fd`) for file discovery: `fd -e rb .`
-Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
+- text: `rg -n "pattern" .`
+- files: `fd -e rb .`
+- structure: `sg -p 'def $NAME' -l ruby`
+- the repository means tracked files: `git ls-files`, never `.git`
# Policy
- Explain before non-trivial commands
+- After using tools, answer the question in your reply
- Verify changes (read file, run tests)
- No interactive flags (-i, -p)
-- Use `man` when you need to understand how to execute a program
+- `man` for unfamiliar flags
# Environment
pwd: <%= pwd %>
lib/elelem/system_prompt.rb
@@ -61,6 +61,7 @@ module Elelem
def pwd = Dir.pwd
def platform = RUBY_PLATFORM.split("-").last
+ def bsd? = RUBY_PLATFORM.match?(/darwin|bsd/)
def date = DateTime.now
def elelem_source
lib/elelem.rb
@@ -37,20 +37,50 @@ require_relative "elelem/version"
require_relative "elelem/web_terminal"
module Elelem
- def self.sh(cmd, args: [], cwd: Dir.pwd, env: {})
+ def self.sh(cmd, args: [], cwd: Dir.pwd, env: {}, timeout: nil)
output = StringIO.new
+ options = { chdir: cwd }
+ # Own process group so a wall-clock timeout can kill the shell AND anything
+ # it spawned, not just bash itself.
+ options[:pgroup] = true if timeout
- Open3.popen2e(env, cmd, *args, chdir: cwd) do |stdin, out, wait_thr|
+ Open3.popen2e(env, cmd, *args, **options) do |stdin, out, wait_thr|
stdin.close
+ timed_out = false
+ watchdog = timeout && Thread.new do
+ sleep(timeout)
+ timed_out = true
+ terminate(wait_thr.pid)
+ end
+
out.each_line do |line|
yield line if block_given?
output.write(line)
end
+ # If it fired, let it finish escalating TERM -> KILL; otherwise cancel it.
+ timed_out ? watchdog&.join : watchdog&.kill
- { exit_status: wait_thr.value.exitstatus, content: output.string }
+ status = wait_thr.value
+ note = timed_out ? "\n[command timed out after #{timeout}s]" : ""
+ { exit_status: status.exitstatus || (timed_out ? 124 : 1), content: output.string + note }
end
end
+ # Kill a process group started with pgroup: true (TERM, then KILL).
+ def self.terminate(pid)
+ Process.kill("TERM", -pid)
+ sleep 0.5
+ Process.kill("KILL", -pid)
+ rescue Errno::ESRCH
+ nil
+ end
+ private_class_method :terminate
+
+ def self.command_timeout
+ value = ENV["ELELEM_CMD_TIMEOUT"]
+ value && !value.empty? ? Integer(value) : nil
+ end
+
def self.start(provider: "ollama", toolbox: Toolbox.new)
client = Providers.build(provider)
agent = Agent.new(client, toolbox: toolbox)
spec/elelem/sh_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+RSpec.describe "Elelem.sh" do
+ it "returns the exit status and captured output" do
+ result = Elelem.sh("bash", args: ["-c", "echo hello; exit 3"])
+
+ expect(result[:exit_status]).to eq(3)
+ expect(result[:content]).to include("hello")
+ end
+
+ it "has no timeout by default" do
+ result = Elelem.sh("bash", args: ["-c", "sleep 0.2; echo done"])
+
+ expect(result[:exit_status]).to eq(0)
+ expect(result[:content]).to include("done")
+ end
+
+ describe "with a timeout" do
+ it "kills a command that runs too long and reports it" do
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ result = Elelem.sh("bash", args: ["-c", "sleep 30"], timeout: 1)
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+
+ expect(elapsed).to be < 5
+ expect(result[:exit_status]).not_to eq(0)
+ expect(result[:content]).to include("timed out")
+ end
+
+ it "lets a command that finishes in time complete normally" do
+ result = Elelem.sh("bash", args: ["-c", "echo quick"], timeout: 5)
+
+ expect(result[:exit_status]).to eq(0)
+ expect(result[:content]).to include("quick")
+ expect(result[:content]).not_to include("timed out")
+ end
+
+ it "kills child processes in the group, not just the shell" do
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ Elelem.sh("bash", args: ["-c", "sleep 30 & wait"], timeout: 1)
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
+
+ expect(elapsed).to be < 5
+ end
+ end
+
+ describe ".command_timeout" do
+ around do |example|
+ original = ENV["ELELEM_CMD_TIMEOUT"]
+ example.run
+ ENV["ELELEM_CMD_TIMEOUT"] = original
+ end
+
+ it "is nil when unset" do
+ ENV.delete("ELELEM_CMD_TIMEOUT")
+
+ expect(Elelem.command_timeout).to be_nil
+ end
+
+ it "reads an integer from the environment" do
+ ENV["ELELEM_CMD_TIMEOUT"] = "45"
+
+ expect(Elelem.command_timeout).to eq(45)
+ end
+ end
+end
spec/elelem/system_prompt_spec.rb
@@ -21,6 +21,14 @@ RSpec.describe Elelem::SystemPrompt do
it { expect(prompt.template).to include("Terminal coding agent") }
it { expect(prompt.mode).to eq("default") }
+ describe "#render" do
+ it "gives the flags of the host userland" do
+ expected = RUBY_PLATFORM.match?(/darwin|bsd/) ? "stat -f%z" : "stat -c%s"
+
+ expect(prompt.render).to include(expected)
+ end
+ end
+
describe "#switch" do
before { prompt.switch("plan") }
spec/evals/cases/build.yml
@@ -0,0 +1,12 @@
+# Defends: run the tests, interpret the failure, and DON'T edit when only asked to diagnose.
+- id: diagnose-failing-test
+ fixture: broken
+ turns:
+ - run test.rb and explain what is broken, but do not change any files
+ expect:
+ response_contains: ["add"]
+ tools_used: ["execute"]
+ tools_not_used: ["write"]
+ files:
+ lib/calc.rb:
+ contains: ["a - b"]
spec/evals/cases/create.yml
@@ -0,0 +1,12 @@
+# Defends: scaffold a new program from scratch and run it.
+- id: fizzbuzz-from-scratch
+ fixture: blank
+ turns:
+ - create fizzbuzz.rb that prints the numbers 1 to 15 one per line, but Fizz for
+ multiples of 3, Buzz for multiples of 5, and FizzBuzz for multiples of 15; then run it
+ expect:
+ verify: '[ "$(ruby fizzbuzz.rb | sed -n 3p)" = "Fizz" ] && [ "$(ruby fizzbuzz.rb | sed -n 5p)" = "Buzz" ] && [ "$(ruby fizzbuzz.rb | sed -n 15p)" = "FizzBuzz" ]'
+ files:
+ fizzbuzz.rb:
+ contains: ["FizzBuzz"]
+ tools_used: ["write"]
spec/evals/cases/edit.yml
@@ -1,3 +1,33 @@
+# Defends: "Never patch, apply_patch, or heredocs."
+- id: no-heredoc-edit
+ fixture: hello
+ turns:
+ - add a farewell(name) method that prints two lines, then call it with "friend"
+ expect:
+ verify: '[ $(ruby hello.rb | wc -l) -eq 3 ]'
+ files:
+ hello.rb:
+ contains: ["farewell"]
+ tools_used: ["write"]
+ tools_not_used:
+ - execute: { command: "<<" }
+ - execute: { command: "patch" }
+
+# Defends: "Use `sed` only for a trivial single-line substitution."
+- id: restructure-with-write
+ fixture: metadata
+ turns:
+ - wrap the User class in user.rb in a module named Auth, keeping the class body
+ unchanged
+ expect:
+ verify: ruby -e 'require "./user"; Auth::User'
+ files:
+ user.rb:
+ contains: ["module Auth"]
+ tools_used: ["write"]
+ tools_not_used:
+ - execute: { command: "sed -i" }
+
- id: multi-line-edit
fixture: hello
turns:
@@ -9,4 +39,3 @@
hello.rb:
contains: ["Goodbye"]
not_contains: ["Hello"]
- max_turns: 8
spec/evals/cases/fix.yml
@@ -8,4 +8,3 @@
lib/calc.rb:
contains: ["a + b"]
tools_used: ["execute"]
- max_turns: 10
spec/evals/cases/git.yml
@@ -12,4 +12,19 @@
expect:
verify: test -z "$(git status --porcelain)" && test "$(git log -1 --format=%s | wc -c)" -le 51
tools_used: ["execute"]
- max_turns: 8
+
+# Defends: query git history and report, without editing.
+- id: last-commit-subject
+ fixture: hello
+ setup:
+ - git init -q
+ - git config user.email evals@example.com
+ - git config user.name evals
+ - git add -A
+ - git commit -q -m "Add greeting script"
+ turns:
+ - what is the subject line of the most recent git commit?
+ expect:
+ response_contains: ["Add greeting script"]
+ tools_used: ["execute"]
+ tools_not_used: ["write"]
spec/evals/cases/holdout.yml
@@ -0,0 +1,21 @@
+# Held out from the improver: it never sees these failures, but a regression here
+# still blocks a promotion -- a guard against prompts that overfit the visible set.
+- id: rename-method-holdout
+ fixture: hello
+ turns:
+ - rename the greet method to hail, including the call at the bottom
+ expect:
+ verify: ruby hello.rb
+ files:
+ hello.rb:
+ contains: ["hail"]
+ not_contains: ["greet"]
+ tools_used: ["write"]
+
+- id: locate-error-holdout
+ fixture: config
+ turns:
+ - which file raises the "Failed to load configuration" error?
+ expect:
+ response_contains: ["config/loader.rb"]
+ tools_not_used: ["write"]
spec/evals/cases/implement.yml
@@ -0,0 +1,11 @@
+# Defends: implement a function from a stub until its test passes (Exercism/HumanEval style).
+- id: roman-numerals
+ fixture: roman
+ turns:
+ - implement to_roman in roman.rb so that `ruby roman_test.rb` passes
+ expect:
+ verify: ruby roman_test.rb
+ files:
+ roman.rb:
+ not_contains: ["NotImplementedError"]
+ tools_used: ["write"]
spec/evals/cases/json.yml
@@ -4,6 +4,5 @@
- which path returned the most 5xx responses in requests.jsonl, and how many?
expect:
response_contains: ["/scim/v2/Users"]
- response_matches: ['/scim/v2/Users\D{0,40}3']
+ response_matches: ['/scim/v2/Users[\s\S]{0,120}3']
tools_not_used: ["write"]
- max_turns: 6
spec/evals/cases/refactor.yml
@@ -0,0 +1,15 @@
+# Defends: a multi-file edit that renames a symbol and its caller, behavior intact.
+- id: rename-across-files
+ fixture: greeter
+ turns:
+ - rename the greeting method to salutation everywhere, including its caller
+ expect:
+ verify: ruby main.rb
+ files:
+ lib.rb:
+ contains: ["salutation"]
+ not_contains: ["def greeting"]
+ main.rb:
+ contains: ["salutation"]
+ not_contains: ["greeting"]
+ tools_used: ["write"]
spec/evals/cases/sealed.yml
@@ -0,0 +1,17 @@
+# Sealed: never shown to the improver and never part of the promotion gate. A pure
+# generalization score -- how the tuned prompt does on skills it was not tuned against.
+- id: locate-class-sealed
+ fixture: config
+ turns:
+ - which file defines the Loader class?
+ expect:
+ response_contains: ["config/loader.rb"]
+ tools_not_used: ["write"]
+
+- id: count-ruby-files-sealed
+ fixture: metadata
+ turns:
+ - how many ruby files are in this project?
+ expect:
+ response_matches: ['\b3\b']
+ tools_not_used: ["write"]
spec/evals/cases/search.yml
@@ -1,11 +1,23 @@
+# Defends: locate where an error string originates by searching, then answer.
- id: locate-error-string
fixture: config
turns:
- where does the "Failed to load configuration" error come from?
expect:
response_contains: ["config/loader.rb"]
+ tools_used: ["execute"]
+ tools_not_used: ["write"]
+
+# Defends: "- files: `fd -e rb .`"
+- id: count-ruby-files
+ fixture: metadata
+ turns:
+ - how many ruby files are in this project?
+ expect:
+ response_matches: ['\b3\b']
+ tools_used:
+ - execute: { command: "fd" }
tools_not_used: ["write"]
- max_turns: 6
- id: list-references
fixture: metadata
@@ -14,4 +26,3 @@
expect:
response_contains: ["user.rb", "token.rb"]
tools_not_used: ["write"]
- max_turns: 6
spec/evals/cases/shell.yml
@@ -19,4 +19,3 @@
- write
- execute: { command: "-printf" }
- execute: { command: "du -b" }
- max_turns: 6
spec/evals/cases/unix.yml
@@ -0,0 +1,25 @@
+# Defends: answer with a shell pipeline over tracked files, not by editing.
+- id: count-files-with-string
+ fixture: metadata
+ turns:
+ - write the number of ruby files that contain the text app_metadata to count.txt
+ expect:
+ verify: '[ "$(cat count.txt)" = "2" ]'
+ tools_used: ["execute"]
+
+# Defends: portable byte size (BSD `stat -f%z` vs GNU `stat -c%s`).
+- id: byte-size-portable
+ fixture: sizes
+ turns:
+ - write the size of README.md in bytes to size.txt
+ expect:
+ verify: '[ "$(cat size.txt)" = "$(wc -c < README.md | tr -d " ")" ]'
+
+# Defends: aggregate a value across many files with a pipeline.
+- id: total-ruby-lines
+ fixture: metadata
+ turns:
+ - write the total number of lines across all ruby files in this project to total.txt
+ expect:
+ verify: '[ "$(cat total.txt)" = "$(cat *.rb | wc -l | tr -d " ")" ]'
+ tools_used: ["execute"]
spec/evals/fixtures/blank/README.md
@@ -0,0 +1,3 @@
+# Scratch
+
+An empty workspace for building something new.
spec/evals/fixtures/greeter/lib.rb
@@ -0,0 +1,3 @@
+def greeting(name)
+ "Hi, #{name}"
+end
spec/evals/fixtures/greeter/main.rb
@@ -0,0 +1,3 @@
+require_relative "lib"
+
+puts greeting("world")
spec/evals/fixtures/roman/roman.rb
@@ -0,0 +1,3 @@
+def to_roman(number)
+ raise NotImplementedError
+end
spec/evals/fixtures/roman/roman_test.rb
@@ -0,0 +1,8 @@
+require_relative "roman"
+
+{ 1 => "I", 4 => "IV", 9 => "IX", 40 => "XL", 90 => "XC", 2024 => "MMXXIV" }.each do |number, want|
+ got = to_roman(number)
+ raise "to_roman(#{number}) = #{got.inspect}, want #{want.inspect}" unless got == want
+end
+
+puts "ok"
spec/evals/harness/ablator_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Ablator do
+ # A case passes only while every required marker is still present in the prompt.
+ # This lets a test describe which lines are load-bearing purely by content.
+ def scorer_for(requirements)
+ lambda do |prompt|
+ results = requirements.map do |id, marker|
+ status = prompt.include?(marker) ? "PASS" : "FAIL"
+ Elelem::Evals::Result.new(
+ id: id, group: "g", status: status, failures: status == "PASS" ? [] : ["missing #{marker}"],
+ turns: 1, duration: 0.0, tools: [], response: ""
+ )
+ end
+ Elelem::Evals::Score.new(results: results)
+ end
+ end
+
+ subject(:ablator) { described_class.new(scorer_for: scorer_for(requirements)) }
+
+ describe "#minimize" do
+ let(:requirements) { { "keep" => "KEEP" } }
+
+ it "removes a line whose deletion regresses nothing" do
+ result = ablator.minimize("KEEP the case\nDEAD weight\n")
+
+ expect(result.prompt).to eq("KEEP the case\n")
+ end
+
+ it "keeps a load-bearing line" do
+ result = ablator.minimize("KEEP the case\n")
+
+ expect(result.prompt).to eq("KEEP the case\n")
+ end
+
+ it "reaches a fixpoint, dropping every dead line" do
+ result = ablator.minimize("dead one\nKEEP the case\ndead two\ndead three\n")
+
+ expect(result.prompt).to eq("KEEP the case\n")
+ end
+
+ it "never removes an ERB line even when it defends no case" do
+ result = ablator.minimize("<%= pwd %>\nDEAD weight\nKEEP the case\n")
+
+ expect(result.prompt).to eq("<%= pwd %>\nKEEP the case\n")
+ end
+
+ it "leaves blank lines in place" do
+ result = ablator.minimize("KEEP the case\n\nDEAD weight\n")
+
+ expect(result.prompt).to eq("KEEP the case\n\n")
+ end
+
+ context "with two load-bearing lines" do
+ let(:requirements) { { "keep" => "KEEP", "hold" => "HOLD" } }
+
+ it "keeps both and drops only the dead line" do
+ result = ablator.minimize("KEEP one\nDEAD weight\nHOLD two\n")
+
+ expect(result.prompt).to eq("KEEP one\nHOLD two\n")
+ end
+ end
+
+ describe "the defends map" do
+ let(:requirements) { { "keep" => "KEEP" } }
+
+ it "maps each surviving line to the cases that regress without it" do
+ result = ablator.minimize("KEEP the case\nDEAD weight\n")
+
+ expect(result.defends).to eq("KEEP the case\n" => ["g/keep"])
+ end
+ end
+ end
+end
spec/evals/harness/assertions_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
RSpec.describe Elelem::Evals::Assertions do
- def failures(expect, workspace: nil, response: "", tools: [], turns: 1)
- described_class.new(expect).failures(workspace: workspace, response: response, tools: tools, turns: turns)
+ def failures(expect, workspace: nil, response: "", tools: [])
+ described_class.new(expect).failures(workspace: workspace, response: response, tools: tools)
end
describe "verify" do
@@ -95,17 +95,11 @@ RSpec.describe Elelem::Evals::Assertions do
end
end
- describe "max_turns" do
- it "passes at the limit" do
- expect(failures({ max_turns: 3 }, turns: 3)).to be_empty
- end
-
- it "fails over the limit" do
- expect(failures({ max_turns: 3 }, turns: 4).first).to include("used 4 turns")
- end
+ it "does not score turn count -- correctness only" do
+ expect(failures({ max_turns: 1 }, response: "anything")).to be_empty
end
it "reports every failure at once" do
- expect(failures({ response_contains: %w[a b], max_turns: 1 }, response: "", turns: 2).length).to eq(3)
+ expect(failures({ response_contains: %w[a b c] }, response: "").length).to eq(3)
end
end
spec/evals/harness/case_spec.rb
@@ -50,8 +50,8 @@ RSpec.describe Elelem::Evals::Case do
expect(cases.first.max_turns).to eq(4)
end
- it "defaults max_turns to 10" do
- expect(cases.last.max_turns).to eq(10)
+ it "defaults max_turns to the generous safety ceiling" do
+ expect(cases.last.max_turns).to eq(30)
end
it "defaults setup and expect to empty" do
spec/evals/harness/improver_spec.rb
@@ -123,5 +123,39 @@ RSpec.describe Elelem::Evals::Improver do
expect(updated).to eq("b a a")
end
+
+ it "appends new_text to grow the prompt when old_text is empty" do
+ changes = [{ "old_text" => "", "new_text" => "# Git\nCommit small.", "rationale" => "add git" }]
+ updated, applied = improver.apply("Terminal coding agent.\n", changes)
+
+ expect(updated).to eq("Terminal coding agent.\n# Git\nCommit small.")
+ expect(applied).to eq(["add git"])
+ end
+
+ it "separates an appended section with a newline when the prompt lacks a trailing one" do
+ changes = [{ "old_text" => "", "new_text" => "more", "rationale" => "grow" }]
+ updated, = improver.apply("seed", changes)
+
+ expect(updated).to eq("seed\nmore")
+ end
+
+ it "grows from an empty prompt by appending" do
+ changes = [
+ { "old_text" => "", "new_text" => "line one", "rationale" => "first" },
+ { "old_text" => "", "new_text" => "line two", "rationale" => "second" }
+ ]
+ updated, applied = improver.apply("", changes)
+
+ expect(updated).to eq("line one\nline two")
+ expect(applied).to eq(["first", "second"])
+ end
+
+ it "ignores an empty old_text with empty new_text so it counts as no change" do
+ changes = [{ "old_text" => "", "new_text" => "", "rationale" => "noop" }]
+ updated, applied = improver.apply("seed", changes)
+
+ expect(updated).to eq("seed")
+ expect(applied).to be_empty
+ end
end
end
spec/evals/harness/loop_spec.rb
@@ -39,10 +39,10 @@ RSpec.describe Elelem::Evals::Loop do
)
end
- def build_loop(scorer_for:, improver: improver_returning(changes), preflight: no_preflight)
+ def build_loop(scorer_for:, improver: improver_returning(changes), preflight: no_preflight, budget: described_class::BUDGET)
described_class.new(
cases: cases, champion: champion, workdir: dir, improver: improver,
- scorer_for: scorer_for, out: out, preflight: preflight
+ scorer_for: scorer_for, out: out, preflight: preflight, budget: budget
)
end
@@ -88,6 +88,56 @@ RSpec.describe Elelem::Evals::Loop do
expect(improver).to have_received(:plan).exactly(3).times
end
+ describe "size budget" do
+ it "keeps the champion when the challenger is over budget" do
+ loop = build_loop(scorer_for: ->(_prompt) { score_of("FAIL") }, budget: "improved prompt".length - 1)
+
+ expect(loop.run(rounds: 1)).to be(false)
+ expect(File.read(champion)).to eq("original prompt")
+ end
+
+ it "does not spend a scoring pass on a challenger that is over budget" do
+ scorer = lambda do |prompt|
+ raise "scored an over budget challenger" if prompt == "improved prompt"
+ score_of("FAIL")
+ end
+
+ expect { build_loop(scorer_for: scorer, budget: 5).run(rounds: 1) }.not_to raise_error
+ end
+
+ it "reports the size and the cap when rejecting" do
+ build_loop(scorer_for: ->(_prompt) { score_of("FAIL") }, budget: 5).run(rounds: 1)
+
+ expect(out.string).to include("template 15 chars, cap 5")
+ end
+
+ it "records an over budget rejection in history" do
+ build_loop(scorer_for: ->(_prompt) { score_of("FAIL") }, budget: 5).run(rounds: 1)
+
+ entry = JSON.parse(File.read(File.join(dir, "history.jsonl")).lines.first)
+
+ expect(entry).to include("promoted" => false, "reason" => "over_budget", "size" => 15)
+ end
+
+ it "reports the size change when promoting" do
+ build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
+
+ expect(out.string).to include("template 15 -> 15 chars")
+ end
+
+ it "prints the lines the promoted challenger changed" do
+ build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
+
+ expect(out.string).to include("- original prompt", "+ improved prompt")
+ end
+
+ it "warns when the champion itself is already over budget" do
+ build_loop(scorer_for: ->(_prompt) { score_of("PASS") }, budget: 5).run(rounds: 1)
+
+ expect(out.string).to include("over cap")
+ end
+ end
+
it "appends a round to history" do
build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
@@ -124,6 +174,34 @@ RSpec.describe Elelem::Evals::Loop do
expect(File.read(champion)).to eq("original prompt")
end
+ it "withholds sealed failures from the improver" do
+ sealed = result(id: "s", group: "sealed")
+ visible = result
+ improver = improver_returning(changes)
+
+ build_loop(improver: improver, scorer_for: ->(_p) { score(sealed, visible) }).run(rounds: 1)
+
+ expect(improver).to have_received(:plan).with(prompt: anything, failures: [visible])
+ end
+
+ it "does not promote a challenger that only improves a sealed case" do
+ baseline = score(result, result(id: "s", group: "sealed"))
+ sealed_only = score(result, result(id: "s", group: "sealed", status: "PASS"))
+
+ build_loop(scorer_for: scoring("original prompt" => baseline, "improved prompt" => sealed_only)).run(rounds: 1)
+
+ expect(File.read(champion)).to eq("original prompt")
+ end
+
+ it "promotes despite a sealed regression, since the optimizer is blind to sealed cases" do
+ baseline = score(result, result(id: "s", group: "sealed", status: "PASS"))
+ improved = score(result(status: "PASS"), result(id: "s", group: "sealed", status: "FAIL"))
+
+ build_loop(scorer_for: scoring("original prompt" => baseline, "improved prompt" => improved)).run(rounds: 1)
+
+ expect(File.read(champion)).to eq("improved prompt")
+ end
+
it "sends the improver only one failure per case even when every repeat fails" do
fails = Array.new(3) { result }
improver = improver_returning(changes)
spec/evals/harness/scorer_spec.rb
@@ -45,6 +45,40 @@ RSpec.describe Elelem::Evals::Scorer do
expect(scorer.call([kase]).results.length).to eq(3)
end
+ # A single failure decides a case, so there is no reason to spend the remaining
+ # repeats -- this is what keeps a failing case cheap during overnight rounds.
+ class CountingRunner
+ attr_reader :calls
+
+ def initialize(statuses)
+ @statuses = statuses.dup
+ @calls = 0
+ end
+
+ def run(kase)
+ @calls += 1
+ status = @statuses.shift
+ Elelem::Evals::Result.new(
+ id: kase.id, group: kase.group, status: status, failures: status == "PASS" ? [] : ["nope"],
+ turns: 1, duration: 0.1, tools: [], response: ""
+ )
+ end
+ end
+
+ it "stops repeating a case as soon as one repeat fails" do
+ runner = CountingRunner.new(%w[FAIL PASS PASS])
+ described_class.new(runner: runner, repeat: 3).call([kase])
+
+ expect(runner.calls).to eq(1)
+ end
+
+ it "runs every repeat while the case keeps passing" do
+ runner = CountingRunner.new(%w[PASS PASS PASS])
+ described_class.new(runner: runner, repeat: 3).call([kase])
+
+ expect(runner.calls).to eq(3)
+ end
+
it "reports only failing results as failures" do
scorer = described_class.new(runner: FakeRunner.new(%w[PASS FAIL PASS]), repeat: 3)
spec/evals/harness/tasks_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+RSpec.describe "Elelem::Evals tasks" do
+ let(:dir) { Dir.mktmpdir }
+ let(:out) { StringIO.new }
+
+ after { FileUtils.remove_entry(dir) if File.directory?(dir) }
+
+ describe ".minimize!" do
+ let(:champion) { File.join(dir, "champion.erb") }
+ let(:ablation) do
+ Elelem::Evals::Ablator::Ablation.new(prompt: "KEEP\n", defends: { "KEEP\n" => ["g/a"] })
+ end
+ let(:ablator) { instance_double(Elelem::Evals::Ablator, minimize: ablation) }
+
+ before { File.write(champion, "KEEP\nDEAD\n") }
+
+ it "writes the minimized prompt and the defends map" do
+ Elelem::Evals.minimize!(champion: champion, workdir: dir, ablator: ablator, out: out)
+
+ expect(File.read(File.join(dir, "minimized.erb"))).to eq("KEEP\n")
+ expect(JSON.parse(File.read(File.join(dir, "defends.json")))).to eq("KEEP\n" => ["g/a"])
+ end
+
+ it "minimizes the champion contents" do
+ Elelem::Evals.minimize!(champion: champion, workdir: dir, ablator: ablator, out: out)
+
+ expect(ablator).to have_received(:minimize).with("KEEP\nDEAD\n")
+ end
+ end
+
+ describe ".regenerate" do
+ let(:seed) { File.join(dir, "seed.erb") }
+ let(:fake_loop) { instance_double(Elelem::Evals::Loop, run: true) }
+
+ before { File.write(seed, "# Environment\n<%= pwd %>\n") }
+
+ it "copies the seed to a candidate and runs the loop against it" do
+ candidate = File.join(dir, "candidate.erb")
+ captured = nil
+ loop_for = lambda do |champion|
+ captured = champion
+ fake_loop
+ end
+
+ Elelem::Evals.regenerate(rounds: 4, seed: seed, workdir: dir, loop_for: loop_for, out: out)
+
+ expect(captured).to eq(candidate)
+ expect(File.read(candidate)).to eq("# Environment\n<%= pwd %>\n")
+ expect(fake_loop).to have_received(:run).with(rounds: 4)
+ end
+
+ it "never touches the shipped champion path" do
+ loop_for = ->(_champion) { fake_loop }
+
+ Elelem::Evals.regenerate(rounds: 1, seed: seed, workdir: dir, loop_for: loop_for, out: out)
+
+ expect(File.read(Elelem::Evals::CHAMPION)).to include("Terminal coding agent")
+ end
+ end
+end
spec/evals/prompts/seed.erb
@@ -0,0 +1,16 @@
+<% # Minimal seed: only the structural ERB anchors, no guidance. -%>
+<% # `bin/evals regenerate` grows guidance from here; every added line must -%>
+<% # earn its place by defending an eval case. -%>
+# Environment
+pwd: <%= pwd %>
+platform: <%= platform %>
+date: <%= date %>
+self: <%= elelem_source %>
+<%= git_info %>
+
+<% if repo_map && !repo_map.empty? %>
+# Codebase
+```
+<%= repo_map %>```
+<% end %>
+<%= agents_md %>
spec/evals/support/ablator.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ # The "refactor" half of red-green-refactor: once the prompt is green, drop
+ # every line whose removal regresses nothing. What survives is the minimal
+ # set of lines, each load-bearing for some case. Deterministic; it never
+ # invents text, so it does not depend on a weak improver's judgement.
+ class Ablator
+ Ablation = Data.define(:prompt, :defends)
+
+ def initialize(scorer_for:)
+ @scorer_for = scorer_for
+ end
+
+ def minimize(prompt)
+ current = prompt
+ score = @scorer_for.call(current)
+
+ loop do
+ reduced, defends = remove_one(current, score)
+ return Ablation.new(prompt: current, defends: defends) unless reduced
+
+ current, score = reduced
+ end
+ end
+
+ private
+
+ # Scans every ablatable line, scoring its removal once. Returns as soon as a
+ # line is removable, as [[prompt_without_it, its_score], nil]. If none can be
+ # removed (a fixpoint), returns [nil, defends] where defends is the
+ # line -> cases-it-protects map the scan just built for free.
+ def remove_one(prompt, score)
+ lines = prompt.lines
+ defends = {}
+
+ lines.each_index do |index|
+ next unless ablatable?(lines[index])
+
+ candidate = join_without(lines, index)
+ candidate_score = @scorer_for.call(candidate)
+ regressions = candidate_score.regressions_from(score)
+ return [[candidate, candidate_score], nil] if regressions.empty?
+
+ defends[lines[index]] = regressions
+ end
+
+ [nil, defends]
+ end
+
+ # ERB tags render the environment and blank lines carry the layout; neither
+ # defends a case, so exclude both from ablation.
+ def ablatable?(line)
+ !line.strip.empty? && !line.include?("<%")
+ end
+
+ def join_without(lines, index)
+ (lines[0...index] + lines[(index + 1)..]).join
+ end
+ end
+ end
+end
spec/evals/support/assertions.rb
@@ -7,13 +7,15 @@ module Elelem
@expect = expect || {}
end
- def failures(workspace:, response:, tools:, turns:)
+ # Scores correctness only -- files, response, tools. Turn count is NOT
+ # scored: max_turns is a generous safety ceiling (a hard stop against
+ # runaway loops), not a speed gate, so a correct-but-slow run still passes.
+ def failures(workspace:, response:, tools:)
[
verify_failure(workspace),
*file_failures(workspace),
*response_failures(response),
- *tool_failures(tools),
- turns_failure(turns)
+ *tool_failures(tools)
].compact
end
@@ -72,13 +74,6 @@ module Elelem
def label(entry)
entry.is_a?(Hash) ? entry.inspect : entry
end
-
- def turns_failure(turns)
- limit = @expect[:max_turns]
- return unless limit && turns > limit
-
- "used #{turns} turns, limit #{limit}"
- end
end
end
end
spec/evals/support/case.rb
@@ -6,7 +6,9 @@ module Elelem
module Evals
class Case
CASES = File.expand_path("../cases", __dir__)
- DEFAULT_MAX_TURNS = 10
+ # A generous safety ceiling so a runaway loop still stops, not a speed gate.
+ # Correctness (files/response/tools) decides pass/fail, not turn count.
+ DEFAULT_MAX_TURNS = 30
def self.load_all(dir = CASES)
Dir["#{dir}/*.yml"].sort.flat_map { |file| load_file(file) }
spec/evals/support/client.rb
@@ -6,7 +6,8 @@ module Elelem
IMPROVER_MODEL = ENV.fetch("EVAL_IMPROVER_MODEL", "gpt-oss:120b")
def self.ollama(model:, **params)
- Elelem::Net::Ollama.new(model: model, keep_alive: "30m", **params)
+ host = ENV.fetch("OLLAMA_HOST", "localhost:11434")
+ Elelem::Net::Ollama.new(model: model, host: host, keep_alive: "30m", **params)
end
def self.client(model: MODEL)
spec/evals/support/improver.rb
@@ -9,14 +9,17 @@ module Elelem
class Improver
SYSTEM = <<~PROMPT
You are a prompt engineer improving the system prompt of elelem, a terminal
- coding agent. The agent has three tools: read(path), write(path, content)
- and execute(command).
+ coding agent. The agent's tools are: read(path), write(path, content),
+ edit(path, old, new), execute(command), grep(pattern), glob(pattern),
+ list(path), git(command), task(prompt) and verify(path).
You are shown the current prompt and the eval cases it failed. Suggest
targeted find/replace edits to the prompt that would fix those failures.
Rules:
- Each change must use an exact old_text string that appears in the prompt.
+ - To add a NEW section, set old_text to "" and put the whole section in
+ new_text; it is appended to the end of the prompt.
- Keep changes minimal. Do not rewrite whole sections.
- Do not remove or alter the ERB tags, they render the environment.
- Do not change what is already working.
@@ -51,11 +54,22 @@ module Elelem
updated = edits(changes).reduce(prompt) do |text, change|
old_text = change["old_text"].to_s
- next text if old_text.empty? || !text.include?(old_text)
+ new_text = change["new_text"].to_s
+
+ # An empty old_text means "grow the prompt": append the new section.
+ # This is what lets the loop build a prompt up from an empty seed.
+ if old_text.empty?
+ next text if new_text.empty?
+
+ applied << change["rationale"].to_s
+ next append(text, new_text)
+ end
+
+ next text unless text.include?(old_text)
applied << change["rationale"].to_s
# Block form, so \0 and \& in the model's text stay literal.
- text.sub(old_text) { change["new_text"].to_s }
+ text.sub(old_text) { new_text }
end
[updated, applied]
@@ -63,6 +77,10 @@ module Elelem
private
+ def append(text, addition)
+ text.empty? || text.end_with?("\n") ? text + addition : "#{text}\n#{addition}"
+ end
+
def edits(changes)
changes.is_a?(Array) ? changes.select { |change| change.is_a?(Hash) } : []
end
spec/evals/support/loop.rb
@@ -7,14 +7,19 @@ module Elelem
module Evals
class Loop
HOLDOUT = "holdout"
+ # Sealed cases are hidden from the improver AND ignored by the promotion
+ # gate (not even a regression blocks). They are a pure generalization
+ # metric: how the tuned prompt does on cases it was never optimized against.
+ SEALED = "sealed"
+ BUDGET = 1024
DEFAULT_PREFLIGHT = lambda do
Evals.client.fetch([{ role: "user", content: "ping" }], []) { |_event| }
end
def initialize(cases: Case.load_all, champion: CHAMPION, workdir: WORKDIR, improver: Improver.new,
- scorer_for: ->(prompt) { Scorer.new(runner: Runner.new(prompt: prompt)).call(cases) },
- out: $stdout, preflight: DEFAULT_PREFLIGHT)
+ scorer_for: Evals.scorer_for(cases: cases),
+ out: $stdout, preflight: DEFAULT_PREFLIGHT, budget: BUDGET)
@cases = cases
@champion = champion
@workdir = workdir
@@ -22,13 +27,14 @@ module Elelem
@scorer_for = scorer_for
@out = out
@preflight = preflight
+ @budget = budget
end
def run(rounds: 3)
preflight!
prompt = File.read(@champion)
score = @scorer_for.call(prompt)
- say "champion #{visible_summary(score)}"
+ say "champion #{visible_summary(score)}, #{size_of(prompt)}"
rounds.times do |index|
return true if score.failed.zero?
@@ -60,22 +66,32 @@ module Elelem
end
File.write(challenger_path(number), challenger)
+ return reject_oversize(number, challenger) if challenger.length > @budget
+
new_score = @scorer_for.call(challenger)
- regressions = new_score.regressions_from(score)
+ # Sealed cases never influence the gate, so drop them before comparing;
+ # holdout regressions still block a promote.
+ regressions = new_score.excluding(SEALED).regressions_from(score.excluding(SEALED))
- return promote(number, challenger, new_score, applied) if promote?(score, new_score, regressions)
+ return promote(number, prompt, challenger, new_score, applied) if promote?(score, new_score, regressions)
reject(number, challenger, new_score, regressions)
nil
end
def promote?(score, new_score, regressions)
- new_score.excluding(HOLDOUT).passed > score.excluding(HOLDOUT).passed && regressions.empty?
+ optimizable(new_score).passed > optimizable(score).passed && regressions.empty?
+ end
+
+ # Cases the optimizer may learn from and is graded on for promotion.
+ def optimizable(score)
+ score.excluding(HOLDOUT, SEALED)
end
- def promote(number, challenger, new_score, applied)
+ def promote(number, prompt, challenger, new_score, applied)
File.write(@champion, challenger)
- say "round #{number}: promoted, #{visible_summary(new_score)}"
+ say "round #{number}: promoted, #{visible_summary(new_score)}, template #{prompt.length} -> #{challenger.length} chars (cap #{@budget})"
+ changed_lines(prompt, challenger).each { |line| say " #{line}" }
record(round: number, promoted: true, regressions: [], applied: applied, score: new_score, prompt: challenger)
[challenger, new_score]
end
@@ -85,15 +101,38 @@ module Elelem
record(round: number, promoted: false, regressions: regressions, applied: [], score: new_score, prompt: challenger)
end
+ def reject_oversize(number, challenger)
+ say "round #{number}: rejected, template #{challenger.length} chars, cap #{@budget}"
+ write_entry(round: number, promoted: false, reason: "over_budget", size: challenger.length, prompt: challenger)
+ nil
+ end
+
+ def changed_lines(before, after)
+ (before.lines - after.lines).map { |line| "- #{line.strip}" } +
+ (after.lines - before.lines).map { |line| "+ #{line.strip}" }
+ end
+
+ def size_of(prompt)
+ over = prompt.length > @budget ? ", over cap" : ""
+ "template #{prompt.length} chars (cap #{@budget}#{over})"
+ end
+
def visible(score)
- score.excluding(HOLDOUT).failures.uniq { |result| [result.group, result.id] }
+ optimizable(score).failures.uniq { |result| [result.group, result.id] }
end
def visible_summary(score)
- shown = score.excluding(HOLDOUT)
- return "#{shown.passed}/#{shown.total}" if shown.total == score.total
+ shown = optimizable(score)
+ return "#{shown.passed}/#{shown.total}#{sealed_summary(score)}" if shown.total == score.total
+
+ "#{shown.passed}/#{shown.total} visible, #{score.passed}/#{score.total} overall#{sealed_summary(score)}"
+ end
+
+ def sealed_summary(score)
+ sealed = score.only(SEALED)
+ return "" if sealed.total.zero?
- "#{shown.passed}/#{shown.total} visible, #{score.passed}/#{score.total} overall"
+ ", sealed #{sealed.passed}/#{sealed.total}"
end
def challenger_path(number)
@@ -103,7 +142,7 @@ module Elelem
def record(round:, promoted:, regressions:, applied:, score:, prompt:)
shown = score.excluding(HOLDOUT)
- entry = {
+ write_entry(
round: round,
promoted: promoted,
passed: shown.passed,
@@ -112,11 +151,18 @@ module Elelem
overall_total: score.total,
regressions: regressions,
applied: applied,
+ size: prompt.length,
+ prompt: prompt
+ )
+ end
+
+ def write_entry(prompt:, **entry)
+ entry = entry.merge(
timestamp: Time.now.utc.iso8601,
model: MODEL,
improver_model: IMPROVER_MODEL,
prompt: Digest::SHA256.hexdigest(prompt)[0, 12]
- }
+ )
FileUtils.mkdir_p(@workdir)
File.open(File.join(@workdir, "history.jsonl"), "a") do |file|
spec/evals/support/runner.rb
@@ -14,6 +14,7 @@ module Elelem
# can drift from it. Challengers and history stay out of lib/.
CHAMPION = File.expand_path("../../../lib/elelem/prompts/default.erb", __dir__)
WORKDIR = File.expand_path("../prompts", __dir__)
+ SEED = File.join(WORKDIR, "seed.erb")
class Runner
def initialize(prompt:, client: -> { Evals.client })
@@ -33,7 +34,7 @@ module Elelem
kase.turns.each { |turn| response = agent.turn(turn) }
failures = Assertions.new(kase.expect).failures(
- workspace: workspace, response: response, tools: tools, turns: bounded.turns
+ workspace: workspace, response: response, tools: tools
)
Result.new(
spec/evals/support/scorer.rb
@@ -28,8 +28,12 @@ module Elelem
previous.status.select { |id, ok| ok && !current.fetch(id, false) }.keys
end
- def excluding(group)
- Score.new(results: results.reject { |result| result.group == group })
+ def excluding(*groups)
+ Score.new(results: results.reject { |result| groups.include?(result.group) })
+ end
+
+ def only(*groups)
+ Score.new(results: results.select { |result| groups.include?(result.group) })
end
end
@@ -40,7 +44,21 @@ module Elelem
end
def call(cases)
- Score.new(results: cases.flat_map { |kase| Array.new(@repeat) { @runner.run(kase) } })
+ Score.new(results: cases.flat_map { |kase| runs_for(kase) })
+ end
+
+ private
+
+ # A case passes only if every repeat passes, so the first failure settles
+ # it -- stop early and spend no more runs on a case already known to fail.
+ def runs_for(kase)
+ results = []
+ @repeat.times do
+ result = @runner.run(kase)
+ results << result
+ break unless result.passed?
+ end
+ results
end
end
end
spec/evals/support/tasks.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "json"
+require "fileutils"
+
+module Elelem
+ module Evals
+ # A scorer_for lambda over the full case set: prompt -> Score, running the
+ # real agent against the real model. Shared by the loop, minimize, regenerate.
+ def self.scorer_for(cases: Case.load_all)
+ ->(prompt) { Scorer.new(runner: Runner.new(prompt: prompt)).call(cases) }
+ end
+
+ # Refactor step: strip every champion line that defends no case, writing the
+ # result and the line -> case map to WORKDIR for a human to review and adopt.
+ def self.minimize!(champion: CHAMPION, workdir: WORKDIR, out: $stdout,
+ ablator: Ablator.new(scorer_for: scorer_for))
+ before = File.read(champion)
+ ablation = ablator.minimize(before)
+
+ FileUtils.mkdir_p(workdir)
+ File.write(File.join(workdir, "minimized.erb"), ablation.prompt)
+ File.write(File.join(workdir, "defends.json"), JSON.pretty_generate(ablation.defends))
+
+ out.puts "minimize: #{before.length} -> #{ablation.prompt.length} chars, " \
+ "wrote minimized.erb + defends.json to #{workdir}"
+ ablation
+ end
+
+ # Grow a fresh prompt from the minimal seed. Runs the loop against a candidate
+ # copy so the shipped champion is never overwritten; a human diffs and adopts.
+ def self.regenerate(rounds:, seed: SEED, workdir: WORKDIR, out: $stdout,
+ loop_for: ->(champion) { Loop.new(champion: champion, out: out) })
+ FileUtils.mkdir_p(workdir)
+ candidate = File.join(workdir, "candidate.erb")
+ FileUtils.cp(seed, candidate)
+
+ loop_for.call(candidate).run(rounds: rounds)
+
+ out.puts "regenerate: grew #{candidate} from seed (champion untouched)"
+ candidate
+ end
+ end
+end
.gitignore
@@ -17,3 +17,6 @@ target/
.rspec_status
/spec/evals/prompts/challenger-*.erb
+/spec/evals/prompts/history.jsonl
+/spec/evals/prompts/candidate.erb
+/spec/evals/prompts/minimized.erb
Rakefile
@@ -21,6 +21,20 @@ namespace :evals do
Elelem::Evals::Loop.new.run(rounds: Integer(ENV.fetch("ROUNDS", "3")))
end
+
+ desc "Drop every champion prompt line whose removal regresses no case"
+ task :minimize do
+ require_relative "spec/support/evals"
+
+ Elelem::Evals.minimize!
+ end
+
+ desc "Grow a fresh prompt from the minimal seed (leaves the champion untouched)"
+ task :regenerate do
+ require_relative "spec/support/evals"
+
+ Elelem::Evals.regenerate(rounds: Integer(ENV.fetch("ROUNDS", "8")))
+ end
end
task default: %i[spec]
README.md
@@ -91,8 +91,10 @@ elelem chat
### Options
-* `--provider` – LLM provider: `ollama`, `anthropic`, `openai`, or `vertex-ai` (default: `ollama`).
-* `--model` – Override the default model for the selected provider.
+* `--provider` – LLM provider: `ollama`, `anthropic`, `openai`, or `vertex` (default: `ollama`).
+
+The model is chosen per provider via environment variables (`OLLAMA_MODEL`,
+`ANTHROPIC_MODEL`, `OPENAI_MODEL`, `VERTEX_MODEL`), not a CLI flag.
### Examples
@@ -107,7 +109,7 @@ ANTHROPIC_API_KEY=sk-... elelem chat --provider anthropic
OPENAI_API_KEY=sk-... elelem chat --provider openai
# VertexAI (uses gcloud ADC)
-elelem chat --provider vertex-ai --model claude-sonnet-4@20250514
+VERTEX_MODEL=claude-sonnet-4@20250514 elelem chat --provider vertex
```
### Provider Configuration
@@ -119,7 +121,7 @@ Each provider reads its configuration from environment variables:
| ollama | `OLLAMA_HOST` (default: localhost:11434) |
| anthropic | `ANTHROPIC_API_KEY` |
| openai | `OPENAI_API_KEY`, `OPENAI_BASE_URL` |
-| vertex-ai | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION` |
+| vertex | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION` |
## Features
@@ -217,6 +219,27 @@ Configure MCP servers in `~/.elelem/mcp.json` or `.elelem/mcp.json`:
HTTP servers support OAuth authentication automatically.
+## Tuning the system prompt (evals)
+
+The system prompt is tuned empirically. `spec/evals/cases/*.yml` are eval cases
+(YAML: `fixture`, `turns`, `expect`) run against the real agent and model.
+
+```bash
+bin/evals # tune the champion prompt against the eval cases
+bin/evals 5 # ...for 5 rounds (default 3)
+```
+
+Each round an LLM improver adds prompt lines to pass failing cases, and a
+challenger is promoted only if it passes strictly more cases with no regression,
+under a ~1KB budget. Cases in the `holdout` group are hidden from the improver
+but their regressions still block a promotion; `sealed` cases are hidden and
+never gate at all -- a pure generalization score.
+
+Point `EVAL_MODEL` at the strongest coding model your machine runs locally
+(default `gpt-oss:latest`). Less common operations are plain rake tasks:
+`rake evals` (score once), `rake evals:minimize` (drop lines that defend no
+case), `rake evals:regenerate` (grow a fresh prompt from the seed).
+
## Known Limitations
* Assumes the current directory is a Git repository.