brute 0.4.1 → 1.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +18 -28
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +60 -146
  11. data/lib/brute/middleware/doom_loop_detection.rb +95 -92
  12. data/lib/brute/middleware/llm_call.rb +78 -80
  13. data/lib/brute/middleware/message_tracking.rb +115 -162
  14. data/lib/brute/middleware/otel/span.rb +25 -106
  15. data/lib/brute/middleware/otel/token_usage.rb +29 -84
  16. data/lib/brute/middleware/otel/tool_calls.rb +23 -107
  17. data/lib/brute/middleware/otel/tool_results.rb +22 -86
  18. data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
  19. data/lib/brute/middleware/retry.rb +95 -76
  20. data/lib/brute/middleware/session_persistence.rb +38 -37
  21. data/lib/brute/middleware/token_tracking.rb +64 -63
  22. data/lib/brute/middleware/tool_error_tracking.rb +108 -82
  23. data/lib/brute/middleware/tool_use_guard.rb +57 -90
  24. data/lib/brute/middleware/tracing.rb +53 -63
  25. data/lib/brute/middleware.rb +18 -0
  26. data/lib/brute/orchestrator/turn.rb +105 -0
  27. data/lib/brute/pipeline.rb +77 -133
  28. data/lib/brute/prompts/build_switch.rb +21 -25
  29. data/lib/brute/prompts/environment.rb +31 -35
  30. data/lib/brute/prompts/identity.rb +22 -29
  31. data/lib/brute/prompts/instructions.rb +15 -18
  32. data/lib/brute/prompts/max_steps.rb +18 -25
  33. data/lib/brute/prompts/plan_reminder.rb +18 -26
  34. data/lib/brute/prompts/skills.rb +8 -30
  35. data/lib/brute/prompts.rb +28 -0
  36. data/lib/brute/providers/ollama.rb +135 -0
  37. data/lib/brute/providers/shell.rb +2 -2
  38. data/lib/brute/providers/shell_response.rb +2 -2
  39. data/lib/brute/providers.rb +62 -0
  40. data/lib/brute/queue/base_queue.rb +222 -0
  41. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  42. data/lib/brute/queue/parallel_queue.rb +66 -0
  43. data/lib/brute/queue/sequential_queue.rb +63 -0
  44. data/lib/brute/store/message_store.rb +362 -0
  45. data/lib/brute/store/session.rb +106 -0
  46. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  47. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  48. data/lib/brute/system_prompt.rb +81 -194
  49. data/lib/brute/tools/delegate.rb +46 -116
  50. data/lib/brute/tools/fs_patch.rb +36 -37
  51. data/lib/brute/tools/fs_remove.rb +2 -2
  52. data/lib/brute/tools/fs_undo.rb +2 -2
  53. data/lib/brute/tools/fs_write.rb +29 -41
  54. data/lib/brute/tools/todo_read.rb +1 -1
  55. data/lib/brute/tools/todo_write.rb +1 -1
  56. data/lib/brute/tools.rb +31 -0
  57. data/lib/brute/version.rb +1 -1
  58. data/lib/brute.rb +40 -204
  59. metadata +31 -20
  60. data/lib/brute/agent_stream.rb +0 -181
  61. data/lib/brute/hooks.rb +0 -84
  62. data/lib/brute/message_store.rb +0 -463
  63. data/lib/brute/orchestrator.rb +0 -550
  64. data/lib/brute/session.rb +0 -161
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
7
5
 
8
6
  module Brute
9
7
  module Middleware
@@ -20,7 +18,7 @@ module Brute
20
18
  # corresponding tool parts to "completed" (or "error").
21
19
  #
22
20
  # The middleware also stores itself in env[:message_tracking] so the
23
- # orchestrator can access the current assistant message ID for callbacks.
21
+ # agent loop can access the current assistant message ID for callbacks.
24
22
  #
25
23
  class MessageTracking < Base
26
24
  attr_reader :store
@@ -116,8 +114,7 @@ module Brute
116
114
  end
117
115
 
118
116
  def record_tool_calls(env)
119
- ctx = env[:context]
120
- functions = ctx.functions
117
+ functions = env[:pending_functions]
121
118
  return if functions.nil? || functions.empty?
122
119
 
123
120
  functions.each do |fn|
@@ -170,14 +167,11 @@ module Brute
170
167
  # ── Helpers ────────────────────────────────────────────────────
171
168
 
172
169
  # Resolve the actual model used for the request.
173
- # Prefers the model set on the LLM::Context (which respects user overrides)
174
- # and falls back to the provider's default_model.
170
+ # Prefers env[:model] (set by AgentTurn) and falls back to the
171
+ # provider's default_model.
175
172
  def resolve_model_name(env)
176
- ctx = env[:context]
177
- if ctx && ctx.instance_variable_defined?(:@params)
178
- ctx_model = ctx.instance_variable_get(:@params)&.dig(:model)
179
- return ctx_model.to_s if ctx_model
180
- end
173
+ model = env[:model]
174
+ return model.to_s if model
181
175
 
182
176
  # Fall back to provider default
183
177
  env[:provider]&.respond_to?(:default_model) ? env[:provider].default_model.to_s : nil
@@ -213,174 +207,133 @@ module Brute
213
207
  end
214
208
  end
215
209
 
216
- if __FILE__ == $0
217
- require_relative "../../../spec/spec_helper"
218
-
219
- RSpec.describe Brute::Middleware::MessageTracking do
220
- let(:tmpdir) { Dir.mktmpdir("brute_test_") }
221
- let(:store) { Brute::MessageStore.new(session_id: "test-session", dir: tmpdir) }
222
- let(:response) { MockResponse.new(content: "Hello from the LLM") }
223
- let(:inner_app) { ->(_env) { response } }
224
- let(:middleware) { described_class.new(inner_app, store: store) }
225
-
226
- after { FileUtils.rm_rf(tmpdir) }
227
-
228
- describe "user message recording" do
229
- it "records a user message on the first call of a turn" do
230
- env = build_env(input: "What is Ruby?", tool_results: nil)
231
- middleware.call(env)
232
-
233
- msgs = store.messages
234
- user_msg = msgs.find { |m| m[:info][:role] == "user" }
235
- expect(user_msg).not_to be_nil
236
- expect(user_msg[:parts][0][:text]).to eq("What is Ruby?")
237
- end
210
+ test do
211
+ require_relative "../../../spec/support/mock_provider"
212
+ require_relative "../../../spec/support/mock_response"
213
+ require "tmpdir"
214
+ require "fileutils"
238
215
 
239
- it "does not record a user message on subsequent calls (tool results)" do
240
- env = build_env(input: "Hello", tool_results: nil)
241
- middleware.call(env)
216
+ def build_env(**overrides)
217
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
218
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
219
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
220
+ end
242
221
 
243
- env[:tool_results] = [["read", "file contents"]]
244
- middleware.call(env)
222
+ def with_tracking
223
+ tmpdir = Dir.mktmpdir("brute_test_")
224
+ store = Brute::Store::MessageStore.new(session_id: "test-session", dir: tmpdir)
225
+ response = MockResponse.new(content: "Hello from the LLM")
226
+ inner_app = ->(_env) { response }
227
+ middleware = Brute::Middleware::MessageTracking.new(inner_app, store: store)
228
+ yield middleware, store, response
229
+ ensure
230
+ FileUtils.rm_rf(tmpdir)
231
+ end
245
232
 
246
- user_msgs = store.messages.select { |m| m[:info][:role] == "user" }
247
- expect(user_msgs.size).to eq(1)
248
- end
233
+ it "records a user message on first call of a turn" do
234
+ with_tracking do |mw, store, _|
235
+ mw.call(build_env(input: "What is Ruby?", tool_results: nil))
236
+ user_msg = store.messages.find { |m| m[:info][:role] == "user" }
237
+ user_msg[:parts][0][:text].should == "What is Ruby?"
249
238
  end
239
+ end
250
240
 
251
- describe "assistant message recording" do
252
- it "records an assistant message after each LLM call" do
253
- env = build_env(input: "Hello", tool_results: nil)
254
- middleware.call(env)
255
-
256
- msgs = store.messages
257
- asst_msg = msgs.find { |m| m[:info][:role] == "assistant" }
258
- expect(asst_msg).not_to be_nil
259
- expect(asst_msg[:info][:parentID]).not_to be_nil
260
- end
261
-
262
- it "captures text content as a text part" do
263
- env = build_env(input: "Hello", tool_results: nil)
264
- middleware.call(env)
265
-
266
- asst_msg = store.messages.find { |m| m[:info][:role] == "assistant" }
267
- text_parts = asst_msg[:parts].select { |p| p[:type] == "text" }
268
- expect(text_parts.size).to eq(1)
269
- expect(text_parts[0][:text]).to eq("Hello from the LLM")
270
- end
271
-
272
- it "captures token usage from response" do
273
- env = build_env(input: "Hello", tool_results: nil)
274
- middleware.call(env)
275
-
276
- asst_msg = store.messages.find { |m| m[:info][:role] == "assistant" }
277
- expect(asst_msg[:info][:tokens][:input]).to eq(100)
278
- expect(asst_msg[:info][:tokens][:output]).to eq(50)
279
- end
241
+ it "records only one user message per turn" do
242
+ with_tracking do |mw, store, _|
243
+ env = build_env(input: "Hello", tool_results: nil)
244
+ mw.call(env)
245
+ env[:tool_results] = [["read", "contents"]]
246
+ mw.call(env)
247
+ store.messages.select { |m| m[:info][:role] == "user" }.size.should == 1
280
248
  end
249
+ end
281
250
 
282
- describe "tool call recording" do
283
- it "records tool calls as tool parts in running state" do
284
- fn = double("function", id: "call_001", name: "read", arguments: { file_path: "/test" })
285
- provider = MockProvider.new
286
- ctx = LLM::Context.new(provider, tools: [])
287
- allow(ctx).to receive(:functions).and_return([fn])
288
- allow(provider).to receive(:complete).and_return(response)
289
-
290
- env = build_env(input: "Read the file", tool_results: nil, context: ctx)
291
- middleware.call(env)
292
-
293
- asst_msg = store.messages.find { |m| m[:info][:role] == "assistant" }
294
- tool_parts = asst_msg[:parts].select { |p| p[:type] == "tool" }
295
- expect(tool_parts.size).to eq(1)
296
- expect(tool_parts[0][:tool]).to eq("read")
297
- expect(tool_parts[0][:callID]).to eq("call_001")
298
- expect(tool_parts[0][:state][:status]).to eq("running")
299
- end
251
+ it "records an assistant message after LLM call" do
252
+ with_tracking do |mw, store, _|
253
+ mw.call(build_env(input: "Hello", tool_results: nil))
254
+ asst = store.messages.find { |m| m[:info][:role] == "assistant" }
255
+ asst.should.not.be.nil
300
256
  end
257
+ end
301
258
 
302
- describe "tool result completion" do
303
- it "updates tool parts when results arrive" do
304
- fn = double("function", id: "call_001", name: "read", arguments: { file_path: "/test" })
305
- provider = MockProvider.new
306
- ctx = LLM::Context.new(provider, tools: [])
307
- allow(ctx).to receive(:functions).and_return([fn])
308
- allow(provider).to receive(:complete).and_return(response)
309
-
310
- env = build_env(input: "Read the file", tool_results: nil, context: ctx)
311
- middleware.call(env)
312
-
313
- allow(ctx).to receive(:functions).and_return([])
314
- env[:tool_results] = [["read", "file contents here"]]
315
- middleware.call(env)
316
-
317
- msgs = store.messages
318
- first_asst = msgs.find { |m| m[:info][:role] == "assistant" }
319
- tool_part = first_asst[:parts].find { |p| p[:type] == "tool" }
320
- expect(tool_part[:state][:status]).to eq("completed")
321
- expect(tool_part[:state][:output]).to eq("file contents here")
322
- end
259
+ it "captures text content as a text part" do
260
+ with_tracking do |mw, store, _|
261
+ mw.call(build_env(input: "Hello", tool_results: nil))
262
+ asst = store.messages.find { |m| m[:info][:role] == "assistant" }
263
+ text_parts = asst[:parts].select { |p| p[:type] == "text" }
264
+ text_parts[0][:text].should == "Hello from the LLM"
323
265
  end
266
+ end
324
267
 
325
- describe "model name resolution" do
326
- it "records the provider default_model when no override is set" do
327
- env = build_env(input: "Hello", tool_results: nil)
328
- middleware.call(env)
329
-
330
- asst_msg = store.messages.find { |m| m[:info][:role] == "assistant" }
331
- expect(asst_msg[:info][:modelID]).to eq("mock-model")
332
- end
333
-
334
- it "records the overridden model when context was created with model:" do
335
- provider = MockProvider.new
336
- ctx = LLM::Context.new(provider, tools: [], model: "custom-haiku-model")
337
-
338
- env = build_env(input: "Hello", tool_results: nil, context: ctx, provider: provider)
339
- middleware.call(env)
340
-
341
- asst_msg = store.messages.find { |m| m[:info][:role] == "assistant" }
342
- expect(asst_msg[:info][:modelID]).to eq("custom-haiku-model")
343
- end
344
-
345
- it "does not fall back to default_model when an override is present" do
346
- provider = MockProvider.new
347
- ctx = LLM::Context.new(provider, tools: [], model: "claude-3-haiku-20240307")
348
-
349
- env = build_env(input: "Hello", tool_results: nil, context: ctx, provider: provider)
350
- middleware.call(env)
268
+ it "captures token usage from response" do
269
+ with_tracking do |mw, store, _|
270
+ mw.call(build_env(input: "Hello", tool_results: nil))
271
+ asst = store.messages.find { |m| m[:info][:role] == "assistant" }
272
+ asst[:info][:tokens][:input].should == 100
273
+ end
274
+ end
351
275
 
352
- asst_msg = store.messages.find { |m| m[:info][:role] == "assistant" }
353
- expect(asst_msg[:info][:modelID]).not_to eq(provider.default_model)
354
- expect(asst_msg[:info][:modelID]).to eq("claude-3-haiku-20240307")
355
- end
276
+ it "records tool calls as tool parts in running state" do
277
+ with_tracking do |mw, store, _|
278
+ fn = Struct.new(:id, :name, :arguments, keyword_init: true).new(id: "call_001", name: "read", arguments: { file_path: "/test" })
279
+ mw.call(build_env(input: "Read the file", tool_results: nil, pending_functions: [fn]))
280
+ asst = store.messages.find { |m| m[:info][:role] == "assistant" }
281
+ tool_parts = asst[:parts].select { |p| p[:type] == "tool" }
282
+ tool_parts[0][:state][:status].should == "running"
356
283
  end
284
+ end
357
285
 
358
- describe "middleware passthrough" do
359
- it "stores itself in env[:message_tracking]" do
360
- env = build_env(input: "Hello", tool_results: nil)
361
- middleware.call(env)
286
+ it "updates tool parts when results arrive" do
287
+ with_tracking do |mw, store, _|
288
+ fn = Struct.new(:id, :name, :arguments, keyword_init: true).new(id: "call_001", name: "read", arguments: { file_path: "/test" })
289
+ env = build_env(input: "Read the file", tool_results: nil, pending_functions: [fn])
290
+ mw.call(env)
291
+ env[:pending_functions] = []
292
+ env[:tool_results] = [["read", "file contents here"]]
293
+ mw.call(env)
294
+ first_asst = store.messages.find { |m| m[:info][:role] == "assistant" }
295
+ tool_part = first_asst[:parts].find { |p| p[:type] == "tool" }
296
+ tool_part[:state][:status].should == "completed"
297
+ end
298
+ end
362
299
 
363
- expect(env[:message_tracking]).to eq(middleware)
364
- end
300
+ it "records provider default_model when no override" do
301
+ with_tracking do |mw, store, _|
302
+ mw.call(build_env(input: "Hello", tool_results: nil))
303
+ asst = store.messages.find { |m| m[:info][:role] == "assistant" }
304
+ asst[:info][:modelID].should == "mock-model"
305
+ end
306
+ end
365
307
 
366
- it "returns the inner app response unchanged" do
367
- env = build_env(input: "Hello", tool_results: nil)
368
- result = middleware.call(env)
308
+ it "records overridden model when env[:model] is set" do
309
+ with_tracking do |mw, store, _|
310
+ mw.call(build_env(input: "Hello", tool_results: nil, model: "custom-haiku"))
311
+ asst = store.messages.find { |m| m[:info][:role] == "assistant" }
312
+ asst[:info][:modelID].should == "custom-haiku"
313
+ end
314
+ end
369
315
 
370
- expect(result).to eq(response)
371
- end
316
+ it "stores itself in env[:message_tracking]" do
317
+ with_tracking do |mw, _, _|
318
+ env = build_env(input: "Hello", tool_results: nil)
319
+ mw.call(env)
320
+ env[:message_tracking].should == mw
372
321
  end
322
+ end
373
323
 
374
- describe "step-finish parts" do
375
- it "adds a step-finish part to each assistant message" do
376
- env = build_env(input: "Hello", tool_results: nil)
377
- middleware.call(env)
324
+ it "returns the inner app response unchanged" do
325
+ with_tracking do |mw, _, response|
326
+ result = mw.call(build_env(input: "Hello", tool_results: nil))
327
+ result.should == response
328
+ end
329
+ end
378
330
 
379
- asst_msg = store.messages.find { |m| m[:info][:role] == "assistant" }
380
- step_finish = asst_msg[:parts].find { |p| p[:type] == "step-finish" }
381
- expect(step_finish).not_to be_nil
382
- expect(step_finish[:reason]).to eq("stop")
383
- end
331
+ it "adds a step-finish part to assistant messages" do
332
+ with_tracking do |mw, store, _|
333
+ mw.call(build_env(input: "Hello", tool_results: nil))
334
+ asst = store.messages.find { |m| m[:info][:role] == "assistant" }
335
+ step_finish = asst[:parts].find { |p| p[:type] == "step-finish" }
336
+ step_finish[:reason].should == "stop"
384
337
  end
385
338
  end
386
339
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
7
5
 
8
6
  module Brute
9
7
  module Middleware
@@ -28,13 +26,13 @@ module Brute
28
26
  return @app.call(env) unless defined?(::OpenTelemetry::SDK)
29
27
 
30
28
  provider_name = provider_type(env[:provider])
31
- model = begin; env[:context].model; rescue; nil; end
29
+ model = env[:model] || (env[:provider].default_model rescue nil)
32
30
  span_name = model ? "llm.call #{model}" : "llm.call"
33
31
 
34
32
  attributes = {
35
33
  "brute.provider" => provider_name,
36
34
  "brute.streaming" => !!env[:streaming],
37
- "brute.context_messages" => env[:context].messages.to_a.size,
35
+ "brute.context_messages" => env[:messages].size,
38
36
  }
39
37
  attributes["brute.model"] = model.to_s if model
40
38
  attributes["brute.session_id"] = env[:metadata][:session_id].to_s if env.dig(:metadata, :session_id)
@@ -79,108 +77,29 @@ module Brute
79
77
  end
80
78
  end
81
79
 
82
- if __FILE__ == $0
83
- require_relative "../../../../spec/spec_helper"
84
-
85
- RSpec.describe Brute::Middleware::OTel::Span do
86
- let(:response) { MockResponse.new(content: "hello from LLM") }
87
- let(:inner_app) { ->(_env) { response } }
88
- let(:middleware) { described_class.new(inner_app) }
89
-
90
- context "when OpenTelemetry::SDK is not defined" do
91
- it "passes through to the inner app without touching env[:span]" do
92
- hide_const("OpenTelemetry::SDK") if defined?(OpenTelemetry::SDK)
93
-
94
- env = build_env
95
- result = middleware.call(env)
96
-
97
- expect(result).to eq(response)
98
- expect(env[:span]).to be_nil
99
- end
100
- end
101
-
102
- context "when OpenTelemetry::SDK is defined" do
103
- let(:span) { mock_span }
104
- let(:tracer) { double("tracer") }
105
- let(:tracer_provider) { double("tracer_provider", tracer: tracer) }
106
-
107
- before do
108
- stub_const("OpenTelemetry::SDK", Module.new)
109
- stub_const("OpenTelemetry::Trace::Status", Class.new {
110
- def self.error(msg) = new(msg)
111
- def initialize(msg = nil) = nil
112
- })
80
+ test do
81
+ require_relative "../../../../spec/support/mock_provider"
82
+ require_relative "../../../../spec/support/mock_response"
113
83
 
114
- allow(tracer).to receive(:in_span) do |_name, **_opts, &block|
115
- block.call(span)
116
- end
117
-
118
- allow(::OpenTelemetry).to receive(:tracer_provider).and_return(tracer_provider)
119
-
120
- middleware.instance_variable_set(:@tracer, nil)
121
- end
122
-
123
- it "creates a span and sets env[:span] during the call" do
124
- captured_span = nil
125
- app = ->(env) { captured_span = env[:span]; response }
126
- mw = described_class.new(app)
127
- env = build_env
128
-
129
- mw.call(env)
130
-
131
- expect(captured_span).to eq(span)
132
- end
133
-
134
- it "cleans up env[:span] after the call" do
135
- env = build_env
136
- middleware.call(env)
137
-
138
- expect(env[:span]).to be_nil
139
- end
140
-
141
- it "passes the response through" do
142
- env = build_env
143
- result = middleware.call(env)
144
-
145
- expect(result).to eq(response)
146
- end
147
-
148
- it "creates a span with the tracer" do
149
- env = build_env
150
- middleware.call(env)
151
-
152
- expect(tracer).to have_received(:in_span).with(
153
- anything,
154
- hash_including(
155
- attributes: hash_including(
156
- "brute.provider" => anything,
157
- "brute.streaming" => false,
158
- "brute.context_messages" => anything
159
- ),
160
- kind: :internal
161
- )
162
- )
163
- end
164
-
165
- it "records exceptions on the span and re-raises" do
166
- error = RuntimeError.new("LLM exploded")
167
- app = ->(_env) { raise error }
168
- mw = described_class.new(app)
169
- env = build_env
170
-
171
- expect { mw.call(env) }.to raise_error(RuntimeError, "LLM exploded")
172
- expect(span).to have_received(:record_exception).with(error)
173
- expect(span).to have_received(:status=)
174
- end
84
+ def build_env(**overrides)
85
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
86
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
87
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
88
+ end
175
89
 
176
- it "cleans up env[:span] even on error" do
177
- app = ->(_env) { raise "boom" }
178
- mw = described_class.new(app)
179
- env = build_env
90
+ it "passes through when OpenTelemetry::SDK is not defined" do
91
+ response = MockResponse.new(content: "hello from LLM")
92
+ middleware = Brute::Middleware::OTel::Span.new(->(_env) { response })
93
+ env = build_env
94
+ result = middleware.call(env)
95
+ result.should == response
96
+ end
180
97
 
181
- expect { mw.call(env) }.to raise_error(RuntimeError)
182
- expect(env[:span]).to be_nil
183
- end
184
- end
98
+ it "env[:span] is nil when OTel is not defined" do
99
+ response = MockResponse.new(content: "hello from LLM")
100
+ middleware = Brute::Middleware::OTel::Span.new(->(_env) { response })
101
+ env = build_env
102
+ middleware.call(env)
103
+ env[:span].should.be.nil
185
104
  end
186
105
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if __FILE__ == $0
4
- require "bundler/setup"
5
- require "brute"
6
- end
3
+ require "bundler/setup"
4
+ require "brute"
7
5
 
8
6
  module Brute
9
7
  module Middleware
@@ -34,90 +32,37 @@ module Brute
34
32
  end
35
33
  end
36
34
 
37
- if __FILE__ == $0
38
- require_relative "../../../../spec/spec_helper"
39
-
40
- RSpec.describe Brute::Middleware::OTel::TokenUsage do
41
- let(:response) do
42
- MockResponse.new(
43
- content: "hello",
44
- usage: LLM::Usage.new(input_tokens: 100, output_tokens: 50, reasoning_tokens: 10, total_tokens: 160)
45
- )
46
- end
47
- let(:inner_app) { ->(_env) { response } }
48
- let(:middleware) { described_class.new(inner_app) }
49
-
50
- it "passes the response through unchanged" do
51
- env = build_env
52
- result = middleware.call(env)
53
- expect(result).to eq(response)
54
- end
55
-
56
- context "when env[:span] is nil" do
57
- it "passes through without error" do
58
- env = build_env
59
- result = middleware.call(env)
60
- expect(result).to eq(response)
61
- end
62
- end
63
-
64
- context "when env[:span] is present" do
65
- let(:span) { mock_span }
35
+ test do
36
+ require_relative "../../../../spec/support/mock_provider"
37
+ require_relative "../../../../spec/support/mock_response"
66
38
 
67
- it "sets token usage attributes on the span" do
68
- env = build_env(span: span)
69
- middleware.call(env)
70
-
71
- expect(span).to have_received(:set_attribute).with("gen_ai.usage.input_tokens", 100)
72
- expect(span).to have_received(:set_attribute).with("gen_ai.usage.output_tokens", 50)
73
- expect(span).to have_received(:set_attribute).with("gen_ai.usage.total_tokens", 160)
74
- end
75
-
76
- it "sets reasoning_tokens when greater than zero" do
77
- env = build_env(span: span)
78
- middleware.call(env)
79
-
80
- expect(span).to have_received(:set_attribute).with("gen_ai.usage.reasoning_tokens", 10)
81
- end
82
-
83
- it "omits reasoning_tokens when zero" do
84
- zero_reasoning = MockResponse.new(
85
- content: "hello",
86
- usage: LLM::Usage.new(input_tokens: 100, output_tokens: 50, reasoning_tokens: 0, total_tokens: 150)
87
- )
88
- app = ->(_env) { zero_reasoning }
89
- mw = described_class.new(app)
90
- env = build_env(span: span)
91
-
92
- mw.call(env)
93
-
94
- expect(span).not_to have_received(:set_attribute).with("gen_ai.usage.reasoning_tokens", anything)
95
- end
96
-
97
- it "handles a response without usage gracefully" do
98
- no_usage = double("response")
99
- allow(no_usage).to receive(:respond_to?).with(:usage).and_return(false)
100
- app = ->(_env) { no_usage }
101
- mw = described_class.new(app)
102
- env = build_env(span: span)
103
-
104
- result = mw.call(env)
39
+ def build_env(**overrides)
40
+ { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
41
+ messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
42
+ tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
43
+ end
105
44
 
106
- expect(result).to eq(no_usage)
107
- expect(span).not_to have_received(:set_attribute)
108
- end
45
+ def make_response
46
+ MockResponse.new(content: "hello",
47
+ usage: LLM::Usage.new(input_tokens: 100, output_tokens: 50, reasoning_tokens: 10, total_tokens: 160))
48
+ end
109
49
 
110
- it "handles a response where usage returns nil" do
111
- nil_usage = double("response", usage: nil)
112
- allow(nil_usage).to receive(:respond_to?).with(:usage).and_return(true)
113
- app = ->(_env) { nil_usage }
114
- mw = described_class.new(app)
115
- env = build_env(span: span)
50
+ it "passes the response through unchanged" do
51
+ response = make_response
52
+ middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { response })
53
+ result = middleware.call(build_env)
54
+ result.should == response
55
+ end
116
56
 
117
- mw.call(env)
57
+ it "passes through without error when span is nil" do
58
+ response = make_response
59
+ middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { response })
60
+ lambda { middleware.call(build_env) }.should.not.raise
61
+ end
118
62
 
119
- expect(span).not_to have_received(:set_attribute)
120
- end
121
- end
63
+ it "handles a response without usage gracefully" do
64
+ no_usage = Object.new
65
+ middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { no_usage })
66
+ lambda { middleware.call(build_env) }.should.not.raise
122
67
  end
123
68
  end