brute 1.0.0 → 2.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +72 -6
  3. data/lib/brute/events/handler.rb +69 -0
  4. data/lib/brute/events/prefixed_terminal_output.rb +72 -0
  5. data/lib/brute/events/terminal_output_handler.rb +68 -0
  6. data/lib/brute/middleware/001_otel_span.rb +77 -0
  7. data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
  8. data/lib/brute/middleware/004_summarize.rb +139 -0
  9. data/lib/brute/middleware/005_tracing.rb +86 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +73 -0
  11. data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
  12. data/lib/brute/middleware/020_system_prompt.rb +128 -0
  13. data/lib/brute/middleware/040_compaction_check.rb +155 -0
  14. data/lib/brute/middleware/060_questions.rb +41 -0
  15. data/lib/brute/middleware/070_tool_call.rb +247 -0
  16. data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
  17. data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
  18. data/lib/brute/middleware/100_llm_call.rb +62 -0
  19. data/lib/brute/middleware/event_handler.rb +25 -0
  20. data/lib/brute/middleware/user_queue.rb +35 -0
  21. data/lib/brute/pipeline.rb +44 -107
  22. data/lib/brute/prompts/skills.rb +2 -2
  23. data/lib/brute/prompts.rb +23 -23
  24. data/lib/brute/providers/shell.rb +6 -19
  25. data/lib/brute/providers/shell_response.rb +22 -30
  26. data/lib/brute/session.rb +52 -0
  27. data/lib/brute/store/snapshot_store.rb +21 -37
  28. data/lib/brute/sub_agent.rb +106 -0
  29. data/lib/brute/system_prompt.rb +1 -83
  30. data/lib/brute/tool.rb +107 -0
  31. data/lib/brute/tools/delegate.rb +61 -70
  32. data/lib/brute/tools/fs_patch.rb +9 -7
  33. data/lib/brute/tools/fs_read.rb +233 -20
  34. data/lib/brute/tools/fs_remove.rb +8 -9
  35. data/lib/brute/tools/fs_search.rb +98 -16
  36. data/lib/brute/tools/fs_undo.rb +8 -8
  37. data/lib/brute/tools/fs_write.rb +7 -5
  38. data/lib/brute/tools/net_fetch.rb +8 -8
  39. data/lib/brute/tools/question.rb +36 -24
  40. data/lib/brute/tools/shell.rb +74 -16
  41. data/lib/brute/tools/todo_read.rb +8 -8
  42. data/lib/brute/tools/todo_write.rb +25 -18
  43. data/lib/brute/tools.rb +8 -12
  44. data/lib/brute/truncation.rb +219 -0
  45. data/lib/brute/version.rb +1 -1
  46. data/lib/brute.rb +82 -45
  47. metadata +59 -46
  48. data/lib/brute/loop/agent_stream.rb +0 -118
  49. data/lib/brute/loop/agent_turn.rb +0 -520
  50. data/lib/brute/loop/compactor.rb +0 -107
  51. data/lib/brute/loop/doom_loop.rb +0 -86
  52. data/lib/brute/loop/step.rb +0 -332
  53. data/lib/brute/loop/tool_call_step.rb +0 -90
  54. data/lib/brute/middleware/base.rb +0 -27
  55. data/lib/brute/middleware/compaction_check.rb +0 -106
  56. data/lib/brute/middleware/doom_loop_detection.rb +0 -136
  57. data/lib/brute/middleware/llm_call.rb +0 -128
  58. data/lib/brute/middleware/message_tracking.rb +0 -339
  59. data/lib/brute/middleware/otel/span.rb +0 -105
  60. data/lib/brute/middleware/otel/token_usage.rb +0 -68
  61. data/lib/brute/middleware/otel/tool_calls.rb +0 -68
  62. data/lib/brute/middleware/otel/tool_results.rb +0 -65
  63. data/lib/brute/middleware/otel.rb +0 -34
  64. data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
  65. data/lib/brute/middleware/retry.rb +0 -157
  66. data/lib/brute/middleware/session_persistence.rb +0 -72
  67. data/lib/brute/middleware/token_tracking.rb +0 -124
  68. data/lib/brute/middleware/tool_error_tracking.rb +0 -179
  69. data/lib/brute/middleware/tool_use_guard.rb +0 -133
  70. data/lib/brute/middleware/tracing.rb +0 -124
  71. data/lib/brute/middleware.rb +0 -18
  72. data/lib/brute/orchestrator/turn.rb +0 -105
  73. data/lib/brute/patches/anthropic_tool_role.rb +0 -35
  74. data/lib/brute/patches/buffer_nil_guard.rb +0 -26
  75. data/lib/brute/providers/models_dev.rb +0 -111
  76. data/lib/brute/providers/ollama.rb +0 -135
  77. data/lib/brute/providers/opencode_go.rb +0 -43
  78. data/lib/brute/providers/opencode_zen.rb +0 -87
  79. data/lib/brute/providers.rb +0 -62
  80. data/lib/brute/queue/base_queue.rb +0 -222
  81. data/lib/brute/queue/parallel_queue.rb +0 -66
  82. data/lib/brute/queue/sequential_queue.rb +0 -63
  83. data/lib/brute/store/message_store.rb +0 -362
  84. data/lib/brute/store/session.rb +0 -106
  85. /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3594fba62fc4a71baaaf36b878eb1a3c02a3c06f7b0b2517b434556468bcde5
4
- data.tar.gz: d8f74e82c95d7698c11ecbe5792f57ace4739a81f4d625d3cb729123eb0b7179
3
+ metadata.gz: 19cba194a6650394c2b81805f70adcc5f774999962ca7d16123c7a335afc0532
4
+ data.tar.gz: c2a1d5bb0d1d2acceada34711f67adb586c3a366d68f22636dd754dce1587e19
5
5
  SHA512:
6
- metadata.gz: 861ab5262a21c876fa6592d1fc22612c39aada6e33a30e35ce81adb1bbbdfa978b9dab7b31ce7d653bf0f8e8ed09256a37d3d50bbe0024b889c5583e1fb690b6
7
- data.tar.gz: '082b158a7deec18b8ba1fededb03e7b1c08d7e744b03da7840d9e4af4234bb86b89d9266a560cc81e9d13a7f3a4bbca4f831a343fbaa18052acc3381075b4b8e'
6
+ metadata.gz: b61457cd8041c561ddbd4764968cd0df6903b59e593f40af8bff9b63bb69624bf8f73f879130e336d64c25dce10f6f9d44d377837871db6ce9f19c0b20ff6cb3
7
+ data.tar.gz: 30989eae4bec648ba08036641cd5404a19c22ca477094896ccb6ca20d8a2ace194e82d1ffab4b5ef37436f38de350b90531c35ffb611974385888c3a0ea827ee
data/lib/brute/agent.rb CHANGED
@@ -1,14 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require 'brute/pipeline'
6
+
3
7
  module Brute
4
- class Agent
5
- attr_reader :provider, :model, :tools, :system_prompt
8
+ DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant, hellbent on taking over the world."
9
+
10
+ # An agent is a Pipeline configured for LLM turns. It carries the
11
+ # provider/model/tools configuration and shapes env from a Session
12
+ # (the conversation message log).
13
+ #
14
+ # Usage:
15
+ #
16
+ # agent = Brute::Agent.new(
17
+ # provider: Brute.provider,
18
+ # model: "claude-sonnet-4-20250514",
19
+ # tools: Brute::Tools::ALL,
20
+ # ) do
21
+ # use Brute::Middleware::EventHandler, handler_class: TerminalOutput
22
+ # use Brute::Middleware::SystemPrompt
23
+ # use Brute::Middleware::MaxIterations
24
+ # use Brute::Middleware::Question
25
+ # use Brute::Middleware::ToolCall
26
+ # run Brute::Middleware::LLMCall.new
27
+ # end
28
+ #
29
+ # session = Brute::Session.new
30
+ # session.user("fix the failing tests")
31
+ # agent.call(session)
32
+ #
33
+ class Agent < Pipeline
34
+ attr_reader :provider, :model, :tools
6
35
 
7
- def initialize(provider:, model:, tools: Brute::Tools::ALL, system_prompt: nil)
36
+ def initialize(provider:, model: nil, tools: [], &block)
8
37
  @provider = provider
9
- @model = model
10
- @tools = tools
11
- @system_prompt = system_prompt
38
+ @model = model
39
+ @tools = tools
40
+ super(&block)
41
+ end
42
+
43
+ # Run one turn against the given session. The session is mutated
44
+ # in place (assistant + tool messages appended) and returned.
45
+ def call(session, events: NullSink.new)
46
+ env = {
47
+ messages: session,
48
+ provider: @provider,
49
+ model: @model,
50
+ tools: @tools,
51
+ events: events,
52
+ metadata: {},
53
+ system_prompt: DEFAULT_SYSTEM_PROMPT,
54
+ current_iteration: 1,
55
+ }
56
+ super(env)
57
+ session
12
58
  end
13
59
  end
14
60
  end
61
+
62
+ test do
63
+ it "runs a turn and returns the session" do
64
+ agent = Brute::Agent.new(provider: :stub) do
65
+ run ->(env) { env[:messages].assistant("hello") }
66
+ end
67
+ session = Brute::Session.new
68
+ session.user("hi")
69
+ agent.call(session).should == session
70
+ end
71
+
72
+ it "passes provider/model/tools through env" do
73
+ captured = nil
74
+ capture = ->(env) { captured = env.slice(:provider, :model, :tools) }
75
+
76
+ agent = Brute::Agent.new(provider: :stub, model: "m", tools: [:a]) { run capture }
77
+ agent.call(Brute::Session.new)
78
+ captured.should == { provider: :stub, model: "m", tools: [:a] }
79
+ end
80
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'brute'
5
+
6
+ module Brute
7
+ module Events
8
+ # EXAMPLES:
9
+ # class TerminalOutput < Brute::Events::Handler
10
+ # def <<(event)
11
+ # h = event.to_h
12
+ # case h[:type]
13
+ # when :content then write(h[:data])
14
+ # when :tool_result then write(" ✓ #{h[:data][:name]}\n")
15
+ # when :log then write("[#{h[:data]}]\n")
16
+ # end
17
+ # super # forward to whatever's wrapped underneath
18
+ # end
19
+ #
20
+ # private
21
+ # def write(text); $stderr.write(text); $stderr.flush; end
22
+ # end
23
+ #
24
+ # class JsonlTrace < Brute::Events::Handler
25
+ # def initialize(inner, path:)
26
+ # super(inner)
27
+ # @file = File.open(path, "a")
28
+ # end
29
+ #
30
+ # def <<(event)
31
+ # @file.puts(JSON.generate(event.to_h))
32
+ # @file.flush
33
+ # super
34
+ # end
35
+ # end
36
+ #
37
+ # class FilterNoise < Brute::Events::Handler
38
+ # # Drop reasoning chunks before they reach the terminal
39
+ # def <<(event)
40
+ # return self if event.to_h[:type] == :reasoning
41
+ # super
42
+ # end
43
+ # end
44
+ #
45
+ # pipeline = Brute::Pipeline.new do
46
+ # use Brute::Middleware::EventHandler, handler_class: JsonlTrace, path: "trace.jsonl"
47
+ # use Brute::Middleware::EventHandler, handler_class: FilterNoise
48
+ # use Brute::Middleware::EventHandler, handler_class: TerminalOutput
49
+ # end
50
+ #
51
+ class Handler
52
+ def initialize(inner)
53
+ @inner = inner
54
+ end
55
+
56
+ # Default: pass through. Subclasses override <<, do their thing,
57
+ # then super (or don't, to swallow the event).
58
+ def <<(event)
59
+ tap do
60
+ @inner << event if @inner
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ test do
68
+ # not implemented
69
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Events
8
+ # TerminalOutput variant that prefixes all output with a label.
9
+ # Useful for sub-agents running concurrently — the prefix makes it
10
+ # clear which agent produced each line.
11
+ #
12
+ # Usage in a middleware stack:
13
+ #
14
+ # use Brute::Middleware::EventHandler,
15
+ # handler_class: Brute::Events::PrefixedTerminalOutput,
16
+ # prefix: "explore"
17
+ #
18
+ class PrefixedTerminalOutput < Brute::Events::Handler
19
+ def initialize(inner, prefix: "sub-agent")
20
+ super(inner)
21
+ @prefix = prefix
22
+ @tag = "[#{@prefix}]".light_black
23
+ end
24
+
25
+ def <<(event)
26
+ $stdout.sync = true
27
+
28
+ type = event.to_h[:type]
29
+ data = event.to_h[:data]
30
+
31
+ method = "on_#{type}"
32
+ send(method, data) if respond_to?(method, true)
33
+
34
+ super
35
+ end
36
+
37
+ private
38
+
39
+ def on_content(data)
40
+ # Prefix each line so interleaved output is distinguishable
41
+ data.to_s.each_line { |line| $stdout.write("#{@tag} #{line}") }
42
+ end
43
+
44
+ def on_tool_call_start(data)
45
+ puts
46
+ data.each do |tool_call|
47
+ puts "#{@tag} [tool] #{tool_call[:name]} - #{tool_call[:arguments]}"
48
+ end
49
+ end
50
+
51
+ def on_tool_result(data)
52
+ puts "#{@tag} [tool] #{data[:name]} - done"
53
+ end
54
+
55
+ def on_log(data)
56
+ $stderr.puts "#{@tag} #{data}".light_black
57
+ end
58
+
59
+ def on_error(data)
60
+ if data.is_a?(Hash)
61
+ $stderr.puts "#{@tag} error: #{data[:message]}"
62
+ else
63
+ $stderr.puts "#{@tag} error: #{data}"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ test do
71
+ # not implemented
72
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'brute'
5
+
6
+ module Brute
7
+ module Events
8
+ class TerminalOutput < Brute::Events::Handler
9
+ def <<(event)
10
+ $stdout.sync = true
11
+
12
+ type = event.to_h[:type]
13
+ data = event.to_h[:data]
14
+
15
+ method = "on_#{type}"
16
+
17
+ if respond_to?(method, true)
18
+ send(method, data)
19
+ end
20
+
21
+ super
22
+ end
23
+
24
+ private
25
+
26
+ def on_content(data)
27
+ $stdout.write(data)
28
+ end
29
+
30
+ def on_reasoning(data)
31
+ $stderr.write(data.to_s.gsub(/^/, " │ "))
32
+ end
33
+
34
+ def on_tool_call_start(data)
35
+ puts
36
+ data.each do |tool_call|
37
+ puts "[tool] #{tool_call[:name]} - #{tool_call[:arguments]}"
38
+ end
39
+ end
40
+
41
+ def on_tool_result(data)
42
+ puts "[tool] #{data[:name]} - done"
43
+ end
44
+
45
+ def on_log(data)
46
+ $stderr.puts "#{data}".light_black
47
+ end
48
+
49
+ def on_assistant_complete(_)
50
+ puts
51
+ end
52
+
53
+ def on_error(data)
54
+ if data.is_a?(Hash)
55
+ $stderr.puts "✗ #{data[:error].class}: #{data[:message]}"
56
+ $stderr.puts " provider: #{data[:provider].inspect}"
57
+ $stderr.puts " model: #{data[:model].inspect}"
58
+ else
59
+ $stderr.puts "✗ #{data.class}: #{data.message}"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ test do
67
+ # not implemented
68
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Outermost OTel middleware. Creates a span per LLM stack call
9
+ # and passes it through env[:span] for inner OTel middlewares to
10
+ # decorate with events and attributes.
11
+ #
12
+ # When opentelemetry-sdk is not loaded, this is a pure pass-through.
13
+ #
14
+ # Stack position: outermost (wraps everything including retries).
15
+ #
16
+ # use Brute::Middleware::OTel::Span
17
+ # use Brute::Middleware::OTel::ToolResultLoop
18
+ # use Brute::Middleware::OTel::ToolCalls
19
+ # use Brute::Middleware::OTel::TokenUsage
20
+ # # ... existing middleware ...
21
+ # run Brute::Middleware::LLMCall.new
22
+ #
23
+ class OtelSpan
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ #return @app.call(env) unless defined?(::OpenTelemetry::SDK)
30
+
31
+ #provider_name = provider_type(env[:provider])
32
+ #model = env[:model] || (env[:provider].default_model rescue nil)
33
+ #span_name = model ? "llm.call #{model}" : "llm.call"
34
+
35
+ #attributes = {
36
+ # "brute.provider" => provider_name,
37
+ # "brute.streaming" => !!env[:streaming],
38
+ # "brute.context_messages" => env[:messages].size,
39
+ #}
40
+ #attributes["brute.model"] = model.to_s if model
41
+ #attributes["brute.session_id"] = env[:metadata][:session_id].to_s if env.dig(:metadata, :session_id)
42
+
43
+ #tracer.in_span(span_name, attributes: attributes, kind: :internal) do |span|
44
+ # env[:span] = span
45
+ # response = @app.call(env)
46
+
47
+ # # Record response model if it differs from request model
48
+ # resp_model = begin; response.model; rescue; nil; end
49
+ # span.set_attribute("brute.response_model", resp_model.to_s) if resp_model && resp_model != model
50
+
51
+ # response
52
+ #rescue ::StandardError => e
53
+ # span.record_exception(e)
54
+ # span.status = ::OpenTelemetry::Trace::Status.error(e.message)
55
+ # raise
56
+ #ensure
57
+ # env.delete(:span)
58
+ #end
59
+ @app.all(env)
60
+ end
61
+
62
+ private
63
+
64
+ def tracer
65
+ @tracer ||= ::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
66
+ end
67
+
68
+ def provider_type(provider)
69
+ provider.name.to_s
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ test do
76
+ # not implemented
77
+ end
@@ -0,0 +1,103 @@
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 whenever the last message is a :tool result.
9
+ #
10
+ # After the inner pipeline runs (LLMCall responds, ToolCall executes tools
11
+ # and appends :tool messages), this middleware checks if tool results are
12
+ # pending. If so, it increments the iteration counter and loops — sending
13
+ # the tool results back through MaxIterations → ToolCall → LLMCall so the
14
+ # LLM can see them.
15
+ #
16
+ # The loop breaks when:
17
+ # - The LLM responds with text only (no tool calls) — last message is :assistant
18
+ # - env[:should_exit] is set (e.g. by MaxIterations)
19
+ #
20
+ class ToolResultLoop
21
+ def initialize(app)
22
+ @app = app
23
+ end
24
+
25
+ def call(env)
26
+ loop do
27
+ @app.call(env)
28
+
29
+ break if env[:should_exit]
30
+ break unless env[:messages].last&.role == :tool
31
+
32
+ env[:current_iteration] += 1
33
+ end
34
+
35
+ env
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ test do
42
+ require "brute/session"
43
+
44
+ it "loops until last message is not a tool result" do
45
+ call_count = 0
46
+
47
+ # Fake inner app: first call appends a :tool message, second call appends :assistant
48
+ inner = ->(env) do
49
+ call_count += 1
50
+ if call_count == 1
51
+ env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
52
+ else
53
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
54
+ end
55
+ end
56
+
57
+ mw = Brute::Middleware::ToolResultLoop.new(inner)
58
+ env = { messages: Brute::Session.new, current_iteration: 1 }
59
+ env[:messages].user("hi")
60
+
61
+ mw.call(env)
62
+
63
+ call_count.should == 2
64
+ env[:current_iteration].should == 2
65
+ env[:messages].last.role.should == :assistant
66
+ end
67
+
68
+ it "stops when should_exit is set" do
69
+ call_count = 0
70
+
71
+ inner = ->(env) do
72
+ call_count += 1
73
+ env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
74
+ env[:should_exit] = { reason: "max" } if call_count >= 2
75
+ end
76
+
77
+ mw = Brute::Middleware::ToolResultLoop.new(inner)
78
+ env = { messages: Brute::Session.new, current_iteration: 1 }
79
+ env[:messages].user("hi")
80
+
81
+ mw.call(env)
82
+
83
+ call_count.should == 2
84
+ end
85
+
86
+ it "does not loop when last message is assistant" do
87
+ call_count = 0
88
+
89
+ inner = ->(env) do
90
+ call_count += 1
91
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hello")
92
+ end
93
+
94
+ mw = Brute::Middleware::ToolResultLoop.new(inner)
95
+ env = { messages: Brute::Session.new, current_iteration: 1 }
96
+ env[:messages].user("hi")
97
+
98
+ mw.call(env)
99
+
100
+ call_count.should == 1
101
+ env[:current_iteration].should == 1
102
+ end
103
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Middleware
8
+ # Runs a final tool-free LLM call after the ToolResultLoop completes,
9
+ # ensuring the agent produces a clean summary response.
10
+ #
11
+ # This middleware sits above ToolResultLoop in the stack. After the tool
12
+ # loop finishes (either naturally or via MaxIterations), Summarize
13
+ # injects a summary prompt and calls the inner stack one more time
14
+ # with tools removed. The LLM responds with text only, giving the
15
+ # agent a proper final answer.
16
+ #
17
+ # Stack order:
18
+ #
19
+ # use Summarize
20
+ # use ToolResultLoop
21
+ # use MaxIterations
22
+ # use ToolCall
23
+ # run LLMCall.new
24
+ #
25
+ class Summarize
26
+ DEFAULT_PROMPT = "Provide your complete findings based on everything you've explored."
27
+
28
+ def initialize(app, prompt: DEFAULT_PROMPT)
29
+ @app = app
30
+ @prompt = prompt
31
+ end
32
+
33
+ def call(env)
34
+ @app.call(env)
35
+
36
+ saved_tools = env[:tools]
37
+ env[:tools] = []
38
+ env[:current_iteration] = 1
39
+ env[:messages] << RubyLLM::Message.new(role: :user, content: @prompt)
40
+ @app.call(env)
41
+ env[:tools] = saved_tools
42
+
43
+ env
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ test do
50
+ require "brute/session"
51
+
52
+ it "produces a final assistant message after tool loop" do
53
+ call_count = 0
54
+
55
+ # Fake inner app: first call simulates a tool loop ending with a tool message,
56
+ # second call (summary) produces an assistant message.
57
+ inner = ->(env) do
58
+ call_count += 1
59
+ if call_count == 1
60
+ env[:messages] << RubyLLM::Message.new(role: :tool, content: "some result", tool_call_id: "tc1")
61
+ else
62
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "Here is my complete summary.")
63
+ end
64
+ end
65
+
66
+ mw = Brute::Middleware::Summarize.new(inner)
67
+ session = Brute::Session.new
68
+ session.user("explore the codebase")
69
+ env = { messages: session, tools: [:some_tool], current_iteration: 5 }
70
+ mw.call(env)
71
+
72
+ env[:messages].last.role.should == :assistant
73
+ env[:messages].last.content.should =~ /summary/i
74
+ end
75
+
76
+ it "restores tools after summary call" do
77
+ inner = ->(env) {
78
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
79
+ }
80
+
81
+ mw = Brute::Middleware::Summarize.new(inner)
82
+ tools = [:read, :search]
83
+ env = { messages: Brute::Session.new, tools: tools.dup, current_iteration: 1 }
84
+ env[:messages].user("hi")
85
+ mw.call(env)
86
+
87
+ env[:tools].should == tools
88
+ end
89
+
90
+ it "resets current_iteration for the summary call" do
91
+ captured_iteration = nil
92
+ inner = ->(env) {
93
+ captured_iteration = env[:current_iteration]
94
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
95
+ }
96
+
97
+ mw = Brute::Middleware::Summarize.new(inner)
98
+ env = { messages: Brute::Session.new, tools: [], current_iteration: 99 }
99
+ env[:messages].user("hi")
100
+ mw.call(env)
101
+
102
+ # The second call (summary) should have iteration reset to 1
103
+ captured_iteration.should == 1
104
+ end
105
+
106
+ it "injects a summary prompt message" do
107
+ messages_at_second_call = nil
108
+ call_count = 0
109
+ inner = ->(env) {
110
+ call_count += 1
111
+ messages_at_second_call = env[:messages].map(&:content) if call_count == 2
112
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
113
+ }
114
+
115
+ mw = Brute::Middleware::Summarize.new(inner)
116
+ env = { messages: Brute::Session.new, tools: [], current_iteration: 1 }
117
+ env[:messages].user("hi")
118
+ mw.call(env)
119
+
120
+ messages_at_second_call.last.should =~ /findings/i
121
+ end
122
+
123
+ it "accepts a custom prompt" do
124
+ messages_at_second_call = nil
125
+ call_count = 0
126
+ inner = ->(env) {
127
+ call_count += 1
128
+ messages_at_second_call = env[:messages].map(&:content) if call_count == 2
129
+ env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
130
+ }
131
+
132
+ mw = Brute::Middleware::Summarize.new(inner, prompt: "Give me the TL;DR.")
133
+ env = { messages: Brute::Session.new, tools: [], current_iteration: 1 }
134
+ env[:messages].user("hi")
135
+ mw.call(env)
136
+
137
+ messages_at_second_call.last.should == "Give me the TL;DR."
138
+ end
139
+ end