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.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +14 -0
- data/lib/brute/diff.rb +18 -28
- data/lib/brute/loop/agent_stream.rb +118 -0
- data/lib/brute/loop/agent_turn.rb +520 -0
- data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
- data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
- data/lib/brute/loop/step.rb +332 -0
- data/lib/brute/loop/tool_call_step.rb +90 -0
- data/lib/brute/middleware/compaction_check.rb +60 -146
- data/lib/brute/middleware/doom_loop_detection.rb +95 -92
- data/lib/brute/middleware/llm_call.rb +78 -80
- data/lib/brute/middleware/message_tracking.rb +115 -162
- data/lib/brute/middleware/otel/span.rb +25 -106
- data/lib/brute/middleware/otel/token_usage.rb +29 -84
- data/lib/brute/middleware/otel/tool_calls.rb +23 -107
- data/lib/brute/middleware/otel/tool_results.rb +22 -86
- data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
- data/lib/brute/middleware/retry.rb +95 -76
- data/lib/brute/middleware/session_persistence.rb +38 -37
- data/lib/brute/middleware/token_tracking.rb +64 -63
- data/lib/brute/middleware/tool_error_tracking.rb +108 -82
- data/lib/brute/middleware/tool_use_guard.rb +57 -90
- data/lib/brute/middleware/tracing.rb +53 -63
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/pipeline.rb +77 -133
- data/lib/brute/prompts/build_switch.rb +21 -25
- data/lib/brute/prompts/environment.rb +31 -35
- data/lib/brute/prompts/identity.rb +22 -29
- data/lib/brute/prompts/instructions.rb +15 -18
- data/lib/brute/prompts/max_steps.rb +18 -25
- data/lib/brute/prompts/plan_reminder.rb +18 -26
- data/lib/brute/prompts/skills.rb +8 -30
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +2 -2
- data/lib/brute/providers.rb +62 -0
- data/lib/brute/queue/base_queue.rb +222 -0
- data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
- data/lib/brute/queue/parallel_queue.rb +66 -0
- data/lib/brute/queue/sequential_queue.rb +63 -0
- data/lib/brute/store/message_store.rb +362 -0
- data/lib/brute/store/session.rb +106 -0
- data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
- data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
- data/lib/brute/system_prompt.rb +81 -194
- data/lib/brute/tools/delegate.rb +46 -116
- data/lib/brute/tools/fs_patch.rb +36 -37
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +29 -41
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +31 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +40 -204
- metadata +31 -20
- data/lib/brute/agent_stream.rb +0 -181
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/message_store.rb +0 -463
- data/lib/brute/orchestrator.rb +0 -550
- data/lib/brute/session.rb +0 -161
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
174
|
-
#
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
217
|
-
require_relative "../../../spec/
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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 =
|
|
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[:
|
|
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
|
-
|
|
83
|
-
require_relative "../../../../spec/
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
38
|
-
require_relative "../../../../spec/
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|