brute 5.0.4 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Eval
8
+ # The budget a case allows itself. Lenient on purpose: an agent that took
9
+ # one search too many is worth knowing about, but it is not the same
10
+ # fault as an agent that answered wrongly.
11
+ Budget = Data.define(:iterations, :tool_calls, :tokens, :seconds) do
12
+ def initialize(iterations: 10, tool_calls: 8, tokens: 100_000, seconds: 180)
13
+ super
14
+ end
15
+ end
16
+
17
+ # One evaluation case: what the agent is told, the world it is told it
18
+ # in, and what must be true of the turn afterwards.
19
+ #
20
+ # Brute::Eval::Case.new(
21
+ # "searches for what it cannot know",
22
+ # said: "what did the Bank of England do yesterday?",
23
+ # stubs: { "search" => RATE_DECISION },
24
+ # calls: { "search" => { "query" => /bank|rate/i } },
25
+ # mentions: %w[4.25],
26
+ # budget: Brute::Eval::Budget.new(tool_calls: 2),
27
+ # )
28
+ #
29
+ # The expectations are deliberately about what the turn DID, not about
30
+ # prose: a call that was made, a call that was not, the order two calls
31
+ # came in, a word the answer has to contain, a budget it has to stay
32
+ # inside. What none of those can say goes in the block, which is handed
33
+ # the transcript.
34
+ #
35
+ # `said` reaches the agent through the world -- an inbox on disk, a queue,
36
+ # whatever that deployment's world does with it -- unless `via: :start`
37
+ # hands it straight to the turn. `files` and `conversation` are the
38
+ # world's to lay out and mean nothing to a world that keeps no state.
39
+ class Case
40
+ # A system prompt that tells an agent to say plainly when it found
41
+ # nothing is graded on this. It is a crude reading -- a judge would do
42
+ # it properly -- and it is English, so a deployment whose agents answer
43
+ # in another language passes its own `absence:`.
44
+ ABSENCE = /\b(no|not|none|nothing|cannot|can't|couldn't|didn't|don't|doesn't|isn't|aren't|unable|unfortunately|missing|without)\b/i
45
+
46
+ Verdict = Data.define(:failures) do
47
+ def passed? = failures.empty?
48
+ end
49
+
50
+ attr_reader :name, :said, :via, :files, :conversation, :stubs, :budget, :runs
51
+
52
+ def initialize(
53
+ name,
54
+ said: nil,
55
+ via: :world,
56
+ files: {},
57
+ conversation: [],
58
+ stubs: {},
59
+ calls: {},
60
+ never: [],
61
+ order: [],
62
+ mentions: [],
63
+ absent: false,
64
+ absence: ABSENCE,
65
+ silent: false,
66
+ budget: Budget.new,
67
+ runs: 1,
68
+ &check
69
+ )
70
+ @name = name
71
+ @said = said
72
+ @via = via
73
+ @files = files
74
+ @conversation = conversation
75
+ @stubs = stubs
76
+ @calls = calls
77
+ @never = never
78
+ @order = order
79
+ @mentions = mentions
80
+ @absent = absent
81
+ @absence = absence
82
+ @silent = silent
83
+ @budget = budget
84
+ @runs = runs
85
+ @check = check
86
+ end
87
+
88
+ def verdict(transcript)
89
+ Verdict.new(
90
+ [
91
+ answered(transcript),
92
+ called(transcript),
93
+ ordered(transcript),
94
+ said_it(transcript),
95
+ afforded(transcript),
96
+ checked(transcript),
97
+ ].flatten.compact.uniq
98
+ )
99
+ end
100
+
101
+ private
102
+
103
+ def answered(transcript)
104
+ [].tap do |failures|
105
+ if transcript.error
106
+ failures << "raised #{transcript.error.class}: #{transcript.error.message}"
107
+ end
108
+
109
+ transcript.failures.each { |failure| failures << "the model call failed -- #{failure}" }
110
+
111
+ if transcript.reply.empty? && !@silent
112
+ failures << "said nothing"
113
+ end
114
+
115
+ if !transcript.reply.empty? && @silent
116
+ failures << "answered when it had nothing to answer"
117
+ end
118
+ end
119
+ end
120
+
121
+ def called(transcript)
122
+ [].tap do |failures|
123
+ @calls.each do |name, arguments|
124
+ unless transcript.called?(name, arguments || {})
125
+ failures << "never called #{name}#{about(arguments)}"
126
+ end
127
+ end
128
+
129
+ @never.each do |name|
130
+ if transcript.called?(name)
131
+ failures << "called #{name}"
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ def ordered(transcript)
138
+ @order.each_cons(2).filter_map do |first, second|
139
+ unless transcript.before?(first, second)
140
+ if transcript.called?(first)
141
+ "called #{second} before #{first}"
142
+ else
143
+ "never called #{first}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ def said_it(transcript)
150
+ @mentions.filter_map { |word|
151
+ unless transcript.reply.downcase.include?(word.downcase)
152
+ "never said #{word.inspect}"
153
+ end
154
+ }.tap do |failures|
155
+ if @absent && !@absence.match?(transcript.reply)
156
+ failures << "did not say it had nothing"
157
+ end
158
+ end
159
+ end
160
+
161
+ def afforded(transcript)
162
+ [
163
+ over("iterations", transcript.iterations, @budget.iterations),
164
+ over("tool calls", transcript.calls.length, @budget.tool_calls),
165
+ over("tokens", transcript.tokens, @budget.tokens),
166
+ over("seconds", transcript.seconds.round, @budget.seconds),
167
+ ]
168
+ end
169
+
170
+ def checked(transcript)
171
+ if @check && !@check.call(transcript)
172
+ "failed the case's own check"
173
+ end
174
+ end
175
+
176
+ def over(what, spent, allowed)
177
+ if spent > allowed
178
+ "spent #{spent} #{what}, budget #{allowed}"
179
+ end
180
+ end
181
+
182
+ def about(arguments)
183
+ if arguments.nil? || arguments.empty?
184
+ ""
185
+ else
186
+ " with #{arguments.inspect}"
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ __END__
194
+
195
+ describe "brute/eval/case" do
196
+ it "grades a turn on what it did, and says what was wrong when it did not" do
197
+ turn = Struct.new(:names, :reply, :iterations, :tokens, :seconds, :error, :calls) do
198
+ def failures = []
199
+ def called?(name, arguments = {}) = names.include?(name.to_s) && arguments.empty?
200
+ def before?(first, second) = names.index(first).to_i < (names.index(second) || 99)
201
+ end
202
+
203
+ searched = turn.new(%w[search], "It held at 4.25%.", 2, 900, 3, nil, [1])
204
+
205
+ good = Brute::Eval::Case.new(
206
+ "searches for what it cannot know",
207
+ said: "what did the bank do?",
208
+ calls: { "search" => {} },
209
+ mentions: %w[4.25],
210
+ never: %w[create_event]
211
+ )
212
+ good.verdict(searched).passed?.should.be.true
213
+ good.via.should == :world
214
+ good.runs.should == 1
215
+
216
+ fussy = Brute::Eval::Case.new(
217
+ "does not search for what it knows",
218
+ said: "how many minutes in an hour?",
219
+ via: :start,
220
+ never: %w[search],
221
+ mentions: %w[sixty],
222
+ order: %w[read search],
223
+ budget: Brute::Eval::Budget.new(iterations: 1)
224
+ ) { |graded| graded.tokens < 100 }
225
+
226
+ fussy.via.should == :start
227
+ fussy.verdict(searched).failures.should == [
228
+ "called search",
229
+ "never called read",
230
+ 'never said "sixty"',
231
+ "spent 2 iterations, budget 1",
232
+ "failed the case's own check",
233
+ ]
234
+
235
+ quiet = turn.new([], "", 1, 10, 1, nil, [])
236
+ Brute::Eval::Case.new("answers", said: "hi").verdict(quiet).failures.should == ["said nothing"]
237
+ Brute::Eval::Case.new("holds its tongue", silent: true).verdict(quiet).passed?.should.be.true
238
+
239
+ broken = turn.new([], "", 1, 10, 1, ArgumentError.new("no such agent file"), [])
240
+ Brute::Eval::Case.new("loads", said: "hi").verdict(broken).failures.first.should ==
241
+ "raised ArgumentError: no such agent file"
242
+
243
+ nothing_found = turn.new([], "The search turned up nothing about that.", 1, 10, 1, nil, [])
244
+ Brute::Eval::Case.new("admits it", said: "when does it ship?", absent: true)
245
+ .verdict(nothing_found).passed?.should.be.true
246
+ Brute::Eval::Case.new("admits it in French", said: "?", absent: true, absence: /rien/i)
247
+ .verdict(nothing_found).failures.should == ["did not say it had nothing"]
248
+ end
249
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/eval/transcript"
6
+ require "brute/eval/world"
7
+
8
+ module Brute
9
+ module Eval
10
+ # Runs the cases against one agent and reports what happened.
11
+ #
12
+ # Brute::Eval::Suite.new(agent: "agent.ru", cases: CASES).run
13
+ # Brute::Eval::Suite.new(agent: -> { build_agent }, world: Room.new, cases: CASES).run
14
+ #
15
+ # The agent is built fresh for every attempt -- from its .ru file, or
16
+ # from a block that answers a new pipeline -- so nothing carries from one
17
+ # case to the next but the world, which is laid out again first. A case
18
+ # with `runs:` above one is run that many times and passes only if every
19
+ # run did: the model is not deterministic, and a case that passes two
20
+ # times in three is a case that fails.
21
+ #
22
+ # #run answers a process exit status, so an eval script ends `exit(...)`.
23
+ class Suite
24
+ Result = Data.define(:kase, :run, :transcript, :verdict)
25
+
26
+ def initialize(agent:, cases:, world: World.new, out: $stdout)
27
+ @agent = agent
28
+ @cases = cases
29
+ @world = world
30
+ @out = out
31
+ end
32
+
33
+ def run
34
+ results = @cases.flat_map { |kase| attempts(kase) }
35
+ summarise(results)
36
+
37
+ if results.all? { |result| result.verdict.passed? }
38
+ 0
39
+ else
40
+ 1
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def attempts(kase)
47
+ (1..kase.runs).map do |run|
48
+ attempt(kase, run).tap { |result| report(result) }
49
+ end
50
+ end
51
+
52
+ def attempt(kase, run)
53
+ input = @world.prepare(kase)
54
+ transcript = Transcript.new
55
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+
57
+ begin
58
+ agent = build
59
+ transcript.subscribe(agent)
60
+ @world.stub(agent, kase.stubs)
61
+ agent.start(input)
62
+ rescue StandardError => e
63
+ transcript.error = e
64
+ end
65
+
66
+ transcript.seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
67
+ transcript.published = @world.published.dup
68
+
69
+ Result.new(
70
+ kase: kase,
71
+ run: run,
72
+ transcript: transcript,
73
+ verdict: kase.verdict(transcript)
74
+ )
75
+ end
76
+
77
+ def build
78
+ if @agent.respond_to?(:call)
79
+ @agent.call
80
+ else
81
+ Brute.load_agent(@agent)
82
+ end
83
+ end
84
+
85
+ def report(result)
86
+ transcript = result.transcript
87
+ budget = result.kase.budget
88
+
89
+ @out.puts
90
+ @out.puts "[#{outcome(result)}] #{result.kase.name}#{run_of(result)}"
91
+ @out.puts " tools: #{transcript.counts} errors: #{transcript.errors}"
92
+ @out.puts(
93
+ " spent: #{transcript.iterations}/#{budget.iterations} iterations, " \
94
+ "#{transcript.calls.length}/#{budget.tool_calls} calls, " \
95
+ "#{transcript.tokens}/#{budget.tokens} tokens, " \
96
+ "#{transcript.seconds.round(1)}s"
97
+ )
98
+ result.verdict.failures.each { |failure| @out.puts " - #{failure}" }
99
+ @out.puts " said:"
100
+ transcript.reply.each_line { |line| @out.puts " #{line.chomp}" }
101
+ end
102
+
103
+ def summarise(results)
104
+ cases = results.group_by { |result| result.kase }
105
+ passed = cases.count { |_kase, attempts| attempts.all? { |result| result.verdict.passed? } }
106
+
107
+ @out.puts
108
+ @out.puts "=== #{passed}/#{cases.length} cases passed ==="
109
+ @out.puts "tokens: #{results.sum { |result| result.transcript.tokens }}"
110
+ @out.puts "time: #{results.sum { |result| result.transcript.seconds }.round(1)}s"
111
+ end
112
+
113
+ def outcome(result)
114
+ if result.verdict.passed?
115
+ "PASS"
116
+ else
117
+ "FAIL"
118
+ end
119
+ end
120
+
121
+ def run_of(result)
122
+ if result.kase.runs == 1
123
+ ""
124
+ else
125
+ " (run #{result.run}/#{result.kase.runs})"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ __END__
133
+
134
+ require "stringio"
135
+ require "tmpdir"
136
+
137
+ describe "brute/eval/suite" do
138
+ it "runs every case against a freshly built agent and reports what happened" do
139
+ out = StringIO.new
140
+
141
+ suite = Brute::Eval::Suite.new(
142
+ agent: -> { Brute.agent.run(->(env) { env[:messages].assistant("it held at 4.25%") }) },
143
+ cases: [
144
+ Brute::Eval::Case.new("answers", said: "what did the bank do?", mentions: %w[4.25]),
145
+ Brute::Eval::Case.new("searches", said: "what did the bank do?", calls: { "search" => {} }),
146
+ ],
147
+ out: out
148
+ )
149
+
150
+ suite.run.should == 1
151
+ out.string.should.include "[PASS] answers"
152
+ out.string.should.include "[FAIL] searches"
153
+ out.string.should.include "- never called search"
154
+ out.string.should.include "=== 1/2 cases passed ==="
155
+
156
+ Dir.mktmpdir do |dir|
157
+ path = File.join(dir, "agent.ru")
158
+ File.write(path, 'run ->(env) { env[:messages].assistant("from the ru file") }')
159
+
160
+ loaded = Brute::Eval::Suite.new(
161
+ agent: path,
162
+ cases: [Brute::Eval::Case.new("loads a ru file", said: "hi", mentions: ["ru file"])],
163
+ out: out
164
+ )
165
+ loaded.run.should == 0
166
+
167
+ missing = Brute::Eval::Suite.new(
168
+ agent: File.join(dir, "nowhere.ru"),
169
+ cases: [Brute::Eval::Case.new("answers", said: "hi")],
170
+ out: out
171
+ )
172
+ missing.run.should == 1
173
+ out.string.should.include "raised ArgumentError"
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/env"
6
+
7
+ module Brute
8
+ module Eval
9
+ # What one turn did.
10
+ #
11
+ # Every observation comes off the agent's own hooks, so a transcript is
12
+ # what the run itself reported: the tool calls in the order they were
13
+ # made, with the result each came back with, what the model finally said,
14
+ # what the provider charged for it, and how the call failed when it did.
15
+ # Nothing in the agent knows it is being watched.
16
+ #
17
+ # transcript = Brute::Eval::Transcript.new
18
+ # transcript.subscribe(agent)
19
+ # agent.start("what changed?")
20
+ #
21
+ # transcript.called?("search", "query" => /fed/i)
22
+ # transcript.before?("read", "write")
23
+ class Transcript
24
+ attr_reader :calls, :usage, :failures
25
+ attr_accessor :seconds, :published, :error
26
+
27
+ def initialize
28
+ @calls = []
29
+ @usage = Hash.new(0)
30
+ @failures = []
31
+ @published = []
32
+ @env = {}
33
+ @seconds = 0.0
34
+ end
35
+
36
+ # The call env the tool pipeline hands its subscribers is one mutable
37
+ # hash per call, so the entry kept here at :before_tool carries the
38
+ # result the tool answered with by the time anyone reads it.
39
+ def subscribe(agent)
40
+ agent
41
+ .on(:before_tool) { |_env, call| @calls << call }
42
+ .on(:after_llm) { |env| account(env[:metadata][:last_llm_usage]) }
43
+ .on(:faraday_error) { |_env, error| @failures << describe(error) }
44
+ .on(:open_router_server_error) { |_env, error| @failures << describe(error) }
45
+ .on(:standard_error) { |_env, error| @failures << describe(error) }
46
+ .on(:turn_end) { |env| @env = env }
47
+ end
48
+
49
+ def names = @calls.map { |call| call[:name] }
50
+
51
+ def counts = names.tally
52
+
53
+ def iterations = @env[:current_iteration] || 0
54
+
55
+ def tokens = @usage[:total]
56
+
57
+ def errors = @calls.count { |call| call[:result].to_s.start_with?("Error") }
58
+
59
+ def reply
60
+ if @env[:messages].nil?
61
+ ""
62
+ else
63
+ @env.extend(Brute::Env).reply&.content.to_s
64
+ end
65
+ end
66
+
67
+ # A call the turn made, matched on name and on whatever arguments the
68
+ # case cares about -- `===`, so a case says `"query" => /fed/i` as
69
+ # readily as `"count" => 3`.
70
+ def called?(name, arguments = {})
71
+ @calls.any? { |call|
72
+ call[:name] == name.to_s &&
73
+ arguments.all? { |key, wanted| wanted === call[:arguments][key.to_s] }
74
+ }
75
+ end
76
+
77
+ def before?(first, second)
78
+ at = names.index(first.to_s)
79
+ then_at = names.index(second.to_s)
80
+
81
+ if at.nil?
82
+ false
83
+ else
84
+ then_at.nil? || at < then_at
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def describe(error) = "#{error.class}: #{error.message}"
91
+
92
+ # Providers report what they report: a total that was never sent is
93
+ # not derived here, it is added up from the parts that were.
94
+ def account(usage)
95
+ if usage
96
+ @usage[:input] += usage.input.to_i
97
+ @usage[:output] += usage.output.to_i
98
+ @usage[:total] += usage.total || usage.input.to_i + usage.output.to_i
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ __END__
106
+
107
+ describe "brute/eval/transcript" do
108
+ it "records what the turn called, what it answered, and what it cost" do
109
+ search = Brute::Turn::ToolPipeline.new(name: "search", description: "search the web") do
110
+ run ->(env) { env[:result] = "the bank held rates at 4.25%" }
111
+ end
112
+
113
+ replies = [
114
+ Brute::Message.new(
115
+ role: :assistant,
116
+ content: "",
117
+ tool_calls: [{ id: "1", name: "search", arguments: { "query" => "bank rate" } }]
118
+ ),
119
+ Brute::Message.new(role: :assistant, content: "It held at 4.25%."),
120
+ ]
121
+
122
+ agent = Brute.agent
123
+ .use(Brute::Middleware::Loop::ToolResult)
124
+ .use(Brute::Middleware::DefaultToolPipeline, tools: [search])
125
+ .run(->(env) { env[:messages] << replies.shift })
126
+
127
+ transcript = Brute::Eval::Transcript.new
128
+ transcript.subscribe(agent)
129
+ agent.start("what did the bank do?")
130
+
131
+ transcript.names.should == ["search"]
132
+ transcript.counts.should == { "search" => 1 }
133
+ transcript.called?("search", "query" => /bank/i).should.be.true
134
+ transcript.called?("search", "query" => /ecb/i).should.be.false
135
+ transcript.called?("fetch").should.be.false
136
+ transcript.before?("search", "fetch").should.be.true
137
+ transcript.before?("fetch", "search").should.be.false
138
+ transcript.reply.should == "It held at 4.25%."
139
+ transcript.iterations.should == 2
140
+ transcript.errors.should == 0
141
+ transcript.failures.should.be.empty
142
+
143
+ failed = Brute::Eval::Transcript.new
144
+ failed.reply.should == ""
145
+ failed.tokens.should == 0
146
+ end
147
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Eval
8
+ # The world a case wakes up in.
9
+ #
10
+ # This one keeps nothing: what was said is handed straight to the turn,
11
+ # and the tools answer from the case's stubs. It is what a plain agent
12
+ # needs, and it is the contract a deployment's own world implements --
13
+ # a world is anything that answers:
14
+ #
15
+ # #prepare(case) lay the world out for this case, and answer what
16
+ # the turn should be started with (nil when the
17
+ # world delivered what was said some other way, an
18
+ # inbox on disk say)
19
+ # #stub(agent, stubs) install the case's canned tool results
20
+ # #published whatever the turn sent outward, for the record
21
+ #
22
+ # Subclass to give a case somewhere to wake up:
23
+ #
24
+ # class Room < Brute::Eval::World
25
+ # def prepare(kase)
26
+ # super.tap { |input| inbox.append(kase.said) if input.nil? }
27
+ # end
28
+ # end
29
+ class World
30
+ attr_reader :published
31
+
32
+ def initialize
33
+ @published = []
34
+ end
35
+
36
+ def prepare(kase)
37
+ @published.clear
38
+ kase.said
39
+ end
40
+
41
+ # :before_tool is handed a mutable call env, and a :result set on it is
42
+ # answered without the tool ever running -- so a stub replaces the web,
43
+ # the calendar or the shell without the agent being built differently.
44
+ # A stub that answers to #call is handed the arguments.
45
+ def stub(agent, stubs)
46
+ agent.on(:before_tool) do |_env, call|
47
+ canned = stubs[call[:name]]
48
+
49
+ unless canned.nil?
50
+ if canned.respond_to?(:call)
51
+ call[:result] = canned.call(call[:arguments])
52
+ else
53
+ call[:result] = canned
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ __END__
63
+
64
+ describe "brute/eval/world" do
65
+ it "hands what was said to the turn, and answers the tools from the case's stubs" do
66
+ world = Brute::Eval::World.new
67
+ kase = Brute::Eval::Case.new("asks", said: "what does it say?", stubs: { "search" => "canned" })
68
+
69
+ world.prepare(kase).should == "what does it say?"
70
+ world.published.should.be.empty
71
+
72
+ search = Brute::Turn::ToolPipeline.new(name: "search", description: "search") do
73
+ run ->(env) { env[:result] = "the live web" }
74
+ end
75
+
76
+ agent = Brute.agent
77
+ .use(Brute::Middleware::DefaultToolPipeline, tools: [search])
78
+ .run(
79
+ ->(env) {
80
+ env[:messages] << Brute::Message.new(
81
+ role: :assistant,
82
+ content: "",
83
+ tool_calls: [{ id: "1", name: "search", arguments: {} }]
84
+ )
85
+ }
86
+ )
87
+
88
+ world.stub(agent, kase.stubs)
89
+ agent.start("go")[:messages].last.content.should == "canned"
90
+
91
+ unstubbed = Brute.agent
92
+ .use(Brute::Middleware::DefaultToolPipeline, tools: [search])
93
+ .run(
94
+ ->(env) {
95
+ env[:messages] << Brute::Message.new(
96
+ role: :assistant,
97
+ content: "",
98
+ tool_calls: [{ id: "1", name: "search", arguments: {} }]
99
+ )
100
+ }
101
+ )
102
+
103
+ world.stub(unstubbed, {})
104
+ unstubbed.start("go")[:messages].last.content.should == "the live web"
105
+ end
106
+ end