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
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/hooks"
6
+
7
+ module Brute
8
+ module Completion
9
+ # Completion backed by the ruby_llm gem.
10
+ #
11
+ # Brute.agent
12
+ # .use(Brute::Middleware::SystemPrompt)
13
+ # .run(Brute::Completion::RubyLLM.new(provider: :ollama, model: "llama3.2:latest"))
14
+ #
15
+ # Anything not given at point of use falls back to env, so a pipeline that
16
+ # sets env[:provider] / env[:model] still flows through.
17
+ #
18
+ # provider: LLM provider name (falls back to env[:provider])
19
+ # model: model id (falls back to env[:model])
20
+ # tools: tools list, any shape Tools::Adapter accepts
21
+ # temperature: sampling temperature (default 0.7)
22
+ # streaming: stream chunks as :content / :reasoning events
23
+ # client: injectable completion client (tests, custom transports);
24
+ # anything responding to complete(messages, **kwargs)
25
+ class RubyLLM
26
+ include Brute::Hooks
27
+
28
+ DEFAULT_TEMPERATURE = 0.7
29
+
30
+ def initialize(**options)
31
+ # Brute depends on no LLM library: the provider gem is required here,
32
+ # at point of use, and only for this completion.
33
+ begin
34
+ require "ruby_llm"
35
+ rescue LoadError
36
+ raise LoadError, "#{self.class} needs the 'ruby_llm' gem — add `gem \"ruby_llm\"` to your Gemfile."
37
+ end
38
+
39
+ Brute::Completion.async_faraday!
40
+
41
+ @options = options
42
+ end
43
+
44
+ def call(env)
45
+ emit(BEFORE_LLM_EVENT, env)
46
+
47
+ messages = Brute::MessageTransport::RubyLLM.dump_all(env[:messages])
48
+ response = nil
49
+ emit(LLM_DURATION_EVENT, env) { response = complete(env, messages) }
50
+
51
+ unless response.nil?
52
+ if (usage = Brute::MessageTransport::RubyLLM.usage_metrics(response))
53
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
54
+ end
55
+
56
+ Brute::MessageTransport::RubyLLM.wrap_each(response) do |message|
57
+ env[:messages] << message
58
+ end
59
+ end
60
+
61
+ emit(AFTER_LLM_EVENT, env)
62
+ env
63
+ # A provider call that raises is reported through the hooks rather than
64
+ # up the stack, the same way the OpenRouter completion does it.
65
+ rescue => error
66
+ emit(LLM_FAILURE_EVENT, env)
67
+
68
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
69
+ emit(FARADAY_ERROR_EVENT, env, error)
70
+ else
71
+ emit(STANDARD_ERROR_EVENT, env, error)
72
+ end
73
+
74
+ env
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :options
80
+
81
+ def complete(env, messages)
82
+ kwargs = {
83
+ model: resolve_model(option(env, :model), option(env, :provider)),
84
+ tools: tool_adapters(env).transform_values(&:to_ruby_llm),
85
+ temperature: temperature(env),
86
+ }
87
+
88
+ provider_client = client(option(env, :provider))
89
+
90
+ # Streaming reports through env[:events] chunk by chunk; the final
91
+ # message still comes back for the log.
92
+ if streaming?(env)
93
+ provider_client.complete(messages, **kwargs) do |chunk|
94
+ stream(env, chunk)
95
+ end
96
+ else
97
+ provider_client.complete(messages, **kwargs)
98
+ end
99
+ end
100
+
101
+ def stream(env, chunk)
102
+ if chunk.content && !chunk.content.to_s.empty?
103
+ env[:events] << { type: :content, data: chunk.content.to_s }
104
+ end
105
+
106
+ if chunk.respond_to?(:thinking) && chunk.thinking.respond_to?(:text) && chunk.thinking&.text
107
+ env[:events] << { type: :reasoning, data: chunk.thinking.text }
108
+ end
109
+ end
110
+
111
+ def client(provider)
112
+ options[:client] || ::RubyLLM::Provider.resolve(provider).new(Brute.config)
113
+ end
114
+
115
+ # Look the model up in ruby_llm's registry; fall back to the raw id for
116
+ # models the registry doesn't know (custom endpoints, injected clients).
117
+ def resolve_model(model, provider)
118
+ ::RubyLLM.models.find(model, provider)
119
+ rescue StandardError
120
+ model
121
+ end
122
+
123
+ # Point-of-use option, falling back to env.
124
+ def option(env, key) = options.fetch(key) { env[key] }
125
+
126
+ def temperature(env) = options.fetch(:temperature) { env.fetch(:temperature, DEFAULT_TEMPERATURE) }
127
+
128
+ def tool_adapters(env) = Brute::Tools::Adapter.wrap_all(option(env, :tools) || [])
129
+
130
+ def streaming?(env) = options.fetch(:streaming) { env[:streaming] } == true
131
+ end
132
+ end
133
+ end
134
+
135
+ __END__
136
+
137
+ describe "brute/completion/ruby_llm" do
138
+ require "brute/messages"
139
+
140
+ FakeRubyLLMClient = Class.new do
141
+ attr_reader :calls
142
+
143
+ def initialize(content = "mock response")
144
+ @content = content
145
+ @calls = []
146
+ end
147
+
148
+ def complete(messages, **kwargs)
149
+ @calls << { messages: messages.dup, kwargs: kwargs }
150
+ Brute::Message.new(role: :assistant, content: @content)
151
+ end
152
+ end unless defined?(FakeRubyLLMClient)
153
+
154
+ # A completion only gets its emit from the pipeline that runs it.
155
+ running = lambda do |completion|
156
+ Brute::Turn::Pipeline.new.tap { |pipeline| pipeline.run completion }
157
+ end
158
+
159
+ it "completes a turn, prefers point-of-use options over env, and reports failure through the hooks" do
160
+ client = FakeRubyLLMClient.new("hello there")
161
+ env = { messages: Brute.log, provider: :stub, model: "env-model", tools: [], events: [] }
162
+ env[:messages].user("hi")
163
+
164
+ seen = []
165
+ pipeline = running.call(Brute::Completion::RubyLLM.new(client: client, model: "use-this-model", temperature: 0.1))
166
+ pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
167
+ pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
168
+ pipeline.call(env)
169
+
170
+ env[:messages].last.role.should == :assistant
171
+ env[:messages].last.content.should == "hello there"
172
+ seen.should == [:before, :after]
173
+
174
+ client.calls.first[:kwargs][:model].should == "use-this-model"
175
+ client.calls.first[:kwargs][:temperature].should == 0.1
176
+
177
+ # Unset options fall back to env, and temperature to its default.
178
+ fallback = FakeRubyLLMClient.new
179
+ fallback_env = { messages: Brute.log, provider: :stub, model: "env-model", tools: [], events: [] }
180
+ fallback_env[:messages].user("hi")
181
+ running.call(Brute::Completion::RubyLLM.new(client: fallback)).call(fallback_env)
182
+ fallback.calls.first[:kwargs][:model].should == "env-model"
183
+ fallback.calls.first[:kwargs][:temperature].should == 0.7
184
+
185
+ # A raising provider is reported, not propagated.
186
+ boom = Object.new
187
+ boom.define_singleton_method(:complete) { |_messages, **_kwargs| raise "no route to host" }
188
+ failed = []
189
+ failing = running.call(Brute::Completion::RubyLLM.new(client: boom))
190
+ failing.on(Brute::Hooks::LLM_FAILURE_EVENT) { |_env| failed << :failure }
191
+ failing.on(Brute::Hooks::STANDARD_ERROR_EVENT) { |_env, error| failed << error.message }
192
+
193
+ error_env = { messages: Brute.log, provider: :stub, tools: [], events: [] }
194
+ error_env[:messages].user("hi")
195
+ failing.call(error_env).should.be.identical_to error_env
196
+ failed.should == [:failure, "no route to host"]
197
+ end
198
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/hooks"
6
+
7
+ module Brute
8
+ module Contrib
9
+ # OpenTelemetry for a turn, as hooks rather than middleware.
10
+ #
11
+ # Brute::Contrib::Otel.subscribe(agent)
12
+ # agent.start("what changed?")
13
+ #
14
+ # These are pure observers: they read the turn and write spans, and never
15
+ # touch what the agent does. That is why they are subscribers and not
16
+ # layers — a middleware earns its place in the stack by being able to
17
+ # alter or skip what is below it, and telemetry never should.
18
+ #
19
+ # Every event it needs already exists:
20
+ #
21
+ # turn_start / turn_end the span itself
22
+ # after_llm token usage, from env[:metadata][:last_llm_usage]
23
+ # before_tool / after_tool a span event per tool call and result
24
+ #
25
+ # The tracer is injectable; without one it asks OpenTelemetry, and when
26
+ # the SDK is not loaded `subscribe` does nothing at all.
27
+ #
28
+ # Note the span is opened and finished by hand rather than through
29
+ # `tracer.in_span`, which wants a block around the work. OpenTelemetry's
30
+ # current context is fiber-local, so this does not attach the span as
31
+ # current: under Async a turn's LLM call and its tools may run in other
32
+ # fibers, and an attached-but-never-detached context leaks across them.
33
+ module Otel
34
+ SPAN_NAME = "brute.turn"
35
+
36
+ class << self
37
+ def subscribe(agent, tracer: default_tracer)
38
+ return agent if tracer.nil?
39
+
40
+ agent
41
+ .on(Brute::Hooks::TURN_START_EVENT) { |env| start(env, tracer) }
42
+ .on(Brute::Hooks::TURN_END_EVENT) { |env| finish(env) }
43
+ .on(Brute::Hooks::AFTER_LLM_EVENT) { |env| record_usage(env) }
44
+ .on(Brute::Hooks::BEFORE_TOOL_EVENT) { |env, call| tool_called(env, call) }
45
+ .on(Brute::Hooks::AFTER_TOOL_EVENT) { |env, call| tool_returned(env, call) }
46
+ .on(Brute::Hooks::LLM_FAILURE_EVENT) { |env| failed(env) }
47
+ .on(Brute::Hooks::DURATION_EVENT) { |env, started, finished, layer| layer_finished(env, started, finished, layer) }
48
+ .on(Brute::Hooks::TURN_DURATION_EVENT) { |env, started, finished| timed(env, "turn", finished - started) }
49
+ .on(Brute::Hooks::LLM_DURATION_EVENT) { |env, started, finished| timed(env, "llm", finished - started) }
50
+ .on(Brute::Hooks::TOOL_DURATION_EVENT) { |env, started, finished, call| timed(env, "tool", finished - started, "tool.name" => call[:name].to_s) }
51
+ end
52
+
53
+ def default_tracer
54
+ return nil unless defined?(::OpenTelemetry)
55
+
56
+ ::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
57
+ end
58
+
59
+ private
60
+
61
+ def start(env, tracer)
62
+ env[:span] = tracer.start_span(SPAN_NAME, attributes: {
63
+ "brute.messages" => env[:messages]&.size,
64
+ }.compact)
65
+ end
66
+
67
+ def finish(env)
68
+ env[:span]&.finish
69
+ end
70
+
71
+ # The completions record a Brute::UsageDetection::Usage, so this
72
+ # needs no per-provider knowledge: whatever the provider reported
73
+ # arrives under the same names, and what it did not report is
74
+ # absent rather than zero.
75
+ def record_usage(env)
76
+ span = env[:span] or return
77
+ usage = env.dig(:metadata, :last_llm_usage) or return
78
+
79
+ {
80
+ "gen_ai.usage.input_tokens" => usage.input,
81
+ "gen_ai.usage.output_tokens" => usage.output,
82
+ "gen_ai.usage.total_tokens" => usage.total,
83
+ "gen_ai.usage.reasoning_tokens" => usage.reasoning,
84
+ "gen_ai.usage.cache_read_tokens" => usage.cache_read,
85
+ "gen_ai.usage.cost" => usage.cost,
86
+ }.each { |name, value| span.set_attribute(name, value) unless value.nil? }
87
+ end
88
+
89
+ def tool_called(env, call)
90
+ env[:span]&.add_event("tool_call", attributes: {
91
+ "tool.name" => call[:name].to_s,
92
+ "tool.arguments" => call[:arguments].to_s,
93
+ })
94
+ end
95
+
96
+ def tool_returned(env, call)
97
+ env[:span]&.add_event("tool_result", attributes: {
98
+ "tool.name" => call[:name].to_s,
99
+ "tool.bytes" => call[:result].to_s.bytesize,
100
+ })
101
+ end
102
+
103
+ # The layer's work is :duration's block, so start and finish arrive
104
+ # with it rather than having to be correlated with :enter by hand.
105
+ def layer_finished(env, started, finished, layer)
106
+ env[:span]&.add_event("middleware", attributes: {
107
+ "middleware.name" => layer.class.name.to_s,
108
+ "middleware.duration" => finished - started,
109
+ })
110
+ end
111
+
112
+ # Every pair in the turn has a timed middle, so each one's duration
113
+ # lands as an attribute without correlating its edges.
114
+ def timed(env, name, duration, attributes = {})
115
+ env[:span]&.set_attribute("brute.#{name}.duration", duration)
116
+ env[:span]&.add_event("#{name}_duration", attributes: attributes.merge("duration" => duration))
117
+ end
118
+
119
+ def failed(env)
120
+ env[:span]&.add_event("llm_failure")
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ __END__
128
+
129
+ describe "brute/contrib/otel" do
130
+ FakeSpan = Class.new do
131
+ attr_reader :attributes, :events, :finished
132
+
133
+ def initialize(name, attributes)
134
+ @name = name
135
+ @attributes = attributes.dup
136
+ @events = []
137
+ @finished = false
138
+ end
139
+
140
+ def set_attribute(name, value) = @attributes[name] = value
141
+ def add_event(name, attributes: {}) = @events << [name, attributes]
142
+ def finish = @finished = true
143
+ end unless defined?(FakeSpan)
144
+
145
+ FakeTracer = Class.new do
146
+ attr_reader :spans
147
+
148
+ def initialize = @spans = []
149
+
150
+ def start_span(name, attributes: {})
151
+ FakeSpan.new(name, attributes).tap { |span| @spans << span }
152
+ end
153
+ end unless defined?(FakeTracer)
154
+
155
+ it "spans a turn, records normalised usage, and notes each tool call and result" do
156
+ tracer = FakeTracer.new
157
+ tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
158
+
159
+ agent = Brute::Turn::AgentPipeline.new
160
+ agent.use Brute::Middleware::ToolPipeline, tools: [tool]
161
+ agent.run(Object.new.tap do |terminal|
162
+ terminal.define_singleton_method(:call) do |env|
163
+ # A completion records usage and announces the call; `run` bound this
164
+ # emit to the pipeline's own store.
165
+ env[:metadata][:last_llm_usage] = Brute::UsageDetection::Usage.new(input: 10, output: 5, total: 15)
166
+
167
+ unless env[:called]
168
+ env[:called] = true
169
+ env[:messages] << Brute::Message.new(role: :assistant, content: "",
170
+ tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "hi" } }])
171
+ end
172
+
173
+ emit(Brute::Hooks::LLM_DURATION_EVENT, env) { :provider_call }
174
+ emit(Brute::Hooks::AFTER_LLM_EVENT, env)
175
+ env
176
+ end
177
+ end)
178
+
179
+ Brute::Contrib::Otel.subscribe(agent, tracer: tracer)
180
+ agent.start("go")
181
+
182
+ span = tracer.spans.first
183
+ span.finished.should.be.true
184
+ span.attributes["gen_ai.usage.total_tokens"].should == 15
185
+ span.attributes["gen_ai.usage.input_tokens"].should == 10
186
+ # Not reported by this provider, so never set.
187
+ span.attributes.key?("gen_ai.usage.cost").should.be.false
188
+
189
+ span.events.map(&:first).should.include "tool_call"
190
+ span.events.map(&:first).should.include "tool_result"
191
+ span.events.find { |name, _| name == "tool_call" }.last["tool.name"].should == "echo"
192
+
193
+ # :exit is timed, so each layer reports its own duration.
194
+ layer_event = span.events.find { |name, _| name == "middleware" }
195
+ layer_event.last["middleware.name"].should == "Brute::Middleware::ToolPipeline"
196
+ layer_event.last["middleware.duration"].should.be >= 0
197
+
198
+ # Each pair's timed middle reports its own duration.
199
+ span.attributes["brute.turn.duration"].should.be >= 0
200
+ span.attributes["brute.llm.duration"].should.be >= 0
201
+ span.attributes["brute.tool.duration"].should.be >= 0
202
+ span.events.find { |name, _| name == "tool_duration" }.last["tool.name"].should == "echo"
203
+
204
+ # Without a tracer — the SDK not loaded — subscribing is a no-op.
205
+ quiet = Brute::Turn::AgentPipeline.new
206
+ Brute::Contrib::Otel.subscribe(quiet, tracer: nil).should.be.identical_to quiet
207
+ end
208
+ end
data/lib/brute/hooks.rb CHANGED
@@ -10,49 +10,130 @@ module Brute
10
10
  # .use(Brute::Middleware::MaxIterations)
11
11
  # .run(->(env) { env[:messages].assistant("done") })
12
12
  # .on(:before_llm) { |env| ... }
13
- # .on(:approve_tool) { |call| call[:name] != "exec" }
13
+ # .on(:approve_tool) { |_env, call| call[:name] != "exec" }
14
+ #
15
+ # Every subscriber is called with the turn env first, followed by whatever
16
+ # extras that event carries. A block that only wants the env can take one
17
+ # argument and ignore the rest.
14
18
  #
15
19
  # Emission points and payloads:
16
20
  #
17
21
  # :turn_start, :turn_end → the turn env (AgentPipeline#start; turn_end
18
- # fires from an ensure, so it also fires on error)
22
+ # fires from an ensure, so it also fires on
23
+ # error)
24
+ # :turn_duration → env, started, finished: the turn's work is
25
+ # this event's block
26
+ # :middleware_added → an empty env, then the middleware and every
27
+ # argument `use` was given (fires at build time,
28
+ # so only subscribers registered before the `use`
29
+ # see it)
30
+ # :enter → env, the middleware instance, before the
31
+ # layer does anything
32
+ # :duration → env, started, finished, the middleware
33
+ # instance: the layer's work is this event's
34
+ # block, so it reports how long that took
35
+ # :exit → env, the middleware instance, marking the
36
+ # layer done (from an ensure, so it fires on
37
+ # error too)
19
38
  # :before_llm, :after_llm → the turn env, around every LLM call
39
+ # :llm_duration → env, started, finished: the provider call is
40
+ # this event's block
20
41
  # :llm_failure → the turn env, when the LLM call raises; the
21
42
  # completion middleware then emits one of
22
43
  # :faraday_error, :open_router_server_error or
23
- # :standard_error with the exception itself
24
- # :before_tool → call env {name:, arguments:, result:, events:,
25
- # metadata:, turn_env:} — mutate :arguments to
26
- # rewrite the call, or set :result (or return a
27
- # value) to skip execution entirely ("respond")
28
- # :approve_tool → call env — a false return denies the call; a
29
- # String return denies it with that message
30
- # :after_tool → call env — mutate :result
44
+ # :standard_error with the exception as an extra
45
+ # :before_tool → env, call env {name:, arguments:, result:,
46
+ # denied:, events:, metadata:, turn_env:} —
47
+ # mutate :arguments to rewrite the call, or set
48
+ # :result to answer it without executing
49
+ # :approve_tool → env, call env — set :denied to true to deny
50
+ # the call, or to a String to deny it with that
51
+ # message
52
+ # :tool_duration → env, started, finished, call env: the tool's
53
+ # own execution is this event's block, so a
54
+ # skipped or denied call never fires it
55
+ # :after_tool → env, call env — mutate :result
31
56
  #
32
57
  # Subscribers run inline (tool events may fire from parallel threads).
33
58
  # Exceptions propagate to the caller — layers that want fail-open semantics
34
59
  # rescue in their own subscriber.
35
- class Hooks
36
- EVENTS = %i[turn_start turn_end before_llm after_llm llm_failure faraday_error
37
- open_router_server_error standard_error before_tool approve_tool
38
- after_tool].freeze
60
+ # Include this in anything that emits or subscribes and the event names are
61
+ # first class there: ENTER_EVENT rather than Brute::Hooks::ENTER_EVENT.
62
+ # The registry itself is Hooks::Registry, and Brute::Hooks.new builds one.
63
+ module Hooks
64
+ TURN_START_EVENT = :turn_start
65
+ TURN_DURATION_EVENT = :turn_duration
66
+ TURN_END_EVENT = :turn_end
67
+ MIDDLEWARE_ADDED_EVENT = :middleware_added
68
+ ENTER_EVENT = :enter
69
+ DURATION_EVENT = :duration
70
+ EXIT_EVENT = :exit
71
+ BEFORE_LLM_EVENT = :before_llm
72
+ LLM_DURATION_EVENT = :llm_duration
73
+ AFTER_LLM_EVENT = :after_llm
74
+ LLM_FAILURE_EVENT = :llm_failure
75
+ FARADAY_ERROR_EVENT = :faraday_error
76
+ OPEN_ROUTER_SERVER_ERROR_EVENT = :open_router_server_error
77
+ STANDARD_ERROR_EVENT = :standard_error
78
+ BEFORE_TOOL_EVENT = :before_tool
79
+ APPROVE_TOOL_EVENT = :approve_tool
80
+ TOOL_DURATION_EVENT = :tool_duration
81
+ AFTER_TOOL_EVENT = :after_tool
39
82
 
40
- def initialize
41
- @subscribers = Hash.new { |hash, key| hash[key] = [] }
42
- end
83
+ # The pub/sub registry a pipeline owns; `use` and `run` bind an emit to it.
84
+ class Registry
85
+ def initialize
86
+ @subscribers = Hash.new { |hash, key| hash[key] = [] }
87
+ end
43
88
 
44
- def on(event, &block)
45
- @subscribers[event.to_sym] << block
46
- self
47
- end
89
+ def on(event, &block)
90
+ @subscribers[event.to_sym] << block
91
+ self
92
+ end
93
+
94
+ # Fire an event. An emitter announces; it answers nothing, and what a
95
+ # subscriber's block happens to evaluate to is not a signal. A layer
96
+ # that wants to take part in a turn does it by mutating what it was
97
+ # handed, never by returning something.
98
+ #
99
+ # Given a block, the event is timed instead: the block is the work, and
100
+ # subscribers fire once it is done, called as
101
+ # `|env, started, finished, *extras|` rather than `|env, *extras|`.
102
+ # Both stamps are monotonic, so a clock adjustment mid-turn cannot
103
+ # produce a negative duration.
104
+ #
105
+ # The block is the work and nothing more: `emit` answers nothing in
106
+ # either form, so a caller that needs the work's value takes it inside
107
+ # the block.
108
+ #
109
+ # result = nil
110
+ # emit(DURATION_EVENT, env, self) { result = @app.call(env) }
111
+ # # => .on(DURATION_EVENT) { |env, started, finished, layer| ... }
112
+ #
113
+ # Subscribers fire from an ensure, so work that raises is still timed
114
+ # and still reported before the exception carries on up.
115
+ def emit(event, env, *extras, &block)
116
+ unless block
117
+ @subscribers[event.to_sym].each { |subscriber| subscriber.call(env, *extras) }
118
+ return nil
119
+ end
120
+
121
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
122
 
49
- # Fire an event; returns every subscriber's raw result (nils and false
50
- # included — the deny contract distinguishes them).
51
- def emit(event, payload)
52
- @subscribers[event.to_sym].map { |subscriber| subscriber.call(payload) }
123
+ begin
124
+ block.call
125
+ ensure
126
+ finished = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ @subscribers[event.to_sym].each { |subscriber| subscriber.call(env, started, finished, *extras) }
128
+ end
129
+
130
+ nil
131
+ end
132
+
133
+ def any?(event) = @subscribers[event.to_sym].any?
53
134
  end
54
135
 
55
- def any?(event) = @subscribers[event.to_sym].any?
136
+ def self.new(...) = Registry.new(...)
56
137
  end
57
138
  end
58
139
 
@@ -62,16 +143,53 @@ describe "brute/hooks" do
62
143
  it "emits to subscribers in registration order" do
63
144
  hooks = Brute::Hooks.new
64
145
  seen = []
65
- hooks.on(:before_llm) { |p| seen << "a#{p}" }
66
- hooks.on(:before_llm) { |p| seen << "b#{p}" }
146
+ hooks.on(:before_llm) { |env| seen << "a#{env}" }
147
+ hooks.on(:before_llm) { |env| seen << "b#{env}" }
67
148
  hooks.emit(:before_llm, 1)
68
149
  seen.should == ["a1", "b1"]
69
150
  end
70
151
 
71
- it "returns raw results, false included (deny contract)" do
152
+ it "answers nothing: a subscriber takes part by mutating, not by returning" do
72
153
  hooks = Brute::Hooks.new
73
- hooks.on(:approve_tool) { |_call| false }
74
- hooks.emit(:approve_tool, {}).should == [false]
154
+ hooks.on(:approve_tool) { |_env, call| call[:denied] = true }
155
+
156
+ call = {}
157
+ hooks.emit(:approve_tool, {}, call).should.be.nil
158
+ call[:denied].should.be.true
159
+ end
160
+
161
+ it "hands every subscriber the env first and the extras after" do
162
+ hooks = Brute::Hooks.new
163
+ seen = []
164
+ hooks.on(:after_tool) { |env, call| seen << [env, call] }
165
+ hooks.emit(:after_tool, :turn, :call)
166
+ seen.should == [[:turn, :call]]
167
+ end
168
+
169
+ it "times a block, hands subscribers start and finish, and reports even when the work raises" do
170
+ seen = []
171
+ hooks = Brute::Hooks.new
172
+ hooks.on(:exit) { |env, started, finished, subject| seen << [env, started, finished, subject] }
173
+
174
+ ran = nil
175
+ hooks.emit(:exit, :env, :layer) { ran = :work_result }.should.be.nil
176
+ ran.should == :work_result
177
+ seen.size.should == 1
178
+ env, started, finished, subject = seen.first
179
+ env.should == :env
180
+ subject.should == :layer
181
+ (finished - started).should.be >= 0
182
+
183
+ # Work that raises is still timed and still reported.
184
+ should.raise(RuntimeError) { hooks.emit(:exit, :env, :layer) { raise "boom" } }
185
+ seen.size.should == 2
186
+
187
+ # Without a block it stays a point event: no timing argument.
188
+ args = []
189
+ hooks2 = Brute::Hooks.new
190
+ hooks2.on(:exit) { |*received| args << received }
191
+ hooks2.emit(:exit, :env, :layer)
192
+ args.should == [[:env, :layer]]
75
193
  end
76
194
 
77
195
  it "answers any? and stays chainable" do
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/message_transport"
6
+ require "brute/message_transport/ruby_open_ai"
7
+
8
+ module Brute
9
+ class MessageTransport
10
+ # MessageTransport for the langchainrb gem. Its LLM classes speak the
11
+ # OpenAI-style wire format, so the message conversion is RubyOpenAI's —
12
+ # what differs is usage, which langchainrb answers through per-provider
13
+ # response methods rather than a usage object.
14
+ class LangChain < RubyOpenAI
15
+ def self.usage_metrics(response)
16
+ Brute::UsageDetection::LangChain.detect(response)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ __END__
23
+
24
+ describe "brute/message_transport/lang_chain" do
25
+ LangChainUsageResponse = Struct.new(:prompt_tokens, :completion_tokens, :total_tokens) unless defined?(LangChainUsageResponse)
26
+
27
+ it "reads usage the langchainrb way and converts messages the OpenAI way" do
28
+ usage = Brute::MessageTransport::LangChain.usage_metrics(LangChainUsageResponse.new(10, 5, 15))
29
+ usage.input.should == 10
30
+ usage.total.should == 15
31
+
32
+ dumped = Brute::MessageTransport::LangChain.dump(Brute::Message.new(role: :user, content: "hi"))
33
+ dumped[:role].should == "user"
34
+ dumped[:content].should == "hi"
35
+ end
36
+ end
@@ -17,6 +17,12 @@ module Brute
17
17
  # response = llm.complete(messages.pop, role: nil, messages: messages, tools: ...)
18
18
  # Brute::MessageTransport::LLM.wrap_each(response) { |m| env[:messages] << m }
19
19
  class LLM < MessageTransport
20
+
21
+ # What the provider reported about this call — the transport knows its
22
+ # own library's shape, so it knows which detector to ask.
23
+ def self.usage_metrics(response)
24
+ Brute::UsageDetection::LLMrb.detect(response)
25
+ end
20
26
  # Brute::Message -> LLM::Message. Assistant tool calls carry llm.rb's
21
27
  # `original_tool_calls` extra (the provider wire format); tool results
22
28
  # become an LLM::Function::Return so llm.rb's request adapters emit
@@ -13,6 +13,12 @@ module Brute
13
13
  #
14
14
  # require "open_router"
15
15
  class OpenRouter < MessageTransport
16
+
17
+ # What the provider reported about this call — the transport knows its
18
+ # own library's shape, so it knows which detector to ask.
19
+ def self.usage_metrics(response)
20
+ Brute::UsageDetection::OpenRouter.detect(response)
21
+ end
16
22
  def self.dump(message)
17
23
  message.to_h
18
24
  end