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.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +72 -6
- data/lib/brute/events/handler.rb +69 -0
- data/lib/brute/events/prefixed_terminal_output.rb +72 -0
- data/lib/brute/events/terminal_output_handler.rb +68 -0
- data/lib/brute/middleware/001_otel_span.rb +77 -0
- data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
- data/lib/brute/middleware/004_summarize.rb +139 -0
- data/lib/brute/middleware/005_tracing.rb +86 -0
- data/lib/brute/middleware/010_max_iterations.rb +73 -0
- data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
- data/lib/brute/middleware/020_system_prompt.rb +128 -0
- data/lib/brute/middleware/040_compaction_check.rb +155 -0
- data/lib/brute/middleware/060_questions.rb +41 -0
- data/lib/brute/middleware/070_tool_call.rb +247 -0
- data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
- data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
- data/lib/brute/middleware/100_llm_call.rb +62 -0
- data/lib/brute/middleware/event_handler.rb +25 -0
- data/lib/brute/middleware/user_queue.rb +35 -0
- data/lib/brute/pipeline.rb +44 -107
- data/lib/brute/prompts/skills.rb +2 -2
- data/lib/brute/prompts.rb +23 -23
- data/lib/brute/providers/shell.rb +6 -19
- data/lib/brute/providers/shell_response.rb +22 -30
- data/lib/brute/session.rb +52 -0
- data/lib/brute/store/snapshot_store.rb +21 -37
- data/lib/brute/sub_agent.rb +106 -0
- data/lib/brute/system_prompt.rb +1 -83
- data/lib/brute/tool.rb +107 -0
- data/lib/brute/tools/delegate.rb +61 -70
- data/lib/brute/tools/fs_patch.rb +9 -7
- data/lib/brute/tools/fs_read.rb +233 -20
- data/lib/brute/tools/fs_remove.rb +8 -9
- data/lib/brute/tools/fs_search.rb +98 -16
- data/lib/brute/tools/fs_undo.rb +8 -8
- data/lib/brute/tools/fs_write.rb +7 -5
- data/lib/brute/tools/net_fetch.rb +8 -8
- data/lib/brute/tools/question.rb +36 -24
- data/lib/brute/tools/shell.rb +74 -16
- data/lib/brute/tools/todo_read.rb +8 -8
- data/lib/brute/tools/todo_write.rb +25 -18
- data/lib/brute/tools.rb +8 -12
- data/lib/brute/truncation.rb +219 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +82 -45
- metadata +59 -46
- data/lib/brute/loop/agent_stream.rb +0 -118
- data/lib/brute/loop/agent_turn.rb +0 -520
- data/lib/brute/loop/compactor.rb +0 -107
- data/lib/brute/loop/doom_loop.rb +0 -86
- data/lib/brute/loop/step.rb +0 -332
- data/lib/brute/loop/tool_call_step.rb +0 -90
- data/lib/brute/middleware/base.rb +0 -27
- data/lib/brute/middleware/compaction_check.rb +0 -106
- data/lib/brute/middleware/doom_loop_detection.rb +0 -136
- data/lib/brute/middleware/llm_call.rb +0 -128
- data/lib/brute/middleware/message_tracking.rb +0 -339
- data/lib/brute/middleware/otel/span.rb +0 -105
- data/lib/brute/middleware/otel/token_usage.rb +0 -68
- data/lib/brute/middleware/otel/tool_calls.rb +0 -68
- data/lib/brute/middleware/otel/tool_results.rb +0 -65
- data/lib/brute/middleware/otel.rb +0 -34
- data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
- data/lib/brute/middleware/retry.rb +0 -157
- data/lib/brute/middleware/session_persistence.rb +0 -72
- data/lib/brute/middleware/token_tracking.rb +0 -124
- data/lib/brute/middleware/tool_error_tracking.rb +0 -179
- data/lib/brute/middleware/tool_use_guard.rb +0 -133
- data/lib/brute/middleware/tracing.rb +0 -124
- data/lib/brute/middleware.rb +0 -18
- data/lib/brute/orchestrator/turn.rb +0 -105
- data/lib/brute/patches/anthropic_tool_role.rb +0 -35
- data/lib/brute/patches/buffer_nil_guard.rb +0 -26
- data/lib/brute/providers/models_dev.rb +0 -111
- data/lib/brute/providers/ollama.rb +0 -135
- data/lib/brute/providers/opencode_go.rb +0 -43
- data/lib/brute/providers/opencode_zen.rb +0 -87
- data/lib/brute/providers.rb +0 -62
- data/lib/brute/queue/base_queue.rb +0 -222
- data/lib/brute/queue/parallel_queue.rb +0 -66
- data/lib/brute/queue/sequential_queue.rb +0 -63
- data/lib/brute/store/message_store.rb +0 -362
- data/lib/brute/store/session.rb +0 -106
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19cba194a6650394c2b81805f70adcc5f774999962ca7d16123c7a335afc0532
|
|
4
|
+
data.tar.gz: c2a1d5bb0d1d2acceada34711f67adb586c3a366d68f22636dd754dce1587e19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
36
|
+
def initialize(provider:, model: nil, tools: [], &block)
|
|
8
37
|
@provider = provider
|
|
9
|
-
@model
|
|
10
|
-
@tools
|
|
11
|
-
|
|
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
|