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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/events/handler.rb +3 -1
  3. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  4. data/lib/brute/events/terminal_output_handler.rb +3 -1
  5. data/lib/brute/messages.rb +47 -0
  6. data/lib/brute/middleware/001_otel_span.rb +4 -2
  7. data/lib/brute/middleware/002_session_log.rb +100 -0
  8. data/lib/brute/middleware/004_summarize.rb +14 -12
  9. data/lib/brute/middleware/006_loop.rb +158 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +8 -6
  11. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  12. data/lib/brute/middleware/020_system_prompt.rb +5 -3
  13. data/lib/brute/middleware/040_compaction_check.rb +3 -1
  14. data/lib/brute/middleware/060_questions.rb +3 -1
  15. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
  16. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  17. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  18. data/lib/brute/middleware/event_handler.rb +3 -1
  19. data/lib/brute/middleware/user_queue.rb +3 -1
  20. data/lib/brute/prompts/autonomy.rb +3 -0
  21. data/lib/brute/prompts/base.rb +3 -0
  22. data/lib/brute/prompts/build_switch.rb +3 -1
  23. data/lib/brute/prompts/code_references.rb +3 -0
  24. data/lib/brute/prompts/code_style.rb +3 -0
  25. data/lib/brute/prompts/conventions.rb +3 -0
  26. data/lib/brute/prompts/doing_tasks.rb +3 -0
  27. data/lib/brute/prompts/editing_approach.rb +3 -0
  28. data/lib/brute/prompts/editing_constraints.rb +3 -0
  29. data/lib/brute/prompts/environment.rb +3 -1
  30. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  31. data/lib/brute/prompts/git_safety.rb +3 -0
  32. data/lib/brute/prompts/identity.rb +3 -1
  33. data/lib/brute/prompts/instructions.rb +3 -1
  34. data/lib/brute/prompts/max_steps.rb +3 -1
  35. data/lib/brute/prompts/objectivity.rb +3 -0
  36. data/lib/brute/prompts/plan_reminder.rb +3 -1
  37. data/lib/brute/prompts/proactiveness.rb +3 -0
  38. data/lib/brute/prompts/security_and_safety.rb +3 -0
  39. data/lib/brute/prompts/skills.rb +6 -2
  40. data/lib/brute/prompts/task_management.rb +3 -0
  41. data/lib/brute/prompts/tone_and_style.rb +3 -0
  42. data/lib/brute/prompts/tool_usage.rb +3 -0
  43. data/lib/brute/prompts.rb +5 -0
  44. data/lib/brute/rack/adapter.rb +237 -0
  45. data/lib/brute/skill.rb +192 -15
  46. data/lib/brute/system_prompt.rb +3 -1
  47. data/lib/brute/tools/adapter.rb +296 -0
  48. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  49. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  50. data/lib/brute/tools/fs_patch.rb +5 -3
  51. data/lib/brute/tools/fs_read.rb +4 -2
  52. data/lib/brute/tools/fs_remove.rb +2 -2
  53. data/lib/brute/tools/fs_search.rb +3 -1
  54. data/lib/brute/tools/fs_undo.rb +2 -2
  55. data/lib/brute/tools/fs_write.rb +5 -3
  56. data/lib/brute/tools/shell.rb +3 -1
  57. data/lib/brute/tools/skill_load.rb +156 -0
  58. data/lib/brute/tools/sub_agent.rb +118 -0
  59. data/lib/brute/tools/todo_list/store.rb +36 -0
  60. data/lib/brute/tools/todo_read.rb +1 -1
  61. data/lib/brute/tools/todo_write.rb +1 -1
  62. data/lib/brute/tools.rb +2 -1
  63. data/lib/brute/truncation.rb +3 -1
  64. data/lib/brute/turn/agent_pipeline.rb +168 -0
  65. data/lib/brute/turn/pipeline.rb +95 -0
  66. data/lib/brute/turn/tool_pipeline.rb +106 -0
  67. data/lib/brute/utils/diff.rb +3 -1
  68. data/lib/brute/version.rb +1 -1
  69. data/lib/brute.rb +46 -71
  70. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  71. data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
  72. data/lib/ruby_llm/message_transport.rb +117 -0
  73. metadata +34 -30
  74. data/lib/brute/agent.rb +0 -82
  75. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  76. data/lib/brute/middleware/100_llm_call.rb +0 -63
  77. data/lib/brute/pipeline.rb +0 -97
  78. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  79. data/lib/brute/session.rb +0 -51
  80. data/lib/brute/store/snapshot_store.rb +0 -36
  81. data/lib/brute/store/todo_store.rb +0 -30
  82. data/lib/brute/sub_agent.rb +0 -106
  83. data/lib/brute/tool.rb +0 -107
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ca9915157a21335990328dcbf4b6104cffa72bd0835e93055ec46e8be05aee7
4
- data.tar.gz: 6e7ef36a7fd32e0351563856d888f832fb4d48fad32444f937fb58c2d45baa4d
3
+ metadata.gz: d6308c538c9efce1d554894738361f1f07720730b3fc66aa46df9f57e2599ae4
4
+ data.tar.gz: 9bc51c1b3020b601bacbab282874f8a789b1420b84a9649c58f6e75c83b4b7b5
5
5
  SHA512:
6
- metadata.gz: ff22892e28c30deb694b7bbea899e8b14ceb31e068fa74b27b9b462d7778ab52da06f332f743ac6c19324bf962d7ee1401e8146f1714496fc1dcba5179f6e551
7
- data.tar.gz: 457fbacd0115628b002c6b274ef7c07d47a267c4841107c52c4d983d23a0967012ca68a084b50f833d5421ff013766c2aef07be5e465d8630ca61cc9c06326f5
6
+ metadata.gz: b7f0b0ab4859d81bdc9885824bca9baf68600ff880f4b804652e10882caf3d2449235f18490d9338ab1e4bce29cc641d8b776bd9a777dc7fd98a3bb8b9b81c94
7
+ data.tar.gz: 53301d50213d626fa6f7ba7e1bb0b1124aa3b4b48485302f176681eabf8d0839d493231416c1f0274f29fd5690754b8a48d6751d83f6a32cbc58a9a59f5fa1c9
@@ -25,6 +25,8 @@ module Brute
25
25
  end
26
26
  end
27
27
 
28
- test do
28
+ __END__
29
+
30
+ describe "brute/events/handler" do
29
31
  # not implemented
30
32
  end
@@ -67,6 +67,8 @@ module Brute
67
67
  end
68
68
  end
69
69
 
70
- test do
70
+ __END__
71
+
72
+ describe "brute/events/prefixed_terminal_output" do
71
73
  # not implemented
72
74
  end
@@ -63,6 +63,8 @@ module Brute
63
63
  end
64
64
  end
65
65
 
66
- test do
66
+ __END__
67
+
68
+ describe "brute/events/terminal_output_handler" do
67
69
  # not implemented
68
70
  end
@@ -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::Middleware::LLMCall.new
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
- test do
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 ToolResultLoop completes,
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 ToolResultLoop in the stack. After the tool
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 ToolResultLoop
20
+ # use Loop::ToolResult
21
21
  # use MaxIterations
22
- # use ToolCall
23
- # run LLMCall.new
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
- test do
50
- require "brute/session"
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::Session.new
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::Session.new, tools: tools.dup, current_iteration: 1 }
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::Session.new, tools: [], current_iteration: 99 }
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::Session.new, tools: [], current_iteration: 1 }
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::Session.new, tools: [], current_iteration: 1 }
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
- # ToolResultLoop to exit its loop naturally (last message is not :tool).
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
- test do
44
- require "brute/session"
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::Session.new })
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::Session.new }
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::Session.new
68
+ session = Brute.log
67
69
  session.user("hi")
68
70
  env = { current_iteration: 1, messages: session }
69
71
  mw.call(env)
@@ -37,6 +37,8 @@ module Brute
37
37
  end
38
38
  end
39
39
 
40
- test do
40
+ __END__
41
+
42
+ describe "brute/middleware/015_otel_token_usage" do
41
43
  # not implemented
42
44
  end
@@ -58,15 +58,17 @@ module Brute
58
58
  end
59
59
  end
60
60
 
61
- test do
62
- require "brute/session"
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::Session.new)
71
+ def base_env(messages: Brute.log)
70
72
  {
71
73
  messages: messages,
72
74
  provider: :anthropic,
@@ -150,6 +150,8 @@ module Brute
150
150
  end
151
151
  end
152
152
 
153
- test do
153
+ __END__
154
+
155
+ describe "brute/middleware/040_compaction_check" do
154
156
  # not implemented
155
157
  end
@@ -17,6 +17,8 @@ module Brute
17
17
  end
18
18
  end
19
19
 
20
- test do
20
+ __END__
21
+
22
+ describe "brute/middleware/060_questions" do
21
23
  # not implemented
22
24
  end
@@ -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::Queue::FileMutationQueue.serialize, which uses Ruby 3.4's
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 ToolCall
62
- def initialize(app)
63
- @app = 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
- tools.each_with_object({}) do |tool, hash|
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
- test do
153
- require "brute/session"
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::ToolCall.new(inner)
163
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [])
161
164
  env = {
162
- messages: Brute::Session.new,
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::ToolCall.new(inner)
209
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [big_tool])
197
210
  env = {
198
- messages: Brute::Session.new,
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::ToolCall.new(inner)
247
+ mw = Brute::Middleware::ToolPipeline.new(inner, tools: [pre_truncated_tool])
236
248
  env = {
237
- messages: Brute::Session.new,
238
- tools: [pre_truncated_tool],
249
+ messages: Brute.log,
239
250
  events: [],
240
251
  }
241
252
  env[:messages].user("hello")
@@ -44,6 +44,8 @@ module Brute
44
44
  end
45
45
  end
46
46
 
47
- test do
47
+ __END__
48
+
49
+ describe "brute/middleware/073_otel_tool_call" do
48
50
  # not implemented
49
51
  end
@@ -41,6 +41,8 @@ module Brute
41
41
  end
42
42
  end
43
43
 
44
- test do
44
+ __END__
45
+
46
+ describe "brute/middleware/075_otel_tool_results" do
45
47
  # not implemented
46
48
  end