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
@@ -46,10 +46,10 @@ module Brute
46
46
  # env[:tools] is not set yet: pass `tools:` to have them counted then.
47
47
  #
48
48
  # Compaction is lossy, and this layer keeps no record of what it gave up:
49
- # it rewrites env[:messages] and says so with :compacted. An application
49
+ # it rewrites env[:messages] and says so with :compact_end. An application
50
50
  # that keeps a transcript preserves it from there.
51
51
  #
52
- # agent.on(:compacted) { |env, payload| archive(env, payload) }
52
+ # agent.on(:compact_end) { |env, payload| archive(env, payload) }
53
53
  #
54
54
  class DefaultCompactionPipeline < Brute::Middleware::Base
55
55
  def initialize(app, window:, summariser: nil, compactor: nil, keep_steps: 2,
@@ -101,22 +101,25 @@ module Brute
101
101
  def compact(env)
102
102
  context = estimate(env)
103
103
 
104
- if context >= @window * @compact_at
105
- before = @token_counter.count(env[:messages])
106
- compacted = attempt(env, target(env))
107
-
108
- unless compacted.nil?
109
- env[:messages].replace(compacted)
110
- # The reported total describes a conversation that no longer
111
- # exists, so the next estimate must not build on it. The call
112
- # below refreshes it; one that fails leaves the estimate to be
113
- # counted from scratch instead of anchored to a fiction.
114
- env[:metadata]&.delete(:last_llm_usage)
115
- emit(
116
- COMPACTED_EVENT,
117
- env,
118
- { context: context, before: before, after: @token_counter.count(compacted) },
119
- )
104
+ env.emit_trace do |env|
105
+ if context >= @window * @compact_at
106
+ env.emit(COMPACT_START_EVENT, { context: context })
107
+
108
+ before = @token_counter.count(env[:messages])
109
+ compacted = attempt(env, target(env))
110
+
111
+ unless compacted.nil?
112
+ env[:messages].replace(compacted)
113
+ # The reported total describes a conversation that no longer
114
+ # exists, so the next estimate must not build on it. The call
115
+ # below refreshes it; one that fails leaves the estimate to be
116
+ # counted from scratch instead of anchored to a fiction.
117
+ env[:metadata]&.delete(:last_llm_usage)
118
+ env.emit(
119
+ COMPACT_END_EVENT,
120
+ { context: context, before: before, after: @token_counter.count(compacted) },
121
+ )
122
+ end
120
123
  end
121
124
  end
122
125
  end
@@ -145,18 +148,17 @@ module Brute
145
148
  def attempt(env, target)
146
149
  compacted = nil
147
150
 
148
- emit(COMPACT_DURATION_EVENT, env, env[:compactor]) do
151
+ env.emit(COMPACT_DURATION_EVENT, env[:compactor]) do
149
152
  compacted = env[:compactor].compact(
150
153
  env[:messages],
151
- target: target,
152
- events: env[:events] || Brute::Turn::Pipeline::NullSink.new,
154
+ target: target,
153
155
  token_counter: @token_counter,
154
156
  )
155
157
  end
156
158
 
157
159
  compacted
158
160
  rescue => error
159
- (env[:events] ||= []) << { type: :error, data: { error: error, message: error.message } }
161
+ env.emit(COMPACT_FAILURE_EVENT, error)
160
162
  nil
161
163
  end
162
164
 
@@ -194,6 +196,10 @@ describe "brute/middleware/040_default_compaction_pipeline" do
194
196
  end
195
197
  end
196
198
 
199
+ def traced(env, hooks = Brute::Hooks::Registry.new)
200
+ Brute::Hooks::Trace.new(env, hooks: hooks)
201
+ end
202
+
197
203
  def compactor(&block)
198
204
  Object.new.tap do |double|
199
205
  double.define_singleton_method(:compact) { |messages, target:, **| block.call(messages, target) }
@@ -207,6 +213,10 @@ describe "brute/middleware/040_default_compaction_pipeline" do
207
213
  declines = compactor { |_messages, target| asked << target; nil }
208
214
  raises = compactor { |_messages, _target| raise IOError, "the summariser is down" }
209
215
 
216
+ hooks = Brute::Hooks::Registry.new
217
+ %i[compact_end compact_failure].each { |event| hooks.on(event) { |_env, *extras| events << [event, *extras.first(1)] } }
218
+ hooks.on(:compact_duration) { |_env, *| events << [:compact_duration] }
219
+
210
220
  layer = lambda do |compactor|
211
221
  Brute::Middleware::DefaultCompactionPipeline.new(
212
222
  ->(env) { env },
@@ -214,18 +224,16 @@ describe "brute/middleware/040_default_compaction_pipeline" do
214
224
  window: 1_000,
215
225
  compact_at: 0.7,
216
226
  compact_to: 0.4,
217
- ).tap do |it|
218
- it.define_singleton_method(:emit) { |event, _env, *extras, &work| work&.call; events << [event, *extras] }
219
- end
227
+ )
220
228
  end
221
229
 
222
- env = { messages: conversation, metadata: usage(900) }
230
+ env = traced({ messages: conversation, metadata: usage(900) }, hooks)
223
231
  layer.call(shrinks).call(env)
224
232
 
225
233
  asked.should == [400]
226
234
  env[:messages].map(&:role).should == [:user]
227
235
  env[:compactor].equal?(shrinks).should.be.true
228
- events.select { |e, _| e == :compacted }.should == [[:compacted, { context: 900, before: 514, after: 256 }]]
236
+ events.select { |e, _| e == :compact_end }.should == [[:compact_end, { context: 900, before: 514, after: 256 }]]
229
237
  # The attempt is timed, so the compactor that spends a model call is visible.
230
238
  events.select { |e, _| e == :compact_duration }.length.should == 1
231
239
 
@@ -233,27 +241,29 @@ describe "brute/middleware/040_default_compaction_pipeline" do
233
241
  # asked and nothing is said.
234
242
  asked.clear
235
243
  events.clear
236
- layer.call(shrinks).call({ messages: conversation, metadata: usage(600) })
244
+ layer.call(shrinks).call(traced({ messages: conversation, metadata: usage(600) }, hooks))
237
245
  asked.should == []
238
246
  events.should == []
239
247
 
240
248
  # ...but what landed since it answered is counted on top of it, and that
241
249
  # is what tips this one over.
242
250
  tail = conversation.tap { |log| log.tool("z" * 400, tool_call_id: "tc1") }
243
- layer.call(declines).call({ messages: tail, metadata: usage(600) })
251
+ layer.call(declines).call(traced({ messages: tail, metadata: usage(600) }, hooks))
244
252
  asked.should == [400]
245
253
 
246
254
  # With nothing reported at all, the whole conversation is counted here.
247
255
  asked.clear
248
- layer.call(declines).call({ messages: conversation })
256
+ layer.call(declines).call(traced({ messages: conversation }, hooks))
249
257
  asked.should == []
250
258
 
251
259
  # A compactor that raises is a compactor that declined: reported, not
252
260
  # fatal. An agent that cannot shrink its context carries on with it.
253
- failing = { messages: conversation, events: [], metadata: usage(900) }
261
+ events.clear
262
+ failing = traced({ messages: conversation, metadata: usage(900) }, hooks)
254
263
  should.not.raise(IOError) { layer.call(raises).call(failing) }
255
264
  failing[:messages].length.should == 2
256
- failing[:events].map { |e| [e[:type], e[:data][:message]] }.should == [[:error, "the summariser is down"]]
265
+ events.select { |e, _| e == :compact_failure }.map { |_, error| error.message }
266
+ .should == ["the summariser is down"]
257
267
 
258
268
  # A target at or above the trigger would compact on every single step.
259
269
  should.raise(ArgumentError) do
@@ -272,16 +282,11 @@ describe "brute/middleware/040_default_compaction_pipeline" do
272
282
  ladder = Brute::Middleware::DefaultCompactionPipeline.compactor(summariser: summariser, keep_steps: 1)
273
283
  ladder.should.be.kind_of Brute::Turn::CompactionPipeline
274
284
 
275
- events = []
276
- sink = Object.new.tap { |it| it.define_singleton_method(:<<) { |e| events << e; it } }
277
-
278
285
  messages = history
279
286
  before = Brute::Compaction::Transcript.tokens(messages)
280
- out = ladder.compact(messages, target: 1_500, events: sink)
287
+ out = ladder.compact(messages, target: 1_500)
281
288
 
282
289
  Brute::Compaction::Transcript.tokens(out).should.be < before
283
- events.select { |e| e[:type] == :compacted }.map { |e| e[:data][:strategy] }
284
- .should.include "sliding_window"
285
290
 
286
291
  # Given no summariser the terminal declines, and the free layers are all
287
292
  # the agent gets -- a policy said out loud rather than configured.
@@ -290,8 +295,7 @@ describe "brute/middleware/040_default_compaction_pipeline" do
290
295
 
291
296
  # One layer, so it still owns the *when* as well as the what.
292
297
  layer = Brute::Middleware::DefaultCompactionPipeline.new(->(e) { e }, window: 1_000, summariser: summariser)
293
- layer.define_singleton_method(:emit) { |_e, _env, *, &work| work&.call }
294
- env = { messages: history, metadata: {} }
298
+ env = traced({ messages: history, metadata: {} })
295
299
  layer.call(env)
296
300
  env[:messages].length.should.be < 11
297
301
  end
@@ -319,33 +323,33 @@ describe "brute/middleware/040_default_compaction_pipeline" do
319
323
  said = -> { Brute.log.tap { |log| log.user("x" * 2_578) } }
320
324
  Brute::TokenCounter.default.count(said.call).should == 650
321
325
 
322
- layer.call.call({ messages: said.call, metadata: {} })
326
+ layer.call.call(traced({ messages: said.call, metadata: {} }))
323
327
  asked.should == []
324
328
 
325
329
  # The same conversation ships with tool schemas in every request, and
326
330
  # counting what they occupy is what tips it over. What is left of the
327
331
  # target after them is what the compactor is asked for -- the schemas are
328
332
  # not room the conversation may have.
329
- layer.call(tools: [tool]).call({ messages: said.call, metadata: {} })
333
+ layer.call(tools: [tool]).call(traced({ messages: said.call, metadata: {} }))
330
334
  asked.should == [400 - schemas]
331
335
 
332
336
  # Reported usage already covers the schemas, so the warm path must not
333
337
  # count them again -- 600 reported and nothing said since is 600, tools
334
338
  # or no tools.
335
339
  asked.clear
336
- layer.call(tools: [tool]).call({ messages: conversation, metadata: usage(600) })
340
+ layer.call(tools: [tool]).call(traced({ messages: conversation, metadata: usage(600) }))
337
341
  asked.should == []
338
342
 
339
343
  # What the provider counted describes the conversation as it was. Once
340
344
  # compaction has given part of it up that number is a fiction, so it goes
341
345
  # -- the next estimate counts from scratch rather than building on it.
342
- compacted = { messages: conversation, metadata: usage(900) }
346
+ compacted = traced({ messages: conversation, metadata: usage(900) })
343
347
  layer.call.call(compacted)
344
348
  compacted[:messages].length.should == 1
345
349
  compacted[:metadata].key?(:last_llm_usage).should.be.false
346
350
 
347
351
  # Nothing given up, nothing invalidated.
348
- kept = { messages: conversation, metadata: usage(900) }
352
+ kept = traced({ messages: conversation, metadata: usage(900) })
349
353
  layer.call(compactor: compactor { |_messages, _target| nil }).call(kept)
350
354
  kept[:metadata].key?(:last_llm_usage).should.be.true
351
355
  end
@@ -33,7 +33,7 @@ module Brute
33
33
  tools_to_run = Array(tools_to_run).reject { |tc| tc.name == "question" }
34
34
 
35
35
  available_tools = Brute::Tools::Adapter.wrap_all(env[:tools])
36
- env[:events] << on_tool_call_start_event(tools_to_run)
36
+ env.emit(TOOL_CALLS_EVENT, tools_to_run)
37
37
 
38
38
  results = []
39
39
 
@@ -44,47 +44,58 @@ module Brute
44
44
  barrier = Async::Barrier.new
45
45
 
46
46
  tools_to_run.each do |tool_call|
47
- barrier.async do
47
+ barrier.async do
48
48
  name = tool_call.name.to_sym
49
49
  args = tool_call.arguments
50
50
 
51
- # Lifecycle hooks (Brute::Hooks): before_tool may rewrite
52
- # :arguments or short-circuit with a :result; approve_tool
53
- # denies on a false (or String) return; after_tool may
51
+ # Lifecycle hooks (Brute::Hooks): tool_start may rewrite
52
+ # :arguments or short-circuit with a :result; tool_approve
53
+ # denies on a false (or String) return; tool_end may
54
54
  # rewrite :result.
55
55
  call_env = {
56
56
  name: name.to_s,
57
57
  arguments: args,
58
58
  result: nil,
59
59
  denied: nil,
60
- events: env[:events],
61
60
  metadata: {},
62
61
  turn_env: env,
63
62
  }
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.)
63
+ env.emit_trace do |env|
64
+ # A subscriber takes part by mutating the call env: set
65
+ # :result to answer without executing, set :denied to refuse.
66
+ env.emit(TOOL_START_EVENT, call_env)
67
+
68
+ if call_env[:result].nil?
69
+ env.emit(TOOL_APPROVE_EVENT, call_env)
70
+
71
+ if (denial = call_env[:denied])
72
+ if denial.is_a?(String)
73
+ call_env[:result] = denial
74
+ else
75
+ call_env[:result] = %(Tool call to "#{name}" was denied.)
76
+ end
77
+ end
73
78
  end
74
- end
75
79
 
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
+ # Only the tool's own execution is timed: a call that
81
+ # tool_start answered, or tool_approve denied, never ran.
82
+ result = call_env[:result]
79
83
 
80
- if result.nil?
81
- emit(TOOL_DURATION_EVENT, env, call_env) do
82
- result = available_tools[name].call(call_env[:arguments])
84
+ if result.nil?
85
+ env.emit(TOOL_DURATION_EVENT, call_env) do
86
+ result = available_tools[name].call(call_env[:arguments])
87
+ end
83
88
  end
84
- end
85
89
 
86
- call_env[:result] = result
87
- emit(AFTER_TOOL_EVENT, env, call_env)
90
+ call_env[:result] = result
91
+ env.emit(TOOL_END_EVENT, call_env)
92
+ rescue => error
93
+ # Capture the error as a tool result so the LLM can see it
94
+ # and reason about the failure, rather than crashing the
95
+ # entire middleware chain.
96
+ env.emit(TOOL_FAILURE_EVENT, call_env, error)
97
+ call_env[:result] = "Error: #{error.class}: #{error.message}"
98
+ end
88
99
  result = call_env[:result]
89
100
 
90
101
  # Coerce to String so Hash results (e.g. Shell's
@@ -101,12 +112,6 @@ module Brute
101
112
  end
102
113
 
103
114
  results << [tool_call, content]
104
- rescue => e
105
- # Capture the error as a tool result so the LLM can see it
106
- # and reason about the failure, rather than crashing the
107
- # entire middleware chain.
108
- env[:events] << { type: :error, data: { error: e, message: e.message } }
109
- results << [tool_call, "Error: #{e.class}: #{e.message}"]
110
115
  end
111
116
  end
112
117
 
@@ -120,7 +125,6 @@ module Brute
120
125
  results.sort_by! { |tool_call, _| tools_to_run.index(tool_call) }
121
126
 
122
127
  results.each do |tool_call, content|
123
- env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
124
128
  env[:messages] << Brute::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
125
129
  end
126
130
  end
@@ -129,19 +133,6 @@ module Brute
129
133
  end
130
134
 
131
135
  private
132
-
133
- def on_tool_call_start_event(pending_tools)
134
- {
135
- type: :tool_call_start,
136
- data: pending_tools.map { |tc|
137
- {
138
- name: tc.name,
139
- call_id: tc.id,
140
- arguments: tc.arguments
141
- }
142
- }
143
- }
144
- end
145
136
  end
146
137
  end
147
138
  end
@@ -193,7 +184,7 @@ describe "brute/middleware/070_default_tool_pipeline" do
193
184
  { messages: Brute.log, events: [] }
194
185
  end
195
186
 
196
- it "before_tool may rewrite arguments and short-circuit with a result" do
187
+ it "tool_start may rewrite arguments and short-circuit with a result" do
197
188
  tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
198
189
  inner = ->(env) do
199
190
  env[:messages] << Brute::Message.new(role: :assistant, content: "",
@@ -201,41 +192,41 @@ describe "brute/middleware/070_default_tool_pipeline" do
201
192
  end
202
193
 
203
194
  pipeline = hooked(inner, tools: [tool]) do |p|
204
- p.on(:before_tool) { |_env, call| call[:arguments] = { text: "rewritten" } }
195
+ p.on(:tool_start) { |_env, call| call[:arguments] = { text: "rewritten" } }
205
196
  end
206
197
  env = hook_env
207
198
  env[:messages].user("hi")
208
199
  pipeline.call(env)
209
200
  env[:messages].last.content.should == "ran:rewritten"
210
201
 
211
- canned = hooked(inner, tools: [tool]) { |p| p.on(:before_tool) { |_env, call| call[:result] = "canned" } }
202
+ canned = hooked(inner, tools: [tool]) { |p| p.on(:tool_start) { |_env, call| call[:result] = "canned" } }
212
203
  env2 = hook_env
213
204
  env2[:messages].user("hi")
214
205
  canned.call(env2)
215
206
  env2[:messages].last.content.should == "canned" # never executed
216
207
  end
217
208
 
218
- it "approve_tool denies on false (generic message) or String (custom)" do
209
+ it "tool_approve denies on false (generic message) or String (custom)" do
219
210
  tool = { name: "exec", description: "", execute: ->(**) { "ran" } }
220
211
  inner = ->(env) do
221
212
  env[:messages] << Brute::Message.new(role: :assistant, content: "",
222
213
  tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
223
214
  end
224
215
 
225
- denied = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = true } }
216
+ denied = hooked(inner, tools: [tool]) { |p| p.on(:tool_approve) { |_env, call| call[:denied] = true } }
226
217
  env = hook_env
227
218
  env[:messages].user("hi")
228
219
  denied.call(env)
229
220
  env[:messages].last.content.should == %(Tool call to "exec" was denied.)
230
221
 
231
- by_policy = hooked(inner, tools: [tool]) { |p| p.on(:approve_tool) { |_env, call| call[:denied] = "denied by policy" } }
222
+ by_policy = hooked(inner, tools: [tool]) { |p| p.on(:tool_approve) { |_env, call| call[:denied] = "denied by policy" } }
232
223
  env2 = hook_env
233
224
  env2[:messages].user("hi")
234
225
  by_policy.call(env2)
235
226
  env2[:messages].last.content.should == "denied by policy"
236
227
  end
237
228
 
238
- it "after_tool may rewrite the result" do
229
+ it "tool_end may rewrite the result" do
239
230
  tool = { name: "echo", description: "", execute: ->(**) { "raw" } }
240
231
  inner = ->(env) do
241
232
  env[:messages] << Brute::Message.new(role: :assistant, content: "",
@@ -243,7 +234,7 @@ describe "brute/middleware/070_default_tool_pipeline" do
243
234
  end
244
235
 
245
236
  pipeline = hooked(inner, tools: [tool]) do |p|
246
- p.on(:after_tool) { |_env, call| call[:result] = "rewrote(#{call[:result]})" }
237
+ p.on(:tool_end) { |_env, call| call[:result] = "rewrote(#{call[:result]})" }
247
238
  end
248
239
  env = hook_env
249
240
  env[:messages].user("hi")
@@ -56,25 +56,29 @@ module Brute
56
56
 
57
57
  private
58
58
 
59
- def locals(ctx)
60
- @section_keys.to_h do |key|
61
- value = self[key]
62
- resolved = value.is_a?(Proc) ? (value.arity.zero? ? value.call : value.call(ctx)) : value
63
- [key, resolved]
64
- end.merge(ctx: ctx)
65
- end
59
+ def locals(ctx)
60
+ @section_keys.to_h do |key|
61
+ value = self[key]
62
+ if value.is_a?(Proc)
63
+ resolved = (value.arity.zero? ? value.call : value.call(ctx))
64
+ else
65
+ resolved = value
66
+ end
67
+ [key, resolved]
68
+ end.merge(ctx: ctx)
69
+ end
66
70
 
67
- def render(values)
68
- context = binding
69
- values.each { |key, value| context.local_variable_set(key, value) }
70
- ERB.new(template_source, trim_mode: "-").result(context)
71
- end
71
+ def render(values)
72
+ context = binding
73
+ values.each { |key, value| context.local_variable_set(key, value) }
74
+ ERB.new(template_source, trim_mode: "-").result(context)
75
+ end
72
76
 
73
77
  # A path that exists is re-read every time; anything else is treated as
74
78
  # an inline ERB source string.
75
- def template_source
76
- File.exist?(@template.to_s) ? File.read(@template) : @template.to_s
77
- end
79
+ def template_source
80
+ File.exist?(@template.to_s) ? File.read(@template) : @template.to_s
81
+ end
78
82
  end
79
83
  end
80
84
 
@@ -14,9 +14,14 @@ module Brute
14
14
  def self.read(section, provider_name)
15
15
  provider = provider_name.to_s
16
16
  path = File.join(TEXT_DIR, section, "#{provider}.txt")
17
- path = File.join(TEXT_DIR, section, "default.txt") unless File.exist?(path)
18
- return nil unless File.exist?(path)
19
- File.read(path)
17
+ unless File.exist?(path)
18
+ path = File.join(TEXT_DIR, section, "default.txt")
19
+ end
20
+ if File.exist?(path)
21
+ File.read(path)
22
+ else
23
+ nil
24
+ end
20
25
  end
21
26
 
22
27
  # Read a named agent prompt (e.g. "explore", "compaction").
@@ -33,9 +38,11 @@ module Brute
33
38
  end
34
39
 
35
40
  def method_missing(name, *args)
36
- return @ctx[name] if args.empty? && @ctx.key?(name)
37
-
38
- super
41
+ if args.empty? && @ctx.key?(name)
42
+ @ctx[name]
43
+ else
44
+ super
45
+ end
39
46
  end
40
47
 
41
48
  def respond_to_missing?(name, include_private = false)
@@ -69,10 +76,12 @@ module Brute
69
76
  path = [provider, "default"]
70
77
  .map { |variant| File.join(TEXT_DIR, section, "#{variant}.erb") }
71
78
  .find { |candidate| File.exist?(candidate) }
72
- return read(section, provider) unless path
73
-
74
- erb = TEMPLATES[path] ||= ERB.new(File.read(path), trim_mode: "-")
75
- erb.result(Context.new(ctx).get_binding)
79
+ if path
80
+ erb = TEMPLATES[path] ||= ERB.new(File.read(path), trim_mode: "-")
81
+ erb.result(Context.new(ctx).get_binding)
82
+ else
83
+ read(section, provider)
84
+ end
76
85
  end
77
86
  end
78
87
  end
@@ -12,7 +12,9 @@ module Brute
12
12
  git = File.exist?(File.join(cwd, ".git"))
13
13
 
14
14
  parts = []
15
- parts << "You are powered by the model named #{model}." unless model.empty?
15
+ unless model.empty?
16
+ parts << "You are powered by the model named #{model}."
17
+ end
16
18
  parts << ""
17
19
  parts << "Here is some useful information about the environment you are running in:"
18
20
  parts << "<env>"
@@ -8,13 +8,15 @@ module Brute
8
8
  module Instructions
9
9
  def self.call(ctx)
10
10
  rules = ctx[:custom_rules]
11
- return nil if rules.nil? || rules.strip.empty?
12
-
13
- <<~TXT
14
- # Project-Specific Rules
15
-
16
- #{rules}
17
- TXT
11
+ if rules.nil? || rules.strip.empty?
12
+ nil
13
+ else
14
+ <<~TXT
15
+ # Project-Specific Rules
16
+
17
+ #{rules}
18
+ TXT
19
+ end
18
20
  end
19
21
  end
20
22
  end
@@ -16,9 +16,11 @@ module Brute
16
16
  def self.call(ctx)
17
17
  skills = ctx[:skills] || Brute::Skill.all(cwd: ctx[:cwd] || Dir.pwd)
18
18
  visible = skills.reject(&:disable_model_invocation?)
19
- return nil if visible.empty?
20
-
21
- Prompts.render("skills", ctx.merge(skills: visible))
19
+ if visible.empty?
20
+ nil
21
+ else
22
+ Prompts.render("skills", ctx.merge(skills: visible))
23
+ end
22
24
  end
23
25
  end
24
26
  end
@@ -38,7 +38,9 @@ module Brute
38
38
  # @parameter agent [#start] Anything with `start(prompt) -> env` — an
39
39
  # AgentPipeline, a SubAgent, or any turn-shaped callable.
40
40
  def initialize(agent)
41
- raise ArgumentError, "agent must respond to #start" unless agent.respond_to?(:start)
41
+ unless agent.respond_to?(:start)
42
+ raise ArgumentError, "agent must respond to #start"
43
+ end
42
44
 
43
45
  @agent = agent
44
46
  end
@@ -48,10 +50,12 @@ module Brute
48
50
  # (client's fault); anything the turn raises is a 500.
49
51
  def call(env)
50
52
  prompt = prompt_from(env)
51
- return response_for(env, 400, "No prompt provided.") if prompt.nil? || prompt.empty?
52
-
53
- turn = @agent.start(prompt)
54
- response_for(env, 200, output_of(turn))
53
+ if prompt.nil? || prompt.empty?
54
+ response_for(env, 400, "No prompt provided.")
55
+ else
56
+ turn = @agent.start(prompt)
57
+ response_for(env, 200, output_of(turn))
58
+ end
55
59
  rescue => error
56
60
  response_for(env, 500, error.message)
57
61
  end
@@ -68,24 +72,29 @@ module Brute
68
72
  request = ::Rack::Request.new(env)
69
73
 
70
74
  if (query = request.GET["prompt"]) && !query.empty?
71
- return query
72
- end
73
-
74
- body = read_body(request)
75
- return nil if body.nil? || body.strip.empty?
76
-
77
- if json?(request.media_type)
78
- case data = parse_json(body)
79
- when ::Hash then return PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
80
- when ::String then return data
75
+ query
76
+ else
77
+ body = read_body(request)
78
+ if body.nil? || body.strip.empty?
79
+ nil
80
+ else
81
+ from_json = nil
82
+ if json?(request.media_type)
83
+ case data = parse_json(body)
84
+ when ::Hash then from_json = PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
85
+ when ::String then from_json = data
86
+ end
87
+ end
88
+
89
+ if from_json
90
+ from_json
91
+ elsif request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
92
+ field
93
+ else
94
+ body
95
+ end
81
96
  end
82
97
  end
83
-
84
- if request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
85
- return field
86
- end
87
-
88
- body
89
98
  end
90
99
 
91
100
  # output -> [status, headers, body]. Content-negotiated: JSON in (or an
@@ -96,7 +105,11 @@ module Brute
96
105
  text = output.to_s
97
106
 
98
107
  if wants_json?(env)
99
- key = status == 200 ? :response : :error
108
+ if status == 200
109
+ key = :response
110
+ else
111
+ key = :error
112
+ end
100
113
  [status, {"content-type" => "application/json"}, [::JSON.generate(key => text)]]
101
114
  else
102
115
  [status, {"content-type" => "text/plain; charset=utf-8"}, [text]]
@@ -107,7 +120,11 @@ module Brute
107
120
 
108
121
  # The agent's answer is the last message it appended to the log.
109
122
  def output_of(turn)
110
- messages = turn.is_a?(::Hash) ? turn[:messages] : turn
123
+ if turn.is_a?(::Hash)
124
+ messages = turn[:messages]
125
+ else
126
+ messages = turn
127
+ end
111
128
  messages&.last&.content.to_s
112
129
  end
113
130