brute 3.0.0 → 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.
- checksums.yaml +4 -4
- data/lib/brute/message_transport/anthropic.rb +143 -0
- data/lib/brute/message_transport/llm.rb +94 -0
- data/lib/brute/message_transport/openai.rb +113 -0
- data/lib/brute/message_transport/ruby_llm.rb +78 -0
- data/lib/brute/message_transport.rb +133 -0
- data/lib/brute/messages.rb +79 -6
- data/lib/brute/middleware/002_session_log.rb +1 -1
- data/lib/brute/middleware/004_summarize.rb +7 -7
- data/lib/brute/middleware/006_loop.rb +4 -4
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +2 -2
- data/lib/brute/middleware/070_tool_pipeline.rb +29 -26
- data/lib/brute/tool.rb +124 -0
- data/lib/brute/tools/adapter.rb +22 -60
- data/lib/brute/tools/fs_patch.rb +1 -1
- data/lib/brute/tools/fs_read.rb +1 -1
- data/lib/brute/tools/fs_remove.rb +1 -1
- data/lib/brute/tools/fs_search.rb +1 -1
- data/lib/brute/tools/fs_undo.rb +1 -1
- data/lib/brute/tools/fs_write.rb +1 -1
- data/lib/brute/tools/net_fetch.rb +1 -1
- data/lib/brute/tools/question.rb +1 -1
- data/lib/brute/tools/shell.rb +1 -1
- data/lib/brute/tools/skill_load.rb +1 -1
- data/lib/brute/tools/sub_agent.rb +5 -22
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/turn/agent_pipeline.rb +2 -1
- data/lib/brute/turn/pipeline.rb +1 -1
- data/lib/brute/turn/tool_pipeline.rb +2 -12
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +17 -18
- data/lib/brute_cli/providers/shell_response.rb +6 -10
- metadata +22 -17
- data/lib/ruby_llm/message_transport.rb +0 -117
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c25e73819e947f92a37f144411ba27b09f93cc48088bc0cc474247bc81149658
|
|
4
|
+
data.tar.gz: 635eff80312c14656cf2a6168f2f4ee9279413ac285e580a58e959812a9e5d4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c43c126ae04f4642df67df70ab780dfc11ef2070709580cc409d6b32652a2f0be41730d252a06d9029f8d156657877f046af6a06d5c2ba1d4ee9a2d5fda75d97
|
|
7
|
+
data.tar.gz: 26599b1c63616c87af1c8537dfe4e473be5a70ee408058b31fb8a67c5203f72b81d41a868e59ceb8831ac570a74a9132c6c2df42794b116ea91e67b70a4441dc
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
# MessageTransport for the official anthropic gem
|
|
10
|
+
# (https://github.com/anthropics/anthropic-sdk-ruby). Brute does not
|
|
11
|
+
# require it — you do:
|
|
12
|
+
#
|
|
13
|
+
# require "anthropic"
|
|
14
|
+
#
|
|
15
|
+
# client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
|
|
16
|
+
# response = client.messages.create(
|
|
17
|
+
# model: "claude-opus-4-8",
|
|
18
|
+
# max_tokens: 16_000,
|
|
19
|
+
# system_: Brute::MessageTransport::Anthropic.system_text(env[:messages]),
|
|
20
|
+
# messages: Brute::MessageTransport::Anthropic.dump_all(env[:messages]),
|
|
21
|
+
# tools: ...,
|
|
22
|
+
# )
|
|
23
|
+
# Brute::MessageTransport::Anthropic.wrap_each(response) { |m| env[:messages] << m }
|
|
24
|
+
#
|
|
25
|
+
# Anthropic's Messages API differs from chat-completions-shaped APIs in
|
|
26
|
+
# two ways this transport absorbs: the system prompt is a top-level
|
|
27
|
+
# parameter (not a message), and tool results are content blocks inside
|
|
28
|
+
# a user message — consecutive tool results are folded into one user
|
|
29
|
+
# turn so roles keep alternating.
|
|
30
|
+
class Anthropic < MessageTransport
|
|
31
|
+
# The :system messages' text, for the top-level `system_:` parameter.
|
|
32
|
+
def self.system_text(messages)
|
|
33
|
+
messages.select { |m| m.role == :system }.map(&:content).join("\n\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Brute log -> the `messages:` array. Drops :system messages (see
|
|
37
|
+
# .system_text) and folds consecutive :tool results into one user turn.
|
|
38
|
+
def self.dump_all(messages)
|
|
39
|
+
chat = messages.reject { |m| m.role == :system }
|
|
40
|
+
|
|
41
|
+
chat.chunk_while { |a, b| a.role == :tool && b.role == :tool }.map do |group|
|
|
42
|
+
if group.first.role == :tool
|
|
43
|
+
{ role: "user", content: group.map { |m| tool_result_block(m) } }
|
|
44
|
+
else
|
|
45
|
+
dump(group.first)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Brute::Message -> an Anthropic message param hash.
|
|
51
|
+
def self.dump(message)
|
|
52
|
+
case message.role
|
|
53
|
+
when :tool
|
|
54
|
+
{ role: "user", content: [tool_result_block(message)] }
|
|
55
|
+
when :assistant
|
|
56
|
+
if message.tool_call?
|
|
57
|
+
blocks = []
|
|
58
|
+
blocks << { type: "text", text: message.content } unless message.content.to_s.empty?
|
|
59
|
+
blocks += message.tool_calls.map { |tc| { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments } }
|
|
60
|
+
{ role: "assistant", content: blocks }
|
|
61
|
+
else
|
|
62
|
+
{ role: "assistant", content: message.content }
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
{ role: message.role.to_s, content: message.content }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.tool_result_block(message)
|
|
70
|
+
{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content.to_s }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# An Anthropic response (content blocks) -> one Brute::Message. Text
|
|
76
|
+
# blocks join into the content; tool_use blocks become tool calls.
|
|
77
|
+
def wrap(message)
|
|
78
|
+
blocks = message.content
|
|
79
|
+
|
|
80
|
+
text = blocks.select { |b| b.type == :text }.map(&:text).join
|
|
81
|
+
tool_calls = blocks.select { |b| b.type == :tool_use }.map do |b|
|
|
82
|
+
arguments = b.input.respond_to?(:to_h) ? b.input.to_h : b.input
|
|
83
|
+
Brute::ToolCall.new(id: b.id, name: b.name, arguments: arguments)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Brute::Message.new(
|
|
87
|
+
role: :assistant,
|
|
88
|
+
content: text,
|
|
89
|
+
tool_calls: (tool_calls unless tool_calls.empty?),
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
__END__
|
|
97
|
+
|
|
98
|
+
describe "brute/message_transport/anthropic" do
|
|
99
|
+
require "brute/messages"
|
|
100
|
+
|
|
101
|
+
# Duck-typed stand-ins for the anthropic gem's response objects so these
|
|
102
|
+
# specs don't need the gem loaded.
|
|
103
|
+
fake_text_block = Struct.new(:type, :text, keyword_init: true)
|
|
104
|
+
fake_tool_block = Struct.new(:type, :id, :name, :input, keyword_init: true)
|
|
105
|
+
fake_response = Struct.new(:content, keyword_init: true)
|
|
106
|
+
|
|
107
|
+
it "extracts the system prompt" do
|
|
108
|
+
log = Brute.log
|
|
109
|
+
log.system("be helpful")
|
|
110
|
+
log.user("hi")
|
|
111
|
+
Brute::MessageTransport::Anthropic.system_text(log).should == "be helpful"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "dumps tool calls as tool_use blocks" do
|
|
115
|
+
m = Brute::Message.new(role: :assistant, content: "thinking...",
|
|
116
|
+
tool_calls: [{ id: "tc1", name: "shell", arguments: { "command" => "ls" } }])
|
|
117
|
+
dumped = Brute::MessageTransport::Anthropic.dump(m)
|
|
118
|
+
dumped[:content].last.should == { type: "tool_use", id: "tc1", name: "shell", input: { "command" => "ls" } }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "folds consecutive tool results into one user turn" do
|
|
122
|
+
log = Brute.log
|
|
123
|
+
log.user("go")
|
|
124
|
+
log.tool("a", tool_call_id: "tc1")
|
|
125
|
+
log.tool("b", tool_call_id: "tc2")
|
|
126
|
+
|
|
127
|
+
dumped = Brute::MessageTransport::Anthropic.dump_all(log)
|
|
128
|
+
dumped.size.should == 2
|
|
129
|
+
dumped.last[:role].should == "user"
|
|
130
|
+
dumped.last[:content].map { |b| b[:tool_use_id] }.should == %w[tc1 tc2]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "wraps text and tool_use blocks into one assistant message" do
|
|
134
|
+
response = fake_response.new(content: [
|
|
135
|
+
fake_text_block.new(type: :text, text: "running ls"),
|
|
136
|
+
fake_tool_block.new(type: :tool_use, id: "tc1", name: "shell", input: { "command" => "ls" }),
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
out = Brute::MessageTransport::Anthropic.new(response).wrap_each.to_a.first
|
|
140
|
+
out.content.should == "running ls"
|
|
141
|
+
out.tool_calls.first.name.should == "shell"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/message_transport"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Brute
|
|
9
|
+
class MessageTransport
|
|
10
|
+
# MessageTransport for the llm.rb gem (https://github.com/llmrb/llm.rb).
|
|
11
|
+
# Brute does not require llm.rb — you do:
|
|
12
|
+
#
|
|
13
|
+
# require "llm"
|
|
14
|
+
#
|
|
15
|
+
# llm = LLM.openai(key: ENV["OPENAI_API_KEY"])
|
|
16
|
+
# messages = Brute::MessageTransport::LLM.dump_all(env[:messages])
|
|
17
|
+
# response = llm.complete(messages.pop, role: nil, messages: messages, tools: ...)
|
|
18
|
+
# Brute::MessageTransport::LLM.wrap_each(response) { |m| env[:messages] << m }
|
|
19
|
+
class LLM < MessageTransport
|
|
20
|
+
# Brute::Message -> LLM::Message. Assistant tool calls carry llm.rb's
|
|
21
|
+
# `original_tool_calls` extra (the provider wire format); tool results
|
|
22
|
+
# become an LLM::Function::Return so llm.rb's request adapters emit
|
|
23
|
+
# them correctly.
|
|
24
|
+
def self.dump(message)
|
|
25
|
+
case message.role
|
|
26
|
+
when :tool
|
|
27
|
+
::LLM::Message.new(
|
|
28
|
+
:tool,
|
|
29
|
+
::LLM::Function::Return.new(message.tool_call_id, nil, message.content.to_s),
|
|
30
|
+
)
|
|
31
|
+
when :assistant
|
|
32
|
+
if message.tool_call?
|
|
33
|
+
wire = message.tool_calls.map do |tc|
|
|
34
|
+
{ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
|
|
35
|
+
end
|
|
36
|
+
::LLM::Message.new(:assistant, message.content.to_s,
|
|
37
|
+
{ tool_calls: wire, original_tool_calls: wire })
|
|
38
|
+
else
|
|
39
|
+
::LLM::Message.new(:assistant, message.content)
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
::LLM::Message.new(message.role, message.content)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# LLM::Message (from an LLM::Response) -> Brute::Message.
|
|
49
|
+
def wrap(message)
|
|
50
|
+
tool_calls = if message.tool_call?
|
|
51
|
+
message.tool_calls.map do |tc|
|
|
52
|
+
Brute::ToolCall.new(id: tc.id, name: tc["name"], arguments: tc.arguments.to_h)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Brute::Message.new(
|
|
57
|
+
role: message.role,
|
|
58
|
+
content: message.content.to_s,
|
|
59
|
+
tool_calls: tool_calls,
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
__END__
|
|
67
|
+
|
|
68
|
+
describe "brute/message_transport/llm" do
|
|
69
|
+
require "brute/messages"
|
|
70
|
+
|
|
71
|
+
# Duck-typed stand-in for a response LLM::Message so these specs don't
|
|
72
|
+
# need the gem loaded.
|
|
73
|
+
fake_tool_call = Struct.new(:id, :arguments, keyword_init: true) do
|
|
74
|
+
def [](key); key == "name" ? "shell" : nil; end
|
|
75
|
+
end
|
|
76
|
+
fake_message = Struct.new(:role, :content, :tool_calls, keyword_init: true) do
|
|
77
|
+
def tool_call?; !tool_calls.nil? && tool_calls.any?; end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "wraps a plain assistant message" do
|
|
81
|
+
m = fake_message.new(role: "assistant", content: "hi")
|
|
82
|
+
out = Brute::MessageTransport::LLM.new(m).wrap_each.to_a.first
|
|
83
|
+
out.role.should == :assistant
|
|
84
|
+
out.content.should == "hi"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "wraps tool calls into Brute::ToolCall" do
|
|
88
|
+
tc = fake_tool_call.new(id: "tc1", arguments: { "command" => "ls" })
|
|
89
|
+
m = fake_message.new(role: "assistant", content: nil, tool_calls: [tc])
|
|
90
|
+
|
|
91
|
+
out = Brute::MessageTransport::LLM.new(m).wrap_each.to_a.first
|
|
92
|
+
out.tool_calls.first.should == Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/message_transport"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Brute
|
|
9
|
+
class MessageTransport
|
|
10
|
+
# MessageTransport for the official openai gem
|
|
11
|
+
# (https://github.com/openai/openai-ruby). Brute does not require it —
|
|
12
|
+
# you do:
|
|
13
|
+
#
|
|
14
|
+
# require "openai"
|
|
15
|
+
#
|
|
16
|
+
# client = OpenAI::Client.new(api_key: ENV["OPENAI_API_KEY"])
|
|
17
|
+
# response = client.chat.completions.create(
|
|
18
|
+
# model: "gpt-5",
|
|
19
|
+
# messages: Brute::MessageTransport::OpenAI.dump_all(env[:messages]),
|
|
20
|
+
# tools: ...,
|
|
21
|
+
# )
|
|
22
|
+
# Brute::MessageTransport::OpenAI.wrap_each(response) { |m| env[:messages] << m }
|
|
23
|
+
class OpenAI < MessageTransport
|
|
24
|
+
# Brute::Message -> a chat.completions message param hash.
|
|
25
|
+
def self.dump(message)
|
|
26
|
+
case message.role
|
|
27
|
+
when :tool
|
|
28
|
+
{ role: "tool", tool_call_id: message.tool_call_id, content: message.content.to_s }
|
|
29
|
+
when :assistant
|
|
30
|
+
if message.tool_call?
|
|
31
|
+
{
|
|
32
|
+
role: "assistant",
|
|
33
|
+
content: (message.content unless message.content.to_s.empty?),
|
|
34
|
+
tool_calls: message.tool_calls.map { |tc|
|
|
35
|
+
{ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
{ role: "assistant", content: message.content }
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
{ role: message.role.to_s, content: message.content }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A chat completion response's messages (one per choice).
|
|
47
|
+
def messages
|
|
48
|
+
return @result.choices.map(&:message) if @result.respond_to?(:choices)
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# An OpenAI chat completion message -> Brute::Message. Tool call
|
|
56
|
+
# arguments arrive as a JSON string; parse them into a Hash.
|
|
57
|
+
def wrap(message)
|
|
58
|
+
tool_calls = message.tool_calls&.map do |tc|
|
|
59
|
+
arguments = begin
|
|
60
|
+
JSON.parse(tc.function.arguments.to_s)
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
{}
|
|
63
|
+
end
|
|
64
|
+
Brute::ToolCall.new(id: tc.id, name: tc.function.name, arguments: arguments)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Brute::Message.new(
|
|
68
|
+
role: message.role,
|
|
69
|
+
content: message.content.to_s,
|
|
70
|
+
tool_calls: tool_calls,
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
__END__
|
|
78
|
+
|
|
79
|
+
describe "brute/message_transport/openai" do
|
|
80
|
+
require "brute/messages"
|
|
81
|
+
|
|
82
|
+
# Duck-typed stand-ins for the openai gem's response objects so these
|
|
83
|
+
# specs don't need the gem loaded.
|
|
84
|
+
fake_function = Struct.new(:name, :arguments, keyword_init: true)
|
|
85
|
+
fake_tool_call = Struct.new(:id, :function, keyword_init: true)
|
|
86
|
+
fake_message = Struct.new(:role, :content, :tool_calls, keyword_init: true)
|
|
87
|
+
fake_choice = Struct.new(:message, keyword_init: true)
|
|
88
|
+
fake_response = Struct.new(:choices, keyword_init: true)
|
|
89
|
+
|
|
90
|
+
it "dumps a tool result to the wire format" do
|
|
91
|
+
m = Brute::Message.new(role: :tool, content: "ok", tool_call_id: "tc1")
|
|
92
|
+
Brute::MessageTransport::OpenAI.dump(m).should ==
|
|
93
|
+
{ role: "tool", tool_call_id: "tc1", content: "ok" }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "dumps assistant tool calls with JSON-encoded arguments" do
|
|
97
|
+
m = Brute::Message.new(role: :assistant, content: "",
|
|
98
|
+
tool_calls: [{ id: "tc1", name: "shell", arguments: { "command" => "ls" } }])
|
|
99
|
+
dumped = Brute::MessageTransport::OpenAI.dump(m)
|
|
100
|
+
dumped[:tool_calls].first[:function][:arguments].should == '{"command":"ls"}'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "unpacks a chat completion's choices and parses arguments" do
|
|
104
|
+
tc = fake_tool_call.new(id: "tc1", function: fake_function.new(name: "shell", arguments: '{"command":"ls"}'))
|
|
105
|
+
response = fake_response.new(choices: [
|
|
106
|
+
fake_choice.new(message: fake_message.new(role: "assistant", content: nil, tool_calls: [tc])),
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
out = Brute::MessageTransport::OpenAI.new(response).wrap_each.to_a.first
|
|
110
|
+
out.role.should == :assistant
|
|
111
|
+
out.tool_calls.first.arguments.should == { "command" => "ls" }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
# 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
|
+
class RubyLLM < MessageTransport
|
|
20
|
+
# Brute::Message -> RubyLLM::Message (tool calls as ruby_llm's
|
|
21
|
+
# id-keyed hash).
|
|
22
|
+
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)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
::RubyLLM::Message.new(
|
|
28
|
+
role: message.role,
|
|
29
|
+
content: message.content,
|
|
30
|
+
tool_calls: tool_calls,
|
|
31
|
+
tool_call_id: message.tool_call_id,
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
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
|
|
42
|
+
|
|
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,
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
__END__
|
|
55
|
+
|
|
56
|
+
describe "brute/message_transport/ruby_llm" do
|
|
57
|
+
require "brute/messages"
|
|
58
|
+
|
|
59
|
+
# Duck-typed stand-ins for RubyLLM::Message / RubyLLM::ToolCall so these
|
|
60
|
+
# specs don't need the gem loaded.
|
|
61
|
+
fake_tool_call = Struct.new(:id, :name, :arguments, keyword_init: true)
|
|
62
|
+
fake_message = Struct.new(:role, :content, :tool_calls, :tool_call_id, keyword_init: true)
|
|
63
|
+
|
|
64
|
+
it "wraps a plain assistant message" do
|
|
65
|
+
m = fake_message.new(role: :assistant, content: "hi")
|
|
66
|
+
out = Brute::MessageTransport::RubyLLM.new(m).wrap_each.to_a
|
|
67
|
+
out.first.should.be.kind_of?(Brute::Message)
|
|
68
|
+
out.first.content.should == "hi"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "wraps ruby_llm's id-keyed tool_calls hash into a flat list" do
|
|
72
|
+
tc = fake_tool_call.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
|
|
73
|
+
m = fake_message.new(role: :assistant, content: "", tool_calls: { "tc1" => tc })
|
|
74
|
+
|
|
75
|
+
out = Brute::MessageTransport::RubyLLM.new(m).wrap_each.to_a.first
|
|
76
|
+
out.tool_calls.first.should == Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
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
|
+
class MessageTransport
|
|
33
|
+
# Convenience: Brute::MessageTransport.wrap_each(result) { |m| ... }
|
|
34
|
+
def self.wrap_each(result, &block)
|
|
35
|
+
new(result).wrap_each(&block)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Outbound: one Brute::Message in the library's format. Identity here.
|
|
39
|
+
def self.dump(message)
|
|
40
|
+
message
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Outbound: the whole log in the library's format.
|
|
44
|
+
def self.dump_all(messages)
|
|
45
|
+
messages.map { |message| dump(message) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(result)
|
|
49
|
+
@result = result
|
|
50
|
+
end
|
|
51
|
+
|
|
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
|
+
def wrap_each
|
|
56
|
+
return enum_for(:wrap_each) unless block_given?
|
|
57
|
+
|
|
58
|
+
messages.each { |message| yield wrap(message) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The result normalized to a flat list of the library's messages. A
|
|
62
|
+
# single message, an array, or anything transcript-shaped (responds to
|
|
63
|
+
# #messages).
|
|
64
|
+
def messages
|
|
65
|
+
case @result
|
|
66
|
+
when Array then @result.compact
|
|
67
|
+
else @result.respond_to?(:messages) ? @result.messages : [@result].compact
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# The Brute::Message view of a single library message. Identity here —
|
|
74
|
+
# subclasses normalize their library's shape.
|
|
75
|
+
def wrap(message)
|
|
76
|
+
message
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
__END__
|
|
82
|
+
|
|
83
|
+
describe "brute/message_transport" do
|
|
84
|
+
require "brute/messages"
|
|
85
|
+
|
|
86
|
+
it "wraps a single message" do
|
|
87
|
+
message = Brute::Message.new(role: :assistant, content: "hi")
|
|
88
|
+
Brute::MessageTransport.new(message).wrap_each.to_a.should == [message]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "wraps an array of messages" do
|
|
92
|
+
a = Brute::Message.new(role: :assistant, content: "one")
|
|
93
|
+
b = Brute::Message.new(role: :tool, content: "two", tool_call_id: "tc1")
|
|
94
|
+
Brute::MessageTransport.new([a, b]).wrap_each.to_a.should == [a, b]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "wraps a transcript-shaped object (responds to #messages)" do
|
|
98
|
+
fake_chat = Class.new do
|
|
99
|
+
attr_reader :messages
|
|
100
|
+
def initialize(messages); @messages = messages; end
|
|
101
|
+
end
|
|
102
|
+
msgs = [Brute::Message.new(role: :user, content: "hi"),
|
|
103
|
+
Brute::Message.new(role: :assistant, content: "hello")]
|
|
104
|
+
|
|
105
|
+
Brute::MessageTransport.new(fake_chat.new(msgs)).wrap_each.to_a.map(&:role).should == [:user, :assistant]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "yields each message to the block for the caller to append" do
|
|
109
|
+
session = Brute.log
|
|
110
|
+
session.user("hello")
|
|
111
|
+
response = Brute::Message.new(role: :assistant, content: "hi there")
|
|
112
|
+
|
|
113
|
+
Brute::MessageTransport.new(response).wrap_each { |message| session << message }
|
|
114
|
+
|
|
115
|
+
session.last.role.should == :assistant
|
|
116
|
+
session.last.content.should == "hi there"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "exposes a class-level wrap_each convenience" do
|
|
120
|
+
session = Brute.log
|
|
121
|
+
a = Brute::Message.new(role: :assistant, content: "a")
|
|
122
|
+
b = Brute::Message.new(role: :assistant, content: "b")
|
|
123
|
+
|
|
124
|
+
Brute::MessageTransport.wrap_each([a, b]) { |message| session << message }
|
|
125
|
+
|
|
126
|
+
session.map(&:content).should == ["a", "b"]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "dump_all is the identity by default" do
|
|
130
|
+
msgs = [Brute::Message.new(role: :user, content: "hi")]
|
|
131
|
+
Brute::MessageTransport.dump_all(msgs).should == msgs
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/brute/messages.rb
CHANGED
|
@@ -4,14 +4,56 @@ require "bundler/setup"
|
|
|
4
4
|
require "brute"
|
|
5
5
|
|
|
6
6
|
module Brute
|
|
7
|
-
#
|
|
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.
|
|
8
46
|
# This module adds a little sugar for appending role-tagged messages; mix it
|
|
9
47
|
# into an array via `Brute.log`. Persistence is NOT here — loading/saving the
|
|
10
48
|
# log to disk is the Brute::Middleware::SessionLog middleware's job.
|
|
11
49
|
module Messages
|
|
12
|
-
def user(content); self <<
|
|
13
|
-
def assistant(content); self <<
|
|
14
|
-
def system(content); self <<
|
|
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
|
|
15
57
|
end
|
|
16
58
|
|
|
17
59
|
# Build a fresh conversation log (an Array + Messages sugar), optionally
|
|
@@ -19,7 +61,7 @@ module Brute
|
|
|
19
61
|
#
|
|
20
62
|
# log = Brute.log
|
|
21
63
|
# log.user("hello")
|
|
22
|
-
# Brute.log(
|
|
64
|
+
# Brute.log(Brute::Message.new(role: :user, content: "hi"))
|
|
23
65
|
def self.log(*messages)
|
|
24
66
|
[].extend(Messages).tap { |log| messages.flatten.each { |m| log << m } }
|
|
25
67
|
end
|
|
@@ -36,12 +78,43 @@ describe "brute/messages" do
|
|
|
36
78
|
log.first.content.should == "hi"
|
|
37
79
|
end
|
|
38
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
|
+
|
|
39
88
|
it "seeds from given messages" do
|
|
40
|
-
m =
|
|
89
|
+
m = Brute::Message.new(role: :user, content: "seed")
|
|
41
90
|
Brute.log(m).should == [m]
|
|
42
91
|
end
|
|
43
92
|
|
|
44
93
|
it "is a plain Array (no special class)" do
|
|
45
94
|
Brute.log.should.be.kind_of?(Array)
|
|
46
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
|
|
47
120
|
end
|