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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/message_transport/anthropic.rb +143 -0
  3. data/lib/brute/message_transport/llm.rb +94 -0
  4. data/lib/brute/message_transport/openai.rb +113 -0
  5. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  6. data/lib/brute/message_transport.rb +133 -0
  7. data/lib/brute/messages.rb +79 -6
  8. data/lib/brute/middleware/002_session_log.rb +1 -1
  9. data/lib/brute/middleware/004_summarize.rb +7 -7
  10. data/lib/brute/middleware/006_loop.rb +4 -4
  11. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  12. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  13. data/lib/brute/middleware/040_compaction_check.rb +2 -2
  14. data/lib/brute/middleware/070_tool_pipeline.rb +29 -26
  15. data/lib/brute/tool.rb +124 -0
  16. data/lib/brute/tools/adapter.rb +22 -60
  17. data/lib/brute/tools/fs_patch.rb +1 -1
  18. data/lib/brute/tools/fs_read.rb +1 -1
  19. data/lib/brute/tools/fs_remove.rb +1 -1
  20. data/lib/brute/tools/fs_search.rb +1 -1
  21. data/lib/brute/tools/fs_undo.rb +1 -1
  22. data/lib/brute/tools/fs_write.rb +1 -1
  23. data/lib/brute/tools/net_fetch.rb +1 -1
  24. data/lib/brute/tools/question.rb +1 -1
  25. data/lib/brute/tools/shell.rb +1 -1
  26. data/lib/brute/tools/skill_load.rb +1 -1
  27. data/lib/brute/tools/sub_agent.rb +5 -22
  28. data/lib/brute/tools/todo_read.rb +1 -1
  29. data/lib/brute/tools/todo_write.rb +1 -1
  30. data/lib/brute/turn/agent_pipeline.rb +2 -1
  31. data/lib/brute/turn/pipeline.rb +1 -1
  32. data/lib/brute/turn/tool_pipeline.rb +2 -12
  33. data/lib/brute/version.rb +1 -1
  34. data/lib/brute.rb +17 -18
  35. data/lib/brute_cli/providers/shell_response.rb +6 -10
  36. metadata +22 -17
  37. data/lib/ruby_llm/message_transport.rb +0 -117
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6308c538c9efce1d554894738361f1f07720730b3fc66aa46df9f57e2599ae4
4
- data.tar.gz: 9bc51c1b3020b601bacbab282874f8a789b1420b84a9649c58f6e75c83b4b7b5
3
+ metadata.gz: c25e73819e947f92a37f144411ba27b09f93cc48088bc0cc474247bc81149658
4
+ data.tar.gz: 635eff80312c14656cf2a6168f2f4ee9279413ac285e580a58e959812a9e5d4d
5
5
  SHA512:
6
- metadata.gz: b7f0b0ab4859d81bdc9885824bca9baf68600ff880f4b804652e10882caf3d2449235f18490d9338ab1e4bce29cc641d8b776bd9a777dc7fd98a3bb8b9b81c94
7
- data.tar.gz: 53301d50213d626fa6f7ba7e1bb0b1124aa3b4b48485302f176681eabf8d0839d493231416c1f0274f29fd5690754b8a48d6751d83f6a32cbc58a9a59f5fa1c9
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
@@ -4,14 +4,56 @@ require "bundler/setup"
4
4
  require "brute"
5
5
 
6
6
  module Brute
7
- # The in-memory conversation log is just a plain Array of RubyLLM::Message.
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 << ::RubyLLM::Message.new(role: :user, content: content); end
13
- def assistant(content); self << ::RubyLLM::Message.new(role: :assistant, content: content); end
14
- def system(content); self << ::RubyLLM::Message.new(role: :system, content: content); end
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(RubyLLM::Message.new(role: :user, content: "hi"))
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 = RubyLLM::Message.new(role: :user, content: "seed")
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