brute 2.0.6 → 3.0.1
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/message_transport/anthropic.rb +143 -0
- data/lib/brute/message_transport/llm.rb +94 -0
- data/lib/brute/message_transport/openai.rb +113 -0
- data/lib/brute/message_transport/ruby_llm.rb +78 -0
- data/lib/brute/message_transport.rb +133 -0
- data/lib/brute/messages.rb +120 -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 +21 -19
- data/lib/brute/middleware/006_loop.rb +158 -0
- data/lib/brute/middleware/010_max_iterations.rb +9 -7
- data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
- data/lib/brute/middleware/020_system_prompt.rb +6 -4
- data/lib/brute/middleware/040_compaction_check.rb +5 -3
- data/lib/brute/middleware/060_questions.rb +3 -1
- data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
- 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/tool.rb +92 -75
- data/lib/brute/tools/adapter.rb +258 -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 +6 -4
- data/lib/brute/tools/fs_read.rb +5 -3
- data/lib/brute/tools/fs_remove.rb +3 -3
- data/lib/brute/tools/fs_search.rb +4 -2
- data/lib/brute/tools/fs_undo.rb +3 -3
- data/lib/brute/tools/fs_write.rb +6 -4
- data/lib/brute/tools/net_fetch.rb +1 -1
- data/lib/brute/tools/question.rb +1 -1
- data/lib/brute/tools/shell.rb +4 -2
- data/lib/brute/tools/skill_load.rb +156 -0
- data/lib/brute/tools/sub_agent.rb +101 -0
- data/lib/brute/tools/todo_list/store.rb +36 -0
- data/lib/brute/tools/todo_read.rb +2 -2
- data/lib/brute/tools/todo_write.rb +2 -2
- data/lib/brute/tools.rb +2 -1
- data/lib/brute/truncation.rb +3 -1
- data/lib/brute/turn/agent_pipeline.rb +169 -0
- data/lib/brute/turn/pipeline.rb +95 -0
- data/lib/brute/turn/tool_pipeline.rb +96 -0
- data/lib/brute/utils/diff.rb +3 -1
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +45 -71
- data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
- data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
- metadata +52 -43
- 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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
# One tool invocation requested by the model. Arguments are always a Hash.
|
|
8
|
+
ToolCall = Data.define(:id, :name, :arguments) do
|
|
9
|
+
def initialize(id:, name:, arguments: {})
|
|
10
|
+
super(id: id, name: name.to_s, arguments: arguments.to_h)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Brute's canonical, framework-agnostic message. The rest of the stack
|
|
15
|
+
# (middleware, tool loop, persistence) never calls anything beyond
|
|
16
|
+
# #role, #content, #tool_calls, #tool_call_id and #to_h — so any object
|
|
17
|
+
# that duck-types those methods can ride in env[:messages] too. This Data
|
|
18
|
+
# class is simply the canonical implementation.
|
|
19
|
+
#
|
|
20
|
+
# Brute::Message.new(role: :user, content: "hi")
|
|
21
|
+
# Brute::Message.new(role: :assistant, content: "", tool_calls: [
|
|
22
|
+
# Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" }),
|
|
23
|
+
# ])
|
|
24
|
+
# Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
|
|
25
|
+
Message = Data.define(:role, :content, :tool_calls, :tool_call_id) do
|
|
26
|
+
def initialize(role:, content: nil, tool_calls: nil, tool_call_id: nil)
|
|
27
|
+
tool_calls = tool_calls&.map do |tc|
|
|
28
|
+
tc.is_a?(ToolCall) ? tc : ToolCall.new(**tc.to_h.transform_keys(&:to_sym))
|
|
29
|
+
end
|
|
30
|
+
super(role: role.to_sym, content: content, tool_calls: tool_calls, tool_call_id: tool_call_id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def tool_call?
|
|
34
|
+
!tool_calls.nil? && !tool_calls.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Plain, JSON-ready view (nils dropped, tool calls as hashes).
|
|
38
|
+
def to_h
|
|
39
|
+
h = super
|
|
40
|
+
h[:tool_calls] = tool_calls.map(&:to_h) if tool_calls
|
|
41
|
+
h.compact
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The in-memory conversation log is just a plain Array of Brute::Message.
|
|
46
|
+
# This module adds a little sugar for appending role-tagged messages; mix it
|
|
47
|
+
# into an array via `Brute.log`. Persistence is NOT here — loading/saving the
|
|
48
|
+
# log to disk is the Brute::Middleware::SessionLog middleware's job.
|
|
49
|
+
module Messages
|
|
50
|
+
def user(content); self << Message.new(role: :user, content: content); end
|
|
51
|
+
def assistant(content); self << Message.new(role: :assistant, content: content); end
|
|
52
|
+
def system(content); self << Message.new(role: :system, content: content); end
|
|
53
|
+
|
|
54
|
+
def tool(content, tool_call_id:)
|
|
55
|
+
self << Message.new(role: :tool, content: content, tool_call_id: tool_call_id)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build a fresh conversation log (an Array + Messages sugar), optionally
|
|
60
|
+
# seeded with messages.
|
|
61
|
+
#
|
|
62
|
+
# log = Brute.log
|
|
63
|
+
# log.user("hello")
|
|
64
|
+
# Brute.log(Brute::Message.new(role: :user, content: "hi"))
|
|
65
|
+
def self.log(*messages)
|
|
66
|
+
[].extend(Messages).tap { |log| messages.flatten.each { |m| log << m } }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
__END__
|
|
71
|
+
|
|
72
|
+
describe "brute/messages" do
|
|
73
|
+
it "appends role-tagged messages" do
|
|
74
|
+
log = Brute.log
|
|
75
|
+
log.user("hi")
|
|
76
|
+
log.assistant("hello")
|
|
77
|
+
log.map(&:role).should == [:user, :assistant]
|
|
78
|
+
log.first.content.should == "hi"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "appends tool results" do
|
|
82
|
+
log = Brute.log
|
|
83
|
+
log.tool("result", tool_call_id: "tc1")
|
|
84
|
+
log.last.role.should == :tool
|
|
85
|
+
log.last.tool_call_id.should == "tc1"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "seeds from given messages" do
|
|
89
|
+
m = Brute::Message.new(role: :user, content: "seed")
|
|
90
|
+
Brute.log(m).should == [m]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "is a plain Array (no special class)" do
|
|
94
|
+
Brute.log.should.be.kind_of?(Array)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "symbolizes string roles" do
|
|
98
|
+
Brute::Message.new(role: "user", content: "hi").role.should == :user
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "coerces tool_calls hashes into ToolCall" do
|
|
102
|
+
m = Brute::Message.new(
|
|
103
|
+
role: :assistant, content: "",
|
|
104
|
+
tool_calls: [{ "id" => "tc1", "name" => "shell", "arguments" => { "command" => "ls" } }]
|
|
105
|
+
)
|
|
106
|
+
m.tool_calls.first.name.should == "shell"
|
|
107
|
+
m.tool_calls.first.arguments.should == { "command" => "ls" }
|
|
108
|
+
m.tool_call?.should.be.true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "round-trips through to_h" do
|
|
112
|
+
m = Brute::Message.new(role: :assistant, content: "",
|
|
113
|
+
tool_calls: [{ id: "tc1", name: "shell", arguments: {} }])
|
|
114
|
+
Brute::Message.new(**m.to_h).should == m
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "to_h drops nil fields" do
|
|
118
|
+
Brute::Message.new(role: :user, content: "hi").to_h.should == { role: :user, content: "hi" }
|
|
119
|
+
end
|
|
120
|
+
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 << Brute::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."
|
|
@@ -36,7 +36,7 @@ module Brute
|
|
|
36
36
|
saved_tools = env[:tools]
|
|
37
37
|
env[:tools] = []
|
|
38
38
|
env[:current_iteration] = 1
|
|
39
|
-
env[:messages] <<
|
|
39
|
+
env[:messages] << Brute::Message.new(role: :user, content: @prompt)
|
|
40
40
|
@app.call(env)
|
|
41
41
|
env[:tools] = saved_tools
|
|
42
42
|
|
|
@@ -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
|
|
@@ -57,14 +59,14 @@ test do
|
|
|
57
59
|
inner = ->(env) do
|
|
58
60
|
call_count += 1
|
|
59
61
|
if call_count == 1
|
|
60
|
-
env[:messages] <<
|
|
62
|
+
env[:messages] << Brute::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
|
|
61
63
|
else
|
|
62
|
-
env[:messages] <<
|
|
64
|
+
env[:messages] << Brute::Message.new(role: :assistant, content: "Here is my complete summary.")
|
|
63
65
|
end
|
|
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)
|
|
@@ -75,12 +77,12 @@ test do
|
|
|
75
77
|
|
|
76
78
|
it "restores tools after summary call" do
|
|
77
79
|
inner = ->(env) {
|
|
78
|
-
env[:messages] <<
|
|
80
|
+
env[:messages] << Brute::Message.new(role: :assistant, content: "done")
|
|
79
81
|
}
|
|
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
|
|
|
@@ -91,11 +93,11 @@ test do
|
|
|
91
93
|
captured_iteration = nil
|
|
92
94
|
inner = ->(env) {
|
|
93
95
|
captured_iteration = env[:current_iteration]
|
|
94
|
-
env[:messages] <<
|
|
96
|
+
env[:messages] << Brute::Message.new(role: :assistant, content: "done")
|
|
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
|
|
|
@@ -109,11 +111,11 @@ test do
|
|
|
109
111
|
inner = ->(env) {
|
|
110
112
|
call_count += 1
|
|
111
113
|
messages_at_second_call = env[:messages].map(&:content) if call_count == 2
|
|
112
|
-
env[:messages] <<
|
|
114
|
+
env[:messages] << Brute::Message.new(role: :assistant, content: "done")
|
|
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
|
|
|
@@ -126,11 +128,11 @@ test do
|
|
|
126
128
|
inner = ->(env) {
|
|
127
129
|
call_count += 1
|
|
128
130
|
messages_at_second_call = env[:messages].map(&:content) if call_count == 2
|
|
129
|
-
env[:messages] <<
|
|
131
|
+
env[:messages] << Brute::Message.new(role: :assistant, content: "done")
|
|
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] << Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
|
|
112
|
+
else
|
|
113
|
+
env[:messages] << Brute::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] << Brute::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] << Brute::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
|
|
|
@@ -22,7 +22,7 @@ module Brute
|
|
|
22
22
|
|
|
23
23
|
def call(env)
|
|
24
24
|
if max_iterations_reached?(env)
|
|
25
|
-
env[:messages] <<
|
|
25
|
+
env[:messages] << Brute::Message.new(
|
|
26
26
|
role: :user,
|
|
27
27
|
content: "Maximum iterations reached.",
|
|
28
28
|
)
|
|
@@ -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)
|
|
@@ -37,7 +37,7 @@ module Brute
|
|
|
37
37
|
result = @system_prompt.prepare(ctx)
|
|
38
38
|
unless result.empty?
|
|
39
39
|
env[:messages].unshift(
|
|
40
|
-
|
|
40
|
+
Brute::Message.new(role: :system, content: result.to_s)
|
|
41
41
|
)
|
|
42
42
|
end
|
|
43
43
|
end
|
|
@@ -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,
|
|
@@ -39,8 +39,8 @@ module Brute
|
|
|
39
39
|
# }
|
|
40
40
|
# # Replace the message history with the summary
|
|
41
41
|
# env[:messages] = [
|
|
42
|
-
#
|
|
43
|
-
#
|
|
42
|
+
# Brute::Message.new(role: :system, content: @system_prompt),
|
|
43
|
+
# Brute::Message.new(role: :user, content: "[Previous conversation summary]\n\n#{summary_text}"),
|
|
44
44
|
# ]
|
|
45
45
|
# end
|
|
46
46
|
#end
|
|
@@ -150,6 +150,8 @@ module Brute
|
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
__END__
|
|
154
|
+
|
|
155
|
+
describe "brute/middleware/040_compaction_check" do
|
|
154
156
|
# not implemented
|
|
155
157
|
end
|