brute 2.0.6 → 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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/events/handler.rb +3 -1
  3. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  4. data/lib/brute/events/terminal_output_handler.rb +3 -1
  5. data/lib/brute/message_transport/anthropic.rb +143 -0
  6. data/lib/brute/message_transport/llm.rb +94 -0
  7. data/lib/brute/message_transport/openai.rb +113 -0
  8. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  9. data/lib/brute/message_transport.rb +133 -0
  10. data/lib/brute/messages.rb +120 -0
  11. data/lib/brute/middleware/001_otel_span.rb +4 -2
  12. data/lib/brute/middleware/002_session_log.rb +100 -0
  13. data/lib/brute/middleware/004_summarize.rb +21 -19
  14. data/lib/brute/middleware/006_loop.rb +158 -0
  15. data/lib/brute/middleware/010_max_iterations.rb +9 -7
  16. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  17. data/lib/brute/middleware/020_system_prompt.rb +6 -4
  18. data/lib/brute/middleware/040_compaction_check.rb +5 -3
  19. data/lib/brute/middleware/060_questions.rb +3 -1
  20. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
  21. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  22. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  23. data/lib/brute/middleware/event_handler.rb +3 -1
  24. data/lib/brute/middleware/user_queue.rb +3 -1
  25. data/lib/brute/prompts/autonomy.rb +3 -0
  26. data/lib/brute/prompts/base.rb +3 -0
  27. data/lib/brute/prompts/build_switch.rb +3 -1
  28. data/lib/brute/prompts/code_references.rb +3 -0
  29. data/lib/brute/prompts/code_style.rb +3 -0
  30. data/lib/brute/prompts/conventions.rb +3 -0
  31. data/lib/brute/prompts/doing_tasks.rb +3 -0
  32. data/lib/brute/prompts/editing_approach.rb +3 -0
  33. data/lib/brute/prompts/editing_constraints.rb +3 -0
  34. data/lib/brute/prompts/environment.rb +3 -1
  35. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  36. data/lib/brute/prompts/git_safety.rb +3 -0
  37. data/lib/brute/prompts/identity.rb +3 -1
  38. data/lib/brute/prompts/instructions.rb +3 -1
  39. data/lib/brute/prompts/max_steps.rb +3 -1
  40. data/lib/brute/prompts/objectivity.rb +3 -0
  41. data/lib/brute/prompts/plan_reminder.rb +3 -1
  42. data/lib/brute/prompts/proactiveness.rb +3 -0
  43. data/lib/brute/prompts/security_and_safety.rb +3 -0
  44. data/lib/brute/prompts/skills.rb +6 -2
  45. data/lib/brute/prompts/task_management.rb +3 -0
  46. data/lib/brute/prompts/tone_and_style.rb +3 -0
  47. data/lib/brute/prompts/tool_usage.rb +3 -0
  48. data/lib/brute/prompts.rb +5 -0
  49. data/lib/brute/rack/adapter.rb +237 -0
  50. data/lib/brute/skill.rb +192 -15
  51. data/lib/brute/system_prompt.rb +3 -1
  52. data/lib/brute/tool.rb +92 -75
  53. data/lib/brute/tools/adapter.rb +258 -0
  54. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  55. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  56. data/lib/brute/tools/fs_patch.rb +6 -4
  57. data/lib/brute/tools/fs_read.rb +5 -3
  58. data/lib/brute/tools/fs_remove.rb +3 -3
  59. data/lib/brute/tools/fs_search.rb +4 -2
  60. data/lib/brute/tools/fs_undo.rb +3 -3
  61. data/lib/brute/tools/fs_write.rb +6 -4
  62. data/lib/brute/tools/net_fetch.rb +1 -1
  63. data/lib/brute/tools/question.rb +1 -1
  64. data/lib/brute/tools/shell.rb +4 -2
  65. data/lib/brute/tools/skill_load.rb +156 -0
  66. data/lib/brute/tools/sub_agent.rb +101 -0
  67. data/lib/brute/tools/todo_list/store.rb +36 -0
  68. data/lib/brute/tools/todo_read.rb +2 -2
  69. data/lib/brute/tools/todo_write.rb +2 -2
  70. data/lib/brute/tools.rb +2 -1
  71. data/lib/brute/truncation.rb +3 -1
  72. data/lib/brute/turn/agent_pipeline.rb +169 -0
  73. data/lib/brute/turn/pipeline.rb +95 -0
  74. data/lib/brute/turn/tool_pipeline.rb +96 -0
  75. data/lib/brute/utils/diff.rb +3 -1
  76. data/lib/brute/version.rb +1 -1
  77. data/lib/brute.rb +45 -71
  78. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  79. data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
  80. metadata +52 -43
  81. data/lib/brute/agent.rb +0 -82
  82. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  83. data/lib/brute/middleware/100_llm_call.rb +0 -63
  84. data/lib/brute/pipeline.rb +0 -97
  85. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  86. data/lib/brute/session.rb +0 -51
  87. data/lib/brute/store/snapshot_store.rb +0 -36
  88. data/lib/brute/store/todo_store.rb +0 -30
  89. data/lib/brute/sub_agent.rb +0 -106
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ca9915157a21335990328dcbf4b6104cffa72bd0835e93055ec46e8be05aee7
4
- data.tar.gz: 6e7ef36a7fd32e0351563856d888f832fb4d48fad32444f937fb58c2d45baa4d
3
+ metadata.gz: c25e73819e947f92a37f144411ba27b09f93cc48088bc0cc474247bc81149658
4
+ data.tar.gz: 635eff80312c14656cf2a6168f2f4ee9279413ac285e580a58e959812a9e5d4d
5
5
  SHA512:
6
- metadata.gz: ff22892e28c30deb694b7bbea899e8b14ceb31e068fa74b27b9b462d7778ab52da06f332f743ac6c19324bf962d7ee1401e8146f1714496fc1dcba5179f6e551
7
- data.tar.gz: 457fbacd0115628b002c6b274ef7c07d47a267c4841107c52c4d983d23a0967012ca68a084b50f833d5421ff013766c2aef07be5e465d8630ca61cc9c06326f5
6
+ metadata.gz: c43c126ae04f4642df67df70ab780dfc11ef2070709580cc409d6b32652a2f0be41730d252a06d9029f8d156657877f046af6a06d5c2ba1d4ee9a2d5fda75d97
7
+ data.tar.gz: 26599b1c63616c87af1c8537dfe4e473be5a70ee408058b31fb8a67c5203f72b81d41a868e59ceb8831ac570a74a9132c6c2df42794b116ea91e67b70a4441dc
@@ -25,6 +25,8 @@ module Brute
25
25
  end
26
26
  end
27
27
 
28
- test do
28
+ __END__
29
+
30
+ describe "brute/events/handler" do
29
31
  # not implemented
30
32
  end
@@ -67,6 +67,8 @@ module Brute
67
67
  end
68
68
  end
69
69
 
70
- test do
70
+ __END__
71
+
72
+ describe "brute/events/prefixed_terminal_output" do
71
73
  # not implemented
72
74
  end
@@ -63,6 +63,8 @@ module Brute
63
63
  end
64
64
  end
65
65
 
66
- test do
66
+ __END__
67
+
68
+ describe "brute/events/terminal_output_handler" do
67
69
  # not implemented
68
70
  end
@@ -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