brute 5.0.4 → 5.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46c27c1fb3c90344f693d63e91d7d0ccd8cf0b33c44c51d9d1cced16b3728b9e
4
- data.tar.gz: d6016cef2788aa4dfb5551816538ca9ea6ff1fe80f4ce6b6d5e5dfabae1cc5f3
3
+ metadata.gz: 146d42f66c911ce3ce45384eaeadd96bb877befe2740a417f405fb693c73acc2
4
+ data.tar.gz: 439445efa583943587f1d70d03c626a6d34e58d40509f03d3d3ce33c9edf03c9
5
5
  SHA512:
6
- metadata.gz: e2b383c66662c7f3fdd0c9ce6c8f9a1f98d4f6cddbfd8192f4bfc5c46ec335787146e143035e489491220c8ac9e2bedac16a1538caf7be9350dc86fe37a6c4e9
7
- data.tar.gz: 383c89c05e8c85368aec9107769ec0e540b823f89a0e17eaf691840cea1cd0c07d1a496f3f42b81ff5517262a78cbee8f32f51ba9fa00d27255e23ef3b6c4aae
6
+ metadata.gz: 36ed02483e9838d2d71272bf471f5ffa025cf980b56b54c576873c011c85085c89320f513a00002acb814fc01c333f8882c796af2a900828b5532b26aea5c800
7
+ data.tar.gz: 925cc550884beadf57fbc631f7b796afc5484af8fea604e6b2cad19459159c2a664eff45a7429b0c385bc894320867ad0d12946ed26ccd42b7086b9c93b66926
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # The head of every agent chain, put there by the builder itself rather
9
+ # than by a `use` anyone writes. It switches on what was just said: the
10
+ # newest message, and only when it is a user message, is offered to each
11
+ # of env[:commands]'s checks in turn -- the commands registered with
12
+ # `AgentPipeline#map`, which `start` puts there. The first check that
13
+ # passes has its block run here, before the rest of the stack.
14
+ #
15
+ # Brute.agent
16
+ # .map("/compact") { |env| ... }
17
+ # .run(->(env) { ... })
18
+ #
19
+ # A command's block is a middleware, so what it leaves in env is what
20
+ # the rest of the chain works on.
21
+ class SlashCommands < Brute::Middleware::Base
22
+ def call(env)
23
+ matched(env)&.call(env)
24
+ @app.call(env)
25
+ end
26
+
27
+ private
28
+
29
+ # The block of the first command whose check passes on what was just
30
+ # said -- and nothing at all unless the room said it, so not the
31
+ # assistant and not an empty log.
32
+ def matched(env)
33
+ message = Array(env[:messages]).last
34
+
35
+ if message.respond_to?(:role) && message.role == :user
36
+ _check, block = Array(env[:commands]).find { |check, _block| check.call(message.content.to_s) }
37
+ block
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ __END__
45
+
46
+ describe "brute/middleware/001_slash_commands" do
47
+ require "brute/messages"
48
+
49
+ it "runs the first of env[:commands] whose check matches what was just said" do
50
+ ran = []
51
+ commands = [
52
+ [->(said) { said.start_with?("/compact") }, ->(env) { ran << [:compact, env[:messages].last.content] }],
53
+ [->(said) { said.length > 20 }, ->(_env) { ran << :long }],
54
+ ]
55
+
56
+ reached = []
57
+ inner = ->(env) { reached << env[:messages].last&.content; env }
58
+ middleware = Brute::Middleware::SlashCommands.new(inner)
59
+
60
+ def turn(commands, said, role = :user)
61
+ { commands: commands, messages: Brute.log.tap { |log| log << Brute::Message.new(role: role, content: said) } }
62
+ end
63
+
64
+ # The matching command runs before the stack below it.
65
+ middleware.call(turn(commands, "/compact keep the notes"))
66
+ ran.should == [[:compact, "/compact keep the notes"]]
67
+ reached.should == ["/compact keep the notes"]
68
+
69
+ # Checks are tried in order, and only the first to pass runs.
70
+ ran.clear
71
+ middleware.call(turn(commands, "something else, at some length"))
72
+ ran.should == [:long]
73
+
74
+ # Nothing matches, nothing runs -- and the turn carries on regardless.
75
+ ran.clear
76
+ reached.clear
77
+ middleware.call(turn(commands, "short"))
78
+ middleware.call(turn(commands, "/compact", :assistant))
79
+ middleware.call({ commands: commands, messages: Brute.log })
80
+ ran.should.be.empty
81
+ reached.should == ["short", "/compact", nil]
82
+
83
+ # A turn carrying no commands at all still runs, and does nothing.
84
+ ran.clear
85
+ middleware.call({ messages: Brute.log.tap { |log| log.user("/compact") } })
86
+ ran.should.be.empty
87
+ end
88
+ end
@@ -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
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.0.5"
5
5
  end
data/lib/brute.rb CHANGED
@@ -116,6 +116,7 @@ require_relative "brute/message_transport/openai"
116
116
  require_relative "brute/message_transport/ruby_llm"
117
117
  require_relative "brute/message_transport/ruby_open_ai"
118
118
  require_relative "brute/message_transport/lang_chain"
119
+ require_relative "brute/middleware/001_slash_commands"
119
120
  require_relative "brute/middleware/002_session_log"
120
121
  require_relative "brute/middleware/004_summarize"
121
122
  require_relative "brute/middleware/006_loop"
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.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -302,6 +302,7 @@ files:
302
302
  - lib/brute/message_transport/ruby_open_ai.rb
303
303
  - lib/brute/messages.rb
304
304
  - lib/brute/middleware/000_base.rb
305
+ - lib/brute/middleware/001_slash_commands.rb
305
306
  - lib/brute/middleware/002_session_log.rb
306
307
  - lib/brute/middleware/004_summarize.rb
307
308
  - lib/brute/middleware/006_loop.rb