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.
Files changed (89) 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/message_transport/anthropic.rb +143 -0
  6. data/lib/brute/message_transport/llm.rb +94 -0
  7. data/lib/brute/message_transport/openai.rb +113 -0
  8. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  9. data/lib/brute/message_transport.rb +133 -0
  10. data/lib/brute/messages.rb +120 -0
  11. data/lib/brute/middleware/001_otel_span.rb +4 -2
  12. data/lib/brute/middleware/002_session_log.rb +100 -0
  13. data/lib/brute/middleware/004_summarize.rb +21 -19
  14. data/lib/brute/middleware/006_loop.rb +158 -0
  15. data/lib/brute/middleware/010_max_iterations.rb +9 -7
  16. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  17. data/lib/brute/middleware/020_system_prompt.rb +6 -4
  18. data/lib/brute/middleware/040_compaction_check.rb +5 -3
  19. data/lib/brute/middleware/060_questions.rb +3 -1
  20. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
  21. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  22. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  23. data/lib/brute/middleware/event_handler.rb +3 -1
  24. data/lib/brute/middleware/user_queue.rb +3 -1
  25. data/lib/brute/prompts/autonomy.rb +3 -0
  26. data/lib/brute/prompts/base.rb +3 -0
  27. data/lib/brute/prompts/build_switch.rb +3 -1
  28. data/lib/brute/prompts/code_references.rb +3 -0
  29. data/lib/brute/prompts/code_style.rb +3 -0
  30. data/lib/brute/prompts/conventions.rb +3 -0
  31. data/lib/brute/prompts/doing_tasks.rb +3 -0
  32. data/lib/brute/prompts/editing_approach.rb +3 -0
  33. data/lib/brute/prompts/editing_constraints.rb +3 -0
  34. data/lib/brute/prompts/environment.rb +3 -1
  35. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  36. data/lib/brute/prompts/git_safety.rb +3 -0
  37. data/lib/brute/prompts/identity.rb +3 -1
  38. data/lib/brute/prompts/instructions.rb +3 -1
  39. data/lib/brute/prompts/max_steps.rb +3 -1
  40. data/lib/brute/prompts/objectivity.rb +3 -0
  41. data/lib/brute/prompts/plan_reminder.rb +3 -1
  42. data/lib/brute/prompts/proactiveness.rb +3 -0
  43. data/lib/brute/prompts/security_and_safety.rb +3 -0
  44. data/lib/brute/prompts/skills.rb +6 -2
  45. data/lib/brute/prompts/task_management.rb +3 -0
  46. data/lib/brute/prompts/tone_and_style.rb +3 -0
  47. data/lib/brute/prompts/tool_usage.rb +3 -0
  48. data/lib/brute/prompts.rb +5 -0
  49. data/lib/brute/rack/adapter.rb +237 -0
  50. data/lib/brute/skill.rb +192 -15
  51. data/lib/brute/system_prompt.rb +3 -1
  52. data/lib/brute/tool.rb +92 -75
  53. data/lib/brute/tools/adapter.rb +258 -0
  54. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  55. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  56. data/lib/brute/tools/fs_patch.rb +6 -4
  57. data/lib/brute/tools/fs_read.rb +5 -3
  58. data/lib/brute/tools/fs_remove.rb +3 -3
  59. data/lib/brute/tools/fs_search.rb +4 -2
  60. data/lib/brute/tools/fs_undo.rb +3 -3
  61. data/lib/brute/tools/fs_write.rb +6 -4
  62. data/lib/brute/tools/net_fetch.rb +1 -1
  63. data/lib/brute/tools/question.rb +1 -1
  64. data/lib/brute/tools/shell.rb +4 -2
  65. data/lib/brute/tools/skill_load.rb +156 -0
  66. data/lib/brute/tools/sub_agent.rb +101 -0
  67. data/lib/brute/tools/todo_list/store.rb +36 -0
  68. data/lib/brute/tools/todo_read.rb +2 -2
  69. data/lib/brute/tools/todo_write.rb +2 -2
  70. data/lib/brute/tools.rb +2 -1
  71. data/lib/brute/truncation.rb +3 -1
  72. data/lib/brute/turn/agent_pipeline.rb +169 -0
  73. data/lib/brute/turn/pipeline.rb +95 -0
  74. data/lib/brute/turn/tool_pipeline.rb +96 -0
  75. data/lib/brute/utils/diff.rb +3 -1
  76. data/lib/brute/version.rb +1 -1
  77. data/lib/brute.rb +45 -71
  78. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  79. data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
  80. metadata +52 -43
  81. data/lib/brute/agent.rb +0 -82
  82. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  83. data/lib/brute/middleware/100_llm_call.rb +0 -63
  84. data/lib/brute/pipeline.rb +0 -97
  85. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  86. data/lib/brute/session.rb +0 -51
  87. data/lib/brute/store/snapshot_store.rb +0 -36
  88. data/lib/brute/store/todo_store.rb +0 -30
  89. 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::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 << 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 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."
@@ -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] << RubyLLM::Message.new(role: :user, content: @prompt)
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
- 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
@@ -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] << RubyLLM::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
62
+ env[:messages] << Brute::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
61
63
  else
62
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "Here is my complete summary.")
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::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)
@@ -75,12 +77,12 @@ test do
75
77
 
76
78
  it "restores tools after summary call" do
77
79
  inner = ->(env) {
78
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
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::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
 
@@ -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] << RubyLLM::Message.new(role: :assistant, content: "done")
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::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
 
@@ -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] << RubyLLM::Message.new(role: :assistant, content: "done")
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::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
 
@@ -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] << RubyLLM::Message.new(role: :assistant, content: "done")
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::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] << 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
- # 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
 
@@ -22,7 +22,7 @@ module Brute
22
22
 
23
23
  def call(env)
24
24
  if max_iterations_reached?(env)
25
- env[:messages] << RubyLLM::Message.new(
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
- 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
@@ -37,7 +37,7 @@ module Brute
37
37
  result = @system_prompt.prepare(ctx)
38
38
  unless result.empty?
39
39
  env[:messages].unshift(
40
- RubyLLM::Message.new(role: :system, content: result.to_s)
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
- 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,
@@ -39,8 +39,8 @@ module Brute
39
39
  # }
40
40
  # # Replace the message history with the summary
41
41
  # env[:messages] = [
42
- # RubyLLM::Message.new(role: :system, content: @system_prompt),
43
- # RubyLLM::Message.new(role: :user, content: "[Previous conversation summary]\n\n#{summary_text}"),
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
- 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