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
data/lib/brute/eval.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ require_relative "eval/case"
7
+ require_relative "eval/suite"
8
+ require_relative "eval/transcript"
9
+ require_relative "eval/world"
10
+
11
+ module Brute
12
+ # Evaluating an agent, rather than testing a middleware.
13
+ #
14
+ # A spec asks whether one layer does what it says. An eval asks whether the
15
+ # whole assembled agent -- its system prompt, its tools, the model behind
16
+ # it -- behaves. That answer is not a boolean about code: it is a real turn,
17
+ # against a real model, graded on what the turn DID.
18
+ #
19
+ # CASES = [
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
+ # ),
27
+ # Brute::Eval::Case.new(
28
+ # "does not search for what it already knows",
29
+ # said: "how many minutes are there in an hour?",
30
+ # never: %w[search],
31
+ # budget: Brute::Eval::Budget.new(iterations: 2, tool_calls: 0),
32
+ # ),
33
+ # ]
34
+ #
35
+ # exit(Brute::Eval::Suite.new(agent: "agent.ru", cases: CASES).run)
36
+ #
37
+ # Everything the harness sees comes off the agent's own hooks -- the same
38
+ # registry any other subscriber uses -- so the agent under evaluation is the
39
+ # agent that ships: no eval-only middleware, no branch in agent.ru. The
40
+ # model and the tool schemas are real; the tools themselves answer from the
41
+ # case's stubs, installed on :tool_start, which answers a call without
42
+ # executing it.
43
+ #
44
+ # Where a case wakes up is the World's business, and a deployment that
45
+ # delivers what was said through an inbox, a queue or a room subclasses it.
46
+ module Eval
47
+ end
48
+ end
@@ -18,7 +18,9 @@ module Brute
18
18
  # thing, then call super (or don't, to swallow the event).
19
19
  def <<(event)
20
20
  tap do
21
- @inner << event if @inner
21
+ if @inner
22
+ @inner << event
23
+ end
22
24
  end
23
25
  end
24
26
  end
@@ -29,7 +29,9 @@ module Brute
29
29
  data = event.to_h[:data]
30
30
 
31
31
  method = "on_#{type}"
32
- send(method, data) if respond_to?(method, true)
32
+ if respond_to?(method, true)
33
+ send(method, data)
34
+ end
33
35
 
34
36
  super
35
37
  end
@@ -15,7 +15,7 @@ module Brute
15
15
  method = "on_#{type}"
16
16
 
17
17
  if respond_to?(method, true)
18
- send(method, data)
18
+ send(method, data)
19
19
  end
20
20
 
21
21
  super
data/lib/brute/hooks.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/setup"
4
+ require "delegate"
5
+ require "securerandom"
6
+
4
7
  require "brute"
5
8
 
6
9
  module Brute
@@ -9,76 +12,69 @@ module Brute
9
12
  # Brute.agent
10
13
  # .use(Brute::Middleware::MaxIterations)
11
14
  # .run(->(env) { env[:messages].assistant("done") })
12
- # .on(:before_llm) { |env| ... }
13
- # .on(:approve_tool) { |_env, call| call[:name] != "exec" }
14
- #
15
- # Every subscriber is called with the turn env first, followed by whatever
16
- # extras that event carries. A block that only wants the env can take one
17
- # argument and ignore the rest.
18
- #
19
- # Emission points and payloads:
15
+ # .on(:llm_start) { |env| ... }
16
+ # .on(:tool_approve) { |_env, call| call[:name] != "exec" }
20
17
  #
21
- # :turn_start, :turn_end → the turn env (AgentPipeline#start; turn_end
22
- # fires from an ensure, so it also fires on
23
- # error)
24
- # :turn_duration → env, started, finished: the turn's work is
25
- # this event's block
26
- # :middleware_added → an empty env, then the middleware and every
27
- # argument `use` was given (fires at build time,
28
- # so only subscribers registered before the `use`
29
- # see it)
30
- # :enter → env, the middleware instance, before the
31
- # layer does anything
32
- # :duration → env, started, finished, the middleware
33
- # instance: the layer's work is this event's
34
- # block, so it reports how long that took
35
- # :exit → env, the middleware instance, marking the
36
- # layer done (from an ensure, so it fires on
37
- # error too)
38
- # :before_llm, :after_llm → the turn env, around every LLM call
39
- # :llm_duration → env, started, finished: the provider call is
40
- # this event's block
41
- # :llm_failure → the turn env, when the LLM call raises; the
42
- # completion middleware then emits one of
43
- # :faraday_error, :open_router_server_error or
44
- # :standard_error with the exception as an extra
45
- # :before_tool → env, call env {name:, arguments:, result:,
46
- # denied:, events:, metadata:, turn_env:} —
47
- # mutate :arguments to rewrite the call, or set
48
- # :result to answer it without executing
49
- # :approve_tool → env, call env — set :denied to true to deny
50
- # the call, or to a String to deny it with that
51
- # message
52
- # :tool_duration → env, started, finished, call env: the tool's
53
- # own execution is this event's block, so a
54
- # skipped or denied call never fires it
55
- # :after_tool → env, call env — mutate :result
56
- #
57
- # Subscribers run inline (tool events may fire from parallel threads).
58
- # Exceptions propagate to the caller — layers that want fail-open semantics
59
- # rescue in their own subscriber.
60
- # Include this in anything that emits or subscribes and the event names are
61
- # first class there: ENTER_EVENT rather than Brute::Hooks::ENTER_EVENT.
62
- # The registry itself is Hooks::Registry, and Brute::Hooks.new builds one.
63
18
  module Hooks
64
19
  TURN_START_EVENT = :turn_start
65
20
  TURN_DURATION_EVENT = :turn_duration
66
21
  TURN_END_EVENT = :turn_end
22
+ TURN_FAILURE_EVENT = :turn_failure
67
23
  MIDDLEWARE_ADDED_EVENT = :middleware_added
68
- ENTER_EVENT = :enter
69
- DURATION_EVENT = :duration
70
- EXIT_EVENT = :exit
71
- BEFORE_LLM_EVENT = :before_llm
24
+ MIDDLEWARE_START_EVENT = :middleware_start
25
+ MIDDLEWARE_DURATION_EVENT = :middleware_duration
26
+ MIDDLEWARE_END_EVENT = :middleware_end
27
+ MIDDLEWARE_FAILURE_EVENT = :middleware_failure
28
+ LLM_START_EVENT = :llm_start
72
29
  LLM_DURATION_EVENT = :llm_duration
73
- AFTER_LLM_EVENT = :after_llm
30
+ LLM_END_EVENT = :llm_end
74
31
  LLM_FAILURE_EVENT = :llm_failure
75
32
  FARADAY_ERROR_EVENT = :faraday_error
76
33
  OPEN_ROUTER_SERVER_ERROR_EVENT = :open_router_server_error
77
34
  STANDARD_ERROR_EVENT = :standard_error
78
- BEFORE_TOOL_EVENT = :before_tool
79
- APPROVE_TOOL_EVENT = :approve_tool
35
+ COMPACT_START_EVENT = :compact_start
36
+ COMPACT_DURATION_EVENT = :compact_duration
37
+ COMPACT_END_EVENT = :compact_end
38
+ COMPACT_FAILURE_EVENT = :compact_failure
39
+ CONTENT_EVENT = :content
40
+ REASONING_EVENT = :reasoning
41
+ TOOL_CALLS_EVENT = :tool_calls
42
+ TOOL_START_EVENT = :tool_start
43
+ TOOL_APPROVE_EVENT = :tool_approve
80
44
  TOOL_DURATION_EVENT = :tool_duration
81
- AFTER_TOOL_EVENT = :after_tool
45
+ TOOL_END_EVENT = :tool_end
46
+ TOOL_FAILURE_EVENT = :tool_failure
47
+
48
+ # The env, wrapped, for as long as one call lasts. Everything emitted
49
+ # through it carries that call's id, and a call opened inside another
50
+ # delegates to it -- so the wrapper is the parent and unwrapping ends the
51
+ # call. It is still the env: SimpleDelegator forwards the rest.
52
+ class Trace < SimpleDelegator
53
+ attr_reader :id, :hooks
54
+
55
+ def initialize(env, hooks: nil, id: SecureRandom.uuid)
56
+ super(env)
57
+ if hooks
58
+ @hooks = hooks
59
+ elsif env.is_a?(Trace)
60
+ @hooks = env.hooks
61
+ end
62
+ @id = id
63
+ end
64
+
65
+ def current_trace = self
66
+
67
+ def emit(event, *extras, &work) = @hooks.emit(
68
+ event,
69
+ self,
70
+ *extras,
71
+ @id,
72
+ &work
73
+ )
74
+
75
+ def emit_trace(&block) = self.class.new(self).tap(&block).then { |trace| trace.__getobj__ }
76
+ end
77
+
82
78
 
83
79
  # The pub/sub registry a pipeline owns; `use` and `run` bind an emit to it.
84
80
  class Registry
@@ -91,40 +87,33 @@ module Brute
91
87
  self
92
88
  end
93
89
 
94
- # Fire an event. An emitter announces; it answers nothing, and what a
95
- # subscriber's block happens to evaluate to is not a signal. A layer
96
- # that wants to take part in a turn does it by mutating what it was
97
- # handed, never by returning something.
98
- #
99
- # Given a block, the event is timed instead: the block is the work, and
100
- # subscribers fire once it is done, called as
101
- # `|env, started, finished, *extras|` rather than `|env, *extras|`.
102
- # Both stamps are monotonic, so a clock adjustment mid-turn cannot
103
- # produce a negative duration.
104
- #
105
90
  # The block is the work and nothing more: `emit` answers nothing in
106
91
  # either form, so a caller that needs the work's value takes it inside
107
92
  # the block.
108
93
  #
109
94
  # result = nil
110
- # emit(DURATION_EVENT, env, self) { result = @app.call(env) }
111
- # # => .on(DURATION_EVENT) { |env, started, finished, layer| ... }
95
+ # emit(MIDDLEWARE_DURATION_EVENT, env, self) { result = @app.call(env) }
96
+ # # => .on(MIDDLEWARE_DURATION_EVENT) { |env, started, finished, layer| ... }
112
97
  #
113
98
  # Subscribers fire from an ensure, so work that raises is still timed
114
99
  # and still reported before the exception carries on up.
115
100
  def emit(event, env, *extras, &block)
116
- unless block
101
+ if block
102
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
103
+
104
+ begin
105
+ block.call
106
+ ensure
107
+ finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ @subscribers[event.to_sym].each { |subscriber| subscriber.call(
109
+ env,
110
+ started,
111
+ finished,
112
+ *extras,
113
+ ) }
114
+ end
115
+ else
117
116
  @subscribers[event.to_sym].each { |subscriber| subscriber.call(env, *extras) }
118
- return nil
119
- end
120
-
121
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
-
123
- begin
124
- block.call
125
- ensure
126
- finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
- @subscribers[event.to_sym].each { |subscriber| subscriber.call(env, started, finished, *extras) }
128
117
  end
129
118
 
130
119
  nil
@@ -132,8 +121,6 @@ module Brute
132
121
 
133
122
  def any?(event) = @subscribers[event.to_sym].any?
134
123
  end
135
-
136
- def self.new(...) = Registry.new(...)
137
124
  end
138
125
  end
139
126
 
@@ -141,38 +128,38 @@ __END__
141
128
 
142
129
  describe "brute/hooks" do
143
130
  it "emits to subscribers in registration order" do
144
- hooks = Brute::Hooks.new
131
+ hooks = Brute::Hooks::Registry.new
145
132
  seen = []
146
- hooks.on(:before_llm) { |env| seen << "a#{env}" }
147
- hooks.on(:before_llm) { |env| seen << "b#{env}" }
148
- hooks.emit(:before_llm, 1)
133
+ hooks.on(:llm_start) { |env| seen << "a#{env}" }
134
+ hooks.on(:llm_start) { |env| seen << "b#{env}" }
135
+ hooks.emit(:llm_start, 1)
149
136
  seen.should == ["a1", "b1"]
150
137
  end
151
138
 
152
139
  it "answers nothing: a subscriber takes part by mutating, not by returning" do
153
- hooks = Brute::Hooks.new
154
- hooks.on(:approve_tool) { |_env, call| call[:denied] = true }
140
+ hooks = Brute::Hooks::Registry.new
141
+ hooks.on(:tool_approve) { |_env, call| call[:denied] = true }
155
142
 
156
143
  call = {}
157
- hooks.emit(:approve_tool, {}, call).should.be.nil
144
+ hooks.emit(:tool_approve, {}, call).should.be.nil
158
145
  call[:denied].should.be.true
159
146
  end
160
147
 
161
148
  it "hands every subscriber the env first and the extras after" do
162
- hooks = Brute::Hooks.new
149
+ hooks = Brute::Hooks::Registry.new
163
150
  seen = []
164
- hooks.on(:after_tool) { |env, call| seen << [env, call] }
165
- hooks.emit(:after_tool, :turn, :call)
151
+ hooks.on(:tool_end) { |env, call| seen << [env, call] }
152
+ hooks.emit(:tool_end, :turn, :call)
166
153
  seen.should == [[:turn, :call]]
167
154
  end
168
155
 
169
156
  it "times a block, hands subscribers start and finish, and reports even when the work raises" do
170
157
  seen = []
171
- hooks = Brute::Hooks.new
172
- hooks.on(:exit) { |env, started, finished, subject| seen << [env, started, finished, subject] }
158
+ hooks = Brute::Hooks::Registry.new
159
+ hooks.on(:middleware_end) { |env, started, finished, subject| seen << [env, started, finished, subject] }
173
160
 
174
161
  ran = nil
175
- hooks.emit(:exit, :env, :layer) { ran = :work_result }.should.be.nil
162
+ hooks.emit(:middleware_end, :env, :layer) { ran = :work_result }.should.be.nil
176
163
  ran.should == :work_result
177
164
  seen.size.should == 1
178
165
  env, started, finished, subject = seen.first
@@ -181,19 +168,19 @@ describe "brute/hooks" do
181
168
  (finished - started).should.be >= 0
182
169
 
183
170
  # Work that raises is still timed and still reported.
184
- should.raise(RuntimeError) { hooks.emit(:exit, :env, :layer) { raise "boom" } }
171
+ should.raise(RuntimeError) { hooks.emit(:middleware_end, :env, :layer) { raise "boom" } }
185
172
  seen.size.should == 2
186
173
 
187
174
  # Without a block it stays a point event: no timing argument.
188
175
  args = []
189
- hooks2 = Brute::Hooks.new
190
- hooks2.on(:exit) { |*received| args << received }
191
- hooks2.emit(:exit, :env, :layer)
176
+ hooks2 = Brute::Hooks::Registry.new
177
+ hooks2.on(:middleware_end) { |*received| args << received }
178
+ hooks2.emit(:middleware_end, :env, :layer)
192
179
  args.should == [[:env, :layer]]
193
180
  end
194
181
 
195
182
  it "answers any? and stays chainable" do
196
- hooks = Brute::Hooks.new
183
+ hooks = Brute::Hooks::Registry.new
197
184
  hooks.any?(:turn_start).should.be.false
198
185
  hooks.on(:turn_start) { nil }.should.equal?(hooks)
199
186
  hooks.any?(:turn_start).should.be.true
@@ -55,7 +55,9 @@ module Brute
55
55
  when :assistant
56
56
  if message.tool_call?
57
57
  blocks = []
58
- blocks << { type: "text", text: message.content } unless message.content.to_s.empty?
58
+ unless message.content.to_s.empty?
59
+ blocks << { type: "text", text: message.content }
60
+ end
59
61
  blocks += message.tool_calls.map { |tc| { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments } }
60
62
  { role: "assistant", content: blocks }
61
63
  else
@@ -79,14 +81,22 @@ module Brute
79
81
 
80
82
  text = blocks.select { |b| b.type == :text }.map(&:text).join
81
83
  tool_calls = blocks.select { |b| b.type == :tool_use }.map do |b|
82
- arguments = b.input.respond_to?(:to_h) ? b.input.to_h : b.input
84
+ if b.input.respond_to?(:to_h)
85
+ arguments = b.input.to_h
86
+ else
87
+ arguments = b.input
88
+ end
83
89
  Brute::ToolCall.new(id: b.id, name: b.name, arguments: arguments)
84
90
  end
85
91
 
92
+ if tool_calls.empty?
93
+ tool_calls = nil
94
+ end
95
+
86
96
  Brute::Message.new(
87
97
  role: :assistant,
88
98
  content: text,
89
- tool_calls: (tool_calls unless tool_calls.empty?),
99
+ tool_calls: tool_calls,
90
100
  )
91
101
  end
92
102
  end
@@ -39,8 +39,11 @@ module Brute
39
39
  wire = message.tool_calls.map do |tc|
40
40
  { id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
41
41
  end
42
- ::LLM::Message.new(:assistant, message.content.to_s,
43
- { tool_calls: wire, original_tool_calls: wire })
42
+ ::LLM::Message.new(
43
+ :assistant,
44
+ message.content.to_s,
45
+ { tool_calls: wire, original_tool_calls: wire },
46
+ )
44
47
  else
45
48
  ::LLM::Message.new(:assistant, message.content)
46
49
  end
@@ -53,8 +56,9 @@ module Brute
53
56
 
54
57
  # LLM::Message (from an LLM::Response) -> Brute::Message.
55
58
  def wrap(message)
56
- tool_calls = if message.tool_call?
57
- message.tool_calls.map do |tc|
59
+ tool_calls = nil
60
+ if message.tool_call?
61
+ tool_calls = message.tool_calls.map do |tc|
58
62
  Brute::ToolCall.new(id: tc.id, name: tc["name"], arguments: tc.arguments.to_h)
59
63
  end
60
64
  end
@@ -26,9 +26,11 @@ module Brute
26
26
  # An OpenRouter::Response's messages (one per choice; in practice
27
27
  # OpenRouter returns exactly one).
28
28
  def messages
29
- return @result.choices.map { |choice| choice["message"] || choice[:message] } if @result.respond_to?(:choices)
30
-
31
- super
29
+ if @result.respond_to?(:choices)
30
+ @result.choices.map { |choice| choice["message"] || choice[:message] }
31
+ else
32
+ super
33
+ end
32
34
  end
33
35
 
34
36
  private
@@ -36,14 +38,25 @@ module Brute
36
38
  def wrap(message)
37
39
  # Coerce string keys to symbol keys if necessary
38
40
  hash = message.to_h.transform_keys(&:to_sym)
39
- hash[:role] = hash[:role].to_sym if hash.key?(:role)
40
- hash[:tool_calls] = hash[:tool_calls].map { |tc| wrap_tool_call(tc) } if hash[:tool_calls]
41
+ if hash.key?(:role)
42
+ hash[:role] = hash[:role].to_sym
43
+ end
44
+ if hash[:tool_calls]
45
+ hash[:tool_calls] = hash[:tool_calls].map { |tc| wrap_tool_call(tc) }
46
+ end
41
47
 
42
48
  case hash
43
49
  in { role: (:system | :user | :assistant | :tool) }
44
50
  # Slice away provider extras (refusal, reasoning, model, ...)
45
51
  # that Brute::Message doesn't know.
46
- Brute::Message.new(**hash.slice(:role, :content, :tool_calls, :tool_call_id))
52
+ Brute::Message.new(
53
+ **hash.slice(
54
+ :role,
55
+ :content,
56
+ :tool_calls,
57
+ :tool_call_id,
58
+ ),
59
+ )
47
60
  else
48
61
  raise "Unrecognised message format #{message.inspect}"
49
62
  end
@@ -53,16 +66,17 @@ module Brute
53
66
  # -> the flat { id:, name:, arguments: Hash } Brute::Message understands.
54
67
  def wrap_tool_call(tool_call)
55
68
  tc = tool_call.to_h.transform_keys(&:to_sym)
56
- return tc unless tc[:function] # already flat { id:, name:, arguments: }
57
-
58
- function = tc[:function].to_h.transform_keys(&:to_sym)
59
- arguments = function[:arguments].to_s
60
-
61
- {
62
- id: tc[:id],
63
- name: function[:name],
64
- arguments: JSON.parse(arguments.empty? ? "{}" : arguments),
65
- }
69
+ if tc[:function]
70
+ function = tc[:function].to_h.transform_keys(&:to_sym)
71
+ arguments = function[:arguments].to_s
72
+ {
73
+ id: tc[:id],
74
+ name: function[:name],
75
+ arguments: JSON.parse(arguments.empty? ? "{}" : arguments),
76
+ }
77
+ else
78
+ tc
79
+ end
66
80
  end
67
81
  end
68
82
  end
@@ -28,9 +28,14 @@ module Brute
28
28
  { role: "tool", tool_call_id: message.tool_call_id, content: message.content.to_s }
29
29
  when :assistant
30
30
  if message.tool_call?
31
+ content = message.content
32
+ if content.to_s.empty?
33
+ content = nil
34
+ end
35
+
31
36
  {
32
37
  role: "assistant",
33
- content: (message.content unless message.content.to_s.empty?),
38
+ content: content,
34
39
  tool_calls: message.tool_calls.map { |tc|
35
40
  { id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
36
41
  },
@@ -45,9 +50,11 @@ module Brute
45
50
 
46
51
  # A chat completion response's messages (one per choice).
47
52
  def messages
48
- return @result.choices.map(&:message) if @result.respond_to?(:choices)
49
-
50
- super
53
+ if @result.respond_to?(:choices)
54
+ @result.choices.map(&:message)
55
+ else
56
+ super
57
+ end
51
58
  end
52
59
 
53
60
  private
@@ -56,10 +63,10 @@ module Brute
56
63
  # arguments arrive as a JSON string; parse them into a Hash.
57
64
  def wrap(message)
58
65
  tool_calls = message.tool_calls&.map do |tc|
59
- arguments = begin
60
- JSON.parse(tc.function.arguments.to_s)
66
+ begin
67
+ arguments = JSON.parse(tc.function.arguments.to_s)
61
68
  rescue JSON::ParserError
62
- {}
69
+ arguments = {}
63
70
  end
64
71
  Brute::ToolCall.new(id: tc.id, name: tc.function.name, arguments: arguments)
65
72
  end
@@ -24,32 +24,36 @@ module Brute
24
24
  role: message.role,
25
25
  content: message.content,
26
26
  tool_calls: tool_calls,
27
- tool_call_id: message.tool_call_id
27
+ tool_call_id: message.tool_call_id,
28
28
  )
29
29
  end
30
30
 
31
31
  private
32
32
 
33
33
  # RubyLLM::Message -> Brute::Message.
34
- def wrap(message)
35
- raw_calls = message.tool_calls
36
- calls_list = raw_calls.respond_to?(:values) ? raw_calls.values : raw_calls
34
+ def wrap(message)
35
+ raw_calls = message.tool_calls
36
+ if raw_calls.respond_to?(:values)
37
+ calls_list = raw_calls.values
38
+ else
39
+ calls_list = raw_calls
40
+ end
41
+
42
+ tool_calls = calls_list&.map do |tc|
43
+ Brute::ToolCall.new(
44
+ id: tc.id,
45
+ name: tc.name,
46
+ arguments: tc.arguments,
47
+ )
48
+ end
37
49
 
38
- tool_calls = calls_list&.map do |tc|
39
- Brute::ToolCall.new(
40
- id: tc.id,
41
- name: tc.name,
42
- arguments: tc.arguments
50
+ Brute::Message.new(
51
+ role: message.role,
52
+ content: message.content&.to_s, # Preserves nil safely
53
+ tool_calls: tool_calls,
54
+ tool_call_id: message.tool_call_id,
43
55
  )
44
56
  end
45
-
46
- Brute::Message.new(
47
- role: message.role,
48
- content: message.content&.to_s, # Preserves nil safely
49
- tool_calls: tool_calls,
50
- tool_call_id: message.tool_call_id
51
- )
52
- end
53
57
  end
54
58
  end
55
59
  end