brute 5.1.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +1 -1
  3. data/lib/brute/compaction/middleware/strategy.rb +0 -2
  4. data/lib/brute/compaction/summarize.rb +30 -21
  5. data/lib/brute/completion/lang_chain.rb +34 -31
  6. data/lib/brute/completion/llmrb.rb +31 -29
  7. data/lib/brute/completion/open_router.rb +43 -40
  8. data/lib/brute/completion/ruby_llm.rb +28 -27
  9. data/lib/brute/contrib/otel.rb +81 -49
  10. data/lib/brute/eval/case.rb +7 -2
  11. data/lib/brute/eval/suite.rb +10 -5
  12. data/lib/brute/eval/transcript.rb +3 -3
  13. data/lib/brute/eval/world.rb +2 -2
  14. data/lib/brute/eval.rb +1 -1
  15. data/lib/brute/events/handler.rb +3 -1
  16. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  17. data/lib/brute/events/terminal_output_handler.rb +1 -1
  18. data/lib/brute/hooks.rb +88 -112
  19. data/lib/brute/message_transport/anthropic.rb +13 -3
  20. data/lib/brute/message_transport/llm.rb +8 -4
  21. data/lib/brute/message_transport/open_router.rb +30 -16
  22. data/lib/brute/message_transport/openai.rb +14 -7
  23. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  24. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  25. data/lib/brute/messages.rb +15 -8
  26. data/lib/brute/middleware/000_base.rb +5 -5
  27. data/lib/brute/middleware/002_session_log.rb +12 -4
  28. data/lib/brute/middleware/008_checkpoint.rb +26 -18
  29. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  30. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  31. data/lib/brute/middleware/040_default_compaction_pipeline.rb +49 -45
  32. data/lib/brute/middleware/070_default_tool_pipeline.rb +44 -53
  33. data/lib/brute/prompt_template.rb +19 -15
  34. data/lib/brute/prompts/base.rb +19 -10
  35. data/lib/brute/prompts/environment.rb +3 -1
  36. data/lib/brute/prompts/instructions.rb +9 -7
  37. data/lib/brute/prompts/skills.rb +5 -3
  38. data/lib/brute/rack/adapter.rb +40 -23
  39. data/lib/brute/skill.rb +136 -89
  40. data/lib/brute/system_prompt.rb +9 -9
  41. data/lib/brute/token_counter.rb +3 -1
  42. data/lib/brute/tool.rb +10 -6
  43. data/lib/brute/tools/adapter.rb +53 -43
  44. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  45. data/lib/brute/tools/fs_patch.rb +16 -8
  46. data/lib/brute/tools/fs_read.rb +107 -80
  47. data/lib/brute/tools/fs_remove.rb +6 -2
  48. data/lib/brute/tools/fs_search.rb +14 -4
  49. data/lib/brute/tools/fs_undo.rb +6 -2
  50. data/lib/brute/tools/fs_write.rb +5 -1
  51. data/lib/brute/tools/net_fetch.rb +6 -2
  52. data/lib/brute/tools/question.rb +42 -39
  53. data/lib/brute/tools/shell.rb +20 -5
  54. data/lib/brute/tools/skill_load.rb +44 -39
  55. data/lib/brute/tools/todo_write.rb +19 -15
  56. data/lib/brute/truncation.rb +68 -43
  57. data/lib/brute/turn/agent_pipeline.rb +23 -13
  58. data/lib/brute/turn/compaction_pipeline.rb +4 -3
  59. data/lib/brute/turn/pipeline.rb +87 -96
  60. data/lib/brute/turn/tool_pipeline.rb +4 -3
  61. data/lib/brute/usage_detection/llmrb.rb +18 -14
  62. data/lib/brute/usage_detection/open_router.rb +20 -17
  63. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  64. data/lib/brute/usage_detection/usage.rb +10 -1
  65. data/lib/brute/utils/diff.rb +18 -10
  66. data/lib/brute/version.rb +1 -1
  67. data/lib/brute.rb +10 -11
  68. data/lib/brute_cli/providers/shell.rb +32 -29
  69. data/lib/brute_cli/providers/shell_response.rb +20 -18
  70. metadata +30 -5
  71. data/lib/brute/middleware/040_compaction_check.rb +0 -73
  72. data/lib/brute/middleware/070_tool_pipeline.rb +0 -61
  73. data/lib/brute/middleware/event_handler.rb +0 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd46c42475fdbdaef5ec4f22cd15ff43d6d1bcf2dc2c37c744fb4c6dbbdc2289
4
- data.tar.gz: 2ef42a57205047a0a0f0783a736a333ef7ee0d1fcbe583c0dbf7b97c7e2ba514
3
+ metadata.gz: fec1b76713fc59a69f9f826eec8ed9cdaa79a7c5cebbb82087ce00f15dccb138
4
+ data.tar.gz: 54f48b286b30e133c59bfebdbf4689f57d09b84d7c3eaf3512ddb17dbcfa81c4
5
5
  SHA512:
6
- metadata.gz: cf83163546bc17c6ed562581067db5e6341088d2746fac3476ac56fcfeb2ac5230d3a7317a02c94a42a9b3ba6c43203578ecc995517d561741d788946b168064
7
- data.tar.gz: 9f42fb22bc76a611043dadbcd2acb6f6b4862efda77e31af58c862d0ad1cb99587b46169055cd8ccd74936eeef8d1af2b42faa14b14fa6b6548c2ccf8b0dbdf0
6
+ metadata.gz: bff875db4f34842415b8e31e50d9fabffa92867d278549a44e36e85bc00f31ad6b23543a780b85158f702e3b35a2130403a2a391f655b7bfcfaef0ccee85809d
7
+ data.tar.gz: 105b8f5e04fd82ac2af37451cfccd5f086b3da1bb35681f7ab36fd0620e12d2cd391ce372381c7633aa133b2f7fd51f58355c2afa576339ec09845c475979795
@@ -33,7 +33,7 @@ module Brute
33
33
  # it anyway; declining here says why.
34
34
  if dropped.any? && !dropped.all? { |index| Brute::Compaction::Transcript.marked?(messages[index], strategy) }
35
35
  note = Brute::Message.new(
36
- role: :user,
36
+ role: :user,
37
37
  content: Brute::Compaction::Transcript.mark(strategy, format(NOTE, dropped.length)),
38
38
  )
39
39
  [*kept[...note_index], note, *kept[note_index..]]
@@ -70,7 +70,6 @@ module Brute
70
70
  if after < before
71
71
  env[:conversation] = rewritten
72
72
  env[:applied] << { strategy: strategy, before: before, after: after }
73
- env[:events] << { type: :compacted, data: env[:applied].last }
74
73
  end
75
74
  end
76
75
  end
@@ -113,7 +112,6 @@ describe "brute/compaction/middleware/strategy" do
113
112
  env[:conversation].map { |m| m.content[0, 1] }.should == ["a", "b"]
114
113
  asked.should == ["halves"]
115
114
  env[:applied].should == [{ strategy: "halves", before: 4_022, after: 2_011 }]
116
- env[:events].should == [{ type: :compacted, data: { strategy: "halves", before: 4_022, after: 2_011 } }]
117
115
 
118
116
  # A rewrite that saved nothing is refused and the ladder carries on past
119
117
  # it, so the strategy itself never has to check.
@@ -113,22 +113,21 @@ module Brute
113
113
  end
114
114
 
115
115
  def summarise(env, indices)
116
- asked = { messages: prompt(env[:conversation], indices), metadata: {}, events: env[:events] }
116
+ asked = { messages: prompt(env[:conversation], indices), metadata: {} }
117
117
  @generator.call(asked)
118
118
  answer = asked[:messages].last&.content.to_s.strip
119
119
 
120
120
  unless answer.empty?
121
121
  answer
122
122
  end
123
- rescue => error
124
- env[:events] << { type: :error, data: { error: error, message: error.message } }
123
+ rescue
125
124
  nil
126
125
  end
127
126
 
128
127
  def swap(env, indices, text)
129
128
  before = tokens(env[:conversation])
130
129
  summary = Brute::Message.new(
131
- role: :user,
130
+ role: :user,
132
131
  content: Brute::Compaction::Transcript.mark(STRATEGY, text),
133
132
  )
134
133
  compacted = splice(env[:conversation], indices, summary)
@@ -137,7 +136,6 @@ module Brute
137
136
  if after < before
138
137
  env[:conversation] = compacted
139
138
  env[:applied] << { strategy: STRATEGY, before: before, after: after }
140
- env[:events] << { type: :compacted, data: env[:applied].last }
141
139
  true
142
140
  else
143
141
  false
@@ -177,27 +175,34 @@ module Brute
177
175
  conversation = env[:conversation]
178
176
  system_end = Brute::Compaction::Transcript.system_end(conversation)
179
177
  task = Brute::Compaction::Transcript.task_index(conversation)
178
+ target = env[:target]
180
179
 
181
- turns(conversation, system_end, task, env[:target]) ||
182
- history_summaries(conversation, system_end, task || system_end, env[:target]) ||
183
- steps(conversation, env[:target]) ||
184
- task_summaries(conversation, task, env[:target])
180
+ # Everything before the current task: what tiers 1 and 2 are allowed
181
+ # to give up, and the only thing either of them needs to be told.
182
+ history = system_end...(task || system_end)
183
+
184
+ t = turns(conversation, history, target)
185
+ h = history_summaries(conversation, history, target)
186
+ s = steps(conversation, target)
187
+ ts = task_summaries(conversation, task, target)
188
+
189
+ t || h || s || ts
185
190
  end
186
191
 
187
192
  # Tier 1. Whole historical turns, oldest first.
188
- def turns(conversation, system_end, task, target)
193
+ def turns(conversation, history, target)
189
194
  groups = Brute::Compaction::Transcript.turns(
190
195
  conversation,
191
- from: system_end,
192
- to: task || system_end,
196
+ from: history.begin,
197
+ to: history.end,
193
198
  ).map(&:to_a)
194
199
 
195
200
  enough(conversation, groups, target)
196
201
  end
197
202
 
198
203
  # Tier 2. History is nothing but summaries, so combine the oldest.
199
- def history_summaries(conversation, system_end, history_end, target)
200
- combine(conversation, summaries(conversation, system_end, history_end), target)
204
+ def history_summaries(conversation, history, target)
205
+ combine(conversation, summaries(conversation, history), target)
201
206
  end
202
207
 
203
208
  # Tier 3. The current task's own oldest steps, keeping the newest.
@@ -212,12 +217,17 @@ module Brute
212
217
 
213
218
  # Tier 4. No step may go, so combine the task's own summaries.
214
219
  def task_summaries(conversation, task, target)
215
- start = task.nil? ? 0 : task + 1
216
- combine(conversation, summaries(conversation, start, conversation.length), target)
220
+ if task.nil?
221
+ start = 0
222
+ else
223
+ start = task + 1
224
+ end
225
+
226
+ combine(conversation, summaries(conversation, start...conversation.length), target)
217
227
  end
218
228
 
219
- def summaries(conversation, from, to)
220
- (from...to).select do |index|
229
+ def summaries(conversation, range)
230
+ range.select do |index|
221
231
  Brute::Compaction::Transcript.marked?(conversation[index], STRATEGY)
222
232
  end
223
233
  end
@@ -326,11 +336,10 @@ describe "brute/compaction/summarize" do
326
336
  windy = ->(env) { env[:messages] << said(:assistant, "w" * 90_000) }
327
337
  summarised(history, 3_000, windy, keep_steps: 1)[:applied].should == []
328
338
 
329
- # A generator that raises ends the loop rather than the turn, and says so.
330
- env = { conversation: history, target: 3_000, applied: [], events: [] }
339
+ # A generator that raises ends the loop rather than the turn.
340
+ env = { conversation: history, target: 3_000, applied: [] }
331
341
  Brute::Compaction::Summarize.new(->(_e) { raise IOError, "the summariser is down" }).call(env)
332
342
  env[:applied].should == []
333
- env[:events].map { |e| [e[:type], e[:data][:message]] }.should == [[:error, "the summariser is down"]]
334
343
 
335
344
  # And one that works after the tiers are exhausted still stops.
336
345
  once = ->(e) { e[:messages] << said(:assistant, "small") }
@@ -42,31 +42,32 @@ module Brute
42
42
  end
43
43
 
44
44
  def call(env)
45
- emit(BEFORE_LLM_EVENT, env)
45
+ env.emit_trace do |env|
46
+ env.emit(LLM_START_EVENT)
46
47
 
47
- response = nil
48
- emit(LLM_DURATION_EVENT, env) { response = @llm.chat(**params(env)) }
48
+ response = nil
49
+ env.emit(LLM_DURATION_EVENT) { response = @llm.chat(**params(env)) }
49
50
 
50
- if (usage = Brute::MessageTransport::LangChain.usage_metrics(response))
51
- (env[:metadata] ||= {})[:last_llm_usage] = usage
52
- end
51
+ if (usage = Brute::MessageTransport::LangChain.usage_metrics(response))
52
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
53
+ end
53
54
 
54
- # langchainrb speaks the OpenAI-style wire format both ways.
55
- Brute::MessageTransport::LangChain.wrap_each(reply(response)) do |message|
56
- env[:messages] << message
57
- end
55
+ # langchainrb speaks the OpenAI-style wire format both ways.
56
+ Brute::MessageTransport::LangChain.wrap_each(reply(response)) do |message|
57
+ env[:messages] << message
58
+ end
58
59
 
59
- emit(AFTER_LLM_EVENT, env)
60
- env
61
- rescue => error
62
- emit(LLM_FAILURE_EVENT, env)
60
+ env.emit(LLM_END_EVENT)
61
+ rescue => error
62
+ env.emit(LLM_FAILURE_EVENT)
63
63
 
64
- if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
65
- emit(FARADAY_ERROR_EVENT, env, error)
66
- else
67
- emit(STANDARD_ERROR_EVENT, env, error)
68
- end
64
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
65
+ env.emit(FARADAY_ERROR_EVENT, error)
66
+ else
67
+ env.emit(STANDARD_ERROR_EVENT, error)
68
+ end
69
69
 
70
+ end
70
71
  env
71
72
  end
72
73
 
@@ -75,18 +76,20 @@ module Brute
75
76
  attr_reader :options
76
77
 
77
78
  def params(env)
78
- params = {
79
+ {
79
80
  messages: Brute::MessageTransport::LangChain.dump_all(env[:messages]),
80
81
  temperature: temperature(env),
81
- }
82
-
83
- tools = tool_definitions(env)
84
- params[:tools] = tools if tools.any?
85
-
86
- model = option(env, :model)
87
- params[:model] = model if model
88
-
89
- params
82
+ }.tap do |params|
83
+ tools = tool_definitions(env)
84
+ if tools.any?
85
+ params[:tools] = tools
86
+ end
87
+
88
+ model = option(env, :model)
89
+ if model
90
+ params[:model] = model
91
+ end
92
+ end
90
93
  end
91
94
 
92
95
  def reply(response)
@@ -143,8 +146,8 @@ describe "brute/completion/lang_chain" do
143
146
  seen = []
144
147
  pipeline = Brute::Turn::Pipeline.new
145
148
  pipeline.run Brute::Completion::LangChain.new(llm: llm, model: "gpt-4o-mini")
146
- pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
147
- pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
149
+ pipeline.on(Brute::Hooks::LLM_START_EVENT) { |_env| seen << :before }
150
+ pipeline.on(Brute::Hooks::LLM_END_EVENT) { |_env| seen << :after }
148
151
  pipeline.call(env)
149
152
 
150
153
  env[:messages].last.role.should == :assistant
@@ -44,34 +44,35 @@ module Brute
44
44
  end
45
45
 
46
46
  def call(env)
47
- emit(BEFORE_LLM_EVENT, env)
47
+ env.emit_trace do |env|
48
+ env.emit(LLM_START_EVENT)
48
49
 
49
- # llm.rb takes the last message as the prompt and the rest as history.
50
- messages = Brute::MessageTransport::LLM.dump_all(env[:messages])
51
- prompt = messages.pop
50
+ # llm.rb takes the last message as the prompt and the rest as history.
51
+ messages = Brute::MessageTransport::LLM.dump_all(env[:messages])
52
+ prompt = messages.pop
52
53
 
53
- response = nil
54
- emit(LLM_DURATION_EVENT, env) { response = client(env).complete(prompt, **params(env, messages)) }
54
+ response = nil
55
+ env.emit(LLM_DURATION_EVENT) { response = client(env).complete(prompt, **params(env, messages)) }
55
56
 
56
- if (usage = Brute::MessageTransport::LLM.usage_metrics(response))
57
- (env[:metadata] ||= {})[:last_llm_usage] = usage
58
- end
57
+ if (usage = Brute::MessageTransport::LLM.usage_metrics(response))
58
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
59
+ end
59
60
 
60
- Brute::MessageTransport::LLM.wrap_each(response) do |message|
61
- env[:messages] << message
62
- end
61
+ Brute::MessageTransport::LLM.wrap_each(response) do |message|
62
+ env[:messages] << message
63
+ end
63
64
 
64
- emit(AFTER_LLM_EVENT, env)
65
- env
66
- rescue => error
67
- emit(LLM_FAILURE_EVENT, env)
65
+ env.emit(LLM_END_EVENT)
66
+ rescue => error
67
+ env.emit(LLM_FAILURE_EVENT)
68
68
 
69
- if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
70
- emit(FARADAY_ERROR_EVENT, env, error)
71
- else
72
- emit(STANDARD_ERROR_EVENT, env, error)
73
- end
69
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
70
+ env.emit(FARADAY_ERROR_EVENT, error)
71
+ else
72
+ env.emit(STANDARD_ERROR_EVENT, error)
73
+ end
74
74
 
75
+ end
75
76
  env
76
77
  end
77
78
 
@@ -80,16 +81,17 @@ module Brute
80
81
  attr_reader :options
81
82
 
82
83
  def params(env, history)
83
- params = {
84
+ {
84
85
  role: nil,
85
86
  model: option(env, :model),
86
87
  messages: history,
87
88
  temperature: temperature(env),
88
- }.compact
89
-
90
- functions = functions(env)
91
- params[:tools] = functions if functions.any?
92
- params
89
+ }.compact.tap do |params|
90
+ functions = functions(env)
91
+ if functions.any?
92
+ params[:tools] = functions
93
+ end
94
+ end
93
95
  end
94
96
 
95
97
  def client(env)
@@ -154,8 +156,8 @@ describe "brute/completion/llmrb" do
154
156
  seen = []
155
157
  pipeline = Brute::Turn::Pipeline.new
156
158
  pipeline.run Brute::Completion::LLMrb.new(client: client, temperature: 0.1)
157
- pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
158
- pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
159
+ pipeline.on(Brute::Hooks::LLM_START_EVENT) { |_env| seen << :before }
160
+ pipeline.on(Brute::Hooks::LLM_END_EVENT) { |_env| seen << :after }
159
161
  pipeline.call(env)
160
162
 
161
163
  env[:messages].last.role.should == :assistant
@@ -43,52 +43,53 @@ module Brute
43
43
  end
44
44
 
45
45
  def call(env)
46
- emit(BEFORE_LLM_EVENT, env)
47
-
48
- messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
49
-
50
- ::OpenRouter::Client.new(**@config).then do |client|
51
- response = nil
52
- emit(LLM_DURATION_EVENT, env) { response = client.complete(messages, options(env)) }
53
-
54
- response.then do |response|
55
-
56
- # Expose the provider's usage for downstream accounting
57
- # (goal budgets, autonomous limits, compaction thresholds, usage
58
- # attribution) additive metadata only, normalised so a reader
59
- # does not have to know which provider answered.
60
- if (usage = Brute::MessageTransport::OpenRouter.usage_metrics(response))
61
- (env[:metadata] ||= {})[:last_llm_usage] = usage
62
- end
63
-
64
- # OpenRouter in fact only returns a single message...
65
- # https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
66
- Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
67
- env[:messages] << message
46
+ env.emit_trace do |env|
47
+ env.emit(LLM_START_EVENT)
48
+
49
+ messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
50
+
51
+ ::OpenRouter::Client.new(**@config).then do |client|
52
+ response = nil
53
+ env.emit(LLM_DURATION_EVENT) { response = client.complete(messages, options(env)) }
54
+
55
+ response.then do |response|
56
+
57
+ # Expose the provider's usage for downstream accounting
58
+ # (goal budgets, autonomous limits, compaction thresholds, usage
59
+ # attribution) additive metadata only, normalised so a reader
60
+ # does not have to know which provider answered.
61
+ if (usage = Brute::MessageTransport::OpenRouter.usage_metrics(response))
62
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
63
+ end
64
+
65
+ # OpenRouter in fact only returns a single message...
66
+ # https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
67
+ Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
68
+ env[:messages] << message
69
+ end
68
70
  end
69
71
  end
70
- end
71
72
 
72
- emit(AFTER_LLM_EVENT, env)
73
- env
74
- # A provider call that raises is reported through the hooks rather than
75
- # up the stack: :llm_failure with the turn env, then one hook naming the
76
- # kind of failure. The classes are looked up defensively the provider
77
- # gem is only a dependency of the app that uses this middleware.
78
- rescue => error
79
- emit(LLM_FAILURE_EVENT, env)
73
+ env.emit(LLM_END_EVENT)
74
+ # A provider call that raises is reported through the hooks rather than
75
+ # up the stack: :llm_failure with the turn env, then one hook naming the
76
+ # kind of failure. The classes are looked up defensively the provider
77
+ # gem is only a dependency of the app that uses this middleware.
78
+ rescue => error
79
+ env.emit(LLM_FAILURE_EVENT)
80
80
 
81
- if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
82
- emit(FARADAY_ERROR_EVENT, env, error)
81
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
82
+ env.emit(FARADAY_ERROR_EVENT, error)
83
83
 
84
- elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
85
- emit(OPEN_ROUTER_SERVER_ERROR_EVENT, env, error)
84
+ elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
85
+ env.emit(OPEN_ROUTER_SERVER_ERROR_EVENT, error)
86
86
 
87
- else
88
- emit(STANDARD_ERROR_EVENT, env, error)
87
+ else
88
+ env.emit(STANDARD_ERROR_EVENT, error)
89
89
 
90
- end
90
+ end
91
91
 
92
+ end
92
93
  env
93
94
  end
94
95
 
@@ -100,7 +101,9 @@ module Brute
100
101
 
101
102
  unless @options.tools?
102
103
  tools = tool_definitions(env)
103
- overrides[:tools] = tools if tools.any?
104
+ if tools.any?
105
+ overrides[:tools] = tools
106
+ end
104
107
  end
105
108
 
106
109
  if !@model_given && (model = env[:model])
@@ -266,7 +269,7 @@ describe "brute/completion/open_router" do
266
269
  env[:messages].user("hi")
267
270
  pipeline = Brute::Turn::Pipeline.new
268
271
  pipeline.run Brute::Completion::OpenRouter.new
269
- %i[llm_failure standard_error after_llm].each do |event|
272
+ %i[llm_failure standard_error llm_end].each do |event|
270
273
  pipeline.on(event) { |hook_env, extra| seen << [event, hook_env, extra] }
271
274
  end
272
275
  returned = pipeline.call(env)
@@ -59,35 +59,36 @@ module Brute
59
59
  end
60
60
 
61
61
  def call(env)
62
- emit(BEFORE_LLM_EVENT, env)
62
+ env.emit_trace do |env|
63
+ env.emit(LLM_START_EVENT)
63
64
 
64
- messages = Brute::MessageTransport::RubyLLM.dump_all(env[:messages])
65
- response = nil
66
- emit(LLM_DURATION_EVENT, env) { response = complete(env, messages) }
65
+ messages = Brute::MessageTransport::RubyLLM.dump_all(env[:messages])
66
+ response = nil
67
+ env.emit(LLM_DURATION_EVENT) { response = complete(env, messages) }
67
68
 
68
- unless response.nil?
69
- if (usage = Brute::MessageTransport::RubyLLM.usage_metrics(response))
70
- (env[:metadata] ||= {})[:last_llm_usage] = usage
69
+ unless response.nil?
70
+ if (usage = Brute::MessageTransport::RubyLLM.usage_metrics(response))
71
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
72
+ end
73
+
74
+ Brute::MessageTransport::RubyLLM.wrap_each(response) do |message|
75
+ env[:messages] << message
76
+ end
71
77
  end
72
78
 
73
- Brute::MessageTransport::RubyLLM.wrap_each(response) do |message|
74
- env[:messages] << message
79
+ env.emit(LLM_END_EVENT)
80
+ # A provider call that raises is reported through the hooks rather than
81
+ # up the stack, the same way the OpenRouter completion does it.
82
+ rescue => error
83
+ env.emit(LLM_FAILURE_EVENT)
84
+
85
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
86
+ env.emit(FARADAY_ERROR_EVENT, error)
87
+ else
88
+ env.emit(STANDARD_ERROR_EVENT, error)
75
89
  end
76
- end
77
90
 
78
- emit(AFTER_LLM_EVENT, env)
79
- env
80
- # A provider call that raises is reported through the hooks rather than
81
- # up the stack, the same way the OpenRouter completion does it.
82
- rescue => error
83
- emit(LLM_FAILURE_EVENT, env)
84
-
85
- if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
86
- emit(FARADAY_ERROR_EVENT, env, error)
87
- else
88
- emit(STANDARD_ERROR_EVENT, env, error)
89
91
  end
90
-
91
92
  env
92
93
  end
93
94
 
@@ -104,7 +105,7 @@ module Brute
104
105
 
105
106
  provider_client = client(option(env, :provider))
106
107
 
107
- # Streaming reports through env[:events] chunk by chunk; the final
108
+ # Streaming reports through :content and :reasoning chunk by chunk; the final
108
109
  # message still comes back for the log.
109
110
  if streaming?(env)
110
111
  provider_client.complete(messages, **kwargs) do |chunk|
@@ -117,11 +118,11 @@ module Brute
117
118
 
118
119
  def stream(env, chunk)
119
120
  if chunk.content && !chunk.content.to_s.empty?
120
- env[:events] << { type: :content, data: chunk.content.to_s }
121
+ env.emit(CONTENT_EVENT, chunk.content.to_s)
121
122
  end
122
123
 
123
124
  if chunk.respond_to?(:thinking) && chunk.thinking.respond_to?(:text) && chunk.thinking&.text
124
- env[:events] << { type: :reasoning, data: chunk.thinking.text }
125
+ env.emit(REASONING_EVENT, chunk.thinking.text)
125
126
  end
126
127
  end
127
128
 
@@ -214,8 +215,8 @@ describe "brute/completion/ruby_llm" do
214
215
 
215
216
  seen = []
216
217
  pipeline = running.call(Brute::Completion::RubyLLM.new(client: client, model: "use-this-model", temperature: 0.1))
217
- pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
218
- pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
218
+ pipeline.on(Brute::Hooks::LLM_START_EVENT) { |_env| seen << :before }
219
+ pipeline.on(Brute::Hooks::LLM_END_EVENT) { |_env| seen << :after }
219
220
  pipeline.call(env)
220
221
 
221
222
  env[:messages].last.role.should == :assistant