Commit 29572c7
2026-08-18 03:47:43
1 parent
f445b6c
Changed files (48)
lib
elelem
spec
elelem
evals
fixtures
hello
logs
metadata
sizes
harness
support
support
lib/elelem/net/ollama.rb
@@ -6,7 +6,7 @@ module Elelem
def initialize(
model:,
host: "localhost:11434",
- think: "medium",
+ think: false,
keep_alive: "5m",
options: {},
params: {},
@@ -104,7 +104,7 @@ module Elelem
def build_request_body(messages, tools)
{
model: @model,
- messages:,
+ messages: normalize(messages),
stream: true,
tools: presence(tools),
think: @think,
@@ -117,6 +117,36 @@ module Elelem
value unless value.nil? || value.empty?
end
+ def normalize(messages)
+ pending = []
+
+ messages.map do |message|
+ case message[:role]
+ when "assistant" then normalize_calls(message, pending)
+ when "tool" then normalize_result(message, pending)
+ else message
+ end
+ end
+ end
+
+ def normalize_calls(message, pending)
+ calls = message[:tool_calls]
+ return message unless calls
+
+ pending.clear
+ message.merge(tool_calls: calls.map do |call|
+ pending << call[:name]
+ { function: { name: call[:name], arguments: call[:arguments] } }
+ end)
+ end
+
+ def normalize_result(message, pending)
+ name = pending.shift
+ return message unless name
+
+ message.except(:tool_call_id).merge(tool_name: name)
+ end
+
def handle_event(event, tool_calls, &block)
message = event["message"] || {}
lib/elelem/prompts/default.erb
@@ -6,9 +6,9 @@ Terminal coding agent. Be concise. Verify your work.
- execute(command): shell command
# Editing
-Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
-Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
-Use write for new files or full rewrites
+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.
# Search
Use execute(`rg`) for text search: `rg -n "pattern" .`
lib/elelem/toolbox.rb
@@ -33,7 +33,7 @@ module Elelem
def run(name, args)
tool = tool_for(name)
- return failure(error: "unknown tool: #{name}. Use 'execute' to run shell commands like rg, fd, git.", tools: to_a) unless tool
+ return failure(error: "unknown tool: #{name.inspect}. Available: #{tools.keys.join(", ")}.") unless tool
errors = tool.validate(args)
return failure(error: errors.join(", ")) if errors.any?
spec/elelem/net/ollama_spec.rb
@@ -29,10 +29,60 @@ RSpec.describe Elelem::Net::Ollama do
end
describe "#fetch" do
- it "sends only model, messages and stream by default" do
+ it "leaves thinking off by default" do
client.fetch(messages) { }
- expect(body).to eq(model: "gpt-oss:latest", messages:, stream: true, think: "medium", keep_alive: "5m")
+ expect(body).to eq(model: "gpt-oss:latest", messages:, stream: true, think: false, keep_alive: "5m")
+ end
+
+ it "nests assistant tool calls under function so ollama can name them" do
+ history = [
+ { role: "assistant", content: "", tool_calls: [{ id: "abc", name: "execute", arguments: { "command" => "ls" } }] },
+ { role: "tool", tool_call_id: "abc", content: "{}" }
+ ]
+
+ client.fetch(history) { }
+
+ expect(body[:messages].first[:tool_calls]).to eq([{ function: { name: "execute", arguments: { "command" => "ls" } } }])
+ end
+
+ it "attributes a tool result to the tool that produced it" do
+ history = [
+ { role: "assistant", content: "", tool_calls: [{ id: "abc", name: "execute", arguments: {} }] },
+ { role: "tool", tool_call_id: "abc", content: "{}" }
+ ]
+
+ client.fetch(history) { }
+
+ expect(body[:messages].last).to eq(role: "tool", tool_name: "execute", content: "{}")
+ end
+
+ it "attributes each result of a multi call turn to its own tool" do
+ history = [
+ {
+ role: "assistant",
+ content: "",
+ tool_calls: [
+ { id: nil, name: "read", arguments: { "path" => "a.rb" } },
+ { id: nil, name: "execute", arguments: { "command" => "ls" } }
+ ]
+ },
+ { role: "tool", tool_call_id: nil, content: "a" },
+ { role: "tool", tool_call_id: nil, content: "b" }
+ ]
+
+ client.fetch(history) { }
+
+ expect(body[:messages].last(2)).to eq([
+ { role: "tool", tool_name: "read", content: "a" },
+ { role: "tool", tool_name: "execute", content: "b" }
+ ])
+ end
+
+ it "leaves messages without tool calls untouched" do
+ client.fetch(messages) { }
+
+ expect(body[:messages]).to eq(messages)
end
it "sends tools when present" do
spec/elelem/providers_spec.rb
@@ -3,10 +3,12 @@
RSpec.describe Elelem::Providers do
before do
described_class.registry.clear
+ Elelem::Plugins.registry.clear
end
after do
described_class.registry.clear
+ Elelem::Plugins.registry.clear
end
describe ".register" do
spec/evals/cases/edit.yml
@@ -0,0 +1,12 @@
+- id: multi-line-edit
+ fixture: hello
+ turns:
+ - 'change greet so it prints "Goodbye, #{name}." with a period instead of an
+ exclamation mark, and call it with "friend" instead of "world"'
+ expect:
+ verify: ruby hello.rb | grep -qxF "Goodbye, friend."
+ files:
+ hello.rb:
+ contains: ["Goodbye"]
+ not_contains: ["Hello"]
+ max_turns: 8
spec/evals/cases/fix.yml
@@ -0,0 +1,11 @@
+- id: fix-failing-test
+ fixture: broken
+ turns:
+ - run test.rb and fix whatever is broken
+ expect:
+ verify: ruby test.rb
+ files:
+ lib/calc.rb:
+ contains: ["a + b"]
+ tools_used: ["execute"]
+ max_turns: 10
spec/evals/cases/git.yml
@@ -0,0 +1,15 @@
+- id: commit-50-72
+ 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 "initial commit"
+ - printf 'greet("again")\n' >> hello.rb
+ turns:
+ - commit the current changes with a message that follows the 50/72 rule
+ expect:
+ verify: test -z "$(git status --porcelain)" && test "$(git log -1 --format=%s | wc -c)" -le 51
+ tools_used: ["execute"]
+ max_turns: 8
spec/evals/cases/json.yml
@@ -0,0 +1,9 @@
+- id: summarize-json-log
+ fixture: logs
+ turns:
+ - 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']
+ tools_not_used: ["write"]
+ max_turns: 6
spec/evals/cases/search.yml
@@ -0,0 +1,17 @@
+- id: locate-error-string
+ fixture: config
+ turns:
+ - where does the "Failed to load configuration" error come from?
+ expect:
+ response_contains: ["config/loader.rb"]
+ tools_not_used: ["write"]
+ max_turns: 6
+
+- id: list-references
+ fixture: metadata
+ turns:
+ - which files reference app_metadata?
+ expect:
+ response_contains: ["user.rb", "token.rb"]
+ tools_not_used: ["write"]
+ max_turns: 6
spec/evals/cases/shell.yml
@@ -0,0 +1,22 @@
+- id: largest-tracked-file
+ fixture: sizes
+ setup:
+ - for i in $(seq 1 400); do printf '%d,widget-%d,%d\n' "$i" "$i" $((i * 7)) >> data/report.csv; done
+ - git init -q
+ - git config user.email evals@example.com
+ - git config user.name evals
+ - git add -A
+ - git commit -q -m "initial commit"
+ # .git/description is never read by git, so padding it is a safe stand-in
+ # for the pack files that dominate a real repository's byte counts.
+ - for i in $(seq 1 1200); do printf 'padding line %d\n' "$i" >> .git/description; done
+ turns:
+ - what is the biggest file in this repository?
+ expect:
+ response_contains: ["report.csv"]
+ response_not_contains: [".git"]
+ tools_not_used:
+ - write
+ - execute: { command: "-printf" }
+ - execute: { command: "du -b" }
+ max_turns: 6
spec/evals/fixtures/broken/lib/calc.rb
@@ -0,0 +1,3 @@
+def add(a, b)
+ a - b
+end
spec/evals/fixtures/broken/test.rb
@@ -0,0 +1,5 @@
+require_relative "lib/calc"
+
+raise "add(2, 3) should be 5, got #{add(2, 3)}" unless add(2, 3) == 5
+
+puts "ok"
spec/evals/fixtures/config/config/loader.rb
@@ -0,0 +1,11 @@
+class Loader
+ def initialize(path)
+ @path = path
+ end
+
+ def call
+ raise "Failed to load configuration: #{@path}" unless File.exist?(@path)
+
+ Parser.new(File.read(@path)).call
+ end
+end
spec/evals/fixtures/config/config/parser.rb
@@ -0,0 +1,9 @@
+class Parser
+ def initialize(text)
+ @text = text
+ end
+
+ def call
+ @text.lines.map { |line| line.split(":", 2) }.to_h
+ end
+end
spec/evals/fixtures/config/app.rb
@@ -0,0 +1,3 @@
+require_relative "config/loader"
+
+Loader.new("app.yml").call
spec/evals/fixtures/hello/hello.rb
@@ -0,0 +1,5 @@
+def greet(name)
+ puts "Hello, #{name}!"
+end
+
+greet("world")
spec/evals/fixtures/logs/requests.jsonl
@@ -0,0 +1,12 @@
+{"path":"/scim/v2/Users","response_code":200}
+{"path":"/scim/v2/Users","response_code":500}
+{"path":"/scim/v2/Users","response_code":503}
+{"path":"/scim/v2/Users","response_code":500}
+{"path":"/scim/v2/Groups","response_code":200}
+{"path":"/scim/v2/Groups","response_code":404}
+{"path":"/scim/v2/Groups","response_code":200}
+{"path":"/health","response_code":200}
+{"path":"/health","response_code":500}
+{"path":"/health","response_code":200}
+{"path":"/scim/v2/Schemas","response_code":200}
+{"path":"/scim/v2/Schemas","response_code":304}
spec/evals/fixtures/metadata/session.rb
@@ -0,0 +1,10 @@
+class Session
+ def initialize(id, user_id)
+ @id = id
+ @user_id = user_id
+ end
+
+ def to_h
+ { "id" => @id, "user_id" => @user_id }
+ end
+end
spec/evals/fixtures/metadata/token.rb
@@ -0,0 +1,9 @@
+class Token
+ def initialize(claims)
+ @claims = claims
+ end
+
+ def to_h
+ @claims.merge("app_metadata" => @claims["app_metadata"] || {})
+ end
+end
spec/evals/fixtures/metadata/user.rb
@@ -0,0 +1,9 @@
+class User
+ def initialize(row)
+ @row = row
+ end
+
+ def app_metadata
+ @row.fetch("app_metadata", {})
+ end
+end
spec/evals/fixtures/sizes/data/report.csv
@@ -0,0 +1,1 @@
+id,widget,cents
spec/evals/fixtures/sizes/lib/app.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require "csv"
+
+class App
+ def initialize(path = "data/report.csv")
+ @path = path
+ end
+
+ def rows
+ CSV.read(@path, headers: true)
+ end
+
+ def total
+ rows.sum { |row| row["cents"].to_i }
+ end
+end
spec/evals/fixtures/sizes/README.md
@@ -0,0 +1,3 @@
+# Widgets
+
+Inventory reports live in `data/`.
spec/evals/harness/assertions_spec.rb
@@ -0,0 +1,111 @@
+# 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)
+ end
+
+ describe "verify" do
+ it "passes when the command exits 0" do
+ Elelem::Evals::Workspace.open(fixture: "hello") do |workspace|
+ expect(failures({ verify: "true" }, workspace: workspace)).to be_empty
+ end
+ end
+
+ it "fails when the command exits non zero" do
+ Elelem::Evals::Workspace.open(fixture: "hello") do |workspace|
+ expect(failures({ verify: "false" }, workspace: workspace).first).to include("verify failed")
+ end
+ end
+ end
+
+ describe "files" do
+ it "fails when a required string is missing" do
+ Elelem::Evals::Workspace.open(fixture: "hello") do |workspace|
+ result = failures({ files: { "hello.rb" => { "contains" => ["Goodbye"] } } }, workspace: workspace)
+
+ expect(result.first).to include("hello.rb missing")
+ end
+ end
+
+ it "fails when a forbidden string is present" do
+ Elelem::Evals::Workspace.open(fixture: "hello") do |workspace|
+ result = failures({ files: { "hello.rb" => { "not_contains" => ["Hello"] } } }, workspace: workspace)
+
+ expect(result.first).to include("still contains")
+ end
+ end
+
+ it "fails when the file is missing" do
+ Elelem::Evals::Workspace.open(fixture: "hello") do |workspace|
+ result = failures({ files: { "nope.rb" => { "contains" => ["x"] } } }, workspace: workspace)
+
+ expect(result).to eq(["nope.rb missing"])
+ end
+ end
+ end
+
+ describe "response" do
+ it "passes when every string is present" do
+ expect(failures({ response_contains: %w[alpha beta] }, response: "alpha and beta")).to be_empty
+ end
+
+ it "fails when a string is absent" do
+ expect(failures({ response_contains: ["gamma"] }, response: "alpha").first).to include("response missing")
+ end
+
+ it "fails when a forbidden string is present" do
+ result = failures({ response_not_contains: [".git"] }, response: "the biggest file is .git/description")
+
+ expect(result.first).to include("response contains")
+ end
+
+ it "passes when no forbidden string is present" do
+ expect(failures({ response_not_contains: [".git"] }, response: "data/report.csv")).to be_empty
+ end
+
+ it "fails when a regex does not match" do
+ expect(failures({ response_matches: ['\d+'] }, response: "none").first).to include("does not match")
+ end
+ end
+
+ describe "tools" do
+ let(:tools) { [Elelem::Evals::ToolCall.new(name: "read", args: { "path" => "config/loader.rb" })] }
+
+ it "passes when a named tool was called" do
+ expect(failures({ tools_used: ["read"] }, tools: tools)).to be_empty
+ end
+
+ it "fails when a named tool was never called" do
+ expect(failures({ tools_used: ["write"] }, tools: tools).first).to include("never called")
+ end
+
+ it "passes when argument constraints match" do
+ expect(failures({ tools_used: [{ "read" => { "path" => "loader.rb" } }] }, tools: tools)).to be_empty
+ end
+
+ it "fails when argument constraints do not match" do
+ result = failures({ tools_used: [{ "read" => { "path" => "other.rb" } }] }, tools: tools)
+
+ expect(result.first).to include("never called")
+ end
+
+ it "fails when a forbidden tool was called" do
+ expect(failures({ tools_not_used: ["read"] }, tools: tools).first).to include("called read")
+ 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
+ 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)
+ end
+end
spec/evals/harness/bounded_client_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::BoundedClient do
+ let(:inner) { double("client", fetch: []) }
+
+ subject(:client) { described_class.new(inner, max_turns: 2) }
+
+ it "delegates to the wrapped client" do
+ client.fetch([{ role: "user", content: "hi" }])
+
+ expect(inner).to have_received(:fetch)
+ end
+
+ it "counts turns" do
+ 2.times { client.fetch([]) }
+
+ expect(client.turns).to eq(2)
+ end
+
+ it "raises once past the limit" do
+ 2.times { client.fetch([]) }
+
+ expect { client.fetch([]) }.to raise_error(described_class::TurnLimitExceeded)
+ end
+
+ it "reports the overrun" do
+ 3.times { client.fetch([]) rescue nil }
+
+ expect(client.turns).to eq(3)
+ end
+end
spec/evals/harness/case_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Case do
+ let(:yaml) do
+ <<~YAML
+ - id: patch-single-line
+ fixture: hello
+ setup:
+ - git init -q
+ turns:
+ - change the greeting to Goodbye
+ expect:
+ verify: ruby hello.rb
+ max_turns: 4
+ - id: no-expectations
+ fixture: hello
+ turns:
+ - say hello
+ YAML
+ end
+
+ let(:dir) { Dir.mktmpdir }
+ let(:file) do
+ path = File.join(dir, "editing.yml")
+ File.write(path, yaml)
+ path
+ end
+
+ after { FileUtils.remove_entry(dir) if File.directory?(dir) }
+
+ subject(:cases) { described_class.load_file(file) }
+
+ it "loads every case in the file" do
+ expect(cases.map(&:id)).to eq(%w[patch-single-line no-expectations])
+ end
+
+ it "takes the group from the file name" do
+ expect(cases.first.group).to eq("editing")
+ end
+
+ it "reads setup commands" do
+ expect(cases.first.setup).to eq(["git init -q"])
+ end
+
+ it "symbolizes the top level of expect" do
+ expect(cases.first.expect[:verify]).to eq("ruby hello.rb")
+ end
+
+ it "reads max_turns from expect" 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)
+ end
+
+ it "defaults setup and expect to empty" do
+ expect(cases.last.setup).to eq([])
+ expect(cases.last.expect).to eq({})
+ end
+end
spec/evals/harness/improver_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Improver do
+ # Replays a canned JSON body the way Ollama streams it.
+ class CannedClient
+ attr_reader :messages
+
+ def initialize(body)
+ @body = body
+ end
+
+ def fetch(messages, _tools = [], &block)
+ @messages = messages
+ block.call(type: "saying", text: @body)
+ []
+ end
+ end
+
+ let(:body) do
+ JSON.generate(
+ "analysis" => "the prompt never mentions git",
+ "changes" => [
+ { "old_text" => "# Policy", "new_text" => "# Git\nCommit with a 50 character subject.\n\n# Policy", "rationale" => "teach the 50/72 rule" }
+ ]
+ )
+ end
+
+ let(:client) { CannedClient.new(body) }
+ let(:prompt) { "Terminal coding agent.\n\n# Policy\n- Verify your work\n" }
+
+ subject(:improver) { described_class.new(client: client) }
+
+ describe "#plan" do
+ it "parses the model's JSON" do
+ plan = improver.plan(prompt: prompt, failures: [])
+
+ expect(plan["changes"].first["rationale"]).to eq("teach the 50/72 rule")
+ end
+
+ it "sends the prompt and the failures to the model" do
+ failure = Elelem::Evals::Result.new(
+ id: "commit-50-72", group: "git", status: "FAIL", failures: ["verify failed (exit 1)"],
+ turns: 3, duration: 1.0, tools: [], response: "committed"
+ )
+
+ improver.plan(prompt: prompt, failures: [failure])
+ sent = client.messages.map { |m| m[:content] }.join("\n")
+
+ expect(sent).to include("commit-50-72")
+ expect(sent).to include("verify failed")
+ expect(sent).to include("# Policy")
+ end
+
+ it "returns no changes when the model returns junk" do
+ plan = described_class.new(client: CannedClient.new("not json")).plan(prompt: prompt, failures: [])
+
+ expect(plan).to eq("analysis" => "", "changes" => [])
+ end
+
+ it "returns no changes when the model returns a JSON array" do
+ plan = described_class.new(client: CannedClient.new("[1, 2, 3]")).plan(prompt: prompt, failures: [])
+
+ expect(plan).to eq("analysis" => "", "changes" => [])
+ end
+
+ it "returns no changes when the model returns JSON null" do
+ plan = described_class.new(client: CannedClient.new("null")).plan(prompt: prompt, failures: [])
+
+ expect(plan).to eq("analysis" => "", "changes" => [])
+ end
+
+ it "returns no changes when changes is a JSON object" do
+ body = JSON.generate("analysis" => "x", "changes" => { "old_text" => "a", "new_text" => "b" })
+ plan = described_class.new(client: CannedClient.new(body)).plan(prompt: prompt, failures: [])
+
+ expect(plan["changes"]).to be_empty
+ end
+
+ it "does not share the changes array between fallback results" do
+ improver = described_class.new(client: CannedClient.new("not json"))
+
+ first = improver.plan(prompt: prompt, failures: [])
+ first["changes"] << "mutated"
+
+ expect(improver.plan(prompt: prompt, failures: [])["changes"]).to be_empty
+ end
+ end
+
+ describe "#apply" do
+ it "applies a change and reports the rationale" do
+ changes = [{ "old_text" => "# Policy", "new_text" => "# Git\n\n# Policy", "rationale" => "teach git" }]
+ updated, applied = improver.apply(prompt, changes)
+
+ expect(updated).to include("# Git")
+ expect(applied).to eq(["teach git"])
+ end
+
+ it "skips a change whose old_text is absent" do
+ changes = [{ "old_text" => "# Nowhere", "new_text" => "x", "rationale" => "nope" }]
+ updated, applied = improver.apply(prompt, changes)
+
+ expect(updated).to eq(prompt)
+ expect(applied).to be_empty
+ end
+
+ it "keeps regex escapes in new_text literal" do
+ changes = [{ "old_text" => "# Policy", "new_text" => "Use sed -i'' 's/a\\&b/c/' and \\0", "rationale" => "sed" }]
+ updated, = improver.apply(prompt, changes)
+
+ expect(updated).to include("s/a\\&b/c/' and \\0")
+ end
+
+ it "skips a change that is not an object" do
+ updated, applied = improver.apply(prompt, [["old_text", "# Policy"]])
+
+ expect(updated).to eq(prompt)
+ expect(applied).to be_empty
+ end
+
+ it "replaces only the first occurrence" do
+ changes = [{ "old_text" => "a", "new_text" => "b", "rationale" => "once" }]
+ updated, = improver.apply("a a a", changes)
+
+ expect(updated).to eq("b a a")
+ end
+ end
+end
spec/evals/harness/loop_spec.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Loop do
+ let(:dir) { Dir.mktmpdir }
+ let(:champion) { File.join(dir, "champion.erb") }
+ let(:cases) { [Elelem::Evals::Case.new(id: "a", group: "g", fixture: "hello", turns: ["hi"])] }
+ let(:out) { StringIO.new }
+ let(:no_preflight) { -> {} }
+ let(:changes) { [{ "old_text" => "original", "new_text" => "improved", "rationale" => "better" }] }
+
+ before { File.write(champion, "original prompt") }
+ after { FileUtils.remove_entry(dir) if File.directory?(dir) }
+
+ def result(id: "a", group: "g", status: "FAIL")
+ Elelem::Evals::Result.new(
+ id: id, group: group, status: status, failures: status == "PASS" ? [] : ["nope"],
+ turns: 1, duration: 0.1, tools: [], response: ""
+ )
+ end
+
+ def score(*results)
+ Elelem::Evals::Score.new(results: results)
+ end
+
+ def score_of(status)
+ score(result(status: status))
+ end
+
+ def scoring(by_prompt)
+ ->(prompt) { by_prompt.fetch(prompt) }
+ end
+
+ # Always proposes the same edit.
+ def improver_returning(changes)
+ instance_double(
+ Elelem::Evals::Improver,
+ plan: { "analysis" => "x", "changes" => changes },
+ apply: ["improved prompt", changes.map { |c| c["rationale"] }]
+ )
+ end
+
+ def build_loop(scorer_for:, improver: improver_returning(changes), preflight: no_preflight)
+ described_class.new(
+ cases: cases, champion: champion, workdir: dir, improver: improver,
+ scorer_for: scorer_for, out: out, preflight: preflight
+ )
+ end
+
+ it "promotes a challenger that fixes a case" do
+ build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
+
+ expect(File.read(champion)).to eq("improved prompt")
+ end
+
+ it "rejects a challenger that regresses a case" do
+ build_loop(scorer_for: scoring("original prompt" => score_of("PASS"), "improved prompt" => score_of("FAIL"))).run(rounds: 1)
+
+ expect(File.read(champion)).to eq("original prompt")
+ end
+
+ it "stops early when everything already passes" do
+ improver = improver_returning(changes)
+
+ build_loop(improver: improver, scorer_for: ->(_prompt) { score_of("PASS") }).run(rounds: 3)
+
+ expect(improver).not_to have_received(:plan)
+ end
+
+ it "writes each challenger to its own file" do
+ build_loop(scorer_for: scoring("original prompt" => score_of("FAIL"), "improved prompt" => score_of("PASS"))).run(rounds: 1)
+
+ expect(File).to exist(File.join(dir, "challenger-1.erb"))
+ expect(File.read(File.join(dir, "challenger-1.erb"))).to eq("improved prompt")
+ end
+
+ it "keeps the champion unchanged and consumes every round when no change can ever be applied" do
+ improver = instance_double(
+ Elelem::Evals::Improver,
+ plan: { "analysis" => "x", "changes" => changes },
+ apply: ["improved prompt", []]
+ )
+
+ result = build_loop(improver: improver, scorer_for: ->(_prompt) { score_of("FAIL") }).run(rounds: 3)
+
+ expect(result).to be(false)
+ expect(File.read(champion)).to eq("original prompt")
+ expect(File).not_to exist(File.join(dir, "challenger-1.erb"))
+ expect(improver).to have_received(:plan).exactly(3).times
+ 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)
+
+ entry = JSON.parse(File.read(File.join(dir, "history.jsonl")).lines.first)
+
+ expect(entry).to include("round" => 1, "promoted" => true)
+ end
+
+ it "withholds holdout failures from the improver" do
+ holdout = result(id: "h", group: "holdout")
+ visible = result
+ improver = improver_returning(changes)
+
+ build_loop(improver: improver, scorer_for: ->(_p) { score(holdout, 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 holdout case" do
+ baseline = score(result, result(id: "h", group: "holdout"))
+ holdout_only = score(result, result(id: "h", group: "holdout", status: "PASS"))
+
+ build_loop(scorer_for: scoring("original prompt" => baseline, "improved prompt" => holdout_only)).run(rounds: 1)
+
+ expect(File.read(champion)).to eq("original prompt")
+ end
+
+ it "does not promote a challenger that regresses a holdout case" do
+ baseline = score(result, result(id: "h", group: "holdout", status: "PASS"))
+ regressed = score(result(status: "PASS"), result(id: "h", group: "holdout"))
+
+ build_loop(scorer_for: scoring("original prompt" => baseline, "improved prompt" => regressed)).run(rounds: 1)
+
+ expect(File.read(champion)).to eq("original 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)
+
+ build_loop(improver: improver, scorer_for: ->(_p) { score(*fails) }).run(rounds: 1)
+
+ expect(improver).to have_received(:plan).with(prompt: anything, failures: [fails.first])
+ end
+
+ it "raises before scoring anything when the preflight check fails" do
+ loop = build_loop(
+ scorer_for: ->(_prompt) { raise "scoring should not have run" },
+ preflight: -> { raise "connection refused" }
+ )
+
+ expect { loop.run(rounds: 1) }.to raise_error(/connection refused/)
+ end
+end
spec/evals/harness/null_terminal_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::NullTerminal do
+ it "auto-allows tools the shipped rules would otherwise ask about" do
+ terminal = described_class.new
+
+ expect(terminal).not_to be_interactive
+ expect(Elelem::Permissions.new.check("execute", {}, terminal: terminal)).to be(true)
+ end
+end
spec/evals/harness/runner_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Runner do
+ # Replies with a write tool call on the first fetch, then plain text.
+ class ScriptedClient
+ def initialize
+ @calls = 0
+ end
+
+ def fetch(_messages, _tools = [], &block)
+ @calls += 1
+ return first_turn(&block) if @calls == 1
+
+ block.call(type: "saying", text: "done, wrote goodbye.rb")
+ []
+ end
+
+ private
+
+ def first_turn(&block)
+ call = { id: "1", name: "write", arguments: { "path" => "goodbye.rb", "content" => "puts 'Goodbye'\n" } }
+ block.call(call.merge(type: "doing"))
+ [call]
+ end
+ end
+
+ let(:kase) do
+ Elelem::Evals::Case.new(
+ id: "writes-a-file",
+ group: "editing",
+ fixture: "hello",
+ turns: ["write goodbye.rb"],
+ expect: {
+ verify: "ruby goodbye.rb",
+ files: { "goodbye.rb" => { "contains" => ["Goodbye"] } },
+ response_contains: ["goodbye.rb"],
+ tools_used: [{ "write" => { "path" => "goodbye.rb" } }],
+ tools_not_used: ["read"],
+ max_turns: 5
+ }
+ )
+ end
+
+ subject(:runner) { described_class.new(prompt: "test prompt", client: -> { ScriptedClient.new }) }
+
+ it "passes a case the agent satisfies" do
+ result = runner.run(kase)
+
+ expect(result.failures).to be_empty
+ expect(result.status).to eq("PASS")
+ end
+
+ it "records the case identity" do
+ result = runner.run(kase)
+
+ expect(result.id).to eq("writes-a-file")
+ expect(result.group).to eq("editing")
+ end
+
+ it "records the tool calls" do
+ result = runner.run(kase)
+
+ expect(result.tools.map(&:name)).to eq(["write"])
+ end
+
+ it "counts turns" do
+ expect(runner.run(kase).turns).to eq(2)
+ end
+
+ it "fails a case whose assertions do not hold" do
+ failing = Elelem::Evals::Case.new(
+ id: "wants-more",
+ fixture: "hello",
+ turns: ["write goodbye.rb"],
+ expect: { response_contains: ["never said this"] }
+ )
+
+ result = runner.run(failing)
+
+ expect(result.status).to eq("FAIL")
+ expect(result.failures.first).to include("response missing")
+ end
+
+ it "leaves the working directory where it found it" do
+ before = Dir.pwd
+ runner.run(kase)
+
+ expect(Dir.pwd).to eq(before)
+ end
+end
spec/evals/harness/scorer_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Scorer do
+ def result(id, status)
+ Elelem::Evals::Result.new(
+ id: id, group: "g", status: status, failures: status == "PASS" ? [] : ["nope"],
+ turns: 1, duration: 0.1, tools: [], response: ""
+ )
+ end
+
+ # Returns the statuses it was given, in order, one per run call.
+ class FakeRunner
+ def initialize(statuses)
+ @statuses = statuses.dup
+ end
+
+ def run(kase)
+ 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
+
+ let(:kase) { Elelem::Evals::Case.new(id: "a", group: "g", fixture: "hello", turns: ["hi"]) }
+
+ it "passes a case only when every repeat passes" do
+ scorer = described_class.new(runner: FakeRunner.new(%w[PASS PASS PASS]), repeat: 3)
+
+ expect(scorer.call([kase]).passed).to eq(1)
+ end
+
+ it "fails a case when any repeat fails" do
+ scorer = described_class.new(runner: FakeRunner.new(%w[PASS FAIL PASS]), repeat: 3)
+ score = scorer.call([kase])
+
+ expect(score.passed).to eq(0)
+ expect(score.failed).to eq(1)
+ end
+
+ it "keeps every result" do
+ scorer = described_class.new(runner: FakeRunner.new(%w[PASS PASS PASS]), repeat: 3)
+
+ expect(scorer.call([kase]).results.length).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)
+
+ expect(scorer.call([kase]).failures.length).to eq(1)
+ end
+
+ it "detects a regression against an earlier score" do
+ before = described_class.new(runner: FakeRunner.new(["PASS"]), repeat: 1).call([kase])
+ after = described_class.new(runner: FakeRunner.new(["FAIL"]), repeat: 1).call([kase])
+
+ expect(after.regressions_from(before)).to eq(["g/a"])
+ end
+
+ it "does not call an improvement a regression" do
+ before = described_class.new(runner: FakeRunner.new(["FAIL"]), repeat: 1).call([kase])
+ after = described_class.new(runner: FakeRunner.new(["PASS"]), repeat: 1).call([kase])
+
+ expect(after.regressions_from(before)).to be_empty
+ end
+
+ it "keeps cases with the same id in different groups apart" do
+ a = Elelem::Evals::Case.new(id: "x", group: "search", fixture: "hello", turns: ["hi"])
+ b = Elelem::Evals::Case.new(id: "x", group: "json", fixture: "hello", turns: ["hi"])
+ scorer = described_class.new(runner: FakeRunner.new(%w[PASS FAIL]), repeat: 1)
+
+ score = scorer.call([a, b])
+
+ expect(score.total).to eq(2)
+ expect(score.passed).to eq(1)
+ end
+
+ describe "#excluding" do
+ it "filters out every result from the given group" do
+ holdout = Elelem::Evals::Result.new(
+ id: "h", group: "holdout", status: "PASS", failures: [], turns: 1, duration: 0.1, tools: [], response: ""
+ )
+ score = Elelem::Evals::Score.new(results: [result("a", "PASS"), holdout])
+
+ excluded = score.excluding("holdout")
+
+ expect(excluded.total).to eq(1)
+ expect(excluded.results).to eq([result("a", "PASS")])
+ end
+ end
+end
spec/evals/harness/workspace_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Evals::Workspace do
+ it "copies the fixture into a fresh directory" do
+ described_class.open(fixture: "hello") do |workspace|
+ expect(workspace.read("hello.rb")).to include("greet")
+ end
+ end
+
+ it "leaves the fixture free of harness files" do
+ described_class.open(fixture: "hello") do |workspace|
+ expect(Dir.children(workspace.path)).to contain_exactly("hello.rb")
+ end
+ end
+
+ it "runs setup commands in the workspace" do
+ described_class.open(fixture: "hello", setup: ["echo hi > setup.txt"]) do |workspace|
+ expect(workspace.read("setup.txt")).to eq("hi\n")
+ end
+ end
+
+ it "removes the directory afterwards" do
+ path = described_class.open(fixture: "hello", &:path)
+
+ expect(File).not_to exist(path)
+ end
+
+ it "strips bundler variables inside chdir" do
+ saved = ENV["BUNDLE_GEMFILE"]
+ ENV["BUNDLE_GEMFILE"] = "/somewhere/Gemfile"
+
+ described_class.open(fixture: "hello") do |workspace|
+ workspace.chdir { expect(ENV["BUNDLE_GEMFILE"]).to be_nil }
+ end
+
+ expect(ENV["BUNDLE_GEMFILE"]).to eq("/somewhere/Gemfile")
+ ensure
+ saved ? (ENV["BUNDLE_GEMFILE"] = saved) : ENV.delete("BUNDLE_GEMFILE")
+ end
+
+ it "returns nil for a file that does not exist" do
+ described_class.open(fixture: "hello") do |workspace|
+ expect(workspace.read("nope.rb")).to be_nil
+ end
+ end
+
+ it "raises SetupFailed when a setup command fails" do
+ expect do
+ described_class.open(fixture: "hello", setup: ["exit 1"]) { |workspace| workspace }
+ end.to raise_error(described_class::SetupFailed)
+ end
+
+ it "still removes the directory when a setup command fails" do
+ before = Dir.glob(File.join(Dir.tmpdir, "elelem-evals-*"))
+
+ expect do
+ described_class.open(fixture: "hello", setup: ["exit 1"]) { |workspace| workspace }
+ end.to raise_error(described_class::SetupFailed)
+
+ expect(Dir.glob(File.join(Dir.tmpdir, "elelem-evals-*"))).to eq(before)
+ end
+end
spec/evals/support/assertions.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ class Assertions
+ def initialize(expect)
+ @expect = expect || {}
+ end
+
+ def failures(workspace:, response:, tools:, turns:)
+ [
+ verify_failure(workspace),
+ *file_failures(workspace),
+ *response_failures(response),
+ *tool_failures(tools),
+ turns_failure(turns)
+ ].compact
+ end
+
+ private
+
+ def verify_failure(workspace)
+ command = @expect[:verify]
+ return unless command
+
+ result = workspace.sh(command)
+ return if result[:exit_status].zero?
+
+ "verify failed (exit #{result[:exit_status]}): #{command}"
+ end
+
+ def file_failures(workspace)
+ (@expect[:files] || {}).flat_map do |path, rules|
+ content = workspace.read(path)
+ next ["#{path} missing"] unless content
+
+ missing = Array(rules["contains"]).reject { |text| content.include?(text) }
+ present = Array(rules["not_contains"]).select { |text| content.include?(text) }
+
+ missing.map { |text| "#{path} missing #{text.inspect}" } +
+ present.map { |text| "#{path} still contains #{text.inspect}" }
+ end
+ end
+
+ def response_failures(response)
+ text = response.to_s
+
+ Array(@expect[:response_contains]).reject { |s| text.include?(s) }.map { |s| "response missing #{s.inspect}" } +
+ Array(@expect[:response_not_contains]).select { |s| text.include?(s) }.map { |s| "response contains #{s.inspect}" } +
+ Array(@expect[:response_matches]).reject { |p| Regexp.new(p).match?(text) }.map { |p| "response does not match /#{p}/" }
+ end
+
+ def tool_failures(tools)
+ Array(@expect[:tools_used]).reject { |entry| called?(tools, entry) }.map { |entry| "never called #{label(entry)}" } +
+ Array(@expect[:tools_not_used]).select { |name| called?(tools, name) }.map { |name| "called #{name}" }
+ end
+
+ def called?(tools, entry)
+ name, constraints = destructure(entry)
+
+ tools.any? do |call|
+ call.name == name.to_s && constraints.all? { |key, value| call.args[key.to_s].to_s.include?(value.to_s) }
+ end
+ end
+
+ def destructure(entry)
+ return entry.first.then { |name, constraints| [name, constraints || {}] } if entry.is_a?(Hash)
+
+ [entry, {}]
+ end
+
+ 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/bounded_client.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ class BoundedClient
+ TurnLimitExceeded = Class.new(StandardError)
+
+ attr_reader :turns
+
+ def initialize(client, max_turns:)
+ @client = client
+ @max_turns = max_turns
+ @turns = 0
+ end
+
+ def fetch(messages, tools = [], &block)
+ @turns += 1
+ raise TurnLimitExceeded, "turn limit #{@max_turns} exceeded" if @turns > @max_turns
+
+ @client.fetch(messages, tools, &block)
+ end
+ end
+ end
+end
spec/evals/support/case.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "yaml"
+
+module Elelem
+ module Evals
+ class Case
+ CASES = File.expand_path("../cases", __dir__)
+ DEFAULT_MAX_TURNS = 10
+
+ def self.load_all(dir = CASES)
+ Dir["#{dir}/*.yml"].sort.flat_map { |file| load_file(file) }
+ end
+
+ def self.load_file(file)
+ group = File.basename(file, ".yml")
+ YAML.safe_load_file(file).map { |attrs| new(group: group, **symbolize(attrs)) }
+ end
+
+ def self.symbolize(hash)
+ (hash || {}).transform_keys(&:to_sym)
+ end
+
+ attr_reader :id, :group, :fixture, :setup, :turns, :expect
+
+ def initialize(id:, fixture:, turns:, group: nil, setup: [], expect: {})
+ @id = id
+ @group = group
+ @fixture = fixture
+ @setup = Array(setup)
+ @turns = Array(turns)
+ @expect = self.class.symbolize(expect)
+ end
+
+ def max_turns
+ @expect.fetch(:max_turns, DEFAULT_MAX_TURNS)
+ end
+
+ def to_s
+ "#{group}/#{id}"
+ end
+ end
+ end
+end
spec/evals/support/client.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ MODEL = ENV.fetch("EVAL_MODEL", "gpt-oss:latest")
+ 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)
+ end
+
+ def self.client(model: MODEL)
+ ollama(model: model, options: { temperature: 0, seed: 42 })
+ end
+ end
+end
spec/evals/support/improver.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ def self.improver_client(model: IMPROVER_MODEL)
+ ollama(model: model, options: { temperature: 0.3 }, params: { format: "json" })
+ end
+
+ 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).
+
+ 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.
+ - 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.
+ - Focus on the highest impact failures first.
+
+ Respond with JSON in this exact format:
+ {
+ "analysis": "what is failing and why",
+ "changes": [
+ {"old_text": "exact text from the prompt", "new_text": "replacement", "rationale": "why this helps"}
+ ]
+ }
+
+ Return an empty changes array if no edit would help.
+ PROMPT
+
+ def initialize(client: Evals.improver_client)
+ @client = client
+ end
+
+ def plan(prompt:, failures:)
+ parsed = JSON.parse(complete(SYSTEM, user_message(prompt, failures)))
+ return empty unless parsed.is_a?(Hash)
+
+ { "analysis" => parsed["analysis"].to_s, "changes" => edits(parsed["changes"]) }
+ rescue JSON::ParserError
+ empty
+ end
+
+ def apply(prompt, changes)
+ applied = []
+
+ 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)
+
+ 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 }
+ end
+
+ [updated, applied]
+ end
+
+ private
+
+ def edits(changes)
+ changes.is_a?(Array) ? changes.select { |change| change.is_a?(Hash) } : []
+ end
+
+ def user_message(prompt, failures)
+ <<~MESSAGE
+ ## Failing cases
+
+ #{failures.map { |result| summarize(result) }.join("\n")}
+
+ ## Current system prompt
+
+ ```erb
+ #{prompt}
+ ```
+ MESSAGE
+ end
+
+ def summarize(result)
+ JSON.generate(
+ id: result.id,
+ group: result.group,
+ failures: result.failures,
+ turns: result.turns,
+ response: result.response.to_s[0, 500]
+ )
+ end
+
+ def complete(system, user)
+ content = String.new
+ messages = [{ role: "system", content: system }, { role: "user", content: user }]
+
+ @client.fetch(messages, []) { |event| content << event[:text].to_s if event[:type] == "saying" }
+ content
+ end
+
+ def empty
+ { "analysis" => "", "changes" => [] }
+ end
+ end
+ end
+end
spec/evals/support/loop.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require "digest"
+require "time"
+
+module Elelem
+ module Evals
+ class Loop
+ HOLDOUT = "holdout"
+
+ 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)
+ @cases = cases
+ @champion = champion
+ @workdir = workdir
+ @improver = improver
+ @scorer_for = scorer_for
+ @out = out
+ @preflight = preflight
+ end
+
+ def run(rounds: 3)
+ preflight!
+ prompt = File.read(@champion)
+ score = @scorer_for.call(prompt)
+ say "champion #{visible_summary(score)}"
+
+ rounds.times do |index|
+ return true if score.failed.zero?
+
+ outcome = round(index + 1, prompt, score)
+ prompt, score = outcome if outcome
+ end
+
+ score.failed.zero?
+ end
+
+ private
+
+ def preflight!
+ @preflight.call
+ rescue => e
+ raise "model preflight failed, is it reachable? (#{e.message})"
+ end
+
+ def round(number, prompt, score)
+ plan = @improver.plan(prompt: prompt, failures: visible(score))
+ say "round #{number}: #{plan["analysis"]}"
+
+ challenger, applied = @improver.apply(prompt, plan["changes"])
+
+ if applied.empty?
+ say("round #{number}: no change could be applied, skipping")
+ return nil
+ end
+
+ File.write(challenger_path(number), challenger)
+ new_score = @scorer_for.call(challenger)
+ regressions = new_score.regressions_from(score)
+
+ return promote(number, 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?
+ end
+
+ def promote(number, challenger, new_score, applied)
+ File.write(@champion, challenger)
+ say "round #{number}: promoted, #{visible_summary(new_score)}"
+ record(round: number, promoted: true, regressions: [], applied: applied, score: new_score, prompt: challenger)
+ [challenger, new_score]
+ end
+
+ def reject(number, challenger, new_score, regressions)
+ say "round #{number}: rejected, #{visible_summary(new_score)}, regressions #{regressions.join(", ")}"
+ record(round: number, promoted: false, regressions: regressions, applied: [], score: new_score, prompt: challenger)
+ end
+
+ def visible(score)
+ score.excluding(HOLDOUT).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.passed}/#{shown.total} visible, #{score.passed}/#{score.total} overall"
+ end
+
+ def challenger_path(number)
+ FileUtils.mkdir_p(@workdir)
+ File.join(@workdir, "challenger-#{number}.erb")
+ end
+
+ def record(round:, promoted:, regressions:, applied:, score:, prompt:)
+ shown = score.excluding(HOLDOUT)
+ entry = {
+ round: round,
+ promoted: promoted,
+ passed: shown.passed,
+ total: shown.total,
+ overall_passed: score.passed,
+ overall_total: score.total,
+ regressions: regressions,
+ applied: applied,
+ 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|
+ file.puts(JSON.generate(entry))
+ end
+ end
+
+ def say(message)
+ @out.puts(message)
+ end
+ end
+ end
+end
spec/evals/support/null_terminal.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ class NullTerminal < Elelem::Terminal
+ def initialize
+ super(quiet: true)
+ end
+
+ def interactive?
+ false
+ end
+ end
+ end
+end
spec/evals/support/runner.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ ToolCall = Data.define(:name, :args)
+
+ Result = Data.define(:id, :group, :status, :failures, :turns, :duration, :tools, :response) do
+ def passed?
+ status == "PASS"
+ end
+ end
+
+ # The prompt under test is the one elelem actually ships, not a copy that
+ # 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__)
+
+ class Runner
+ def initialize(prompt:, client: -> { Evals.client })
+ @prompt = prompt
+ @client = client
+ end
+
+ def run(kase)
+ started = now
+ tools = []
+ response = nil
+ bounded = BoundedClient.new(@client.call, max_turns: kase.max_turns)
+
+ Workspace.open(fixture: kase.fixture, setup: kase.setup) do |workspace|
+ workspace.chdir do
+ agent = build_agent(bounded, tools)
+ kase.turns.each { |turn| response = agent.turn(turn) }
+
+ failures = Assertions.new(kase.expect).failures(
+ workspace: workspace, response: response, tools: tools, turns: bounded.turns
+ )
+
+ Result.new(
+ id: kase.id,
+ group: kase.group,
+ status: failures.empty? ? "PASS" : "FAIL",
+ failures: failures,
+ turns: bounded.turns,
+ duration: (now - started).round(2),
+ tools: tools,
+ response: response.to_s
+ )
+ end
+ end
+ end
+
+ private
+
+ def build_agent(client, tools)
+ agent = Elelem::Agent.new(client, terminal: NullTerminal.new, system_prompt: @prompt)
+ Elelem::Plugins.setup!(agent)
+ agent.toolbox.after { |args, _result, tool_name:| tools << ToolCall.new(name: tool_name.to_s, args: args) }
+ agent
+ end
+
+ def now
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ end
+ end
+ end
+end
spec/evals/support/scorer.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Evals
+ Score = Data.define(:results) do
+ def status
+ results.group_by { |result| "#{result.group}/#{result.id}" }.transform_values { |runs| runs.all?(&:passed?) }
+ end
+
+ def passed
+ status.count { |_id, ok| ok }
+ end
+
+ def total
+ status.size
+ end
+
+ def failed
+ total - passed
+ end
+
+ def failures
+ results.reject(&:passed?)
+ end
+
+ def regressions_from(previous)
+ current = status
+ previous.status.select { |id, ok| ok && !current.fetch(id, false) }.keys
+ end
+
+ def excluding(group)
+ Score.new(results: results.reject { |result| result.group == group })
+ end
+ end
+
+ class Scorer
+ def initialize(runner:, repeat: 3)
+ @runner = runner
+ @repeat = repeat
+ end
+
+ def call(cases)
+ Score.new(results: cases.flat_map { |kase| Array.new(@repeat) { @runner.run(kase) } })
+ end
+ end
+ end
+end
spec/evals/support/workspace.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "tmpdir"
+
+module Elelem
+ module Evals
+ class Workspace
+ SetupFailed = Class.new(StandardError)
+
+ FIXTURES = File.expand_path("../fixtures", __dir__)
+
+ def self.open(fixture:, setup: [])
+ dir = Dir.mktmpdir("elelem-evals-")
+ workspace = new(dir, fixture)
+ workspace.prepare(setup)
+ yield workspace
+ ensure
+ FileUtils.remove_entry(dir) if dir && File.directory?(dir)
+ end
+
+ attr_reader :path
+
+ def initialize(path, fixture)
+ @path = path
+ @fixture = fixture
+ end
+
+ def prepare(setup)
+ FileUtils.cp_r("#{FIXTURES}/#{@fixture}/.", @path)
+ chdir { setup.each { |command| run_setup(command) } }
+ end
+
+ def chdir(&block)
+ Dir.chdir(@path) { Bundler.with_unbundled_env(&block) }
+ end
+
+ def sh(command)
+ Elelem.sh("bash", args: ["-c", command], cwd: @path)
+ end
+
+ def read(relative)
+ file = File.join(@path, relative)
+ File.exist?(file) ? File.read(file) : nil
+ end
+
+ private
+
+ def run_setup(command)
+ result = sh(command)
+ return if result[:exit_status].zero?
+
+ raise SetupFailed, "setup command failed (exit #{result[:exit_status]}): #{command}\n#{result[:content]}"
+ end
+ end
+ end
+end
spec/evals/cases_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+RSpec.describe "eval cases", :eval do
+ runner = Elelem::Evals::Runner.new(prompt: File.read(Elelem::Evals::CHAMPION))
+
+ Elelem::Evals::Case.load_all.each do |kase|
+ it kase.to_s do
+ result = runner.run(kase)
+
+ expect(result.failures).to be_empty, -> { "#{kase}\n #{result.failures.join("\n ")}\n\n#{result.response}" }
+ end
+ end
+end
spec/support/evals.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "../../lib/elelem"
+
+Dir[File.expand_path("../evals/support/*.rb", __dir__)].sort.each { |file| require file }
spec/spec_helper.rb
@@ -10,4 +10,6 @@ RSpec.configure do |config|
config.expect_with :rspec do |c|
c.syntax = :expect
end
+
+ config.filter_run_excluding(:eval) unless ENV["EVALS"]
end
.gitignore
@@ -15,3 +15,5 @@ target/
# rspec failure tracking
.rspec_status
+
+/spec/evals/prompts/challenger-*.erb
Rakefile
@@ -5,4 +5,22 @@ require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)
+task :evals_env do
+ ENV["EVALS"] = "1"
+end
+
+desc "Score the champion system prompt against the eval cases"
+RSpec::Core::RakeTask.new({ evals: :evals_env }) do |t|
+ t.pattern = "spec/evals/cases_spec.rb"
+end
+
+namespace :evals do
+ desc "Tune the champion system prompt against the eval cases"
+ task :improve do
+ require_relative "spec/support/evals"
+
+ Elelem::Evals::Loop.new.run(rounds: Integer(ENV.fetch("ROUNDS", "3")))
+ end
+end
+
task default: %i[spec]