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 +4 -4
- data/lib/brute/middleware/001_slash_commands.rb +88 -0
- data/lib/brute/turn/agent_pipeline.rb +117 -38
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 146d42f66c911ce3ce45384eaeadd96bb877befe2740a417f405fb693c73acc2
|
|
4
|
+
data.tar.gz: 439445efa583943587f1d70d03c626a6d34e58d40509f03d3d3ce33c9edf03c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
22
|
-
# map(
|
|
21
|
+
# map("/compact") { |env| ... }
|
|
22
|
+
# map(/\Aplease compact/i) { |env| ... }
|
|
23
|
+
# map(->(said) { said.length > 10_000 }) { |env| ... }
|
|
23
24
|
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 "
|
|
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
|
-
.
|
|
182
|
-
.
|
|
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
|
-
|
|
185
|
-
|
|
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 "
|
|
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("/
|
|
192
|
-
.
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
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
|
+
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
|