brute 5.1.0 → 6.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +1 -1
  3. data/lib/brute/compaction/middleware/strategy.rb +0 -2
  4. data/lib/brute/compaction/summarize.rb +30 -21
  5. data/lib/brute/completion/lang_chain.rb +34 -31
  6. data/lib/brute/completion/llmrb.rb +31 -29
  7. data/lib/brute/completion/open_router.rb +43 -40
  8. data/lib/brute/completion/ruby_llm.rb +28 -27
  9. data/lib/brute/contrib/otel.rb +81 -49
  10. data/lib/brute/eval/case.rb +7 -2
  11. data/lib/brute/eval/suite.rb +10 -5
  12. data/lib/brute/eval/transcript.rb +3 -3
  13. data/lib/brute/eval/world.rb +2 -2
  14. data/lib/brute/eval.rb +1 -1
  15. data/lib/brute/events/handler.rb +3 -1
  16. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  17. data/lib/brute/events/terminal_output_handler.rb +1 -1
  18. data/lib/brute/hooks.rb +88 -112
  19. data/lib/brute/message_transport/anthropic.rb +13 -3
  20. data/lib/brute/message_transport/llm.rb +8 -4
  21. data/lib/brute/message_transport/open_router.rb +30 -16
  22. data/lib/brute/message_transport/openai.rb +14 -7
  23. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  24. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  25. data/lib/brute/messages.rb +15 -8
  26. data/lib/brute/middleware/000_base.rb +5 -5
  27. data/lib/brute/middleware/002_session_log.rb +12 -4
  28. data/lib/brute/middleware/008_checkpoint.rb +26 -18
  29. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  30. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  31. data/lib/brute/middleware/040_default_compaction_pipeline.rb +49 -45
  32. data/lib/brute/middleware/070_default_tool_pipeline.rb +44 -53
  33. data/lib/brute/prompt_template.rb +19 -15
  34. data/lib/brute/prompts/base.rb +19 -10
  35. data/lib/brute/prompts/environment.rb +3 -1
  36. data/lib/brute/prompts/instructions.rb +9 -7
  37. data/lib/brute/prompts/skills.rb +5 -3
  38. data/lib/brute/rack/adapter.rb +40 -23
  39. data/lib/brute/skill.rb +136 -89
  40. data/lib/brute/system_prompt.rb +9 -9
  41. data/lib/brute/token_counter.rb +3 -1
  42. data/lib/brute/tool.rb +10 -6
  43. data/lib/brute/tools/adapter.rb +53 -43
  44. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  45. data/lib/brute/tools/fs_patch.rb +16 -8
  46. data/lib/brute/tools/fs_read.rb +107 -80
  47. data/lib/brute/tools/fs_remove.rb +6 -2
  48. data/lib/brute/tools/fs_search.rb +14 -4
  49. data/lib/brute/tools/fs_undo.rb +6 -2
  50. data/lib/brute/tools/fs_write.rb +5 -1
  51. data/lib/brute/tools/net_fetch.rb +6 -2
  52. data/lib/brute/tools/question.rb +42 -39
  53. data/lib/brute/tools/shell.rb +20 -5
  54. data/lib/brute/tools/skill_load.rb +44 -39
  55. data/lib/brute/tools/todo_write.rb +19 -15
  56. data/lib/brute/truncation.rb +68 -43
  57. data/lib/brute/turn/agent_pipeline.rb +23 -13
  58. data/lib/brute/turn/compaction_pipeline.rb +4 -3
  59. data/lib/brute/turn/pipeline.rb +87 -96
  60. data/lib/brute/turn/tool_pipeline.rb +4 -3
  61. data/lib/brute/usage_detection/llmrb.rb +18 -14
  62. data/lib/brute/usage_detection/open_router.rb +20 -17
  63. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  64. data/lib/brute/usage_detection/usage.rb +10 -1
  65. data/lib/brute/utils/diff.rb +18 -10
  66. data/lib/brute/version.rb +1 -1
  67. data/lib/brute.rb +10 -11
  68. data/lib/brute_cli/providers/shell.rb +32 -29
  69. data/lib/brute_cli/providers/shell_response.rb +20 -18
  70. metadata +30 -5
  71. data/lib/brute/middleware/040_compaction_check.rb +0 -73
  72. data/lib/brute/middleware/070_tool_pipeline.rb +0 -61
  73. data/lib/brute/middleware/event_handler.rb +0 -27
@@ -40,7 +40,13 @@ module Brute
40
40
  MAX_LINE_LENGTH = 2000
41
41
  TRUNCATION_MARKER = "[Output truncated:"
42
42
 
43
- TRUNCATION_DIR = File.join(Dir.home, ".local", "share", "brute", "tool-output")
43
+ TRUNCATION_DIR = File.join(
44
+ Dir.home,
45
+ ".local",
46
+ "share",
47
+ "brute",
48
+ "tool-output",
49
+ )
44
50
 
45
51
  # Truncate text to fit within line and byte limits.
46
52
  #
@@ -55,38 +61,48 @@ module Brute
55
61
  # @returns [String] the (possibly truncated) text
56
62
  #
57
63
  def self.truncate(text, max_lines: MAX_LINES, max_bytes: MAX_BYTES, direction: :head, truncation_dir: nil)
58
- return text if text.nil? || text.empty?
59
-
60
- # Per-line truncation first — cap individual lines
61
- lines = text.lines.map { |line| truncate_line(line) }
62
- text = lines.join
63
-
64
- return text if lines.size <= max_lines && text.bytesize <= max_bytes
65
-
66
- # Determine how many lines we can keep within both caps
67
- kept = direction == :tail ? lines.last(max_lines) : lines.first(max_lines)
68
-
69
- # Enforce byte cap
70
- result_lines = []
71
- bytes = 0
72
- kept.each do |line|
73
- break if bytes + line.bytesize > max_bytes
74
- result_lines << line
75
- bytes += line.bytesize
76
- end
77
-
78
- result = result_lines.join
79
- total = lines.size
80
- shown = result_lines.size
81
-
82
- # Overflow to disk — save the full output so it can be inspected later
83
- saved_path = save_to_disk(text, truncation_dir)
84
-
85
- hint = "\n#{TRUNCATION_MARKER} showing #{shown} of #{total} lines]"
86
- if saved_path
87
- hint += "\nFull output saved to: #{saved_path}. Use Read with offset/limit to view specific sections."
64
+ if text.nil? || text.empty?
65
+ text
66
+ else
67
+ # Per-line truncation first cap individual lines
68
+ lines = text.lines.map { |line| truncate_line(line) }
69
+ text = lines.join
70
+
71
+ if lines.size <= max_lines && text.bytesize <= max_bytes
72
+ text
73
+ else
74
+ # Determine how many lines we can keep within both caps
75
+ if direction == :tail
76
+ kept = lines.last(max_lines)
77
+ else
78
+ kept = lines.first(max_lines)
79
+ end
80
+
81
+ # Enforce byte cap
82
+ result_lines = []
83
+ bytes = 0
84
+ kept.each do |line|
85
+ if bytes + line.bytesize > max_bytes
86
+ break
87
+ end
88
+ result_lines << line
89
+ bytes += line.bytesize
90
+ end
91
+
92
+ result = result_lines.join
93
+ total = lines.size
94
+ shown = result_lines.size
95
+
96
+ # Overflow to disk — save the full output so it can be inspected later
97
+ saved_path = save_to_disk(text, truncation_dir)
98
+
99
+ hint = "\n#{TRUNCATION_MARKER} showing #{shown} of #{total} lines]"
100
+ if saved_path
101
+ hint += "\nFull output saved to: #{saved_path}. Use Read with offset/limit to view specific sections."
102
+ end
103
+ result + hint
104
+ end
88
105
  end
89
- result + hint
90
106
  end
91
107
 
92
108
  # Check whether text already contains a truncation marker.
@@ -96,27 +112,36 @@ module Brute
96
112
 
97
113
  # Truncate a single line if it exceeds MAX_LINE_LENGTH.
98
114
  def self.truncate_line(line, max: MAX_LINE_LENGTH)
99
- return line if line.length <= max
100
- line[0, max] + "... (line truncated to #{max} chars)\n"
115
+ if line.length <= max
116
+ line
117
+ else
118
+ line[0, max] + "... (line truncated to #{max} chars)\n"
119
+ end
101
120
  end
102
121
 
103
122
  # Purge files older than retention_days from the given directory.
104
123
  def self.cleanup!(dir, retention_days: 7)
105
- return unless File.directory?(dir)
106
- cutoff = Time.now - (retention_days * 86400)
107
- Dir.glob(File.join(dir, "*")).each do |path|
108
- File.delete(path) if File.file?(path) && File.mtime(path) < cutoff
124
+ if File.directory?(dir)
125
+ cutoff = Time.now - (retention_days * 86400)
126
+ Dir.glob(File.join(dir, "*")).each do |path|
127
+ if File.file?(path) && File.mtime(path) < cutoff
128
+ File.delete(path)
129
+ end
130
+ end
131
+ else
132
+ nil
109
133
  end
110
134
  end
111
135
 
112
136
  # Save text to a file in truncation_dir. Returns the file path, or nil.
113
137
  def self.save_to_disk(text, truncation_dir)
114
138
  dir = truncation_dir || TRUNCATION_DIR
115
- return nil unless dir
116
- FileUtils.mkdir_p(dir)
117
- path = File.join(dir, "tool_#{SecureRandom.hex(8)}.txt")
118
- File.write(path, text)
119
- path
139
+ if dir
140
+ FileUtils.mkdir_p(dir)
141
+ path = File.join(dir, "tool_#{SecureRandom.hex(8)}.txt")
142
+ File.write(path, text)
143
+ path
144
+ end
120
145
  rescue => _e
121
146
  nil
122
147
  end
@@ -38,8 +38,10 @@ module Brute
38
38
  # the newest message, only when it is a user message, before the rest
39
39
  # of the stack.
40
40
  def map(matcher, &block)
41
- (@commands ||= []) << [check(matcher), block]
42
- self
41
+ tap do
42
+ @commands ||= []
43
+ @commands << [check(matcher), block]
44
+ end
43
45
  end
44
46
 
45
47
  # Every chain starts with the commands, registry or no registry: the
@@ -50,21 +52,25 @@ module Brute
50
52
  end
51
53
  alias_method :build, :to_app
52
54
 
53
- def start(input = nil, events: NullSink.new)
54
- env = {
55
+ def start(input = nil)
56
+ {
55
57
  messages: coerce_messages(input),
56
- events: events,
57
58
  metadata: {},
58
59
  current_iteration: 1,
59
60
  commands: @commands || [],
60
- }
61
- hooks.emit(TURN_START_EVENT, env)
62
- begin
63
- hooks.emit(TURN_DURATION_EVENT, env) { build.call(env) }
64
- ensure
65
- hooks.emit(TURN_END_EVENT, env)
61
+ }.tap do |turn|
62
+ Brute::Hooks::Trace.new(turn, hooks: hooks).emit_trace do |env|
63
+ env.emit(TURN_START_EVENT)
64
+ begin
65
+ env.emit(TURN_DURATION_EVENT) { build.call(env) }
66
+ rescue => error
67
+ env.emit(TURN_FAILURE_EVENT, error)
68
+ raise
69
+ ensure
70
+ env.emit(TURN_END_EVENT)
71
+ end
72
+ end
66
73
  end
67
- env
68
74
  end
69
75
 
70
76
  private
@@ -86,7 +92,11 @@ module Brute
86
92
  matcher
87
93
  else
88
94
  name = matcher.to_s
89
- name = name.start_with?("/") ? name : "/#{name}"
95
+ if name.start_with?("/")
96
+ name = name
97
+ else
98
+ name = "/#{name}"
99
+ end
90
100
  /^#{::Regexp.escape(name)}.*/
91
101
  end
92
102
  end
@@ -40,18 +40,19 @@ module Brute
40
40
  class CompactionPipeline
41
41
  def initialize(&block)
42
42
  @pipeline = Pipeline.new
43
- @pipeline.instance_eval(&block) if block
43
+ if block
44
+ @pipeline.instance_eval(&block)
45
+ end
44
46
  end
45
47
 
46
48
  # Answer a smaller conversation, or nil when nothing was given up.
47
- def compact(messages, target:, events: Pipeline::NullSink.new, token_counter: nil)
49
+ def compact(messages, target:, token_counter: nil)
48
50
  env = {
49
51
  conversation: messages,
50
52
  target: target,
51
53
  token_counter: token_counter,
52
54
  applied: [],
53
55
  messages: Brute.log,
54
- events: events,
55
56
  metadata: {},
56
57
  }
57
58
  @pipeline.call(env)
@@ -21,32 +21,46 @@ module Brute
21
21
  # }
22
22
  #
23
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
24
+ # stack, then :middleware_start and :middleware_end around its own work on each turn. Both
25
+ # of those carry the middleware instance as self, and :middleware_end fires from
26
26
  # an ensure so a layer that raises is still reported.
27
27
  def use(middleware, *args, &block)
28
28
  tap do
29
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)
30
+ hooks.emit(
31
+ MIDDLEWARE_ADDED_EVENT,
32
+ {},
33
+ middleware,
34
+ *args,
35
+ )
36
+
37
+ @use.pop.then do |builder|
38
+ @use << lambda do |app|
39
+ builder.call(app).tap do |layer|
40
+ layer.define_singleton_method(:call) do |env|
41
+ result = nil
42
+
43
+ # A layer is one call: what it emits, and what the layers
44
+ # below it emit, belong to this trace.
45
+ env.emit_trace do |env|
46
+ env.emit(MIDDLEWARE_START_EVENT, self)
47
+
48
+ # The layer's work is :middleware_duration's block, so its subscribers
49
+ # are handed how long this layer took — its own work plus
50
+ # everything below it. :middleware_end then marks the layer done,
51
+ # from an ensure so it lands even when the work raised.
52
+ begin
53
+ env.emit(MIDDLEWARE_DURATION_EVENT, self) { result = super(env) }
54
+ rescue => error
55
+ env.emit(MIDDLEWARE_FAILURE_EVENT, self, error)
56
+ raise
57
+ ensure
58
+ env.emit(MIDDLEWARE_END_EVENT, self)
59
+ end
60
+ end
61
+
62
+ result
47
63
  end
48
-
49
- result
50
64
  end
51
65
  end
52
66
  end
@@ -55,11 +69,12 @@ module Brute
55
69
  # Keeps a trailing hash flagged as keywords through `super`, the way
56
70
  # Rack::Builder#use does — without it `use Mw, label: "x"` arrives at
57
71
  # the middleware's constructor as a positional Hash.
58
- ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true)
72
+ if respond_to?(:ruby2_keywords, true)
73
+ ruby2_keywords(:use)
74
+ end
59
75
 
60
76
  def run(app = nil, &block)
61
77
  tap do
62
- bind_emitter(app) unless app.nil?
63
78
  super
64
79
  end
65
80
  end
@@ -69,62 +84,40 @@ module Brute
69
84
  # Brute.agent
70
85
  # .use(MaxProfit)
71
86
  # .run(->(env) { ... })
72
- # .on(:before_llm) { |env| ... }
73
- # .on(:approve_tool) { |call| call[:name] != "exec" }
87
+ # .on(:llm_start) { |env| ... }
88
+ # .on(:tool_approve) { |call| call[:name] != "exec" }
74
89
  #
75
90
  def on(...) = tap { hooks.on(...) }
76
91
 
77
92
  private
78
93
 
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
94
 
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
95
+ public
99
96
  end
100
97
 
101
98
  include Chainable
102
99
 
103
- # Rack's `new_from_string` evaluates the script then returns `to_app` —
104
- # the built callable. An agent, though, is the *builder*: you `.start` it,
105
- # and it may be re-`use`d or served through the Rack adapter. So evaluate
106
- # the script against a fresh builder and hand back the builder itself.
107
- # This also backs `parse_file` (→ `load_file` → here), so
108
- # `AgentPipeline.parse_file("agent.ru").start(prompt)` works as documented.
109
100
  def self.new_from_string(builder_script, path = "(rackup)", **options)
110
- builder = new(**options)
111
- eval(builder_script, ::Rack::BUILDER_TOPLEVEL_BINDING.call(builder), path) # rubocop:disable Security/Eval
112
- builder
101
+ new(**options).tap do |builder|
102
+ eval(builder_script, ::Rack::BUILDER_TOPLEVEL_BINDING.call(builder), path) # rubocop:disable Security/Eval
103
+ end
113
104
  end
114
105
 
115
- # Brute's name for Rack's `to_app`: nest the middleware around the
116
- # terminal app and return the runnable callable. Raises (via `to_app`)
117
- # when `run` was never called.
106
+ # Brute's name for Rack's `to_app`
118
107
  alias_method :build, :to_app
119
108
 
120
- # The lifecycle-hook registry for this pipeline (see Brute::Hooks).
121
- def hooks
122
- @hooks ||= Brute::Hooks.new
109
+ def initialize(default_app = nil, hooks: Brute::Hooks::Registry.new, **options, &block)
110
+ @hooks = hooks
111
+ super(default_app, **options, &block)
123
112
  end
124
113
 
125
- # Default null sink for env[:events] — swallows anything pushed to it.
126
- class NullSink
127
- def <<(_event); self; end
114
+ attr_reader :hooks
115
+
116
+ # Every way into a pipeline arrives at a Trace: what a caller hands over
117
+ # is wrapped once, and a pipeline entered from inside another keeps the
118
+ # trace it was already in.
119
+ def call(env)
120
+ env.is_a?(Brute::Hooks::Trace) ? super : super(Brute::Hooks::Trace.new(env, hooks: hooks))
128
121
  end
129
122
  end
130
123
  end
@@ -135,11 +128,11 @@ __END__
135
128
  describe "brute/turn/pipeline" do
136
129
  it "announces a layer when it is added, then on the way in and out of every turn" do
137
130
  seen = []
138
- hooks = Brute::Hooks.new
131
+ hooks = Brute::Hooks::Registry.new
139
132
  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] }
133
+ hooks.on(Brute::Hooks::MIDDLEWARE_START_EVENT) { |_env, layer| seen << [:middleware_start, layer.class] }
134
+ hooks.on(Brute::Hooks::MIDDLEWARE_DURATION_EVENT) { |_env, started, finished, layer| seen << [:middleware_duration, layer.class, finished >= started] }
135
+ hooks.on(Brute::Hooks::MIDDLEWARE_END_EVENT) { |_env, layer| seen << [:middleware_end, layer.class] }
143
136
 
144
137
  labeller = Class.new do
145
138
  def initialize(app, label:); @app = app; @label = label; end
@@ -153,12 +146,12 @@ describe "brute/turn/pipeline" do
153
146
  pipeline.call({ hooks: hooks })
154
147
 
155
148
  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]
149
+ seen[1].should == [:middleware_start, labeller]
150
+ seen[2].should == [:middleware_duration, labeller, true]
151
+ seen[3].should == [:middleware_end, labeller]
159
152
  end
160
153
 
161
- it "reports a layer that raises through :exit anyway" do
154
+ it "reports a layer that raises through :middleware_end anyway" do
162
155
  seen = []
163
156
 
164
157
  boom = Class.new do
@@ -167,7 +160,7 @@ describe "brute/turn/pipeline" do
167
160
  end
168
161
 
169
162
  pipeline = Brute::Turn::Pipeline.new
170
- pipeline.on(Brute::Hooks::EXIT_EVENT) { |_env, layer| seen << layer.class }
163
+ pipeline.on(Brute::Hooks::MIDDLEWARE_END_EVENT) { |_env, layer| seen << layer.class }
171
164
  pipeline.use boom
172
165
  pipeline.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
173
166
  begin
@@ -179,42 +172,40 @@ describe "brute/turn/pipeline" do
179
172
  seen.should == [boom]
180
173
  end
181
174
 
182
- it "binds emit to its own store, warns for a lambda, and refuses to shadow an existing emit" do
175
+ it "wraps what it is called with in a trace, so a layer emits without being given anything" do
183
176
  seen = []
184
177
  quiet = Class.new do
185
178
  def initialize(app); @app = app; end
186
- def call(env); emit(:ping, env, :from_layer); @app.call(env); end
179
+ def call(env); env.emit(:ping, :from_layer); @app.call(env); end
187
180
  end
188
181
 
189
182
  pipeline = Brute::Turn::Pipeline.new
190
- pipeline.on(:ping) { |env, extra| seen << [env, extra] }
183
+ pipeline.on(:ping) { |env, extra, id| seen << [env[:said], extra, id] }
191
184
  pipeline.use quiet
192
185
  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
186
+ pipeline.call({ said: "hello" })
187
+
188
+ said, extra, id = seen.first
189
+ said.should == "hello"
190
+ extra.should == :from_layer
191
+ id.should.not.be.nil
192
+
193
+ # A lambda is a layer like any other now: it is handed the trace as its env.
194
+ answered = []
195
+ Brute::Turn::Pipeline.new.tap do |lambdas|
196
+ lambdas.on(:ping) { |_env, extra| answered << extra }
197
+ lambdas.run ->(env) { env.emit(:ping, :from_lambda); env }
198
+ lambdas.call({})
209
199
  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({}) }
200
+ answered.should == [:from_lambda]
201
+
202
+ # Given a registry it uses that one rather than building its own, so a
203
+ # nested pipeline reports to the agent that owns it.
204
+ shared = Brute::Hooks::Registry.new
205
+ Brute::Turn::Pipeline.new(hooks: shared).hooks.equal?(shared).should.be.true
216
206
  end
217
207
 
208
+
218
209
  it "builds and calls a chain" do
219
210
  seen = []
220
211
  inc = Class.new do
@@ -38,15 +38,16 @@ module Brute
38
38
  @description = description
39
39
  @params = params
40
40
  @pipeline = Pipeline.new
41
- @pipeline.instance_eval(&block) if block
41
+ if block
42
+ @pipeline.instance_eval(&block)
43
+ end
42
44
  end
43
45
 
44
- def call(events: Pipeline::NullSink.new, **arguments)
46
+ def call(**arguments)
45
47
  env = {
46
48
  name: @name,
47
49
  arguments: arguments,
48
50
  result: nil,
49
- events: events,
50
51
  metadata: {},
51
52
  }
52
53
  @pipeline.call(env)
@@ -11,20 +11,24 @@ module Brute
11
11
  # reading of the response and it is reported as given.
12
12
  module LLMrb
13
13
  def self.detect(response)
14
- return nil unless response.respond_to?(:usage)
15
-
16
- usage = response.usage
17
- return nil if usage.nil?
18
-
19
- Usage.new(
20
- input: read(usage, :input_tokens),
21
- output: read(usage, :output_tokens),
22
- total: read(usage, :total_tokens),
23
- reasoning: read(usage, :reasoning_tokens),
24
- cache_read: read(usage, :cache_read_tokens),
25
- cache_write: read(usage, :cache_write_tokens),
26
- raw: usage,
27
- )
14
+ if response.respond_to?(:usage)
15
+ usage = response.usage
16
+ if usage.nil?
17
+ nil
18
+ else
19
+ Usage.new(
20
+ input: read(usage, :input_tokens),
21
+ output: read(usage, :output_tokens),
22
+ total: read(usage, :total_tokens),
23
+ reasoning: read(usage, :reasoning_tokens),
24
+ cache_read: read(usage, :cache_read_tokens),
25
+ cache_write: read(usage, :cache_write_tokens),
26
+ raw: usage,
27
+ )
28
+ end
29
+ else
30
+ nil
31
+ end
28
32
  end
29
33
 
30
34
  def self.read(usage, name) = usage.respond_to?(name) ? usage.public_send(name) : nil
@@ -11,23 +11,26 @@ module Brute
11
11
  # arrive nested under completion_tokens_details.
12
12
  module OpenRouter
13
13
  def self.detect(response)
14
- return nil unless response.respond_to?(:usage)
15
-
16
- usage = response.usage
17
- return nil if usage.nil?
18
-
19
- usage = usage.to_h
20
- details = (usage["completion_tokens_details"] || usage[:completion_tokens_details] || {}).to_h
21
-
22
- Usage.new(
23
- input: field(usage, "prompt_tokens"),
24
- output: field(usage, "completion_tokens"),
25
- total: field(usage, "total_tokens"),
26
- reasoning: field(details, "reasoning_tokens"),
27
- cache_read: field(usage, "cached_tokens"),
28
- cost: field(usage, "cost"),
29
- raw: usage,
30
- )
14
+ if response.respond_to?(:usage)
15
+ usage = response.usage
16
+ if usage.nil?
17
+ nil
18
+ else
19
+ usage = usage.to_h
20
+ details = (usage["completion_tokens_details"] || usage[:completion_tokens_details] || {}).to_h
21
+ Usage.new(
22
+ input: field(usage, "prompt_tokens"),
23
+ output: field(usage, "completion_tokens"),
24
+ total: field(usage, "total_tokens"),
25
+ reasoning: field(details, "reasoning_tokens"),
26
+ cache_read: field(usage, "cached_tokens"),
27
+ cost: field(usage, "cost"),
28
+ raw: usage,
29
+ )
30
+ end
31
+ else
32
+ nil
33
+ end
31
34
  end
32
35
 
33
36
  # OpenRouter hands back string keys; a hand-built response may not.
@@ -11,20 +11,24 @@ module Brute
11
11
  # provider's own reported_cost when it gives one.
12
12
  module RubyLLM
13
13
  def self.detect(message)
14
- return nil unless message.respond_to?(:tokens)
15
-
16
- tokens = message.tokens
17
- return nil if tokens.nil?
18
-
19
- Usage.new(
20
- input: read(tokens, :input),
21
- output: read(tokens, :output),
22
- reasoning: read(tokens, :thinking),
23
- cache_read: read(tokens, :cache_read),
24
- cache_write: read(tokens, :cache_write),
25
- cost: read(tokens, :reported_cost),
26
- raw: tokens,
27
- )
14
+ if message.respond_to?(:tokens)
15
+ tokens = message.tokens
16
+ if tokens.nil?
17
+ nil
18
+ else
19
+ Usage.new(
20
+ input: read(tokens, :input),
21
+ output: read(tokens, :output),
22
+ reasoning: read(tokens, :thinking),
23
+ cache_read: read(tokens, :cache_read),
24
+ cache_write: read(tokens, :cache_write),
25
+ cost: read(tokens, :reported_cost),
26
+ raw: tokens,
27
+ )
28
+ end
29
+ else
30
+ nil
31
+ end
28
32
  end
29
33
 
30
34
  def self.read(tokens, name) = tokens.respond_to?(name) ? tokens.public_send(name) : nil
@@ -19,7 +19,16 @@ module Brute
19
19
  # turn are the env's business, not the reader's. A strategy answers nil when
20
20
  # the provider reported nothing at all.
21
21
  module UsageDetection
22
- Usage = Data.define(:input, :output, :total, :reasoning, :cache_read, :cache_write, :cost, :raw) do
22
+ Usage = Data.define(
23
+ :input,
24
+ :output,
25
+ :total,
26
+ :reasoning,
27
+ :cache_read,
28
+ :cache_write,
29
+ :cost,
30
+ :raw,
31
+ ) do
23
32
  def initialize(input: nil, output: nil, total: nil, reasoning: nil,
24
33
  cache_read: nil, cache_write: nil, cost: nil, raw: nil)
25
34
  super