brute 5.0.5 → 6.0.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
  3. data/lib/brute/compaction/middleware/strategy.rb +137 -0
  4. data/lib/brute/compaction/middleware/tool_results.rb +146 -0
  5. data/lib/brute/compaction/summarize.rb +349 -0
  6. data/lib/brute/compaction/transcript.rb +166 -0
  7. data/lib/brute/compaction.rb +57 -0
  8. data/lib/brute/completion/lang_chain.rb +34 -31
  9. data/lib/brute/completion/llmrb.rb +31 -29
  10. data/lib/brute/completion/open_router.rb +43 -40
  11. data/lib/brute/completion/ruby_llm.rb +28 -27
  12. data/lib/brute/contrib/otel.rb +83 -51
  13. data/lib/brute/env.rb +54 -0
  14. data/lib/brute/eval/case.rb +254 -0
  15. data/lib/brute/eval/suite.rb +181 -0
  16. data/lib/brute/eval/transcript.rb +147 -0
  17. data/lib/brute/eval/world.rb +106 -0
  18. data/lib/brute/eval.rb +48 -0
  19. data/lib/brute/events/handler.rb +3 -1
  20. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  21. data/lib/brute/events/terminal_output_handler.rb +1 -1
  22. data/lib/brute/hooks.rb +89 -102
  23. data/lib/brute/message_transport/anthropic.rb +13 -3
  24. data/lib/brute/message_transport/llm.rb +8 -4
  25. data/lib/brute/message_transport/open_router.rb +30 -16
  26. data/lib/brute/message_transport/openai.rb +14 -7
  27. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  28. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  29. data/lib/brute/messages.rb +15 -8
  30. data/lib/brute/middleware/000_base.rb +6 -6
  31. data/lib/brute/middleware/002_session_log.rb +12 -4
  32. data/lib/brute/middleware/008_checkpoint.rb +27 -19
  33. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  34. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  35. data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
  36. data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
  37. data/lib/brute/prompt_template.rb +19 -15
  38. data/lib/brute/prompts/base.rb +19 -10
  39. data/lib/brute/prompts/environment.rb +3 -1
  40. data/lib/brute/prompts/instructions.rb +9 -7
  41. data/lib/brute/prompts/skills.rb +5 -3
  42. data/lib/brute/rack/adapter.rb +40 -23
  43. data/lib/brute/skill.rb +136 -89
  44. data/lib/brute/system_prompt.rb +9 -9
  45. data/lib/brute/token_counter/approximate.rb +54 -0
  46. data/lib/brute/token_counter/tiktoken.rb +80 -0
  47. data/lib/brute/token_counter.rb +150 -0
  48. data/lib/brute/tool.rb +10 -6
  49. data/lib/brute/tools/adapter.rb +57 -47
  50. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  51. data/lib/brute/tools/fs_patch.rb +16 -8
  52. data/lib/brute/tools/fs_read.rb +107 -80
  53. data/lib/brute/tools/fs_remove.rb +6 -2
  54. data/lib/brute/tools/fs_search.rb +14 -4
  55. data/lib/brute/tools/fs_undo.rb +6 -2
  56. data/lib/brute/tools/fs_write.rb +5 -1
  57. data/lib/brute/tools/net_fetch.rb +6 -2
  58. data/lib/brute/tools/question.rb +42 -39
  59. data/lib/brute/tools/shell.rb +20 -5
  60. data/lib/brute/tools/skill_load.rb +46 -41
  61. data/lib/brute/tools/sub_agent.rb +2 -2
  62. data/lib/brute/tools/todo_write.rb +19 -15
  63. data/lib/brute/truncation.rb +68 -43
  64. data/lib/brute/turn/agent_pipeline.rb +23 -13
  65. data/lib/brute/turn/compaction_pipeline.rb +123 -0
  66. data/lib/brute/turn/pipeline.rb +87 -96
  67. data/lib/brute/turn/tool_pipeline.rb +4 -3
  68. data/lib/brute/usage_detection/llmrb.rb +18 -14
  69. data/lib/brute/usage_detection/open_router.rb +20 -17
  70. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  71. data/lib/brute/usage_detection/usage.rb +10 -1
  72. data/lib/brute/utils/diff.rb +18 -10
  73. data/lib/brute/version.rb +1 -1
  74. data/lib/brute.rb +25 -12
  75. data/lib/brute_cli/providers/shell.rb +32 -29
  76. data/lib/brute_cli/providers/shell_response.rb +20 -18
  77. metadata +48 -5
  78. data/lib/brute/middleware/040_compaction_check.rb +0 -157
  79. data/lib/brute/middleware/event_handler.rb +0 -27
@@ -0,0 +1,254 @@
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(
12
+ :iterations,
13
+ :tool_calls,
14
+ :tokens,
15
+ :seconds,
16
+ ) do
17
+ def initialize(iterations: 10, tool_calls: 8, tokens: 100_000, seconds: 180)
18
+ super
19
+ end
20
+ end
21
+
22
+ # One evaluation case: what the agent is told, the world it is told it
23
+ # in, and what must be true of the turn afterwards.
24
+ #
25
+ # Brute::Eval::Case.new(
26
+ # "searches for what it cannot know",
27
+ # said: "what did the Bank of England do yesterday?",
28
+ # stubs: { "search" => RATE_DECISION },
29
+ # calls: { "search" => { "query" => /bank|rate/i } },
30
+ # mentions: %w[4.25],
31
+ # budget: Brute::Eval::Budget.new(tool_calls: 2),
32
+ # )
33
+ #
34
+ # The expectations are deliberately about what the turn DID, not about
35
+ # prose: a call that was made, a call that was not, the order two calls
36
+ # came in, a word the answer has to contain, a budget it has to stay
37
+ # inside. What none of those can say goes in the block, which is handed
38
+ # the transcript.
39
+ #
40
+ # `said` reaches the agent through the world -- an inbox on disk, a queue,
41
+ # whatever that deployment's world does with it -- unless `via: :start`
42
+ # hands it straight to the turn. `files` and `conversation` are the
43
+ # world's to lay out and mean nothing to a world that keeps no state.
44
+ class Case
45
+ # A system prompt that tells an agent to say plainly when it found
46
+ # nothing is graded on this. It is a crude reading -- a judge would do
47
+ # it properly -- and it is English, so a deployment whose agents answer
48
+ # in another language passes its own `absence:`.
49
+ 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
50
+
51
+ Verdict = Data.define(:failures) do
52
+ def passed? = failures.empty?
53
+ end
54
+
55
+ attr_reader :name, :said, :via, :files, :conversation, :stubs, :budget, :runs
56
+
57
+ def initialize(
58
+ name,
59
+ said: nil,
60
+ via: :world,
61
+ files: {},
62
+ conversation: [],
63
+ stubs: {},
64
+ calls: {},
65
+ never: [],
66
+ order: [],
67
+ mentions: [],
68
+ absent: false,
69
+ absence: ABSENCE,
70
+ silent: false,
71
+ budget: Budget.new,
72
+ runs: 1,
73
+ &check
74
+ )
75
+ @name = name
76
+ @said = said
77
+ @via = via
78
+ @files = files
79
+ @conversation = conversation
80
+ @stubs = stubs
81
+ @calls = calls
82
+ @never = never
83
+ @order = order
84
+ @mentions = mentions
85
+ @absent = absent
86
+ @absence = absence
87
+ @silent = silent
88
+ @budget = budget
89
+ @runs = runs
90
+ @check = check
91
+ end
92
+
93
+ def verdict(transcript)
94
+ Verdict.new(
95
+ [
96
+ answered(transcript),
97
+ called(transcript),
98
+ ordered(transcript),
99
+ said_it(transcript),
100
+ afforded(transcript),
101
+ checked(transcript),
102
+ ].flatten.compact.uniq,
103
+ )
104
+ end
105
+
106
+ private
107
+
108
+ def answered(transcript)
109
+ [].tap do |failures|
110
+ if transcript.error
111
+ failures << "raised #{transcript.error.class}: #{transcript.error.message}"
112
+ end
113
+
114
+ transcript.failures.each { |failure| failures << "the model call failed -- #{failure}" }
115
+
116
+ if transcript.reply.empty? && !@silent
117
+ failures << "said nothing"
118
+ end
119
+
120
+ if !transcript.reply.empty? && @silent
121
+ failures << "answered when it had nothing to answer"
122
+ end
123
+ end
124
+ end
125
+
126
+ def called(transcript)
127
+ [].tap do |failures|
128
+ @calls.each do |name, arguments|
129
+ unless transcript.called?(name, arguments || {})
130
+ failures << "never called #{name}#{about(arguments)}"
131
+ end
132
+ end
133
+
134
+ @never.each do |name|
135
+ if transcript.called?(name)
136
+ failures << "called #{name}"
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ def ordered(transcript)
143
+ @order.each_cons(2).filter_map do |first, second|
144
+ unless transcript.before?(first, second)
145
+ if transcript.called?(first)
146
+ "called #{second} before #{first}"
147
+ else
148
+ "never called #{first}"
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ def said_it(transcript)
155
+ @mentions.filter_map { |word|
156
+ unless transcript.reply.downcase.include?(word.downcase)
157
+ "never said #{word.inspect}"
158
+ end
159
+ }.tap do |failures|
160
+ if @absent && !@absence.match?(transcript.reply)
161
+ failures << "did not say it had nothing"
162
+ end
163
+ end
164
+ end
165
+
166
+ def afforded(transcript)
167
+ [
168
+ over("iterations", transcript.iterations, @budget.iterations),
169
+ over("tool calls", transcript.calls.length, @budget.tool_calls),
170
+ over("tokens", transcript.tokens, @budget.tokens),
171
+ over("seconds", transcript.seconds.round, @budget.seconds),
172
+ ]
173
+ end
174
+
175
+ def checked(transcript)
176
+ if @check && !@check.call(transcript)
177
+ "failed the case's own check"
178
+ end
179
+ end
180
+
181
+ def over(what, spent, allowed)
182
+ if spent > allowed
183
+ "spent #{spent} #{what}, budget #{allowed}"
184
+ end
185
+ end
186
+
187
+ def about(arguments)
188
+ if arguments.nil? || arguments.empty?
189
+ ""
190
+ else
191
+ " with #{arguments.inspect}"
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ __END__
199
+
200
+ describe "brute/eval/case" do
201
+ it "grades a turn on what it did, and says what was wrong when it did not" do
202
+ turn = Struct.new(:names, :reply, :iterations, :tokens, :seconds, :error, :calls) do
203
+ def failures = []
204
+ def called?(name, arguments = {}) = names.include?(name.to_s) && arguments.empty?
205
+ def before?(first, second) = names.index(first).to_i < (names.index(second) || 99)
206
+ end
207
+
208
+ searched = turn.new(%w[search], "It held at 4.25%.", 2, 900, 3, nil, [1])
209
+
210
+ good = Brute::Eval::Case.new(
211
+ "searches for what it cannot know",
212
+ said: "what did the bank do?",
213
+ calls: { "search" => {} },
214
+ mentions: %w[4.25],
215
+ never: %w[create_event]
216
+ )
217
+ good.verdict(searched).passed?.should.be.true
218
+ good.via.should == :world
219
+ good.runs.should == 1
220
+
221
+ fussy = Brute::Eval::Case.new(
222
+ "does not search for what it knows",
223
+ said: "how many minutes in an hour?",
224
+ via: :start,
225
+ never: %w[search],
226
+ mentions: %w[sixty],
227
+ order: %w[read search],
228
+ budget: Brute::Eval::Budget.new(iterations: 1)
229
+ ) { |graded| graded.tokens < 100 }
230
+
231
+ fussy.via.should == :start
232
+ fussy.verdict(searched).failures.should == [
233
+ "called search",
234
+ "never called read",
235
+ 'never said "sixty"',
236
+ "spent 2 iterations, budget 1",
237
+ "failed the case's own check",
238
+ ]
239
+
240
+ quiet = turn.new([], "", 1, 10, 1, nil, [])
241
+ Brute::Eval::Case.new("answers", said: "hi").verdict(quiet).failures.should == ["said nothing"]
242
+ Brute::Eval::Case.new("holds its tongue", silent: true).verdict(quiet).passed?.should.be.true
243
+
244
+ broken = turn.new([], "", 1, 10, 1, ArgumentError.new("no such agent file"), [])
245
+ Brute::Eval::Case.new("loads", said: "hi").verdict(broken).failures.first.should ==
246
+ "raised ArgumentError: no such agent file"
247
+
248
+ nothing_found = turn.new([], "The search turned up nothing about that.", 1, 10, 1, nil, [])
249
+ Brute::Eval::Case.new("admits it", said: "when does it ship?", absent: true)
250
+ .verdict(nothing_found).passed?.should.be.true
251
+ Brute::Eval::Case.new("admits it in French", said: "?", absent: true, absence: /rien/i)
252
+ .verdict(nothing_found).failures.should == ["did not say it had nothing"]
253
+ end
254
+ end
@@ -0,0 +1,181 @@
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(
25
+ :kase,
26
+ :run,
27
+ :transcript,
28
+ :verdict,
29
+ )
30
+
31
+ def initialize(agent:, cases:, world: World.new, out: $stdout)
32
+ @agent = agent
33
+ @cases = cases
34
+ @world = world
35
+ @out = out
36
+ end
37
+
38
+ def run
39
+ results = @cases.flat_map { |kase| attempts(kase) }
40
+ summarise(results)
41
+
42
+ if results.all? { |result| result.verdict.passed? }
43
+ 0
44
+ else
45
+ 1
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def attempts(kase)
52
+ (1..kase.runs).map do |run|
53
+ attempt(kase, run).tap { |result| report(result) }
54
+ end
55
+ end
56
+
57
+ def attempt(kase, run)
58
+ input = @world.prepare(kase)
59
+ transcript = Transcript.new
60
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+
62
+ begin
63
+ agent = build
64
+ transcript.subscribe(agent)
65
+ @world.stub(agent, kase.stubs)
66
+ agent.start(input)
67
+ rescue StandardError => e
68
+ transcript.error = e
69
+ end
70
+
71
+ transcript.seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
72
+ transcript.published = @world.published.dup
73
+
74
+ Result.new(
75
+ kase: kase,
76
+ run: run,
77
+ transcript: transcript,
78
+ verdict: kase.verdict(transcript),
79
+ )
80
+ end
81
+
82
+ def build
83
+ if @agent.respond_to?(:call)
84
+ @agent.call
85
+ else
86
+ Brute.load_agent(@agent)
87
+ end
88
+ end
89
+
90
+ def report(result)
91
+ transcript = result.transcript
92
+ budget = result.kase.budget
93
+
94
+ @out.puts
95
+ @out.puts "[#{outcome(result)}] #{result.kase.name}#{run_of(result)}"
96
+ @out.puts " tools: #{transcript.counts} errors: #{transcript.errors}"
97
+ @out.puts(
98
+ " spent: #{transcript.iterations}/#{budget.iterations} iterations, " \
99
+ "#{transcript.calls.length}/#{budget.tool_calls} calls, " \
100
+ "#{transcript.tokens}/#{budget.tokens} tokens, " \
101
+ "#{transcript.seconds.round(1)}s",
102
+ )
103
+ result.verdict.failures.each { |failure| @out.puts " - #{failure}" }
104
+ @out.puts " said:"
105
+ transcript.reply.each_line { |line| @out.puts " #{line.chomp}" }
106
+ end
107
+
108
+ def summarise(results)
109
+ cases = results.group_by { |result| result.kase }
110
+ passed = cases.count { |_kase, attempts| attempts.all? { |result| result.verdict.passed? } }
111
+
112
+ @out.puts
113
+ @out.puts "=== #{passed}/#{cases.length} cases passed ==="
114
+ @out.puts "tokens: #{results.sum { |result| result.transcript.tokens }}"
115
+ @out.puts "time: #{results.sum { |result| result.transcript.seconds }.round(1)}s"
116
+ end
117
+
118
+ def outcome(result)
119
+ if result.verdict.passed?
120
+ "PASS"
121
+ else
122
+ "FAIL"
123
+ end
124
+ end
125
+
126
+ def run_of(result)
127
+ if result.kase.runs == 1
128
+ ""
129
+ else
130
+ " (run #{result.run}/#{result.kase.runs})"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ __END__
138
+
139
+ require "stringio"
140
+ require "tmpdir"
141
+
142
+ describe "brute/eval/suite" do
143
+ it "runs every case against a freshly built agent and reports what happened" do
144
+ out = StringIO.new
145
+
146
+ suite = Brute::Eval::Suite.new(
147
+ agent: -> { Brute.agent.run(->(env) { env[:messages].assistant("it held at 4.25%") }) },
148
+ cases: [
149
+ Brute::Eval::Case.new("answers", said: "what did the bank do?", mentions: %w[4.25]),
150
+ Brute::Eval::Case.new("searches", said: "what did the bank do?", calls: { "search" => {} }),
151
+ ],
152
+ out: out
153
+ )
154
+
155
+ suite.run.should == 1
156
+ out.string.should.include "[PASS] answers"
157
+ out.string.should.include "[FAIL] searches"
158
+ out.string.should.include "- never called search"
159
+ out.string.should.include "=== 1/2 cases passed ==="
160
+
161
+ Dir.mktmpdir do |dir|
162
+ path = File.join(dir, "agent.ru")
163
+ File.write(path, 'run ->(env) { env[:messages].assistant("from the ru file") }')
164
+
165
+ loaded = Brute::Eval::Suite.new(
166
+ agent: path,
167
+ cases: [Brute::Eval::Case.new("loads a ru file", said: "hi", mentions: ["ru file"])],
168
+ out: out
169
+ )
170
+ loaded.run.should == 0
171
+
172
+ missing = Brute::Eval::Suite.new(
173
+ agent: File.join(dir, "nowhere.ru"),
174
+ cases: [Brute::Eval::Case.new("answers", said: "hi")],
175
+ out: out
176
+ )
177
+ missing.run.should == 1
178
+ out.string.should.include "raised ArgumentError"
179
+ end
180
+ end
181
+ 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 :tool_start carries the
38
+ # result the tool answered with by the time anyone reads it.
39
+ def subscribe(agent)
40
+ agent
41
+ .on(:tool_start) { |_env, call| @calls << call }
42
+ .on(:llm_end) { |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
+ # :tool_start 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(:tool_start) 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