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
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
82
|
-
call_env
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
208
|
+
pipeline.call(env)
|
|
195
209
|
env[:messages].last.content.should == "ran:rewritten"
|
|
196
210
|
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
env = hook_env
|
|
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
|
-
|
|
228
|
+
denied.call(env)
|
|
217
229
|
env[:messages].last.content.should == %(Tool call to "exec" was denied.)
|
|
218
230
|
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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(
|
|
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(
|
|
53
|
+
hooks.emit(TURN_END_EVENT, env)
|
|
55
54
|
end
|
|
56
55
|
env
|
|
57
56
|
end
|
data/lib/brute/turn/pipeline.rb
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|