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.
- checksums.yaml +4 -4
- data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +139 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +340 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- data/lib/brute/contrib/otel.rb +2 -2
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +249 -0
- data/lib/brute/eval/suite.rb +176 -0
- data/lib/brute/eval/transcript.rb +147 -0
- data/lib/brute/eval/world.rb +106 -0
- data/lib/brute/eval.rb +48 -0
- data/lib/brute/hooks.rb +11 -0
- data/lib/brute/middleware/000_base.rb +1 -1
- data/lib/brute/middleware/001_slash_commands.rb +88 -0
- data/lib/brute/middleware/008_checkpoint.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +51 -135
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +352 -0
- data/lib/brute/middleware/070_default_tool_pipeline.rb +327 -0
- data/lib/brute/middleware/070_tool_pipeline.rb +39 -305
- data/lib/brute/token_counter/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +148 -0
- data/lib/brute/tools/adapter.rb +4 -4
- data/lib/brute/tools/skill_load.rb +2 -2
- data/lib/brute/tools/sub_agent.rb +2 -2
- data/lib/brute/turn/agent_pipeline.rb +117 -38
- data/lib/brute/turn/compaction_pipeline.rb +122 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +16 -1
- metadata +20 -1
|
@@ -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
|