brute 3.1.0 → 3.2.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/message_transport/open_router.rb +30 -0
- data/lib/brute/message_transport/ruby_llm.rb +22 -25
- data/lib/brute/message_transport/ruby_open_ai.rb +83 -0
- data/lib/brute/message_transport.rb +16 -35
- data/lib/brute/messages.rb +17 -12
- data/lib/brute/middleware/006_loop.rb +14 -3
- data/lib/brute/middleware/070_tool_pipeline.rb +39 -84
- data/lib/brute/middleware/open_router.rb +32 -0
- data/lib/brute/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 724fd8c44f58e700e455bf463899e0f3151303dab3a7eeda9b21c184f7aed9ce
|
|
4
|
+
data.tar.gz: 22f47575d1c60e617c1ce6cc7ddc554145d3685e64b59d3d9fc48661427515b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1a1b7588db4a3c3de695a8ce57047ef5875fb14b6fe6efb445f81a1f1cb98013af048a8e47ce39395be66e5b82ae8a08bcc491042afc1ba1ea3df0747b9d1de
|
|
7
|
+
data.tar.gz: 27494840079f7c6eb695dca807ff525854c2f28d9b6b3968f3308e670a217a6ee8e644b5981b1ee2a226d08399c0bac56f9dd6e64270d728620b5c6a270eecd4
|
|
@@ -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
|
-
|
|
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&.
|
|
24
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
else
|
|
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
|
|
data/lib/brute/messages.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
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.
|
|
4
|
+
version: 3.2.0
|
|
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
|