brute 4.3.2 → 5.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/completion/async_faraday.rb +38 -0
  3. data/lib/brute/completion/lang_chain.rb +185 -0
  4. data/lib/brute/completion/llmrb.rb +182 -0
  5. data/lib/brute/completion/open_router.rb +54 -42
  6. data/lib/brute/completion/ruby_llm.rb +198 -0
  7. data/lib/brute/contrib/otel.rb +208 -0
  8. data/lib/brute/hooks.rb +149 -31
  9. data/lib/brute/message_transport/lang_chain.rb +36 -0
  10. data/lib/brute/message_transport/llm.rb +6 -0
  11. data/lib/brute/message_transport/open_router.rb +6 -0
  12. data/lib/brute/message_transport/ruby_llm.rb +6 -0
  13. data/lib/brute/message_transport.rb +8 -0
  14. data/lib/brute/middleware/000_base.rb +61 -0
  15. data/lib/brute/middleware/002_session_log.rb +1 -1
  16. data/lib/brute/middleware/004_summarize.rb +1 -1
  17. data/lib/brute/middleware/005_tracing.rb +1 -1
  18. data/lib/brute/middleware/006_loop.rb +1 -1
  19. data/lib/brute/middleware/008_checkpoint.rb +1 -1
  20. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  21. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  22. data/lib/brute/middleware/025_skills.rb +1 -1
  23. data/lib/brute/middleware/040_compaction_check.rb +1 -1
  24. data/lib/brute/middleware/060_questions.rb +1 -1
  25. data/lib/brute/middleware/070_tool_pipeline.rb +60 -47
  26. data/lib/brute/middleware/event_handler.rb +1 -1
  27. data/lib/brute/middleware/user_queue.rb +1 -1
  28. data/lib/brute/turn/agent_pipeline.rb +3 -4
  29. data/lib/brute/turn/pipeline.rb +151 -2
  30. data/lib/brute/usage_detection/lang_chain.rb +44 -0
  31. data/lib/brute/usage_detection/llmrb.rb +53 -0
  32. data/lib/brute/usage_detection/open_router.rb +62 -0
  33. data/lib/brute/usage_detection/ruby_llm.rb +55 -0
  34. data/lib/brute/usage_detection/usage.rb +51 -0
  35. data/lib/brute/version.rb +1 -1
  36. data/lib/brute.rb +95 -3
  37. metadata +83 -8
  38. data/lib/brute/changelog.rb +0 -322
  39. data/lib/brute/deprecate.rb +0 -132
  40. data/lib/brute/middleware/001_otel_span.rb +0 -79
  41. data/lib/brute/middleware/015_otel_token_usage.rb +0 -44
  42. data/lib/brute/middleware/073_otel_tool_call.rb +0 -51
  43. data/lib/brute/middleware/075_otel_tool_results.rb +0 -48
  44. data/lib/brute/middleware/open_router.rb +0 -56
@@ -8,6 +8,12 @@ module Brute
8
8
  class MessageTransport
9
9
  class RubyLLM < MessageTransport
10
10
 
11
+ # What the provider reported about this call — the transport knows its
12
+ # own library's shape, so it knows which detector to ask.
13
+ def self.usage_metrics(message)
14
+ Brute::UsageDetection::RubyLLM.detect(message)
15
+ end
16
+
11
17
  # Brute::Message -> RubyLLM::Message (tool calls as ruby_llm's id-keyed hash).
12
18
  def self.dump(message)
13
19
  tool_calls = message.tool_calls&.to_h do |tc|
@@ -21,6 +21,14 @@ module Brute
21
21
  messages.map { |message| dump(message) }
22
22
  end
23
23
 
24
+ # Inbound: what the provider reported about this call, as a
25
+ # Brute::UsageDetection::Usage. Each transport knows its own library's
26
+ # response shape, so it is the one that knows which detector to ask.
27
+ # Answers nil for a library that reports no usage at all.
28
+ def self.usage_metrics(_result)
29
+ nil
30
+ end
31
+
24
32
  def initialize(result)
25
33
  @result = result
26
34
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/hooks"
6
+
7
+ module Brute
8
+ module Middleware
9
+ # The parent of every middleware: it takes the next app, does its work
10
+ # around it, and calls it.
11
+ #
12
+ # class Shout < Brute::Middleware::Base
13
+ # def call(env)
14
+ # emit(ENTER_EVENT, env, self)
15
+ # @app.call(env)
16
+ # end
17
+ # end
18
+ #
19
+ # Brute::Hooks is included, so the event names are first class in every
20
+ # subclass — ENTER_EVENT, not Brute::Hooks::ENTER_EVENT.
21
+ class Base
22
+ include Brute::Hooks
23
+
24
+ def initialize(app, *, **)
25
+ @app = app
26
+ end
27
+
28
+ # A layer that adds nothing passes the turn straight through.
29
+ def call(env) = @app.call(env)
30
+
31
+ private
32
+
33
+ attr_reader :app
34
+ end
35
+ end
36
+ end
37
+
38
+ __END__
39
+
40
+ describe "brute/middleware/000_base" do
41
+ it "carries the app, passes the turn through, and makes the event names first class" do
42
+ passed = []
43
+ terminal = ->(env) { passed << env; env }
44
+
45
+ Brute::Middleware::Base.new(terminal).call({ turn: 1 }).should == { turn: 1 }
46
+ passed.should == [{ turn: 1 }]
47
+
48
+ # Whatever a subclass declares, the base swallows.
49
+ Brute::Middleware::Base.new(terminal, :extra, keyword: true).call({}).should == {}
50
+
51
+ # The event names resolve through the class itself, so a subclass writes
52
+ # ENTER_EVENT rather than Brute::Hooks::ENTER_EVENT.
53
+ Brute::Middleware::Base.const_get(:ENTER_EVENT).should == :enter
54
+ Brute::Middleware::SystemPrompt.const_get(:AFTER_TOOL_EVENT).should == :after_tool
55
+
56
+ # Every middleware in the chain descends from it.
57
+ Brute::Middleware::SystemPrompt.ancestors.should.include Brute::Middleware::Base
58
+ Brute::Middleware::ToolPipeline.ancestors.should.include Brute::Middleware::Base
59
+ Brute::Middleware::Loop::ToolResult.ancestors.should.include Brute::Middleware::Base
60
+ end
61
+ end
@@ -23,7 +23,7 @@ module Brute
23
23
  # .use(Brute::Middleware::SystemPrompt)
24
24
  # ...
25
25
  #
26
- class SessionLog
26
+ class SessionLog < Brute::Middleware::Base
27
27
  def initialize(app, path:)
28
28
  @app = app
29
29
  @path = path
@@ -22,7 +22,7 @@ module Brute
22
22
  # use ToolPipeline
23
23
  # run ->(env) { ... } # inline LLM call proc (see Brute.agent)
24
24
  #
25
- class Summarize
25
+ class Summarize < Brute::Middleware::Base
26
26
  DEFAULT_PROMPT = "Provide your complete findings based on everything you've explored."
27
27
 
28
28
  def initialize(app, prompt: DEFAULT_PROMPT)
@@ -21,7 +21,7 @@ module Brute
21
21
  # llm_call_count: number of LLM calls so far
22
22
  # last_call_elapsed: duration of the most recent LLM call
23
23
  #
24
- class Tracing
24
+ class Tracing < Brute::Middleware::Base
25
25
  def initialize(app, logger:)
26
26
  @app = app
27
27
 
@@ -21,7 +21,7 @@ module Brute
21
21
  # !env[:should_exit] && env[:messages].last&.role == :tool
22
22
  # end
23
23
  #
24
- class Loop
24
+ class Loop < Brute::Middleware::Base
25
25
  def initialize(app, condition = nil, &block)
26
26
  @app = app
27
27
  @condition = condition || block
@@ -33,7 +33,7 @@ module Brute
33
33
  # System messages are not persisted (SystemPrompt re-adds them each
34
34
  # turn); restored history is inserted after any leading system message
35
35
  # and before the current turn's input.
36
- class Checkpoint
36
+ class Checkpoint < Brute::Middleware::Base
37
37
  def initialize(app, path:, resume: nil)
38
38
  @app = app
39
39
  @path = path
@@ -11,7 +11,7 @@ module Brute
11
11
  # stating that maximum iterations have been reached. This causes
12
12
  # Loop::ToolResult to exit its loop naturally (last message is not :tool).
13
13
  #
14
- class MaxIterations
14
+ class MaxIterations < Brute::Middleware::Base
15
15
 
16
16
  DEFAULT_MAX_ITERATIONS = 100
17
17
 
@@ -25,7 +25,7 @@ module Brute
25
25
  # message (e.g. from session.system(...)), so manually-set system
26
26
  # prompts are respected.
27
27
  #
28
- class SystemPrompt
28
+ class SystemPrompt < Brute::Middleware::Base
29
29
  def initialize(app, system_prompt: Brute::SystemPrompt.default)
30
30
  @app = app
31
31
  @system_prompt = system_prompt
@@ -23,7 +23,7 @@ module Brute
23
23
  #
24
24
  # Place it before Middleware::SystemPrompt in the stack. It never touches
25
25
  # env[:messages] itself.
26
- class Skills
26
+ class Skills < Brute::Middleware::Base
27
27
  def initialize(app, skills: [])
28
28
  @app = app
29
29
  @skills = skills
@@ -15,7 +15,7 @@ module Brute
15
15
  # Or an LLM that doesn't support it can just use the messages
16
16
  # that come after the compaction
17
17
  #
18
- class CompactionCheck
18
+ class CompactionCheck < Brute::Middleware::Base
19
19
  def initialize(app, compactor: nil, system_prompt:, **compactor_opts)
20
20
  @app = app
21
21
  @compactor = compactor
@@ -5,7 +5,7 @@ require "brute"
5
5
 
6
6
  module Brute
7
7
  module Middleware
8
- class Question
8
+ class Question < Brute::Middleware::Base
9
9
  def initialize(app)
10
10
  @app = app
11
11
  end
@@ -8,7 +8,7 @@ require "async/barrier"
8
8
 
9
9
  module Brute
10
10
  module Middleware
11
- class ToolPipeline
11
+ class ToolPipeline < Brute::Middleware::Base
12
12
  def initialize(app, tools: [])
13
13
  @app = app
14
14
  @tools = tools
@@ -56,34 +56,37 @@ module Brute
56
56
  name: name.to_s,
57
57
  arguments: args,
58
58
  result: nil,
59
+ denied: nil,
59
60
  events: env[:events],
60
61
  metadata: {},
61
62
  turn_env: env,
62
63
  }
63
- if (hooks = env[:hooks])
64
- responses = hooks.emit(:before_tool, call_env).compact
65
- call_env[:result] = responses.last if call_env[:result].nil? && !responses.empty?
66
-
67
- if call_env[:result].nil?
68
- denial = hooks.emit(:approve_tool, call_env).find { |r| r == false || r.is_a?(String) }
69
- unless denial.nil?
70
- call_env[:result] = denial.is_a?(String) ? denial : %(Tool call to "#{name}" was denied.)
71
- end
64
+ # A subscriber takes part by mutating the call env: set
65
+ # :result to answer without executing, set :denied to refuse.
66
+ emit(BEFORE_TOOL_EVENT, env, call_env)
67
+
68
+ if call_env[:result].nil?
69
+ emit(APPROVE_TOOL_EVENT, env, call_env)
70
+
71
+ if (denial = call_env[:denied])
72
+ call_env[:result] = denial.is_a?(String) ? denial : %(Tool call to "#{name}" was denied.)
72
73
  end
73
74
  end
74
75
 
75
- result = if call_env[:result].nil?
76
- available_tools[name].call(call_env[:arguments])
77
- else
78
- call_env[:result]
79
- end
76
+ # Only the tool's own execution is timed: a call that
77
+ # before_tool answered, or approve_tool denied, never ran.
78
+ result = call_env[:result]
80
79
 
81
- if (hooks = env[:hooks])
82
- call_env[:result] = result
83
- hooks.emit(:after_tool, call_env)
84
- result = call_env[:result]
80
+ if result.nil?
81
+ emit(TOOL_DURATION_EVENT, env, call_env) do
82
+ result = available_tools[name].call(call_env[:arguments])
83
+ end
85
84
  end
86
85
 
86
+ call_env[:result] = result
87
+ emit(AFTER_TOOL_EVENT, env, call_env)
88
+ result = call_env[:result]
89
+
87
90
  # Coerce to String so Hash results (e.g. Shell's
88
91
  # {stdout:, stderr:, exit_code:}) serialize predictably.
89
92
  if result.is_a?(String)
@@ -176,8 +179,18 @@ describe "brute/middleware/070_tool_pipeline" do
176
179
 
177
180
  # --- lifecycle hooks (Brute::Hooks) ---
178
181
 
179
- def hook_env(hooks)
180
- { messages: Brute.log, events: [], hooks: hooks }
182
+ # A layer only gets its emit from the builder that made it, so a hook spec
183
+ # builds a real pipeline rather than instantiating the middleware alone.
184
+ def hooked(inner, tools:, &subscribe)
185
+ pipeline = Brute::Turn::Pipeline.new
186
+ pipeline.use Brute::Middleware::ToolPipeline, tools: tools
187
+ pipeline.run(Object.new.tap { |o| o.define_singleton_method(:call, &inner) })
188
+ subscribe.call(pipeline)
189
+ pipeline
190
+ end
191
+
192
+ def hook_env
193
+ { messages: Brute.log, events: [] }
181
194
  end
182
195
 
183
196
  it "before_tool may rewrite arguments and short-circuit with a result" do
@@ -186,20 +199,19 @@ describe "brute/middleware/070_tool_pipeline" do
186
199
  env[:messages] << Brute::Message.new(role: :assistant, content: "",
187
200
  tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "orig" } }])
188
201
  end
189
- hooks = Brute::Hooks.new
190
- hooks.on(:before_tool) { |call| call[:arguments] = { text: "rewritten" }; nil }
191
- mw = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
192
- env = hook_env(hooks)
202
+
203
+ pipeline = hooked(inner, tools: [tool]) do |p|
204
+ p.on(:before_tool) { |_env, call| call[:arguments] = { text: "rewritten" } }
205
+ end
206
+ env = hook_env
193
207
  env[:messages].user("hi")
194
- mw.call(env)
208
+ pipeline.call(env)
195
209
  env[:messages].last.content.should == "ran:rewritten"
196
210
 
197
- hooks2 = Brute::Hooks.new
198
- hooks2.on(:before_tool) { |_call| "canned" }
199
- mw2 = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
200
- env2 = hook_env(hooks2)
211
+ canned = hooked(inner, tools: [tool]) { |p| p.on(:before_tool) { |_env, call| call[:result] = "canned" } }
212
+ env2 = hook_env
201
213
  env2[:messages].user("hi")
202
- mw2.call(env2)
214
+ canned.call(env2)
203
215
  env2[:messages].last.content.should == "canned" # never executed
204
216
  end
205
217
 
@@ -209,18 +221,17 @@ describe "brute/middleware/070_tool_pipeline" do
209
221
  env[:messages] << Brute::Message.new(role: :assistant, content: "",
210
222
  tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
211
223
  end
212
- hooks = Brute::Hooks.new
213
- hooks.on(:approve_tool) { |_call| false }
214
- env = hook_env(hooks)
224
+
225
+ denied = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = true } }
226
+ env = hook_env
215
227
  env[:messages].user("hi")
216
- Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env)
228
+ denied.call(env)
217
229
  env[:messages].last.content.should == %(Tool call to "exec" was denied.)
218
230
 
219
- hooks2 = Brute::Hooks.new
220
- hooks2.on(:approve_tool) { |_call| "denied by policy" }
221
- env2 = hook_env(hooks2)
231
+ by_policy = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = "denied by policy" } }
232
+ env2 = hook_env
222
233
  env2[:messages].user("hi")
223
- Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env2)
234
+ by_policy.call(env2)
224
235
  env2[:messages].last.content.should == "denied by policy"
225
236
  end
226
237
 
@@ -230,11 +241,13 @@ describe "brute/middleware/070_tool_pipeline" do
230
241
  env[:messages] << Brute::Message.new(role: :assistant, content: "",
231
242
  tool_calls: [{ id: "tc1", name: "echo", arguments: {} }])
232
243
  end
233
- hooks = Brute::Hooks.new
234
- hooks.on(:after_tool) { |call| call[:result] = "rewrote(#{call[:result]})" }
235
- env = hook_env(hooks)
244
+
245
+ pipeline = hooked(inner, tools: [tool]) do |p|
246
+ p.on(:after_tool) { |_env, call| call[:result] = "rewrote(#{call[:result]})" }
247
+ end
248
+ env = hook_env
236
249
  env[:messages].user("hi")
237
- Brute::Middleware::ToolPipeline.new(inner, tools: [tool]).call(env)
250
+ pipeline.call(env)
238
251
  env[:messages].last.content.should == "rewrote(raw)"
239
252
  end
240
253
 
@@ -262,13 +275,13 @@ describe "brute/middleware/070_tool_pipeline" do
262
275
  inner = ->(env) {
263
276
  env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
264
277
  }
265
- mw = Brute::Middleware::ToolPipeline.new(inner, tools: [big_tool])
278
+ pipeline = hooked(inner, tools: [big_tool]) { |_p| nil }
266
279
  env = {
267
280
  messages: Brute.log,
268
281
  events: [],
269
282
  }
270
283
  env[:messages].user("hello")
271
- mw.call(env)
284
+ pipeline.call(env)
272
285
 
273
286
  tool_msg = env[:messages].select { |m| m.role == :tool }.last
274
287
  tool_msg.content.lines.size.should.be < 2100
@@ -299,13 +312,13 @@ describe "brute/middleware/070_tool_pipeline" do
299
312
  inner = ->(env) {
300
313
  env[:messages] << Brute::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
301
314
  }
302
- mw = Brute::Middleware::ToolPipeline.new(inner, tools: [pre_truncated_tool])
315
+ pipeline = hooked(inner, tools: [pre_truncated_tool]) { |_p| nil }
303
316
  env = {
304
317
  messages: Brute.log,
305
318
  events: [],
306
319
  }
307
320
  env[:messages].user("hello")
308
- mw.call(env)
321
+ pipeline.call(env)
309
322
 
310
323
  tool_msg = env[:messages].select { |m| m.role == :tool }.last
311
324
  # Should contain exactly one truncation marker, not two
@@ -5,7 +5,7 @@ require 'brute'
5
5
 
6
6
  module Brute
7
7
  module Middleware
8
- class EventHandler
8
+ class EventHandler < Brute::Middleware::Base
9
9
  def initialize(app, handler_class:, **opts)
10
10
  @app = app
11
11
  @handler_class = handler_class
@@ -5,7 +5,7 @@ require "brute"
5
5
 
6
6
  module Brute
7
7
  module Middleware
8
- class UserQueue
8
+ class UserQueue < Brute::Middleware::Base
9
9
 
10
10
  # Useful for testing...
11
11
  # App will keep looping till all inputs are drained.
@@ -45,13 +45,12 @@ module Brute
45
45
  events: events,
46
46
  metadata: {},
47
47
  current_iteration: 1,
48
- hooks: hooks,
49
48
  }
50
- hooks.emit(:turn_start, env)
49
+ hooks.emit(TURN_START_EVENT, env)
51
50
  begin
52
- build.call(env)
51
+ hooks.emit(TURN_DURATION_EVENT, env) { build.call(env) }
53
52
  ensure
54
- hooks.emit(:turn_end, env)
53
+ hooks.emit(TURN_END_EVENT, env)
55
54
  end
56
55
  env
57
56
  end
@@ -2,11 +2,14 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "brute"
5
+ require "brute/hooks"
5
6
 
6
7
  module Brute
7
8
  module Turn
8
9
  class Pipeline < ::Rack::Builder
9
10
  module Chainable
11
+ include Brute::Hooks
12
+
10
13
  # Enables the following syntax:
11
14
  #
12
15
  # Brute.agent
@@ -17,8 +20,49 @@ module Brute
17
20
  # your_llm_library.complete("How to make money?")
18
21
  # }
19
22
  #
20
- def use(...) = tap { super }
21
- def run(...) = tap { super }
23
+ # Every layer announces itself: :middleware_added when it goes on the
24
+ # stack, then :enter and :exit around its own work on each turn. Both
25
+ # of those carry the middleware instance as self, and :exit fires from
26
+ # an ensure so a layer that raises is still reported.
27
+ def use(middleware, *args, &block)
28
+ tap do
29
+ super
30
+ hooks.emit(MIDDLEWARE_ADDED_EVENT, {}, middleware, *args)
31
+
32
+ builder = @use.pop
33
+ @use << lambda do |app|
34
+ bind_emitter(builder.call(app)).tap do |layer|
35
+ layer.define_singleton_method(:call) do |env|
36
+ emit(ENTER_EVENT, env, self)
37
+
38
+ # The layer's work is :duration's block, so its subscribers
39
+ # are handed how long this layer took — its own work plus
40
+ # everything below it. :exit then marks the layer done,
41
+ # from an ensure so it lands even when the work raised.
42
+ result = nil
43
+ begin
44
+ emit(DURATION_EVENT, env, self) { result = super(env) }
45
+ ensure
46
+ emit(EXIT_EVENT, env, self)
47
+ end
48
+
49
+ result
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ # Keeps a trailing hash flagged as keywords through `super`, the way
56
+ # Rack::Builder#use does — without it `use Mw, label: "x"` arrives at
57
+ # the middleware's constructor as a positional Hash.
58
+ ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true)
59
+
60
+ def run(app = nil, &block)
61
+ tap do
62
+ bind_emitter(app) unless app.nil?
63
+ super
64
+ end
65
+ end
22
66
 
23
67
  # Subscribe a lifecycle hook (see Brute::Hooks):
24
68
  #
@@ -29,6 +73,29 @@ module Brute
29
73
  # .on(:approve_tool) { |call| call[:name] != "exec" }
30
74
  #
31
75
  def on(...) = tap { hooks.on(...) }
76
+
77
+ private
78
+
79
+ # Bind an emit() to this builder's store, so a layer fires events at
80
+ # the pipeline it belongs to rather than fishing them out of env.
81
+ def bind_emitter(object)
82
+ if object.is_a?(Proc)
83
+ warn("brute: #{self.class} was given a lambda, which cannot be given an emit — its events will not fire")
84
+ return object
85
+ end
86
+
87
+ if object.respond_to?(:emit)
88
+ raise ArgumentError, "#{object.class} already defines #emit, so the pipeline will not bind its own over it"
89
+ end
90
+
91
+ store = hooks
92
+ object.define_singleton_method(:emit) do |event, env, *extras, &work|
93
+ store.emit(event, env, *extras, &work)
94
+ end
95
+ object
96
+ end
97
+
98
+ public
32
99
  end
33
100
 
34
101
  include Chainable
@@ -66,6 +133,88 @@ end
66
133
  __END__
67
134
 
68
135
  describe "brute/turn/pipeline" do
136
+ it "announces a layer when it is added, then on the way in and out of every turn" do
137
+ seen = []
138
+ hooks = Brute::Hooks.new
139
+ hooks.on(Brute::Hooks::MIDDLEWARE_ADDED_EVENT) { |env, mw, *args| seen << [:added, env, mw, args] }
140
+ hooks.on(Brute::Hooks::ENTER_EVENT) { |_env, layer| seen << [:enter, layer.class] }
141
+ hooks.on(Brute::Hooks::DURATION_EVENT) { |_env, started, finished, layer| seen << [:duration, layer.class, finished >= started] }
142
+ hooks.on(Brute::Hooks::EXIT_EVENT) { |_env, layer| seen << [:exit, layer.class] }
143
+
144
+ labeller = Class.new do
145
+ def initialize(app, label:); @app = app; @label = label; end
146
+ def call(env); @app.call(env); end
147
+ end
148
+
149
+ pipeline = Brute::Turn::Pipeline.new
150
+ pipeline.instance_variable_set(:@hooks, hooks)
151
+ pipeline.use labeller, label: "outer"
152
+ pipeline.run ->(env) { env }
153
+ pipeline.call({ hooks: hooks })
154
+
155
+ seen.first.should == [:added, {}, labeller, [{ label: "outer" }]]
156
+ seen[1].should == [:enter, labeller]
157
+ seen[2].should == [:duration, labeller, true]
158
+ seen[3].should == [:exit, labeller]
159
+ end
160
+
161
+ it "reports a layer that raises through :exit anyway" do
162
+ seen = []
163
+
164
+ boom = Class.new do
165
+ def initialize(app); @app = app; end
166
+ def call(_env); raise "boom"; end
167
+ end
168
+
169
+ pipeline = Brute::Turn::Pipeline.new
170
+ pipeline.on(Brute::Hooks::EXIT_EVENT) { |_env, layer| seen << layer.class }
171
+ pipeline.use boom
172
+ pipeline.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
173
+ begin
174
+ pipeline.call({})
175
+ rescue RuntimeError
176
+ nil
177
+ end
178
+
179
+ seen.should == [boom]
180
+ end
181
+
182
+ it "binds emit to its own store, warns for a lambda, and refuses to shadow an existing emit" do
183
+ seen = []
184
+ quiet = Class.new do
185
+ def initialize(app); @app = app; end
186
+ def call(env); emit(:ping, env, :from_layer); @app.call(env); end
187
+ end
188
+
189
+ pipeline = Brute::Turn::Pipeline.new
190
+ pipeline.on(:ping) { |env, extra| seen << [env, extra] }
191
+ pipeline.use quiet
192
+ pipeline.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
193
+ pipeline.call({})
194
+ seen.should == [[{}, :from_layer]]
195
+
196
+ # A lambda cannot reach the bound method, so it is warned about, not patched.
197
+ warned = []
198
+ lambda_pipeline = Brute::Turn::Pipeline.new
199
+ lambda_pipeline.define_singleton_method(:warn) { |message| warned << message }
200
+ lambda_pipeline.run ->(env) { env }
201
+ warned.size.should == 1
202
+ warned.first.should.match(/lambda/)
203
+
204
+ # An object that already answers to emit is left alone, loudly.
205
+ emitter = Class.new do
206
+ def initialize(app); @app = app; end
207
+ def emit(*); end
208
+ def call(env); env; end
209
+ end
210
+ # The layer is constructed when the stack is built, so that is when the
211
+ # collision is discovered.
212
+ shadowing = Brute::Turn::Pipeline.new
213
+ shadowing.use emitter
214
+ shadowing.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
215
+ should.raise(ArgumentError) { shadowing.call({}) }
216
+ end
217
+
69
218
  it "builds and calls a chain" do
70
219
  seen = []
71
220
  inc = Class.new do
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/usage_detection/usage"
6
+
7
+ module Brute
8
+ module UsageDetection
9
+ # langchainrb has no single usage object: each provider's response
10
+ # subclass answers prompt_tokens / completion_tokens / total_tokens by
11
+ # digging its own raw shape, and some answer none of them.
12
+ module LangChain
13
+ def self.detect(response)
14
+ detected = Usage.new(
15
+ input: read(response, :prompt_tokens),
16
+ output: read(response, :completion_tokens),
17
+ total: read(response, :total_tokens),
18
+ raw: response,
19
+ )
20
+
21
+ detected.empty? ? nil : detected
22
+ end
23
+
24
+ def self.read(response, name) = response.respond_to?(name) ? response.public_send(name) : nil
25
+ end
26
+ end
27
+ end
28
+
29
+ __END__
30
+
31
+ describe "brute/usage_detection/lang_chain" do
32
+ LangChainResponse = Struct.new(:prompt_tokens, :completion_tokens, :total_tokens) unless defined?(LangChainResponse)
33
+
34
+ it "reads the per-provider token methods and answers nil when the response has none" do
35
+ usage = Brute::UsageDetection::LangChain.detect(LangChainResponse.new(10, 5, 15))
36
+ usage.input.should == 10
37
+ usage.output.should == 5
38
+ usage.total.should == 15
39
+
40
+ # A provider subclass that reports nothing.
41
+ Brute::UsageDetection::LangChain.detect(LangChainResponse.new(nil, nil, nil)).should.be.nil
42
+ Brute::UsageDetection::LangChain.detect(Object.new).should.be.nil
43
+ end
44
+ end