brute 5.0.4 → 5.1.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.
@@ -21,7 +21,7 @@ module Brute
21
21
  # use Brute::Middleware::SystemPrompt
22
22
  # use Brute::Middleware::Loop::ToolResult
23
23
  # use Brute::Middleware::MaxIterations, max_iterations: 10
24
- # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
24
+ # use Brute::Middleware::DefaultToolPipeline, tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch]
25
25
  # run ->(env) do
26
26
  # # The LLM call, written with your library of choice. Convert
27
27
  # # env[:messages] to its format, call it, and append the response
@@ -32,7 +32,7 @@ module Brute
32
32
  #
33
33
  # # The SubAgent IS a tool — hand it to a parent agent's ToolPipeline:
34
34
  # main_agent = Brute.agent do
35
- # use Brute::Middleware::ToolPipeline, tools: [Brute::Tools::FSRead, researcher]
35
+ # use Brute::Middleware::DefaultToolPipeline, tools: [Brute::Tools::FSRead, researcher]
36
36
  # run ->(env) { ... }
37
37
  # end
38
38
  # main_agent.start("delegate some research")
@@ -18,26 +18,37 @@ module Brute
18
18
 
19
19
  # Register a slash command:
20
20
  #
21
- # map "/weather", "Get the weather in the following location $ARGUMENTS"
22
- # map("/weather") { "Get the weather in the following location $ARGUMENTS" }
21
+ # map("/compact") { |env| ... }
22
+ # map(/\Aplease compact/i) { |env| ... }
23
+ # map(->(said) { said.length > 10_000 }) { |env| ... }
23
24
  #
24
- def map(command, template = nil, &block)
25
- command = command.to_s
26
- command = command.start_with?("/") ? command : "/#{command}"
27
-
28
- (@map ||= {})[command] = block || proc { template }
25
+ # Whatever is given becomes a check: a function of what was said that
26
+ # answers true or false. A String is a slash command -- "/compact"
27
+ # becomes ^\/compact.*, the command and whatever rides after it -- and
28
+ # a Regexp is wrapped in a function that evaluates it, so by the time
29
+ # a command is registered there are only functions. Anything that
30
+ # already answers to #call is taken as the check itself.
31
+ #
32
+ # This is Rack::Builder's `map` overridden: an agent routes on what was
33
+ # said, not on a path, so there are no sub-builders and no URLMap.
34
+ #
35
+ # The block is a middleware. `start` puts the registry into the turn as
36
+ # env[:commands], and SlashCommands -- which the builder puts at the
37
+ # head of every chain -- runs the first command whose check passes on
38
+ # the newest message, only when it is a user message, before the rest
39
+ # of the stack.
40
+ def map(matcher, &block)
41
+ (@commands ||= []) << [check(matcher), block]
29
42
  self
30
43
  end
31
44
 
32
- def generate_map(default_app, mapping)
33
- super.tap do |routes|
34
- routes.define_singleton_method(:call) do |prompt|
35
- name, args = prompt.to_s.strip.split(/\s+/, 2)
36
- prompt = mapping[name].call.to_s.gsub("$ARGUMENTS", args.to_s) if mapping[name]
37
- default_app.call(prompt)
38
- end
39
- end
45
+ # Every chain starts with the commands, registry or no registry: the
46
+ # builder puts SlashCommands at its head rather than leaving it to a
47
+ # `use` someone has to remember.
48
+ def to_app
49
+ Brute::Middleware::SlashCommands.new(super)
40
50
  end
51
+ alias_method :build, :to_app
41
52
 
42
53
  def start(input = nil, events: NullSink.new)
43
54
  env = {
@@ -45,6 +56,7 @@ module Brute
45
56
  events: events,
46
57
  metadata: {},
47
58
  current_iteration: 1,
59
+ commands: @commands || [],
48
60
  }
49
61
  hooks.emit(TURN_START_EVENT, env)
50
62
  begin
@@ -57,6 +69,28 @@ module Brute
57
69
 
58
70
  private
59
71
 
72
+ # A matcher becomes a function of what was said. A Regexp is
73
+ # evaluated; a String is a slash command first -- given without its
74
+ # slash, it grows one -- and then evaluated the same way.
75
+ def check(matcher)
76
+ if matcher.respond_to?(:call)
77
+ matcher
78
+ else
79
+ pattern = pattern_for(matcher)
80
+ ->(said) { pattern.match?(said) }
81
+ end
82
+ end
83
+
84
+ def pattern_for(matcher)
85
+ if matcher.is_a?(::Regexp)
86
+ matcher
87
+ else
88
+ name = matcher.to_s
89
+ name = name.start_with?("/") ? name : "/#{name}"
90
+ /^#{::Regexp.escape(name)}.*/
91
+ end
92
+ end
93
+
60
94
  def coerce_messages(input)
61
95
  case input
62
96
  when nil then Brute.log
@@ -176,35 +210,80 @@ describe "brute/turn/agent_pipeline" do
176
210
  agent.start("go")[:messages].last.content.should == "done"
177
211
  end
178
212
 
179
- it "map chains and to_app taps generate_map to build a (retargeted) URLMap" do
213
+ it "runs a command's block as a middleware, before the rest of the stack" do
214
+ order = []
180
215
  agent = Brute.agent
181
- .map("/weather", "Get the weather in the following location $ARGUMENTS")
182
- .run(->(prompt) { prompt })
216
+ .use(AgentStubMW)
217
+ .map("/compact") { |env| order << [:compact, env[:messages].last.content] }
218
+ .map("deploy") { |_env| order << :deploy } # grows its slash
219
+ .map(/\Aplease compact\b/i) { |_env| order << :asked } # a Regexp is evaluated
220
+ .map(->(said) { said.length > 20 }) { |_env| order << :long } # so is a function
221
+ .run(->(env) { order << :app; env[:messages].assistant("done") })
222
+
223
+ # The command runs first; the stack below it still runs.
224
+ agent.start("/compact notes")
225
+ order.should == [[:compact, "/compact notes"], :app]
226
+
227
+ # A String matches the command and whatever rides after it.
228
+ order.clear
229
+ agent.start("/deploy now")
230
+ order.should == [:deploy, :app]
231
+
232
+ # Checks are tried in the order they were registered, and the first to
233
+ # pass is the one that runs.
234
+ order.clear
235
+ agent.start("Please compact this, it has gone on long enough")
236
+ order.should == [:asked, :app]
237
+
238
+ order.clear
239
+ agent.start("something else entirely, at some length")
240
+ order.should == [:long, :app]
241
+
242
+ # Conversation and unregistered commands reach the stack untouched.
243
+ order.clear
244
+ agent.start("just a question")
245
+ agent.start("/unregistered")
246
+ order.should == [:app, :app]
247
+
248
+ # Only a user message says anything a command answers to.
249
+ order.clear
250
+ agent.start(Brute::Message.new(role: :assistant, content: "/compact"))
251
+ order.should == [:app]
252
+
253
+ # The registry rides in env, so anything below can see what was mapped.
254
+ agent.start("hello")[:commands].size.should == 4
255
+ end
256
+
257
+ it "turns a matcher into a check that answers true or false" do
258
+ checks = Brute.agent
259
+ .map("/compact") { |_env| }
260
+ .map("deploy") { |_env| }
261
+ .map(/\Ahello/) { |_env| }
262
+ .instance_variable_get(:@commands)
263
+ .map(&:first)
183
264
 
184
- agent.should.be.kind_of?(Brute::Turn::AgentPipeline) # map returns self
185
- agent.build.should.be.kind_of?(::Rack::URLMap) # to_app tapped generate_map (super)
265
+ checks.each { |check| check.should.respond_to?(:call) }
266
+
267
+ checks[0].call("/compact keep the notes").should.be.true
268
+ checks[0].call("/compactor").should.be.true # ^\/compact.* and no more
269
+ checks[0].call("nope").should.be.false
270
+
271
+ checks[1].call("/deploy now").should.be.true # registered without the slash
272
+ checks[1].call("deploy now").should.be.false
273
+
274
+ checks[2].call("hello there").should.be.true
275
+ checks[2].call("well hello").should.be.false
186
276
  end
187
277
 
188
- it "swaps a /command prompt for its template ($ARGUMENTS) and calls the app" do
189
- got = nil
278
+ it "map chains, and every chain is built with the commands at its head" do
190
279
  agent = Brute.agent
191
- .map("/weather", "Get the weather in the following location $ARGUMENTS")
192
- .map("/echo") { "you said: $ARGUMENTS" }
193
- .run(->(prompt) { got = prompt })
194
-
195
- agent.call("/weather London")
196
- got.should == "Get the weather in the following location London"
197
- agent.call("/echo hi there")
198
- got.should == "you said: hi there"
199
- end
280
+ .map("/compact") { |_env| }
281
+ .run(->(env) { env })
200
282
 
201
- it "passes non-commands through untouched (and normalizes the slash)" do
202
- got = nil
203
- agent = Brute.agent.map("weather", "W $ARGUMENTS").run(->(prompt) { got = prompt })
283
+ agent.should.be.kind_of?(Brute::Turn::AgentPipeline) # map returns self
284
+ agent.build.should.be.kind_of?(Brute::Middleware::SlashCommands) # and the head is the commands
204
285
 
205
- agent.call("just a question")
206
- got.should == "just a question"
207
- agent.call("/weather NYC") # registered as "weather", normalized to "/weather"
208
- got.should == "W NYC"
286
+ # No commands, same head.
287
+ Brute.agent.run(->(env) { env }).build.should.be.kind_of?(Brute::Middleware::SlashCommands)
209
288
  end
210
289
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/turn/pipeline"
6
+
7
+ module Brute
8
+ module Turn
9
+ # A compactor built out of middleware.
10
+ #
11
+ # Like ToolPipeline it *composes* a Pipeline rather than inheriting: the
12
+ # definition block is instance_eval'd into the internal Pipeline, so `use`
13
+ # / `run` inside it are the builder's methods.
14
+ #
15
+ # The stack order is the policy. A layer that got the conversation under
16
+ # target does not call the next one, so the strategies that cost nothing
17
+ # sit at the top and the terminal app -- the summariser, the only thing
18
+ # here that spends money -- is reached only when they could not get there.
19
+ # That is `run` meaning what it means everywhere else in Brute: the model
20
+ # call, at the bottom, with the layers deciding what reaches it.
21
+ #
22
+ # Brute::Turn::CompactionPipeline.new do
23
+ # use Brute::Compaction::Middleware::ToolResults, keep_steps: 1
24
+ # use Brute::Compaction::Middleware::SlidingWindow, keep_steps: 2
25
+ #
26
+ # run Brute::Compaction::Summarize.new(
27
+ # Brute::Completion::OpenRouter.new(config: { access_token: key }),
28
+ # )
29
+ # end
30
+ #
31
+ # A pipeline that must never spend a call is the first two layers and
32
+ # `run ->(env) { env }` -- a policy said out loud rather than configured.
33
+ #
34
+ # The conversation being compacted rides on `env[:conversation]`, leaving
35
+ # `env[:messages]` to mean what it means to every other terminal app in
36
+ # Brute: the prompt going down, the reply coming back.
37
+ #
38
+ # It answers #compact, so what comes out drops into
39
+ # Brute::Middleware::DefaultCompactionPipeline as that turn's compactor.
40
+ class CompactionPipeline
41
+ def initialize(&block)
42
+ @pipeline = Pipeline.new
43
+ @pipeline.instance_eval(&block) if block
44
+ end
45
+
46
+ # Answer a smaller conversation, or nil when nothing was given up.
47
+ def compact(messages, target:, events: Pipeline::NullSink.new, token_counter: nil)
48
+ env = {
49
+ conversation: messages,
50
+ target: target,
51
+ token_counter: token_counter,
52
+ applied: [],
53
+ messages: Brute.log,
54
+ events: events,
55
+ metadata: {},
56
+ }
57
+ @pipeline.call(env)
58
+
59
+ unless env[:applied].empty?
60
+ env[:conversation]
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ __END__
68
+
69
+ describe "brute/turn/compaction_pipeline" do
70
+ def said(content) = Brute::Message.new(role: :user, content: content)
71
+
72
+ def layer(name, &block)
73
+ Class.new(Brute::Compaction::Middleware::Strategy) do
74
+ define_method(:strategy) { name }
75
+ define_method(:rewrite) { |conversation, target:| block.call(conversation, target) }
76
+ end
77
+ end
78
+
79
+ it "composes strategies into one compactor, and only reaches run when they could not" do
80
+ reached = []
81
+ halves = layer("halves") { |c, _t| reached << "halves"; c.first(c.length / 2) }
82
+ never = layer("never") { |_c, _t| reached << "never"; nil }
83
+ # A terminal belongs to one pipeline: the builder binds an emit onto it
84
+ # and refuses to bind a second, so each pipeline gets its own.
85
+ terminal = lambda do
86
+ Object.new.tap { |it| it.define_singleton_method(:call) { |env| reached << "run"; env } }
87
+ end
88
+
89
+ four = [said("a" * 4_000), said("b" * 4_000), said("c" * 4_000), said("d" * 4_000)]
90
+
91
+ # Halving is enough, so the terminal -- the one that costs money -- is
92
+ # never reached. The stack order is the policy.
93
+ pipeline = Brute::Turn::CompactionPipeline.new do
94
+ use halves
95
+ run terminal.call
96
+ end
97
+ pipeline.compact(four, target: 3_000).map { |m| m.content[0, 1] }.should == ["a", "b"]
98
+ reached.should == ["halves"]
99
+
100
+ # A strategy that declines lets the turn fall through to it.
101
+ reached.clear
102
+ Brute::Turn::CompactionPipeline.new do
103
+ use never
104
+ run terminal.call
105
+ end.compact(four, target: 3_000).should.be.nil
106
+ reached.should == ["never", "run"]
107
+
108
+ # Nothing given up at all is nil, not the conversation it was handed --
109
+ # the caller applies whatever else comes back.
110
+ reached.clear
111
+ pipeline.compact(four, target: 500_000).should.be.nil
112
+ reached.should == []
113
+
114
+ # The conversation rides on :conversation, leaving :messages to mean what
115
+ # it means to every other terminal app -- the prompt down, the reply back.
116
+ seen = nil
117
+ Brute::Turn::CompactionPipeline.new do
118
+ run ->(env) { seen = [env[:conversation].length, env[:messages], env[:target]] }
119
+ end.compact(four, target: 10)
120
+ seen.should == [4, [], 10]
121
+ end
122
+ end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "5.0.4"
4
+ VERSION = "5.1.0"
5
5
  end
data/lib/brute.rb CHANGED
@@ -42,7 +42,7 @@ module Brute
42
42
  #
43
43
  # agent = Brute.agent
44
44
  # .use(Brute::Middleware::SystemPrompt)
45
- # .use(Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL)
45
+ # .use(Brute::Middleware::DefaultToolPipeline, tools: Brute::Tools::ALL)
46
46
  # .run ->(env) { ... } # the LLM-call proc (provider/model/creds here)
47
47
  #
48
48
  # agent.start("what changed?")
@@ -93,7 +93,14 @@ end
93
93
  require_relative "brute/version"
94
94
  require_relative "brute/hooks"
95
95
  require_relative "brute/messages"
96
+ require_relative "brute/env"
96
97
  require_relative "brute/middleware/000_base"
98
+ require_relative "brute/compaction"
99
+ require_relative "brute/compaction/transcript"
100
+ require_relative "brute/compaction/middleware/strategy"
101
+ require_relative "brute/compaction/middleware/tool_results"
102
+ require_relative "brute/compaction/middleware/sliding_window"
103
+ require_relative "brute/compaction/summarize"
97
104
  require_relative "brute/completion/open_router"
98
105
  require_relative "brute/completion/lang_chain"
99
106
  require_relative "brute/completion/llmrb"
@@ -116,6 +123,7 @@ require_relative "brute/message_transport/openai"
116
123
  require_relative "brute/message_transport/ruby_llm"
117
124
  require_relative "brute/message_transport/ruby_open_ai"
118
125
  require_relative "brute/message_transport/lang_chain"
126
+ require_relative "brute/middleware/001_slash_commands"
119
127
  require_relative "brute/middleware/002_session_log"
120
128
  require_relative "brute/middleware/004_summarize"
121
129
  require_relative "brute/middleware/006_loop"
@@ -123,8 +131,10 @@ require_relative "brute/middleware/008_checkpoint"
123
131
  require_relative "brute/middleware/010_max_iterations"
124
132
  require_relative "brute/middleware/020_system_prompt"
125
133
  require_relative "brute/middleware/025_skills"
134
+ require_relative "brute/middleware/040_default_compaction_pipeline"
126
135
  require_relative "brute/middleware/040_compaction_check"
127
136
  require_relative "brute/middleware/060_questions"
137
+ require_relative "brute/middleware/070_default_tool_pipeline"
128
138
  require_relative "brute/middleware/070_tool_pipeline"
129
139
  require_relative "brute/middleware/event_handler"
130
140
  require_relative "brute/middleware/user_queue"
@@ -156,6 +166,9 @@ require_relative "brute/prompts/tool_usage"
156
166
  require_relative "brute/rack/adapter"
157
167
  require_relative "brute/skill"
158
168
  require_relative "brute/system_prompt"
169
+ require_relative "brute/token_counter"
170
+ require_relative "brute/token_counter/approximate"
171
+ require_relative "brute/token_counter/tiktoken"
159
172
  require_relative "brute/tool"
160
173
  require_relative "brute/tools"
161
174
  require_relative "brute/tools/adapter"
@@ -178,7 +191,9 @@ require_relative "brute/tools/todo_write"
178
191
  require_relative "brute/truncation"
179
192
  require_relative "brute/turn/agent_pipeline"
180
193
  require_relative "brute/turn/pipeline"
194
+ require_relative "brute/turn/compaction_pipeline"
181
195
  require_relative "brute/turn/tool_pipeline"
196
+ require_relative "brute/eval"
182
197
  require_relative "brute/utils/diff"
183
198
 
184
199
  # gangsta g-dogg bruh...
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brute
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.4
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -282,12 +282,24 @@ extensions: []
282
282
  extra_rdoc_files: []
283
283
  files:
284
284
  - lib/brute.rb
285
+ - lib/brute/compaction.rb
286
+ - lib/brute/compaction/middleware/sliding_window.rb
287
+ - lib/brute/compaction/middleware/strategy.rb
288
+ - lib/brute/compaction/middleware/tool_results.rb
289
+ - lib/brute/compaction/summarize.rb
290
+ - lib/brute/compaction/transcript.rb
285
291
  - lib/brute/completion/lang_chain.rb
286
292
  - lib/brute/completion/llmrb.rb
287
293
  - lib/brute/completion/open_router.rb
288
294
  - lib/brute/completion/ruby_llm.rb
289
295
  - lib/brute/contrib/log_file.rb
290
296
  - lib/brute/contrib/otel.rb
297
+ - lib/brute/env.rb
298
+ - lib/brute/eval.rb
299
+ - lib/brute/eval/case.rb
300
+ - lib/brute/eval/suite.rb
301
+ - lib/brute/eval/transcript.rb
302
+ - lib/brute/eval/world.rb
291
303
  - lib/brute/events/handler.rb
292
304
  - lib/brute/events/prefixed_terminal_output.rb
293
305
  - lib/brute/events/terminal_output_handler.rb
@@ -302,6 +314,7 @@ files:
302
314
  - lib/brute/message_transport/ruby_open_ai.rb
303
315
  - lib/brute/messages.rb
304
316
  - lib/brute/middleware/000_base.rb
317
+ - lib/brute/middleware/001_slash_commands.rb
305
318
  - lib/brute/middleware/002_session_log.rb
306
319
  - lib/brute/middleware/004_summarize.rb
307
320
  - lib/brute/middleware/006_loop.rb
@@ -310,7 +323,9 @@ files:
310
323
  - lib/brute/middleware/020_system_prompt.rb
311
324
  - lib/brute/middleware/025_skills.rb
312
325
  - lib/brute/middleware/040_compaction_check.rb
326
+ - lib/brute/middleware/040_default_compaction_pipeline.rb
313
327
  - lib/brute/middleware/060_questions.rb
328
+ - lib/brute/middleware/070_default_tool_pipeline.rb
314
329
  - lib/brute/middleware/070_tool_pipeline.rb
315
330
  - lib/brute/middleware/event_handler.rb
316
331
  - lib/brute/middleware/user_queue.rb
@@ -361,6 +376,9 @@ files:
361
376
  - lib/brute/rack/adapter.rb
362
377
  - lib/brute/skill.rb
363
378
  - lib/brute/system_prompt.rb
379
+ - lib/brute/token_counter.rb
380
+ - lib/brute/token_counter/approximate.rb
381
+ - lib/brute/token_counter/tiktoken.rb
364
382
  - lib/brute/tool.rb
365
383
  - lib/brute/tools.rb
366
384
  - lib/brute/tools/adapter.rb
@@ -382,6 +400,7 @@ files:
382
400
  - lib/brute/tools/todo_write.rb
383
401
  - lib/brute/truncation.rb
384
402
  - lib/brute/turn/agent_pipeline.rb
403
+ - lib/brute/turn/compaction_pipeline.rb
385
404
  - lib/brute/turn/pipeline.rb
386
405
  - lib/brute/turn/tool_pipeline.rb
387
406
  - lib/brute/usage_detection/lang_chain.rb