brute 0.4.0 → 0.4.1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent_stream.rb +118 -0
  3. data/lib/brute/diff.rb +34 -0
  4. data/lib/brute/message_store.rb +194 -0
  5. data/lib/brute/middleware/compaction_check.rb +133 -0
  6. data/lib/brute/middleware/doom_loop_detection.rb +100 -0
  7. data/lib/brute/middleware/llm_call.rb +89 -0
  8. data/lib/brute/middleware/message_tracking.rb +177 -0
  9. data/lib/brute/middleware/otel/span.rb +111 -0
  10. data/lib/brute/middleware/otel/token_usage.rb +93 -0
  11. data/lib/brute/middleware/otel/tool_calls.rb +113 -0
  12. data/lib/brute/middleware/otel/tool_results.rb +92 -0
  13. data/lib/brute/middleware/otel.rb +5 -0
  14. data/lib/brute/middleware/reasoning_normalizer.rb +119 -0
  15. data/lib/brute/middleware/retry.rb +93 -0
  16. data/lib/brute/middleware/session_persistence.rb +42 -0
  17. data/lib/brute/middleware/token_tracking.rb +77 -0
  18. data/lib/brute/middleware/tool_error_tracking.rb +101 -0
  19. data/lib/brute/middleware/tool_use_guard.rb +69 -0
  20. data/lib/brute/middleware/tracing.rb +71 -0
  21. data/lib/brute/orchestrator.rb +160 -1
  22. data/lib/brute/patches/buffer_nil_guard.rb +5 -0
  23. data/lib/brute/pipeline.rb +135 -0
  24. data/lib/brute/prompts/build_switch.rb +33 -0
  25. data/lib/brute/prompts/environment.rb +47 -0
  26. data/lib/brute/prompts/identity.rb +36 -0
  27. data/lib/brute/prompts/instructions.rb +24 -0
  28. data/lib/brute/prompts/max_steps.rb +32 -0
  29. data/lib/brute/prompts/plan_reminder.rb +33 -0
  30. data/lib/brute/prompts/skills.rb +35 -0
  31. data/lib/brute/providers/opencode_go.rb +5 -0
  32. data/lib/brute/providers/opencode_zen.rb +7 -2
  33. data/lib/brute/providers/shell_response.rb +5 -0
  34. data/lib/brute/system_prompt.rb +214 -0
  35. data/lib/brute/tools/delegate.rb +129 -0
  36. data/lib/brute/tools/fs_patch.rb +53 -0
  37. data/lib/brute/tools/fs_read.rb +5 -0
  38. data/lib/brute/tools/fs_remove.rb +5 -0
  39. data/lib/brute/tools/fs_search.rb +5 -0
  40. data/lib/brute/tools/fs_undo.rb +5 -0
  41. data/lib/brute/tools/fs_write.rb +50 -0
  42. data/lib/brute/tools/net_fetch.rb +5 -0
  43. data/lib/brute/tools/question.rb +5 -0
  44. data/lib/brute/tools/shell.rb +5 -0
  45. data/lib/brute/tools/todo_read.rb +5 -0
  46. data/lib/brute/tools/todo_write.rb +5 -0
  47. data/lib/brute/version.rb +1 -1
  48. data/lib/brute.rb +8 -8
  49. metadata +1 -1
@@ -39,3 +39,92 @@ module Brute
39
39
  end
40
40
  end
41
41
  end
42
+
43
+ if __FILE__ == $0
44
+ require_relative "../../../spec/spec_helper"
45
+
46
+ RSpec.describe Brute::Middleware::LLMCall do
47
+ let(:provider) { MockProvider.new }
48
+ let(:middleware) { described_class.new }
49
+
50
+ it "calls ctx.talk with env[:input] and returns the response" do
51
+ ctx = LLM::Context.new(provider, tools: [])
52
+ prompt = ctx.prompt { |p| p.system("sys"); p.user("hello") }
53
+ env = build_env(context: ctx, provider: provider, input: prompt, streaming: false)
54
+
55
+ response = middleware.call(env)
56
+
57
+ expect(response).not_to be_nil
58
+ expect(provider.calls.size).to eq(1)
59
+ end
60
+
61
+ context "when not streaming" do
62
+ it "fires on_content callback with the response text" do
63
+ received_content = nil
64
+ callback = ->(text) { received_content = text }
65
+
66
+ response = MockResponse.new(content: "Hello world")
67
+ allow(provider).to receive(:complete).and_return(response)
68
+
69
+ ctx = LLM::Context.new(provider, tools: [])
70
+ prompt = ctx.prompt { |p| p.system("sys"); p.user("hi") }
71
+ env = build_env(
72
+ context: ctx,
73
+ provider: provider,
74
+ input: prompt,
75
+ streaming: false,
76
+ callbacks: { on_content: callback }
77
+ )
78
+
79
+ middleware.call(env)
80
+
81
+ expect(received_content).to eq("Hello world")
82
+ end
83
+ end
84
+
85
+ context "when streaming" do
86
+ it "does not fire on_content callback" do
87
+ callback_called = false
88
+ callback = ->(_text) { callback_called = true }
89
+
90
+ ctx = LLM::Context.new(provider, tools: [])
91
+ prompt = ctx.prompt { |p| p.system("sys"); p.user("hi") }
92
+ env = build_env(
93
+ context: ctx,
94
+ provider: provider,
95
+ input: prompt,
96
+ streaming: true,
97
+ callbacks: { on_content: callback }
98
+ )
99
+
100
+ middleware.call(env)
101
+
102
+ expect(callback_called).to be false
103
+ end
104
+ end
105
+
106
+ context "when response content raises NoMethodError (tool-only response)" do
107
+ it "does not crash and does not fire on_content" do
108
+ received_content = :not_called
109
+ callback = ->(text) { received_content = text }
110
+
111
+ bad_response = MockResponse.new(content: "")
112
+ allow(bad_response).to receive(:content).and_raise(NoMethodError)
113
+ allow(provider).to receive(:complete).and_return(bad_response)
114
+
115
+ ctx = LLM::Context.new(provider, tools: [])
116
+ prompt = ctx.prompt { |p| p.system("sys"); p.user("hi") }
117
+ env = build_env(
118
+ context: ctx,
119
+ provider: provider,
120
+ input: prompt,
121
+ streaming: false,
122
+ callbacks: { on_content: callback }
123
+ )
124
+
125
+ expect { middleware.call(env) }.not_to raise_error
126
+ expect(received_content).to eq(:not_called)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Middleware
5
10
  # Records every LLM exchange into a MessageStore in the OpenCode
@@ -207,3 +212,175 @@ module Brute
207
212
  end
208
213
  end
209
214
  end
215
+
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
238
+
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)
242
+
243
+ env[:tool_results] = [["read", "file contents"]]
244
+ middleware.call(env)
245
+
246
+ user_msgs = store.messages.select { |m| m[:info][:role] == "user" }
247
+ expect(user_msgs.size).to eq(1)
248
+ end
249
+ end
250
+
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
280
+ end
281
+
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
300
+ end
301
+
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
323
+ end
324
+
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)
351
+
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
356
+ end
357
+
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)
362
+
363
+ expect(env[:message_tracking]).to eq(middleware)
364
+ end
365
+
366
+ it "returns the inner app response unchanged" do
367
+ env = build_env(input: "Hello", tool_results: nil)
368
+ result = middleware.call(env)
369
+
370
+ expect(result).to eq(response)
371
+ end
372
+ end
373
+
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)
378
+
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
384
+ end
385
+ end
386
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Middleware
5
10
  module OTel
@@ -73,3 +78,109 @@ module Brute
73
78
  end
74
79
  end
75
80
  end
81
+
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
+ })
113
+
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
175
+
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
180
+
181
+ expect { mw.call(env) }.to raise_error(RuntimeError)
182
+ expect(env[:span]).to be_nil
183
+ end
184
+ end
185
+ end
186
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Middleware
5
10
  module OTel
@@ -28,3 +33,91 @@ module Brute
28
33
  end
29
34
  end
30
35
  end
36
+
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 }
66
+
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)
105
+
106
+ expect(result).to eq(no_usage)
107
+ expect(span).not_to have_received(:set_attribute)
108
+ end
109
+
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)
116
+
117
+ mw.call(env)
118
+
119
+ expect(span).not_to have_received(:set_attribute)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Middleware
5
10
  module OTel
@@ -37,3 +42,111 @@ module Brute
37
42
  end
38
43
  end
39
44
  end
45
+
46
+ if __FILE__ == $0
47
+ require_relative "../../../../spec/spec_helper"
48
+
49
+ RSpec.describe Brute::Middleware::OTel::ToolCalls do
50
+ let(:response) { MockResponse.new(content: "here's my plan") }
51
+ let(:inner_app) { ->(_env) { response } }
52
+ let(:middleware) { described_class.new(inner_app) }
53
+
54
+ it "passes the response through unchanged" do
55
+ env = build_env
56
+ result = middleware.call(env)
57
+ expect(result).to eq(response)
58
+ end
59
+
60
+ context "when env[:span] is nil" do
61
+ it "passes through without error even with pending functions" do
62
+ ctx = build_env[:context]
63
+ fn = double("function", name: "fs_read", id: "tc_001", arguments: { "path" => "/tmp" })
64
+ allow(ctx).to receive(:functions).and_return([fn])
65
+
66
+ env = build_env(context: ctx)
67
+ result = middleware.call(env)
68
+ expect(result).to eq(response)
69
+ end
70
+ end
71
+
72
+ context "when env[:span] is present" do
73
+ let(:span) { mock_span }
74
+
75
+ it "does nothing when there are no pending functions" do
76
+ ctx = build_env[:context]
77
+ allow(ctx).to receive(:functions).and_return([])
78
+
79
+ env = build_env(context: ctx, span: span)
80
+ middleware.call(env)
81
+
82
+ expect(span).not_to have_received(:add_event)
83
+ expect(span).not_to have_received(:set_attribute)
84
+ end
85
+
86
+ it "does nothing when functions is nil" do
87
+ ctx = build_env[:context]
88
+ allow(ctx).to receive(:functions).and_return(nil)
89
+
90
+ env = build_env(context: ctx, span: span)
91
+ middleware.call(env)
92
+
93
+ expect(span).not_to have_received(:add_event)
94
+ end
95
+
96
+ it "records a tool_call event per pending function" do
97
+ ctx = build_env[:context]
98
+ fn1 = double("function", name: "fs_read", id: "tc_001", arguments: { "path" => "/src/main.rb" })
99
+ fn2 = double("function", name: "shell", id: "tc_002", arguments: { "command" => "rspec" })
100
+ allow(ctx).to receive(:functions).and_return([fn1, fn2])
101
+
102
+ env = build_env(context: ctx, span: span)
103
+ middleware.call(env)
104
+
105
+ expect(span).to have_received(:set_attribute).with("brute.tool_calls.count", 2)
106
+ expect(span).to have_received(:add_event).with(
107
+ "tool_call",
108
+ attributes: hash_including(
109
+ "tool.name" => "fs_read",
110
+ "tool.id" => "tc_001"
111
+ )
112
+ )
113
+ expect(span).to have_received(:add_event).with(
114
+ "tool_call",
115
+ attributes: hash_including(
116
+ "tool.name" => "shell",
117
+ "tool.id" => "tc_002"
118
+ )
119
+ )
120
+ end
121
+
122
+ it "serializes arguments as JSON" do
123
+ ctx = build_env[:context]
124
+ args = { "path" => "/tmp/test.rb", "content" => "puts 'hi'" }
125
+ fn = double("function", name: "fs_write", id: "tc_003", arguments: args)
126
+ allow(ctx).to receive(:functions).and_return([fn])
127
+
128
+ env = build_env(context: ctx, span: span)
129
+ middleware.call(env)
130
+
131
+ expect(span).to have_received(:add_event).with(
132
+ "tool_call",
133
+ attributes: hash_including("tool.arguments" => args.to_json)
134
+ )
135
+ end
136
+
137
+ it "handles nil arguments" do
138
+ ctx = build_env[:context]
139
+ fn = double("function", name: "todo_read", id: "tc_004", arguments: nil)
140
+ allow(ctx).to receive(:functions).and_return([fn])
141
+
142
+ env = build_env(context: ctx, span: span)
143
+ middleware.call(env)
144
+
145
+ expect(span).to have_received(:add_event).with(
146
+ "tool_call",
147
+ attributes: { "tool.name" => "todo_read", "tool.id" => "tc_004" }
148
+ )
149
+ end
150
+ end
151
+ end
152
+ end