brute 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fe7f1994a944255146721ea3030261622735e33d412f07ba6fd118287b76517
4
- data.tar.gz: 0b36c9d8c38afb152c16f0bc26c305145ed94f1c53e84316399d4f32b7f8752a
3
+ metadata.gz: f3375f62e8d019bd4a1dd45d2407112ef421c7ce437f604addb0fac51586c5ec
4
+ data.tar.gz: 15fbc6ed0dce515a143f2424957de3c120ff4f68e5b097d732b418f9fe43aecc
5
5
  SHA512:
6
- metadata.gz: 92f1aaba43df5f7fc0dad527c2b52a47e5336d4efabd95a6211cd9cdff97ae1a583d29ab94364d48b56760c5d7126fd3f9fe8f6e51b16518a250178daac30ee5
7
- data.tar.gz: fce06871143bd87726637ece09887261b87aec3042470c0bb38ace70db42546541ef6a12f0310a3f72433a6ab65ade8f8c894a70a2f865ba21d752e94145f235
6
+ metadata.gz: f2ea1006786ddbb531aaa2bf708f60a362422acf87159f731d7ed459bf409d89103ea48a6530953eb31382eb6ca44ea314afdfb4b61bf870a6153b8128f498cd
7
+ data.tar.gz: 4ba3e5a9822d3c50b498cab1f33730decc3d3298eb289bd0a450c52e681eaa06fdc002cca73bf0c45b353283e95a0f12ec9cc8605a2623a0f43e1df7eb064789
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/message_transport"
6
+
7
+ module Brute
8
+ class MessageTransport
9
+ class OpenRouter < MessageTransport
10
+ def self.dump(message)
11
+ message.to_h
12
+ end
13
+
14
+ private
15
+
16
+ def wrap(message)
17
+ # Coerce string keys to symbol keys if necessary
18
+ hash = message.to_h.transform_keys(&:to_sym)
19
+
20
+ case hash
21
+ in { role: (:system | :user | :assistant | :tool) }
22
+ # Message#initialize handles converting tool_calls & symbolising role!
23
+ Brute::Message.new(**hash)
24
+ else
25
+ raise "Unrecognised message format #{message.inspect}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -6,47 +6,44 @@ require "brute/message_transport"
6
6
 
7
7
  module Brute
8
8
  class MessageTransport
9
- # MessageTransport for the ruby_llm gem (https://rubyllm.com).
10
- # Brute does not require ruby_llm — you do:
11
- #
12
- # require "ruby_llm"
13
- #
14
- # response = provider.complete(
15
- # Brute::MessageTransport::RubyLLM.dump_all(env[:messages]),
16
- # tools: ..., model: model,
17
- # )
18
- # Brute::MessageTransport::RubyLLM.wrap_each(response) { |m| env[:messages] << m }
19
9
  class RubyLLM < MessageTransport
20
- # Brute::Message -> RubyLLM::Message (tool calls as ruby_llm's
21
- # id-keyed hash).
10
+
11
+ # Brute::Message -> RubyLLM::Message (tool calls as ruby_llm's id-keyed hash).
22
12
  def self.dump(message)
23
- tool_calls = message.tool_calls&.each_with_object({}) do |tc, hash|
24
- hash[tc.id] = ::RubyLLM::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)
13
+ tool_calls = message.tool_calls&.to_h do |tc|
14
+ [tc.id, ::RubyLLM::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)]
25
15
  end
26
16
 
27
17
  ::RubyLLM::Message.new(
28
18
  role: message.role,
29
19
  content: message.content,
30
20
  tool_calls: tool_calls,
31
- tool_call_id: message.tool_call_id,
21
+ tool_call_id: message.tool_call_id
32
22
  )
33
23
  end
34
24
 
35
25
  private
36
26
 
37
- # RubyLLM::Message -> Brute::Message.
38
- def wrap(message)
39
- tool_calls = message.tool_calls&.values&.map do |tc|
40
- Brute::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)
41
- end
27
+ # RubyLLM::Message -> Brute::Message.
28
+ def wrap(message)
29
+ raw_calls = message.tool_calls
30
+ calls_list = raw_calls.respond_to?(:values) ? raw_calls.values : raw_calls
42
31
 
43
- Brute::Message.new(
44
- role: message.role,
45
- content: message.content.to_s,
46
- tool_calls: tool_calls,
47
- tool_call_id: message.tool_call_id,
32
+ tool_calls = calls_list&.map do |tc|
33
+ Brute::ToolCall.new(
34
+ id: tc.id,
35
+ name: tc.name,
36
+ arguments: tc.arguments
48
37
  )
49
38
  end
39
+
40
+ Brute::Message.new(
41
+ role: message.role,
42
+ content: message.content&.to_s, # Preserves nil safely
43
+ tool_calls: tool_calls,
44
+ tool_call_id: message.tool_call_id
45
+ )
46
+ end
50
47
  end
51
48
  end
52
49
  end
@@ -0,0 +1,83 @@
1
+ module Brute
2
+ class MessageTransport
3
+ class RubyOpenAI < MessageTransport
4
+
5
+ # Brute::Message -> ruby-openai Hash payload
6
+ def self.dump(message)
7
+ payload = {
8
+ role: message.role.to_s
9
+ }
10
+
11
+ # Include content if present
12
+ payload[:content] = message.content if message.content
13
+
14
+ # Include tool call ID for tool outputs
15
+ payload[:tool_call_id] = message.tool_call_id if message.tool_call_id
16
+
17
+ # Convert Brute::ToolCall objects to ruby-openai nested tool call hashes
18
+ if message.tool_call?
19
+ payload[:tool_calls] = message.tool_calls.map do |tc|
20
+ {
21
+ id: tc.id,
22
+ type: "function",
23
+ function: {
24
+ name: tc.name,
25
+ arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
26
+ }
27
+ }
28
+ end
29
+ end
30
+
31
+ payload
32
+ end
33
+
34
+ private
35
+
36
+ # ruby-openai Hash (or API response choice message) -> Brute::Message
37
+ def wrap(message)
38
+ # Normalize keys to symbols for pattern matching
39
+ hash = message.to_h.transform_keys(&:to_sym)
40
+
41
+ case hash
42
+ # Branch 1: System, User, or Tool responses with text content
43
+ in { role: ("system" | "user" | "tool") => role }
44
+ Brute::Message.new(
45
+ role: role,
46
+ content: hash[:content],
47
+ tool_call_id: hash[:tool_call_id]
48
+ )
49
+
50
+ # Branch 2: Assistant tool calls request
51
+ in { role: "assistant", tool_calls: Array => raw_calls }
52
+ tool_calls = raw_calls.map do |tc|
53
+ # Handle both string and symbol keys within nested tool_call hashes
54
+ tc_hash = tc.to_h.transform_keys(&:to_sym)
55
+ fn_hash = (tc_hash[:function] || {}).transform_keys(&:to_sym)
56
+
57
+ Brute::ToolCall.new(
58
+ id: tc_hash[:id],
59
+ name: fn_hash[:name],
60
+ arguments: fn_hash[:arguments]
61
+ )
62
+ end
63
+
64
+ Brute::Message.new(
65
+ role: :assistant,
66
+ content: hash[:content],
67
+ tool_calls: tool_calls
68
+ )
69
+
70
+ # Branch 3: Standard Assistant text message
71
+ in { role: "assistant" }
72
+ Brute::Message.new(
73
+ role: :assistant,
74
+ content: hash[:content]
75
+ )
76
+
77
+ else
78
+ raise "Unrecognised message format for ruby-openai: #{message.inspect}"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -4,38 +4,14 @@ require "bundler/setup"
4
4
  require "brute"
5
5
 
6
6
  module Brute
7
- # Transports messages between an LLM library's format and Brute's format
8
- # (Brute::Message). This is the seam that keeps Brute framework-agnostic:
9
- # calling an LLM is trivial with any library, so Brute has no "completion
10
- # middleware" — the terminal `run` proc of an agent pipeline makes the LLM
11
- # call itself, and a MessageTransport translates at the boundary.
12
- #
13
- # Inbound (library response -> Brute), the transport wraps whatever the
14
- # proc got back and yields each message as a Brute::Message; the proc
15
- # appends:
16
- #
17
- # response = client.complete(...)
18
- # Brute::MessageTransport::RubyLLM.new(response).wrap_each do |message|
19
- # env[:messages] << message
20
- # end
21
- #
22
- # Outbound (Brute -> library), `dump_all` converts env[:messages] into the
23
- # shape the library's completion call expects:
24
- #
25
- # client.complete(Brute::MessageTransport::RubyLLM.dump_all(env[:messages]), ...)
26
- #
27
- # This base class is the identity transport: it flattens the result into a
28
- # list of messages and yields them untouched. Library-specific subclasses
29
- # (see message_transport/*.rb) override #wrap and .dump. They reference
30
- # their library lazily, so requiring the library is your job — Brute
31
- # depends on none of them.
32
7
  class MessageTransport
8
+
33
9
  # Convenience: Brute::MessageTransport.wrap_each(result) { |m| ... }
34
10
  def self.wrap_each(result, &block)
35
11
  new(result).wrap_each(&block)
36
12
  end
37
13
 
38
- # Outbound: one Brute::Message in the library's format. Identity here.
14
+ # Outbound: one Brute::Message in the library's format. Identity here.
39
15
  def self.dump(message)
40
16
  message
41
17
  end
@@ -49,22 +25,27 @@ module Brute
49
25
  @result = result
50
26
  end
51
27
 
52
- # Yield each result message as a Brute::Message. Without a block, returns
53
- # an Enumerator. The caller decides what to do with each (typically
54
- # append to env[:messages]).
55
28
  def wrap_each
56
- return enum_for(:wrap_each) unless block_given?
57
-
58
- messages.each { |message| yield wrap(message) }
29
+ if block_given?
30
+ messages.each { |message| yield wrap(message) }
31
+ else
32
+ # https://docs.ruby-lang.org/en/4.0/Object.html#method-i-enum_for
33
+ enum_for(:wrap_each)
34
+ end
59
35
  end
60
36
 
61
37
  # The result normalized to a flat list of the library's messages. A
62
38
  # single message, an array, or anything transcript-shaped (responds to
63
39
  # #messages).
64
40
  def messages
65
- case @result
66
- when Array then @result.compact
67
- else @result.respond_to?(:messages) ? @result.messages : [@result].compact
41
+ if @result.is_a?(Array)
42
+ @result.compact
43
+ else
44
+ if @result.respond_to?(:messages)
45
+ @result.messages
46
+ else
47
+ [@result].compact
48
+ end
68
49
  end
69
50
  end
70
51
 
@@ -24,21 +24,26 @@ module Brute
24
24
  # Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
25
25
  Message = Data.define(:role, :content, :tool_calls, :tool_call_id) do
26
26
  def initialize(role:, content: nil, tool_calls: nil, tool_call_id: nil)
27
- tool_calls = tool_calls&.map do |tc|
27
+ formatted_calls = tool_calls&.map do |tc|
28
28
  tc.is_a?(ToolCall) ? tc : ToolCall.new(**tc.to_h.transform_keys(&:to_sym))
29
29
  end
30
- super(role: role.to_sym, content: content, tool_calls: tool_calls, tool_call_id: tool_call_id)
30
+
31
+ super(
32
+ role: role.to_sym,
33
+ content: content,
34
+ tool_calls: formatted_calls,
35
+ tool_call_id: tool_call_id
36
+ )
31
37
  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
38
+
39
+ def tool_call? = !tool_calls.nil? && !tool_calls.empty?
40
+ alias_method :has_tool_calls?, :tool_call?
41
+
42
+ # Clean, JSON-ready hash export dropping nil values
43
+ def to_h(...)
44
+ hash = super
45
+ hash[:tool_calls] = tool_calls.map(&:to_h) if tool_calls
46
+ hash.compact
42
47
  end
43
48
  end
44
49
 
@@ -25,6 +25,7 @@ module Brute
25
25
  def initialize(app, condition = nil, &block)
26
26
  @app = app
27
27
  @condition = condition || block
28
+
28
29
  unless @condition.respond_to?(:call)
29
30
  raise ArgumentError, "Brute::Middleware::Loop requires a proc or block condition"
30
31
  end
@@ -33,8 +34,12 @@ module Brute
33
34
  def call(env)
34
35
  loop do
35
36
  @app.call(env)
36
- break unless @condition.call(env)
37
+
38
+ unless @condition.call(env)
39
+ break
40
+ end
37
41
  end
42
+
38
43
  env
39
44
  end
40
45
 
@@ -49,10 +54,16 @@ module Brute
49
54
  #
50
55
  class ToolResult < Loop
51
56
  CONDITION = lambda do |env|
52
- next false if env[:should_exit]
53
- next false unless env[:messages].last&.role == :tool
57
+ if env[:should_exit]
58
+ next false
59
+ end
60
+
61
+ unless env[:messages].last&.role == :tool
62
+ next false
63
+ end
54
64
 
55
65
  env[:current_iteration] += 1
66
+
56
67
  true
57
68
  end
58
69
 
@@ -8,94 +8,63 @@ require "async/barrier"
8
8
 
9
9
  module Brute
10
10
  module Middleware
11
- # Executes pending tool calls from the LLM response.
12
- #
13
- # Existing features (ref: opencode tool.ts wrap / truncate.ts):
14
- #
15
- # 1. Universal output truncation — after every tool.call(), pass the
16
- # result string through Brute::Truncation.truncate() which enforces
17
- # a 2000-line / 50 KB cap. This is a safety net so no single tool
18
- # result can blow up the context window, regardless of whether the
19
- # tool itself has internal limits.
20
- # 2. Overflow to disk — when truncating, the full output is saved to
21
- # a temp file under the truncation directory. The path is included
22
- # in the truncated result with a hint.
23
- # 3. Configurable limits — MAX_LINES / MAX_BYTES default to 2000 / 50 KB.
24
- # 4. Skip truncation when tool already truncated — if the tool result
25
- # already contains the truncation marker (e.g. Shell or FSSearch
26
- # truncated internally), don't double-truncate.
27
- #
28
- # == Concurrency model (Async)
29
- #
30
- # Tool calls are executed concurrently using the `async` gem's fiber-based
31
- # scheduler. Each tool call is dispatched as an Async::Task inside an
32
- # Async::Barrier, so all tools run in parallel and we wait for every task
33
- # to complete before moving on.
34
- #
35
- # Key design decisions:
36
- #
37
- # - Sync {} (not Async{}.wait) — reuses an existing event loop if one is
38
- # already running, or creates one on demand. Blocks the caller until all
39
- # inner work completes, which is what the middleware stack requires.
40
- #
41
- # - Async::Barrier — the idiomatic fan-out / join primitive. Each tool call
42
- # becomes a child task via barrier.async; barrier.wait blocks until every
43
- # task finishes. This is preferable to Async::Queue for a fixed batch of
44
- # work with no producer/consumer relationship.
45
- #
46
- # - Deterministic result ordering — tool results are collected into an array
47
- # during concurrent execution, then sorted back into the original
48
- # tools_to_run key order before appending to env[:messages]. This ensures
49
- # the LLM always sees results in a stable order regardless of which tool
50
- # finishes first.
51
- #
52
- # - Fiber-safe shared state — appending to the results array from multiple
53
- # fibers is safe because Async fibers are cooperatively scheduled (only
54
- # one fiber runs at a time within a Sync block). No mutex needed.
55
- #
56
- # - FileMutationQueue compatibility — tools that mutate files use
57
- # Brute::Tools::FS::FileMutationQueue.serialize, which uses Ruby 3.4's
58
- # fiber-scheduler-aware Mutex. Operations on the same file are serialized;
59
- # operations on different files proceed in parallel.
60
- #
61
11
  class ToolPipeline
62
12
  def initialize(app, tools: [])
63
13
  @app = app
64
14
  @tools = tools
65
15
  end
66
16
 
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
70
17
  def call(env)
71
18
  env[:tools] = @tools
72
19
  @app.call(env)
73
20
 
74
- tools_to_run = pending_tool_calls(env[:messages].last)
75
- if tools_to_run.any?
76
- available_tools = resolve_tools(env[:tools])
21
+ response = env[:messages].last
22
+
23
+ if response.respond_to?(:tool_calls) && response.tool_calls.present?
24
+ tools_to_run = response.tool_calls
25
+
26
+ # tool_to_run may be an Array (Brute::ToolCall) or an id-keyed Hash (some libraries' native shape)
27
+ if tools_to_run.respond_to?(:values)
28
+ tools_to_run = tools_to_run.values
29
+ end
30
+
31
+ # No idea why we use Array() here... probably in case it's a hash or something...
32
+ # because reject would work on the hash...
33
+ tools_to_run = Array(tools_to_run).reject { |tc| tc.name == "question" }
34
+
35
+ available_tools = Brute::Tools::Adapter.wrap_all(env[:tools])
77
36
  env[:events] << on_tool_call_start_event(tools_to_run)
78
37
 
79
38
  results = []
80
39
 
40
+ # Async::Barrier blocks until all tasks are complete.
41
+ # Tasks run in parrallel.
42
+ #
81
43
  Sync do
82
44
  barrier = Async::Barrier.new
83
45
 
84
46
  tools_to_run.each do |tool_call|
85
- barrier.async do
86
- tool = available_tools[tool_call.name.to_sym]
87
- result = tool.call(tool_call.arguments)
88
-
89
- # Coerce to String so Hash results (e.g. Shell's
90
- # {stdout:, stderr:, exit_code:}) serialize predictably.
91
- content = result.is_a?(String) ? result : result.to_s
92
-
93
- # Universal truncation safety net — skip if already truncated
94
- unless Brute::Truncation.already_truncated?(content)
95
- content = Brute::Truncation.truncate(content)
47
+ barrier.async do
48
+ name = tool_call.name.to_sym
49
+ args = tool_call.arguments
50
+
51
+ available_tools[name].call(args).then do |result|
52
+
53
+ # Coerce to String so Hash results (e.g. Shell's
54
+ # {stdout:, stderr:, exit_code:}) serialize predictably.
55
+ if result.is_a?(String)
56
+ content = result
57
+ else
58
+ content = result.to_s
59
+ end
60
+
61
+ # Universal truncation safety net — skip if already truncated
62
+ unless Brute::Truncation.already_truncated?(content)
63
+ content = Brute::Truncation.truncate(content)
64
+ end
65
+
66
+ results << [tool_call, content]
96
67
  end
97
-
98
- results << [tool_call, content]
99
68
  rescue => e
100
69
  # Capture the error as a tool result so the LLM can see it
101
70
  # and reason about the failure, rather than crashing the
@@ -120,25 +89,11 @@ module Brute
120
89
  end
121
90
  end
122
91
 
123
- return env
92
+ env
124
93
  end
125
94
 
126
95
  private
127
96
 
128
- # The last message's pending tool calls as a flat list. Duck-typed:
129
- # tool_calls may be an Array (Brute::ToolCall) or an id-keyed Hash
130
- # (some libraries' native shape); each entry needs #id, #name and
131
- # #arguments.
132
- def pending_tool_calls(message)
133
- calls = message.respond_to?(:tool_calls) ? message.tool_calls : nil
134
- calls = calls.values if calls.respond_to?(:values)
135
- Array(calls).reject { |tc| tc.name == "question" }
136
- end
137
-
138
- def resolve_tools(tools)
139
- Brute::Tools::Adapter.wrap_all(tools)
140
- end
141
-
142
97
  def on_tool_call_start_event(pending_tools)
143
98
  {
144
99
  type: :tool_call_start,
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Middleware
5
+ module OpenRouter
6
+ class Completion
7
+ def initialize(app, config: {}, **options)
8
+ @app = app
9
+ @config = ::OpenRouter::Configuration.new(config)
10
+ @options = ::OpenRouter::CompletionOptions.new(options)
11
+ end
12
+
13
+ def call(env)
14
+ messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
15
+
16
+ ::OpenRouter::Client.new(@config).then do |client|
17
+ client.complete(messages, @options).then do |response|
18
+
19
+ # OpenRouter in fact only returns a single message...
20
+ # https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb#L66
21
+ Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
22
+ env[:messages] << message
23
+ end
24
+ end
25
+ end
26
+
27
+ env
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "3.1.0"
4
+ VERSION = "3.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brute
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -176,8 +176,10 @@ files:
176
176
  - lib/brute/message_transport.rb
177
177
  - lib/brute/message_transport/anthropic.rb
178
178
  - lib/brute/message_transport/llm.rb
179
+ - lib/brute/message_transport/open_router.rb
179
180
  - lib/brute/message_transport/openai.rb
180
181
  - lib/brute/message_transport/ruby_llm.rb
182
+ - lib/brute/message_transport/ruby_open_ai.rb
181
183
  - lib/brute/messages.rb
182
184
  - lib/brute/middleware/001_otel_span.rb
183
185
  - lib/brute/middleware/002_session_log.rb
@@ -194,6 +196,7 @@ files:
194
196
  - lib/brute/middleware/073_otel_tool_call.rb
195
197
  - lib/brute/middleware/075_otel_tool_results.rb
196
198
  - lib/brute/middleware/event_handler.rb
199
+ - lib/brute/middleware/open_router.rb
197
200
  - lib/brute/middleware/user_queue.rb
198
201
  - lib/brute/prompts.rb
199
202
  - lib/brute/prompts/autonomy.rb