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.
- checksums.yaml +4 -4
- data/lib/brute/completion/async_faraday.rb +38 -0
- data/lib/brute/completion/lang_chain.rb +185 -0
- data/lib/brute/completion/llmrb.rb +182 -0
- data/lib/brute/completion/open_router.rb +54 -42
- data/lib/brute/completion/ruby_llm.rb +198 -0
- data/lib/brute/contrib/otel.rb +208 -0
- data/lib/brute/hooks.rb +149 -31
- data/lib/brute/message_transport/lang_chain.rb +36 -0
- data/lib/brute/message_transport/llm.rb +6 -0
- data/lib/brute/message_transport/open_router.rb +6 -0
- data/lib/brute/message_transport/ruby_llm.rb +6 -0
- data/lib/brute/message_transport.rb +8 -0
- data/lib/brute/middleware/000_base.rb +61 -0
- data/lib/brute/middleware/002_session_log.rb +1 -1
- data/lib/brute/middleware/004_summarize.rb +1 -1
- data/lib/brute/middleware/005_tracing.rb +1 -1
- data/lib/brute/middleware/006_loop.rb +1 -1
- data/lib/brute/middleware/008_checkpoint.rb +1 -1
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/025_skills.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +1 -1
- data/lib/brute/middleware/060_questions.rb +1 -1
- data/lib/brute/middleware/070_tool_pipeline.rb +60 -47
- data/lib/brute/middleware/event_handler.rb +1 -1
- data/lib/brute/middleware/user_queue.rb +1 -1
- data/lib/brute/turn/agent_pipeline.rb +3 -4
- data/lib/brute/turn/pipeline.rb +151 -2
- data/lib/brute/usage_detection/lang_chain.rb +44 -0
- data/lib/brute/usage_detection/llmrb.rb +53 -0
- data/lib/brute/usage_detection/open_router.rb +62 -0
- data/lib/brute/usage_detection/ruby_llm.rb +55 -0
- data/lib/brute/usage_detection/usage.rb +51 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +95 -3
- metadata +83 -8
- data/lib/brute/changelog.rb +0 -322
- data/lib/brute/deprecate.rb +0 -132
- data/lib/brute/middleware/001_otel_span.rb +0 -79
- data/lib/brute/middleware/015_otel_token_usage.rb +0 -44
- data/lib/brute/middleware/073_otel_tool_call.rb +0 -51
- data/lib/brute/middleware/075_otel_tool_results.rb +0 -48
- 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
|
|
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
|
|
24
|
-
# :before_tool → call env {name:, arguments:, result:,
|
|
25
|
-
# metadata:, turn_env:} —
|
|
26
|
-
# rewrite the call, or set
|
|
27
|
-
#
|
|
28
|
-
# :approve_tool → call env —
|
|
29
|
-
# String
|
|
30
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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) { |
|
|
66
|
-
hooks.on(:before_llm) { |
|
|
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 "
|
|
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) { |
|
|
74
|
-
|
|
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
|