brute 0.3.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.
- checksums.yaml +4 -4
- data/lib/brute/agent_stream.rb +126 -2
- data/lib/brute/diff.rb +34 -0
- data/lib/brute/message_store.rb +194 -0
- data/lib/brute/middleware/compaction_check.rb +133 -0
- data/lib/brute/middleware/doom_loop_detection.rb +100 -0
- data/lib/brute/middleware/llm_call.rb +89 -0
- data/lib/brute/middleware/message_tracking.rb +177 -0
- data/lib/brute/middleware/otel/span.rb +111 -0
- data/lib/brute/middleware/otel/token_usage.rb +93 -0
- data/lib/brute/middleware/otel/tool_calls.rb +113 -0
- data/lib/brute/middleware/otel/tool_results.rb +92 -0
- data/lib/brute/middleware/otel.rb +5 -0
- data/lib/brute/middleware/reasoning_normalizer.rb +119 -0
- data/lib/brute/middleware/retry.rb +93 -0
- data/lib/brute/middleware/session_persistence.rb +42 -0
- data/lib/brute/middleware/token_tracking.rb +77 -0
- data/lib/brute/middleware/tool_error_tracking.rb +101 -0
- data/lib/brute/middleware/tool_use_guard.rb +70 -1
- data/lib/brute/middleware/tracing.rb +71 -0
- data/lib/brute/orchestrator.rb +169 -3
- data/lib/brute/patches/buffer_nil_guard.rb +5 -0
- data/lib/brute/pipeline.rb +135 -0
- data/lib/brute/prompts/build_switch.rb +33 -0
- data/lib/brute/prompts/environment.rb +47 -0
- data/lib/brute/prompts/identity.rb +36 -0
- data/lib/brute/prompts/instructions.rb +24 -0
- data/lib/brute/prompts/max_steps.rb +32 -0
- data/lib/brute/prompts/plan_reminder.rb +33 -0
- data/lib/brute/prompts/skills.rb +35 -0
- data/lib/brute/providers/opencode_go.rb +5 -0
- data/lib/brute/providers/opencode_zen.rb +7 -2
- data/lib/brute/providers/shell_response.rb +5 -0
- data/lib/brute/system_prompt.rb +214 -0
- data/lib/brute/tools/delegate.rb +129 -0
- data/lib/brute/tools/fs_patch.rb +53 -0
- data/lib/brute/tools/fs_read.rb +5 -0
- data/lib/brute/tools/fs_remove.rb +5 -0
- data/lib/brute/tools/fs_search.rb +5 -0
- data/lib/brute/tools/fs_undo.rb +5 -0
- data/lib/brute/tools/fs_write.rb +50 -0
- data/lib/brute/tools/net_fetch.rb +5 -0
- data/lib/brute/tools/question.rb +5 -0
- data/lib/brute/tools/shell.rb +5 -0
- data/lib/brute/tools/todo_read.rb +5 -0
- data/lib/brute/tools/todo_write.rb +5 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +8 -8
- metadata +2 -2
|
@@ -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
|