brute 2.0.6 → 3.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.
- checksums.yaml +4 -4
- data/lib/brute/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +3 -1
- data/lib/brute/messages.rb +47 -0
- data/lib/brute/middleware/001_otel_span.rb +4 -2
- data/lib/brute/middleware/002_session_log.rb +100 -0
- data/lib/brute/middleware/004_summarize.rb +14 -12
- data/lib/brute/middleware/006_loop.rb +158 -0
- data/lib/brute/middleware/010_max_iterations.rb +8 -6
- data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
- data/lib/brute/middleware/020_system_prompt.rb +5 -3
- data/lib/brute/middleware/040_compaction_check.rb +3 -1
- data/lib/brute/middleware/060_questions.rb +3 -1
- data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
- data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
- data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
- data/lib/brute/middleware/event_handler.rb +3 -1
- data/lib/brute/middleware/user_queue.rb +3 -1
- data/lib/brute/prompts/autonomy.rb +3 -0
- data/lib/brute/prompts/base.rb +3 -0
- data/lib/brute/prompts/build_switch.rb +3 -1
- data/lib/brute/prompts/code_references.rb +3 -0
- data/lib/brute/prompts/code_style.rb +3 -0
- data/lib/brute/prompts/conventions.rb +3 -0
- data/lib/brute/prompts/doing_tasks.rb +3 -0
- data/lib/brute/prompts/editing_approach.rb +3 -0
- data/lib/brute/prompts/editing_constraints.rb +3 -0
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/frontend_tasks.rb +3 -0
- data/lib/brute/prompts/git_safety.rb +3 -0
- data/lib/brute/prompts/identity.rb +3 -1
- data/lib/brute/prompts/instructions.rb +3 -1
- data/lib/brute/prompts/max_steps.rb +3 -1
- data/lib/brute/prompts/objectivity.rb +3 -0
- data/lib/brute/prompts/plan_reminder.rb +3 -1
- data/lib/brute/prompts/proactiveness.rb +3 -0
- data/lib/brute/prompts/security_and_safety.rb +3 -0
- data/lib/brute/prompts/skills.rb +6 -2
- data/lib/brute/prompts/task_management.rb +3 -0
- data/lib/brute/prompts/tone_and_style.rb +3 -0
- data/lib/brute/prompts/tool_usage.rb +3 -0
- data/lib/brute/prompts.rb +5 -0
- data/lib/brute/rack/adapter.rb +237 -0
- data/lib/brute/skill.rb +192 -15
- data/lib/brute/system_prompt.rb +3 -1
- data/lib/brute/tools/adapter.rb +296 -0
- data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
- data/lib/brute/tools/fs/snapshot_store.rb +41 -0
- data/lib/brute/tools/fs_patch.rb +5 -3
- data/lib/brute/tools/fs_read.rb +4 -2
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_search.rb +3 -1
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +5 -3
- data/lib/brute/tools/shell.rb +3 -1
- data/lib/brute/tools/skill_load.rb +156 -0
- data/lib/brute/tools/sub_agent.rb +118 -0
- data/lib/brute/tools/todo_list/store.rb +36 -0
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +2 -1
- data/lib/brute/truncation.rb +3 -1
- data/lib/brute/turn/agent_pipeline.rb +168 -0
- data/lib/brute/turn/pipeline.rb +95 -0
- data/lib/brute/turn/tool_pipeline.rb +106 -0
- data/lib/brute/utils/diff.rb +3 -1
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +46 -71
- data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
- data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
- data/lib/ruby_llm/message_transport.rb +117 -0
- metadata +34 -30
- data/lib/brute/agent.rb +0 -82
- data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
- data/lib/brute/middleware/100_llm_call.rb +0 -63
- data/lib/brute/pipeline.rb +0 -97
- data/lib/brute/queue/file_mutation_queue.rb +0 -102
- data/lib/brute/session.rb +0 -51
- data/lib/brute/store/snapshot_store.rb +0 -36
- data/lib/brute/store/todo_store.rb +0 -30
- data/lib/brute/sub_agent.rb +0 -106
- data/lib/brute/tool.rb +0 -107
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6308c538c9efce1d554894738361f1f07720730b3fc66aa46df9f57e2599ae4
|
|
4
|
+
data.tar.gz: 9bc51c1b3020b601bacbab282874f8a789b1420b84a9649c58f6e75c83b4b7b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7f0b0ab4859d81bdc9885824bca9baf68600ff880f4b804652e10882caf3d2449235f18490d9338ab1e4bce29cc641d8b776bd9a777dc7fd98a3bb8b9b81c94
|
|
7
|
+
data.tar.gz: 53301d50213d626fa6f7ba7e1bb0b1124aa3b4b48485302f176681eabf8d0839d493231416c1f0274f29fd5690754b8a48d6751d83f6a32cbc58a9a59f5fa1c9
|
data/lib/brute/events/handler.rb
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
# The in-memory conversation log is just a plain Array of RubyLLM::Message.
|
|
8
|
+
# This module adds a little sugar for appending role-tagged messages; mix it
|
|
9
|
+
# into an array via `Brute.log`. Persistence is NOT here — loading/saving the
|
|
10
|
+
# log to disk is the Brute::Middleware::SessionLog middleware's job.
|
|
11
|
+
module Messages
|
|
12
|
+
def user(content); self << ::RubyLLM::Message.new(role: :user, content: content); end
|
|
13
|
+
def assistant(content); self << ::RubyLLM::Message.new(role: :assistant, content: content); end
|
|
14
|
+
def system(content); self << ::RubyLLM::Message.new(role: :system, content: content); end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Build a fresh conversation log (an Array + Messages sugar), optionally
|
|
18
|
+
# seeded with messages.
|
|
19
|
+
#
|
|
20
|
+
# log = Brute.log
|
|
21
|
+
# log.user("hello")
|
|
22
|
+
# Brute.log(RubyLLM::Message.new(role: :user, content: "hi"))
|
|
23
|
+
def self.log(*messages)
|
|
24
|
+
[].extend(Messages).tap { |log| messages.flatten.each { |m| log << m } }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
__END__
|
|
29
|
+
|
|
30
|
+
describe "brute/messages" do
|
|
31
|
+
it "appends role-tagged messages" do
|
|
32
|
+
log = Brute.log
|
|
33
|
+
log.user("hi")
|
|
34
|
+
log.assistant("hello")
|
|
35
|
+
log.map(&:role).should == [:user, :assistant]
|
|
36
|
+
log.first.content.should == "hi"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "seeds from given messages" do
|
|
40
|
+
m = RubyLLM::Message.new(role: :user, content: "seed")
|
|
41
|
+
Brute.log(m).should == [m]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "is a plain Array (no special class)" do
|
|
45
|
+
Brute.log.should.be.kind_of?(Array)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -18,7 +18,7 @@ module Brute
|
|
|
18
18
|
# use Brute::Middleware::OTel::ToolCalls
|
|
19
19
|
# use Brute::Middleware::OTel::TokenUsage
|
|
20
20
|
# # ... existing middleware ...
|
|
21
|
-
# run Brute
|
|
21
|
+
# run ->(env) { ... } # inline LLM call proc (see Brute.agent)
|
|
22
22
|
#
|
|
23
23
|
class OtelSpan
|
|
24
24
|
def initialize(app)
|
|
@@ -72,6 +72,8 @@ module Brute
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
__END__
|
|
76
|
+
|
|
77
|
+
describe "brute/middleware/001_otel_span" do
|
|
76
78
|
# not implemented
|
|
77
79
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Brute
|
|
9
|
+
module Middleware
|
|
10
|
+
# The "session" is just a JSONL log of the conversation on disk. This
|
|
11
|
+
# middleware owns that log — there is no Session class.
|
|
12
|
+
#
|
|
13
|
+
# in -> if the file exists, prepend its messages to env[:messages] so
|
|
14
|
+
# this turn continues the prior conversation.
|
|
15
|
+
# out <- write the log back to disk (skipping the :system message, which
|
|
16
|
+
# the SystemPrompt middleware re-adds each turn).
|
|
17
|
+
#
|
|
18
|
+
# Put it near the top of the stack (outermost) so history is loaded before
|
|
19
|
+
# the rest of the middleware runs and the whole turn is persisted after:
|
|
20
|
+
#
|
|
21
|
+
# Brute.agent
|
|
22
|
+
# .use(Brute::Middleware::SessionLog, path: "tmp/session.jsonl")
|
|
23
|
+
# .use(Brute::Middleware::SystemPrompt)
|
|
24
|
+
# ...
|
|
25
|
+
#
|
|
26
|
+
class SessionLog
|
|
27
|
+
def initialize(app, path:)
|
|
28
|
+
@app = app
|
|
29
|
+
@path = path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(env)
|
|
33
|
+
load_into(env[:messages]) if @path && File.exist?(@path)
|
|
34
|
+
@app.call(env)
|
|
35
|
+
persist(env[:messages]) if @path
|
|
36
|
+
env
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def load_into(messages)
|
|
42
|
+
loaded = []
|
|
43
|
+
File.foreach(@path) do |line|
|
|
44
|
+
line = line.strip
|
|
45
|
+
loaded << ::RubyLLM::Message.new(**JSON.parse(line, symbolize_names: true)) unless line.empty?
|
|
46
|
+
end
|
|
47
|
+
messages.unshift(*loaded)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def persist(messages)
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
52
|
+
File.open(@path, "w") do |f|
|
|
53
|
+
messages.each do |message|
|
|
54
|
+
next if message.role == :system
|
|
55
|
+
|
|
56
|
+
f.puts(JSON.generate(message.to_h))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
__END__
|
|
65
|
+
|
|
66
|
+
describe "brute/middleware/002_session_log" do
|
|
67
|
+
require "brute/messages"
|
|
68
|
+
require "tmpdir"
|
|
69
|
+
|
|
70
|
+
it "persists the log and reloads it on the next turn" do
|
|
71
|
+
Dir.mktmpdir do |dir|
|
|
72
|
+
path = File.join(dir, "session.jsonl")
|
|
73
|
+
|
|
74
|
+
# Turn 1: no file yet; terminal appends an assistant reply.
|
|
75
|
+
turn = ->(env) { env[:messages].assistant("first reply") }
|
|
76
|
+
Brute::Middleware::SessionLog.new(turn, path: path).call(
|
|
77
|
+
{ messages: Brute.log.tap { |l| l.user("hi") } }
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
File.exist?(path).should.be.true
|
|
81
|
+
|
|
82
|
+
# Turn 2: fresh log with a new user message; history is prepended.
|
|
83
|
+
env = { messages: Brute.log.tap { |l| l.user("again") } }
|
|
84
|
+
Brute::Middleware::SessionLog.new(->(e) { }, path: path).call(env)
|
|
85
|
+
|
|
86
|
+
env[:messages].map(&:content).should == ["hi", "first reply", "again"]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "does not persist the system message" do
|
|
91
|
+
Dir.mktmpdir do |dir|
|
|
92
|
+
path = File.join(dir, "s.jsonl")
|
|
93
|
+
env = { messages: Brute.log.tap { |l| l.system("secret prompt"); l.user("hi") } }
|
|
94
|
+
Brute::Middleware::SessionLog.new(->(e) { }, path: path).call(env)
|
|
95
|
+
|
|
96
|
+
reloaded = File.readlines(path).map(&:strip).reject(&:empty?)
|
|
97
|
+
reloaded.any? { |line| line.include?("secret prompt") }.should.be.false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -5,10 +5,10 @@ require "brute"
|
|
|
5
5
|
|
|
6
6
|
module Brute
|
|
7
7
|
module Middleware
|
|
8
|
-
# Runs a final tool-free LLM call after the
|
|
8
|
+
# Runs a final tool-free LLM call after the Loop::ToolResult completes,
|
|
9
9
|
# ensuring the agent produces a clean summary response.
|
|
10
10
|
#
|
|
11
|
-
# This middleware sits above
|
|
11
|
+
# This middleware sits above Loop::ToolResult in the stack. After the tool
|
|
12
12
|
# loop finishes (either naturally or via MaxIterations), Summarize
|
|
13
13
|
# injects a summary prompt and calls the inner stack one more time
|
|
14
14
|
# with tools removed. The LLM responds with text only, giving the
|
|
@@ -17,10 +17,10 @@ module Brute
|
|
|
17
17
|
# Stack order:
|
|
18
18
|
#
|
|
19
19
|
# use Summarize
|
|
20
|
-
# use
|
|
20
|
+
# use Loop::ToolResult
|
|
21
21
|
# use MaxIterations
|
|
22
|
-
# use
|
|
23
|
-
# run
|
|
22
|
+
# use ToolPipeline
|
|
23
|
+
# run ->(env) { ... } # inline LLM call proc (see Brute.agent)
|
|
24
24
|
#
|
|
25
25
|
class Summarize
|
|
26
26
|
DEFAULT_PROMPT = "Provide your complete findings based on everything you've explored."
|
|
@@ -46,8 +46,10 @@ module Brute
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
__END__
|
|
50
|
+
|
|
51
|
+
describe "brute/middleware/004_summarize" do
|
|
52
|
+
require "brute/messages"
|
|
51
53
|
|
|
52
54
|
it "produces a final assistant message after tool loop" do
|
|
53
55
|
call_count = 0
|
|
@@ -64,7 +66,7 @@ test do
|
|
|
64
66
|
end
|
|
65
67
|
|
|
66
68
|
mw = Brute::Middleware::Summarize.new(inner)
|
|
67
|
-
session = Brute
|
|
69
|
+
session = Brute.log
|
|
68
70
|
session.user("explore the codebase")
|
|
69
71
|
env = { messages: session, tools: [:some_tool], current_iteration: 5 }
|
|
70
72
|
mw.call(env)
|
|
@@ -80,7 +82,7 @@ test do
|
|
|
80
82
|
|
|
81
83
|
mw = Brute::Middleware::Summarize.new(inner)
|
|
82
84
|
tools = [:read, :search]
|
|
83
|
-
env = { messages: Brute
|
|
85
|
+
env = { messages: Brute.log, tools: tools.dup, current_iteration: 1 }
|
|
84
86
|
env[:messages].user("hi")
|
|
85
87
|
mw.call(env)
|
|
86
88
|
|
|
@@ -95,7 +97,7 @@ test do
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
mw = Brute::Middleware::Summarize.new(inner)
|
|
98
|
-
env = { messages: Brute
|
|
100
|
+
env = { messages: Brute.log, tools: [], current_iteration: 99 }
|
|
99
101
|
env[:messages].user("hi")
|
|
100
102
|
mw.call(env)
|
|
101
103
|
|
|
@@ -113,7 +115,7 @@ test do
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
mw = Brute::Middleware::Summarize.new(inner)
|
|
116
|
-
env = { messages: Brute
|
|
118
|
+
env = { messages: Brute.log, tools: [], current_iteration: 1 }
|
|
117
119
|
env[:messages].user("hi")
|
|
118
120
|
mw.call(env)
|
|
119
121
|
|
|
@@ -130,7 +132,7 @@ test do
|
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
mw = Brute::Middleware::Summarize.new(inner, prompt: "Give me the TL;DR.")
|
|
133
|
-
env = { messages: Brute
|
|
135
|
+
env = { messages: Brute.log, tools: [], current_iteration: 1 }
|
|
134
136
|
env[:messages].user("hi")
|
|
135
137
|
mw.call(env)
|
|
136
138
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Middleware
|
|
8
|
+
# Re-invokes the inner stack as long as a condition holds — a generic loop
|
|
9
|
+
# over a turn. The condition is a proc or block that receives env and
|
|
10
|
+
# returns truthy to send control back down the chain again, falsy to stop.
|
|
11
|
+
#
|
|
12
|
+
# The inner app always runs at least once; the condition is checked after
|
|
13
|
+
# each pass (do-while).
|
|
14
|
+
#
|
|
15
|
+
# # loop while the last message is a tool result (see Loop::ToolResult):
|
|
16
|
+
# use Brute::Middleware::Loop, ->(env) { env[:messages].last&.role == :tool }
|
|
17
|
+
#
|
|
18
|
+
# # block form — e.g. bump an iteration counter and stop on should_exit:
|
|
19
|
+
# use Brute::Middleware::Loop do |env|
|
|
20
|
+
# env[:current_iteration] += 1
|
|
21
|
+
# !env[:should_exit] && env[:messages].last&.role == :tool
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
class Loop
|
|
25
|
+
def initialize(app, condition = nil, &block)
|
|
26
|
+
@app = app
|
|
27
|
+
@condition = condition || block
|
|
28
|
+
unless @condition.respond_to?(:call)
|
|
29
|
+
raise ArgumentError, "Brute::Middleware::Loop requires a proc or block condition"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(env)
|
|
34
|
+
loop do
|
|
35
|
+
@app.call(env)
|
|
36
|
+
break unless @condition.call(env)
|
|
37
|
+
end
|
|
38
|
+
env
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Loops while the LLM keeps producing tool results — the standard agentic
|
|
42
|
+
# turn loop. After the inner stack runs (the LLM-call proc responds,
|
|
43
|
+
# ToolPipeline executes tools and appends :tool messages), it loops when
|
|
44
|
+
# the last message is a tool result, bumping the iteration counter. It
|
|
45
|
+
# stops when the LLM answers with text only or env[:should_exit] is set
|
|
46
|
+
# (e.g. by MaxIterations).
|
|
47
|
+
#
|
|
48
|
+
# use Brute::Middleware::Loop::ToolResult
|
|
49
|
+
#
|
|
50
|
+
class ToolResult < Loop
|
|
51
|
+
CONDITION = lambda do |env|
|
|
52
|
+
next false if env[:should_exit]
|
|
53
|
+
next false unless env[:messages].last&.role == :tool
|
|
54
|
+
|
|
55
|
+
env[:current_iteration] += 1
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(app)
|
|
60
|
+
super(app, CONDITION)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
__END__
|
|
68
|
+
|
|
69
|
+
describe "brute/middleware/006_loop" do
|
|
70
|
+
require "brute/messages"
|
|
71
|
+
|
|
72
|
+
it "runs the inner app once when the condition is immediately false" do
|
|
73
|
+
calls = 0
|
|
74
|
+
mw = Brute::Middleware::Loop.new(->(_env) { calls += 1 }, ->(_env) { false })
|
|
75
|
+
mw.call({})
|
|
76
|
+
calls.should == 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "loops while the condition is truthy" do
|
|
80
|
+
calls = 0
|
|
81
|
+
mw = Brute::Middleware::Loop.new(->(_env) { calls += 1 }, ->(_env) { calls < 3 })
|
|
82
|
+
mw.call({})
|
|
83
|
+
calls.should == 3
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "accepts a block condition and passes env to it" do
|
|
87
|
+
seen = []
|
|
88
|
+
mw = Brute::Middleware::Loop.new(->(env) { env[:n] += 1 }) { |env| seen << env[:n]; env[:n] < 2 }
|
|
89
|
+
env = { n: 0 }
|
|
90
|
+
mw.call(env)
|
|
91
|
+
env[:n].should == 2
|
|
92
|
+
seen.should == [1, 2]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "returns env" do
|
|
96
|
+
env = { a: 1 }
|
|
97
|
+
Brute::Middleware::Loop.new(->(_e) {}, ->(_e) { false }).call(env).should == env
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "raises without a proc or block" do
|
|
101
|
+
lambda { Brute::Middleware::Loop.new(->(_e) {}) }.should.raise(ArgumentError)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Loop::ToolResult (reimplements the old ToolResultLoop) ---
|
|
105
|
+
|
|
106
|
+
it "ToolResult loops until the last message is not a tool result" do
|
|
107
|
+
call_count = 0
|
|
108
|
+
inner = ->(env) do
|
|
109
|
+
call_count += 1
|
|
110
|
+
if call_count == 1
|
|
111
|
+
env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
|
|
112
|
+
else
|
|
113
|
+
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
mw = Brute::Middleware::Loop::ToolResult.new(inner)
|
|
118
|
+
env = { messages: Brute.log, current_iteration: 1 }
|
|
119
|
+
env[:messages].user("hi")
|
|
120
|
+
mw.call(env)
|
|
121
|
+
|
|
122
|
+
call_count.should == 2
|
|
123
|
+
env[:current_iteration].should == 2
|
|
124
|
+
env[:messages].last.role.should == :assistant
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "ToolResult stops when should_exit is set" do
|
|
128
|
+
call_count = 0
|
|
129
|
+
inner = ->(env) do
|
|
130
|
+
call_count += 1
|
|
131
|
+
env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
|
|
132
|
+
env[:should_exit] = { reason: "max" } if call_count >= 2
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
mw = Brute::Middleware::Loop::ToolResult.new(inner)
|
|
136
|
+
env = { messages: Brute.log, current_iteration: 1 }
|
|
137
|
+
env[:messages].user("hi")
|
|
138
|
+
mw.call(env)
|
|
139
|
+
|
|
140
|
+
call_count.should == 2
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "ToolResult does not loop when last message is assistant" do
|
|
144
|
+
call_count = 0
|
|
145
|
+
inner = ->(env) do
|
|
146
|
+
call_count += 1
|
|
147
|
+
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hello")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
mw = Brute::Middleware::Loop::ToolResult.new(inner)
|
|
151
|
+
env = { messages: Brute.log, current_iteration: 1 }
|
|
152
|
+
env[:messages].user("hi")
|
|
153
|
+
mw.call(env)
|
|
154
|
+
|
|
155
|
+
call_count.should == 1
|
|
156
|
+
env[:current_iteration].should == 1
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -9,7 +9,7 @@ module Brute
|
|
|
9
9
|
#
|
|
10
10
|
# When the limit is reached, injects a user message into the session
|
|
11
11
|
# stating that maximum iterations have been reached. This causes
|
|
12
|
-
#
|
|
12
|
+
# Loop::ToolResult to exit its loop naturally (last message is not :tool).
|
|
13
13
|
#
|
|
14
14
|
class MaxIterations
|
|
15
15
|
|
|
@@ -40,14 +40,16 @@ module Brute
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
__END__
|
|
44
|
+
|
|
45
|
+
describe "brute/middleware/010_max_iterations" do
|
|
46
|
+
require "brute/messages"
|
|
45
47
|
|
|
46
48
|
it "can be added to a stack" do
|
|
47
49
|
called = false
|
|
48
50
|
inner = ->(env) { called = true }
|
|
49
51
|
mw = Brute::Middleware::MaxIterations.new(inner)
|
|
50
|
-
mw.call({ current_iteration: 1, messages: Brute
|
|
52
|
+
mw.call({ current_iteration: 1, messages: Brute.log })
|
|
51
53
|
called.should.be.true
|
|
52
54
|
end
|
|
53
55
|
|
|
@@ -55,7 +57,7 @@ test do
|
|
|
55
57
|
called = false
|
|
56
58
|
inner = ->(env) { called = true }
|
|
57
59
|
mw = Brute::Middleware::MaxIterations.new(inner, max_iterations: 0)
|
|
58
|
-
env = { current_iteration: 1, messages: Brute
|
|
60
|
+
env = { current_iteration: 1, messages: Brute.log }
|
|
59
61
|
mw.call(env)
|
|
60
62
|
called.should.be.false
|
|
61
63
|
end
|
|
@@ -63,7 +65,7 @@ test do
|
|
|
63
65
|
it "injects a user message when max is hit" do
|
|
64
66
|
inner = ->(env) { }
|
|
65
67
|
mw = Brute::Middleware::MaxIterations.new(inner, max_iterations: 0)
|
|
66
|
-
session = Brute
|
|
68
|
+
session = Brute.log
|
|
67
69
|
session.user("hi")
|
|
68
70
|
env = { current_iteration: 1, messages: session }
|
|
69
71
|
mw.call(env)
|
|
@@ -58,15 +58,17 @@ module Brute
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
__END__
|
|
62
|
+
|
|
63
|
+
describe "brute/middleware/020_system_prompt" do
|
|
64
|
+
require "brute/messages"
|
|
63
65
|
|
|
64
66
|
def build_middleware(system_prompt: Brute::SystemPrompt.default, &inner_block)
|
|
65
67
|
inner = inner_block || ->(env) { env }
|
|
66
68
|
Brute::Middleware::SystemPrompt.new(inner, system_prompt: system_prompt)
|
|
67
69
|
end
|
|
68
70
|
|
|
69
|
-
def base_env(messages: Brute
|
|
71
|
+
def base_env(messages: Brute.log)
|
|
70
72
|
{
|
|
71
73
|
messages: messages,
|
|
72
74
|
provider: :anthropic,
|
|
@@ -54,16 +54,21 @@ module Brute
|
|
|
54
54
|
# one fiber runs at a time within a Sync block). No mutex needed.
|
|
55
55
|
#
|
|
56
56
|
# - FileMutationQueue compatibility — tools that mutate files use
|
|
57
|
-
# Brute::
|
|
57
|
+
# Brute::Tools::FS::FileMutationQueue.serialize, which uses Ruby 3.4's
|
|
58
58
|
# fiber-scheduler-aware Mutex. Operations on the same file are serialized;
|
|
59
59
|
# operations on different files proceed in parallel.
|
|
60
60
|
#
|
|
61
|
-
class
|
|
62
|
-
def initialize(app)
|
|
63
|
-
@app
|
|
61
|
+
class ToolPipeline
|
|
62
|
+
def initialize(app, tools: [])
|
|
63
|
+
@app = app
|
|
64
|
+
@tools = tools
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
# in -> advertise the tools to the turn (env[:tools], which the LLM-call
|
|
68
|
+
# proc reads to tell the model what it can call)
|
|
69
|
+
# out <- execute the tool calls the model made and append :tool results
|
|
66
70
|
def call(env)
|
|
71
|
+
env[:tools] = @tools
|
|
67
72
|
@app.call(env)
|
|
68
73
|
|
|
69
74
|
tools_to_run = pending_tool_calls(env[:messages].last)
|
|
@@ -126,11 +131,7 @@ module Brute
|
|
|
126
131
|
end
|
|
127
132
|
|
|
128
133
|
def resolve_tools(tools)
|
|
129
|
-
|
|
130
|
-
instance = tool.is_a?(Class) ? tool.new : tool
|
|
131
|
-
instance = instance.to_ruby_llm if instance.respond_to?(:to_ruby_llm)
|
|
132
|
-
hash[instance.name.to_sym] = instance
|
|
133
|
-
end
|
|
134
|
+
Brute::Tools::Adapter.wrap_all(tools)
|
|
134
135
|
end
|
|
135
136
|
|
|
136
137
|
def on_tool_call_start_event(pending_tools)
|
|
@@ -149,18 +150,19 @@ module Brute
|
|
|
149
150
|
end
|
|
150
151
|
end
|
|
151
152
|
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
__END__
|
|
154
|
+
|
|
155
|
+
describe "brute/middleware/070_tool_pipeline" do
|
|
156
|
+
require "brute/messages"
|
|
154
157
|
require "brute/truncation"
|
|
155
158
|
|
|
156
159
|
it "passes through when no tool calls pending" do
|
|
157
160
|
inner = ->(env) {
|
|
158
161
|
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hi")
|
|
159
162
|
}
|
|
160
|
-
mw = Brute::Middleware::
|
|
163
|
+
mw = Brute::Middleware::ToolPipeline.new(inner, tools: [])
|
|
161
164
|
env = {
|
|
162
|
-
messages: Brute
|
|
163
|
-
tools: [],
|
|
165
|
+
messages: Brute.log,
|
|
164
166
|
events: [],
|
|
165
167
|
}
|
|
166
168
|
env[:messages].user("hello")
|
|
@@ -168,6 +170,17 @@ test do
|
|
|
168
170
|
env[:messages].last.content.should == "hi"
|
|
169
171
|
end
|
|
170
172
|
|
|
173
|
+
it "advertises its tools on env[:tools] on the way in" do
|
|
174
|
+
seen = nil
|
|
175
|
+
inner = ->(env) { seen = env[:tools] }
|
|
176
|
+
tool = { name: "echo", description: "", execute: ->(**) { "ok" } }
|
|
177
|
+
mw = Brute::Middleware::ToolPipeline.new(inner, tools: [tool])
|
|
178
|
+
env = { messages: Brute.log, events: [] }
|
|
179
|
+
env[:messages].user("hi")
|
|
180
|
+
mw.call(env)
|
|
181
|
+
seen.should == [tool]
|
|
182
|
+
end
|
|
183
|
+
|
|
171
184
|
# --- Universal output truncation ---
|
|
172
185
|
|
|
173
186
|
it "truncates large tool results via Truncation" do
|
|
@@ -193,10 +206,9 @@ test do
|
|
|
193
206
|
inner = ->(env) {
|
|
194
207
|
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
|
|
195
208
|
}
|
|
196
|
-
mw = Brute::Middleware::
|
|
209
|
+
mw = Brute::Middleware::ToolPipeline.new(inner, tools: [big_tool])
|
|
197
210
|
env = {
|
|
198
|
-
messages: Brute
|
|
199
|
-
tools: [big_tool],
|
|
211
|
+
messages: Brute.log,
|
|
200
212
|
events: [],
|
|
201
213
|
}
|
|
202
214
|
env[:messages].user("hello")
|
|
@@ -232,10 +244,9 @@ test do
|
|
|
232
244
|
inner = ->(env) {
|
|
233
245
|
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
|
|
234
246
|
}
|
|
235
|
-
mw = Brute::Middleware::
|
|
247
|
+
mw = Brute::Middleware::ToolPipeline.new(inner, tools: [pre_truncated_tool])
|
|
236
248
|
env = {
|
|
237
|
-
messages: Brute
|
|
238
|
-
tools: [pre_truncated_tool],
|
|
249
|
+
messages: Brute.log,
|
|
239
250
|
events: [],
|
|
240
251
|
}
|
|
241
252
|
env[:messages].user("hello")
|