brute 1.0.0 → 2.0.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +72 -6
  3. data/lib/brute/events/handler.rb +69 -0
  4. data/lib/brute/events/prefixed_terminal_output.rb +72 -0
  5. data/lib/brute/events/terminal_output_handler.rb +68 -0
  6. data/lib/brute/middleware/001_otel_span.rb +77 -0
  7. data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
  8. data/lib/brute/middleware/004_summarize.rb +139 -0
  9. data/lib/brute/middleware/005_tracing.rb +86 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +73 -0
  11. data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
  12. data/lib/brute/middleware/020_system_prompt.rb +128 -0
  13. data/lib/brute/middleware/040_compaction_check.rb +155 -0
  14. data/lib/brute/middleware/060_questions.rb +41 -0
  15. data/lib/brute/middleware/070_tool_call.rb +247 -0
  16. data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
  17. data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
  18. data/lib/brute/middleware/100_llm_call.rb +62 -0
  19. data/lib/brute/middleware/event_handler.rb +25 -0
  20. data/lib/brute/middleware/user_queue.rb +35 -0
  21. data/lib/brute/pipeline.rb +44 -107
  22. data/lib/brute/prompts/skills.rb +2 -2
  23. data/lib/brute/prompts.rb +23 -23
  24. data/lib/brute/providers/shell.rb +6 -19
  25. data/lib/brute/providers/shell_response.rb +22 -30
  26. data/lib/brute/session.rb +52 -0
  27. data/lib/brute/store/snapshot_store.rb +21 -37
  28. data/lib/brute/sub_agent.rb +106 -0
  29. data/lib/brute/system_prompt.rb +1 -83
  30. data/lib/brute/tool.rb +107 -0
  31. data/lib/brute/tools/delegate.rb +61 -70
  32. data/lib/brute/tools/fs_patch.rb +9 -7
  33. data/lib/brute/tools/fs_read.rb +233 -20
  34. data/lib/brute/tools/fs_remove.rb +8 -9
  35. data/lib/brute/tools/fs_search.rb +98 -16
  36. data/lib/brute/tools/fs_undo.rb +8 -8
  37. data/lib/brute/tools/fs_write.rb +7 -5
  38. data/lib/brute/tools/net_fetch.rb +8 -8
  39. data/lib/brute/tools/question.rb +36 -24
  40. data/lib/brute/tools/shell.rb +74 -16
  41. data/lib/brute/tools/todo_read.rb +8 -8
  42. data/lib/brute/tools/todo_write.rb +25 -18
  43. data/lib/brute/tools.rb +8 -12
  44. data/lib/brute/truncation.rb +219 -0
  45. data/lib/brute/version.rb +1 -1
  46. data/lib/brute.rb +82 -45
  47. metadata +59 -46
  48. data/lib/brute/loop/agent_stream.rb +0 -118
  49. data/lib/brute/loop/agent_turn.rb +0 -520
  50. data/lib/brute/loop/compactor.rb +0 -107
  51. data/lib/brute/loop/doom_loop.rb +0 -86
  52. data/lib/brute/loop/step.rb +0 -332
  53. data/lib/brute/loop/tool_call_step.rb +0 -90
  54. data/lib/brute/middleware/base.rb +0 -27
  55. data/lib/brute/middleware/compaction_check.rb +0 -106
  56. data/lib/brute/middleware/doom_loop_detection.rb +0 -136
  57. data/lib/brute/middleware/llm_call.rb +0 -128
  58. data/lib/brute/middleware/message_tracking.rb +0 -339
  59. data/lib/brute/middleware/otel/span.rb +0 -105
  60. data/lib/brute/middleware/otel/token_usage.rb +0 -68
  61. data/lib/brute/middleware/otel/tool_calls.rb +0 -68
  62. data/lib/brute/middleware/otel/tool_results.rb +0 -65
  63. data/lib/brute/middleware/otel.rb +0 -34
  64. data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
  65. data/lib/brute/middleware/retry.rb +0 -157
  66. data/lib/brute/middleware/session_persistence.rb +0 -72
  67. data/lib/brute/middleware/token_tracking.rb +0 -124
  68. data/lib/brute/middleware/tool_error_tracking.rb +0 -179
  69. data/lib/brute/middleware/tool_use_guard.rb +0 -133
  70. data/lib/brute/middleware/tracing.rb +0 -124
  71. data/lib/brute/middleware.rb +0 -18
  72. data/lib/brute/orchestrator/turn.rb +0 -105
  73. data/lib/brute/patches/anthropic_tool_role.rb +0 -35
  74. data/lib/brute/patches/buffer_nil_guard.rb +0 -26
  75. data/lib/brute/providers/models_dev.rb +0 -111
  76. data/lib/brute/providers/ollama.rb +0 -135
  77. data/lib/brute/providers/opencode_go.rb +0 -43
  78. data/lib/brute/providers/opencode_zen.rb +0 -87
  79. data/lib/brute/providers.rb +0 -62
  80. data/lib/brute/queue/base_queue.rb +0 -222
  81. data/lib/brute/queue/parallel_queue.rb +0 -66
  82. data/lib/brute/queue/sequential_queue.rb +0 -63
  83. data/lib/brute/store/message_store.rb +0 -362
  84. data/lib/brute/store/session.rb +0 -106
  85. /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
data/lib/brute/prompts.rb CHANGED
@@ -1,26 +1,26 @@
1
- require_relative 'prompts/base'
2
- require_relative 'prompts/identity'
3
- require_relative 'prompts/tone_and_style'
4
- require_relative 'prompts/objectivity'
5
- require_relative 'prompts/task_management'
6
- require_relative 'prompts/doing_tasks'
7
- require_relative 'prompts/tool_usage'
8
- require_relative 'prompts/conventions'
9
- require_relative 'prompts/git_safety'
10
- require_relative 'prompts/code_references'
11
- require_relative 'prompts/environment'
12
- require_relative 'prompts/instructions'
13
- require_relative 'prompts/editing_approach'
14
- require_relative 'prompts/autonomy'
15
- require_relative 'prompts/editing_constraints'
16
- require_relative 'prompts/frontend_tasks'
17
- require_relative 'prompts/proactiveness'
18
- require_relative 'prompts/code_style'
19
- require_relative 'prompts/security_and_safety'
20
- require_relative 'prompts/skills'
21
- require_relative 'prompts/plan_reminder'
22
- require_relative 'prompts/max_steps'
23
- require_relative 'prompts/build_switch'
1
+ require 'brute/prompts/base'
2
+ require 'brute/prompts/identity'
3
+ require 'brute/prompts/tone_and_style'
4
+ require 'brute/prompts/objectivity'
5
+ require 'brute/prompts/task_management'
6
+ require 'brute/prompts/doing_tasks'
7
+ require 'brute/prompts/tool_usage'
8
+ require 'brute/prompts/conventions'
9
+ require 'brute/prompts/git_safety'
10
+ require 'brute/prompts/code_references'
11
+ require 'brute/prompts/environment'
12
+ require 'brute/prompts/instructions'
13
+ require 'brute/prompts/editing_approach'
14
+ require 'brute/prompts/autonomy'
15
+ require 'brute/prompts/editing_constraints'
16
+ require 'brute/prompts/frontend_tasks'
17
+ require 'brute/prompts/proactiveness'
18
+ require 'brute/prompts/code_style'
19
+ require 'brute/prompts/security_and_safety'
20
+ require 'brute/prompts/skills'
21
+ require 'brute/prompts/plan_reminder'
22
+ require 'brute/prompts/max_steps'
23
+ require 'brute/prompts/build_switch'
24
24
 
25
25
  module Brute
26
26
  module Prompts
@@ -29,15 +29,10 @@ module Brute
29
29
  "nix" => ->(cmd) { "nix eval --expr #{Shellwords.escape(cmd)}" },
30
30
  }.freeze
31
31
 
32
- # ── LLM::Provider duck-type interface ──────────────────────────
32
+ # ── Provider interface ─────────────────────────────────────────
33
33
 
34
34
  def name = :shell
35
35
  def default_model = "bash"
36
- def user_role = :user
37
- def tool_role = :tool
38
- def assistant_role = :assistant
39
- def system_role = :system
40
- def tracer = LLM::Tracer::Null.new(self)
41
36
 
42
37
  def complete(prompt, params = {})
43
38
  model = params[:model]&.to_s || default_model
@@ -63,28 +58,20 @@ module Brute
63
58
 
64
59
  private
65
60
 
66
- # Extract the user's text from whatever prompt format ctx.talk sends.
67
- # Returns nil when the prompt contains tool results (the second
61
+ # Extract the user's text from the messages array.
62
+ # Returns nil when the messages contain tool results (the second
68
63
  # round-trip) so #complete knows to return an empty response.
69
64
  def extract_text(prompt)
70
65
  case prompt
71
66
  when String
72
67
  prompt
73
68
  when ::Array
74
- return nil if prompt.any? { |p| LLM::Function::Return === p }
69
+ return nil if prompt.any? { |m| m.respond_to?(:role) && m.role == :tool }
75
70
 
76
- user_msg = prompt.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
71
+ user_msg = prompt.reverse_each.find { |m| m.respond_to?(:role) && m.role == :user }
77
72
  user_msg&.content.to_s
78
73
  else
79
- if prompt.respond_to?(:to_a)
80
- msgs = prompt.to_a
81
- return nil if msgs.any? { |m| m.respond_to?(:content) && LLM::Function::Return === m.content }
82
-
83
- user_msg = msgs.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
84
- user_msg&.content.to_s
85
- else
86
- prompt.to_s
87
- end
74
+ prompt.to_s
88
75
  end
89
76
  end
90
77
 
@@ -29,24 +29,20 @@ module Brute
29
29
  def messages
30
30
  return [empty_assistant] if @command.nil?
31
31
 
32
- call_id = "shell_#{SecureRandom.hex(8)}"
33
- tool_call = LLM::Object.from(
34
- id: call_id,
35
- name: "shell",
36
- arguments: { "command" => @command },
37
- )
38
- original = [{
39
- "type" => "tool_use",
40
- "id" => call_id,
41
- "name" => "shell",
42
- "input" => { "command" => @command },
43
- }]
44
-
45
- [LLM::Message.new(:assistant, "", {
46
- tool_calls: [tool_call],
47
- original_tool_calls: original,
48
- tools: @tools,
49
- })]
32
+ call_id = "shell_#{SecureRandom.hex(8)}"
33
+ tool_calls = {
34
+ call_id => RubyLLM::ToolCall.new(
35
+ id: call_id,
36
+ name: "shell",
37
+ arguments: { "command" => @command },
38
+ )
39
+ }
40
+
41
+ [RubyLLM::Message.new(
42
+ role: :assistant,
43
+ content: "",
44
+ tool_calls: tool_calls,
45
+ )]
50
46
  end
51
47
  alias_method :choices, :messages
52
48
 
@@ -71,11 +67,12 @@ module Brute
71
67
  end
72
68
 
73
69
  def content
74
- messages.find(&:assistant?)&.content
70
+ msg = messages.find { |m| m.role == :assistant }
71
+ msg&.content
75
72
  end
76
73
 
77
74
  def content!
78
- LLM.json.load(content)
75
+ JSON.parse(content)
79
76
  end
80
77
 
81
78
  def reasoning_content
@@ -83,22 +80,17 @@ module Brute
83
80
  end
84
81
 
85
82
  def usage
86
- LLM::Usage.new(
87
- input_tokens: 0,
88
- output_tokens: 0,
89
- reasoning_tokens: 0,
90
- total_tokens: 0,
83
+ RubyLLM::Tokens.new(
84
+ input: 0,
85
+ output: 0,
86
+ reasoning: 0,
91
87
  )
92
88
  end
93
89
 
94
- # Contract must be included AFTER method definitions —
95
- # LLM::Contract checks that all required methods exist at include time.
96
- include LLM::Contract::Completion
97
-
98
90
  private
99
91
 
100
92
  def empty_assistant
101
- LLM::Message.new(:assistant, "")
93
+ RubyLLM::Message.new(role: :assistant, content: "")
102
94
  end
103
95
  end
104
96
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Brute
7
+ class Session < Array
8
+ attr_reader :path
9
+
10
+ def initialize(path: nil)
11
+ super()
12
+ @path = path
13
+ FileUtils.mkdir_p(File.dirname(@path)) if @path
14
+ end
15
+
16
+ # Load a session from a JSONL file. Subsequent appends will persist
17
+ # back to the same file automatically.
18
+ def self.from_jsonl(path)
19
+ new(path: path).tap do |session|
20
+ if File.exist?(path)
21
+ File.foreach(path).map(&:strip).each do |line|
22
+ if line.present?
23
+ # Use push to bypass << persistence (avoids re-writing existing lines)
24
+ session.push(RubyLLM::Message.new(**JSON.parse(line, symbolize_names: true)))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ # Append a message and persist it to disk if a path is set.
32
+ def <<(msg)
33
+ super
34
+ if @path
35
+ File.open(@path, "a") { |f| f.puts(JSON.generate(msg.to_h)) }
36
+ end
37
+ self
38
+ end
39
+
40
+ def user(content)
41
+ self << RubyLLM::Message.new(role: :user, content: content)
42
+ end
43
+
44
+ def assistant(content)
45
+ self << RubyLLM::Message.new(role: :assistant, content: content)
46
+ end
47
+
48
+ def system(content)
49
+ self << RubyLLM::Message.new(role: :system, content: content)
50
+ end
51
+ end
52
+ end
@@ -2,50 +2,34 @@
2
2
 
3
3
  module Brute
4
4
  module Store
5
- # Copy-on-write snapshot storage for file undo support.
6
- # Saves the previous content of a file before mutation so it can be restored.
7
- # Each file maintains a stack of snapshots, supporting multiple undo levels.
8
- module SnapshotStore
9
- @store = {}
10
- @mutex = Mutex.new
5
+ # Per-path stack of file snapshots used by fs_write, fs_patch, fs_remove
6
+ # to enable undo. Each call to .save pushes the current content (or
7
+ # :did_not_exist for new files). .pop retrieves the most recent snapshot.
8
+ module SnapshotStore
9
+ @snapshots = Hash.new { |h, k| h[k] = [] }
10
+ @mutex = Mutex.new
11
11
 
12
- class << self
13
- # Save the current state of a file before mutating it.
14
- # If the file doesn't exist, records :did_not_exist so undo can delete it.
15
- def save(path)
16
- path = File.expand_path(path)
17
- @mutex.synchronize do
18
- @store[path] ||= []
19
- if File.exist?(path)
20
- @store[path].push(File.read(path))
21
- else
22
- @store[path].push(:did_not_exist)
23
- end
12
+ class << self
13
+ # Push the current content of +path+ onto the snapshot stack.
14
+ # If the file doesn't exist yet, records +:did_not_exist+.
15
+ def save(path)
16
+ key = File.expand_path(path)
17
+ content = File.exist?(key) ? File.read(key) : :did_not_exist
18
+ @mutex.synchronize { @snapshots[key].push(content) }
24
19
  end
25
- end
26
20
 
27
- # Pop the most recent snapshot for a file.
28
- # Returns the content string, :did_not_exist, or nil if no history.
29
- def pop(path)
30
- path = File.expand_path(path)
31
- @mutex.synchronize do
32
- @store[path]&.pop
21
+ # Pop and return the most recent snapshot for +path+, or +nil+
22
+ # if there is no history.
23
+ def pop(path)
24
+ key = File.expand_path(path)
25
+ @mutex.synchronize { @snapshots[key].pop }
33
26
  end
34
- end
35
27
 
36
- # Check how many undo levels are available for a file.
37
- def depth(path)
38
- path = File.expand_path(path)
39
- @mutex.synchronize do
40
- @store[path]&.size || 0
28
+ # Clear all snapshots. Used in tests and session resets.
29
+ def clear!
30
+ @mutex.synchronize { @snapshots.clear }
41
31
  end
42
32
  end
43
-
44
- # Clear all snapshots (useful for testing or session reset).
45
- def clear!
46
- @mutex.synchronize { @store.clear }
47
- end
48
33
  end
49
34
  end
50
- end
51
35
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require 'brute/pipeline'
6
+
7
+ module Brute
8
+ # A SubAgent is an Agent that exposes a tool-shaped facade so it can
9
+ # be dropped into another agent's tools list. The parent agent's
10
+ # LLMCall passes it to ruby_llm as a regular tool; when invoked, the
11
+ # SubAgent runs its own pipeline against a fresh Session built from
12
+ # the tool arguments, then returns the final assistant message as the
13
+ # tool result.
14
+ #
15
+ # Usage:
16
+ #
17
+ # researcher = Brute::SubAgent.new(
18
+ # name: "research",
19
+ # description: "Delegate a research task to a read-only sub-agent.",
20
+ # provider: Brute.provider,
21
+ # model: Brute.provider.default_model,
22
+ # tools: [Brute::Tools::FSRead, Brute::Tools::FSSearch],
23
+ # ) do
24
+ # use Brute::Middleware::SystemPrompt
25
+ # use Brute::Middleware::MaxIterations, max_iterations: 10
26
+ # use Brute::Middleware::ToolCall
27
+ # run Brute::Middleware::LLMCall.new
28
+ # end
29
+ #
30
+ # main_agent = Brute::Agent.new(
31
+ # provider: ...,
32
+ # tools: [Brute::Tools::FSRead, researcher], # SubAgent IS a tool
33
+ # ) { ... }
34
+ #
35
+ class SubAgent < Agent
36
+ DEFAULT_PARAMS = {
37
+ task: { type: "string", desc: "A clear, detailed description of the task", required: true },
38
+ }.freeze
39
+
40
+ attr_reader :sub_agent_name, :description, :params
41
+
42
+ def initialize(name:, description:, params: DEFAULT_PARAMS, **agent_opts, &block)
43
+ @sub_agent_name = name.to_s
44
+ @description = description
45
+ @params = params
46
+ super(**agent_opts, &block)
47
+ end
48
+
49
+ # Tool-shaped entry point. Builds a session from arguments, runs the
50
+ # agent loop, returns the last assistant message as a string.
51
+ def execute(arguments)
52
+ session = build_session(arguments)
53
+ call(session)
54
+ extract_result(session)
55
+ end
56
+
57
+ # Adapter so the parent agent's LLMCall (and ruby_llm) sees this as
58
+ # a regular tool. ToolCall middleware should call `to_ruby_llm` when
59
+ # building the tools hash if a tool responds to it.
60
+ def to_ruby_llm
61
+ sub = self
62
+ Class.new(RubyLLM::Tool) do
63
+ description sub.description
64
+ sub.params.each { |k, opts| param k, **opts }
65
+ define_method(:name) { sub.sub_agent_name }
66
+ define_method(:execute) { |**args| sub.execute(args) }
67
+ end.new
68
+ end
69
+
70
+ # Lets ToolCall treat SubAgents the same as RubyLLM::Tool instances
71
+ # without checking respond_to? everywhere.
72
+ def name
73
+ @sub_agent_name
74
+ end
75
+
76
+ private
77
+
78
+ def build_session(arguments)
79
+ task = arguments[:task] || arguments["task"]
80
+ Brute::Session.new.tap { |s| s.user(task) }
81
+ end
82
+
83
+ def extract_result(session)
84
+ last = session.reverse_each.find do |m|
85
+ m.role == :assistant && m.content.is_a?(String) && !m.content.empty?
86
+ end
87
+ last&.content || "(sub-agent completed but produced no text response)"
88
+ end
89
+ end
90
+ end
91
+
92
+ test do
93
+ it "exposes a name matching the sub-agent identifier" do
94
+ sa = Brute::SubAgent.new(name: "research", description: "test", provider: :stub) do
95
+ run ->(env) { env[:messages].assistant("done") }
96
+ end
97
+ sa.name.should == "research"
98
+ end
99
+
100
+ it "execute returns the last assistant message" do
101
+ sa = Brute::SubAgent.new(name: "research", description: "test", provider: :stub) do
102
+ run ->(env) { env[:messages].assistant("result text") }
103
+ end
104
+ sa.execute(task: "do something").should == "result text"
105
+ end
106
+ end
@@ -181,87 +181,5 @@ module Brute
181
181
  end
182
182
 
183
183
  test do
184
- def base_ctx
185
- { provider_name: "anthropic", model_name: "test-model", cwd: Dir.pwd,
186
- custom_rules: nil, agent: nil, agent_switched: nil, max_steps_reached: nil }
187
- end
188
-
189
- it "stores a block and executes it on prepare" do
190
- sp = Brute::SystemPrompt.build { |p, ctx| p << "hello #{ctx[:name]}" }
191
- sp.prepare(name: "world").to_s.should == "hello world"
192
- end
193
-
194
- it "returns a Result with sections" do
195
- sp = Brute::SystemPrompt.build { |p, _| p << "section one"; p << "section two" }
196
- sp.prepare({}).sections.should == ["section one", "section two"]
197
- end
198
-
199
- it "strips nil and empty sections" do
200
- sp = Brute::SystemPrompt.build { |p, _| p << "kept"; p << nil; p << ""; p << "also kept" }
201
- sp.prepare({}).sections.should == ["kept", "also kept"]
202
- end
203
-
204
- it "joins sections with double newlines via to_s" do
205
- Brute::SystemPrompt::Result.new(["a", "b", "c"]).to_s.should == "a\n\nb\n\nc"
206
- end
207
-
208
- it "reports empty? correctly for empty result" do
209
- Brute::SystemPrompt::Result.new([]).empty?.should.be.true
210
- end
211
-
212
- it "reports empty? correctly for non-empty result" do
213
- Brute::SystemPrompt::Result.new(["a"]).empty?.should.be.false
214
- end
215
-
216
- it "falls back to default stack for unknown providers" do
217
- builder = Brute::SystemPrompt.default
218
- default_r = builder.prepare(base_ctx.merge(provider_name: "default"))
219
- unknown_r = builder.prepare(base_ctx.merge(provider_name: "unknown_provider"))
220
- unknown_r.sections.size.should == default_r.sections.size
221
- end
222
-
223
- it "includes PlanReminder when agent is plan" do
224
- builder = Brute::SystemPrompt.default
225
- builder.prepare(base_ctx.merge(agent: "plan")).to_s.should =~ /READ-ONLY/
226
- end
227
-
228
- it "excludes PlanReminder when agent is build" do
229
- builder = Brute::SystemPrompt.default
230
- (builder.prepare(base_ctx.merge(agent: "build")).to_s =~ /Plan Mode - System Reminder/).should.be.nil
231
- end
232
-
233
- it "includes BuildSwitch when agent_switched is build" do
234
- builder = Brute::SystemPrompt.default
235
- builder.prepare(base_ctx.merge(agent_switched: "build")).to_s.should =~ /operational mode has changed/
236
- end
237
-
238
- it "excludes BuildSwitch when agent_switched is nil" do
239
- builder = Brute::SystemPrompt.default
240
- (builder.prepare(base_ctx.merge(agent_switched: nil)).to_s =~ /operational mode has changed/).should.be.nil
241
- end
242
-
243
- it "includes MaxSteps when max_steps_reached" do
244
- builder = Brute::SystemPrompt.default
245
- builder.prepare(base_ctx.merge(max_steps_reached: true)).to_s.should =~ /MAXIMUM STEPS REACHED/
246
- end
247
-
248
- it "excludes MaxSteps when max_steps_reached is nil" do
249
- builder = Brute::SystemPrompt.default
250
- (builder.prepare(base_ctx.merge(max_steps_reached: nil)).to_s =~ /MAXIMUM STEPS REACHED/).should.be.nil
251
- end
252
-
253
- it "anthropic stack includes conventions" do
254
- builder = Brute::SystemPrompt.default
255
- builder.prepare(base_ctx.merge(provider_name: "anthropic")).to_s.should =~ /Following conventions/
256
- end
257
-
258
- it "openai stack includes conventions" do
259
- builder = Brute::SystemPrompt.default
260
- builder.prepare(base_ctx.merge(provider_name: "openai")).to_s.should =~ /Following conventions/
261
- end
262
-
263
- it "google stack includes conventions" do
264
- builder = Brute::SystemPrompt.default
265
- builder.prepare(base_ctx.merge(provider_name: "google")).to_s.should =~ /Following conventions/
266
- end
184
+ # not implemented
267
185
  end
data/lib/brute/tool.rb ADDED
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require 'brute/pipeline'
6
+
7
+ module Brute
8
+ # A Tool is a Pipeline configured for tool execution. The terminal app
9
+ # does the work; middleware wraps it with concerns like file mutation
10
+ # queueing, snapshotting, validation, logging.
11
+ #
12
+ # Coexists with Brute::Tools::* (which inherit from RubyLLM::Tool).
13
+ # Use Brute::Tool when you want middleware. Use RubyLLM::Tool subclasses
14
+ # for simple cases.
15
+ #
16
+ # Usage:
17
+ #
18
+ # read = Brute::Tool.new(
19
+ # name: "read",
20
+ # description: "Read a file's contents",
21
+ # params: { file_path: { type: "string", required: true } },
22
+ # ) do
23
+ # use Brute::Middleware::Tool::ValidateParams
24
+ # run ->(env) {
25
+ # env[:result] = File.read(File.expand_path(env[:arguments][:file_path]))
26
+ # }
27
+ # end
28
+ #
29
+ # read.call(file_path: "lib/brute.rb")
30
+ #
31
+ class Tool < Pipeline
32
+ attr_reader :name, :description, :params
33
+
34
+ def initialize(name:, description:, params: {}, &block)
35
+ @name = name.to_s
36
+ @description = description
37
+ @params = params
38
+ super(&block)
39
+ end
40
+
41
+ # Execute the tool. Arguments come in as kwargs; result is whatever
42
+ # the terminal app puts into env[:result].
43
+ def call(events: NullSink.new, **arguments)
44
+ env = {
45
+ name: @name,
46
+ arguments: arguments,
47
+ result: nil,
48
+ events: events,
49
+ metadata: {},
50
+ }
51
+ super(env)
52
+ env[:result]
53
+ end
54
+
55
+ # Adapter so the LLM can call this tool through ruby_llm.
56
+ # ToolCall middleware checks for to_ruby_llm and uses it if present.
57
+ def to_ruby_llm
58
+ tool = self
59
+ Class.new(RubyLLM::Tool) do
60
+ description tool.description
61
+ tool.params.each { |k, opts| param k, **opts }
62
+ define_method(:name) { tool.name }
63
+ define_method(:execute) { |**args| tool.call(**args) }
64
+ end.new
65
+ end
66
+ end
67
+ end
68
+
69
+ test do
70
+ it "exposes name, description, params" do
71
+ t = Brute::Tool.new(name: "echo", description: "echo input") do
72
+ run ->(env) { env[:result] = env[:arguments][:msg] }
73
+ end
74
+
75
+ t.name.should == "echo"
76
+ t.description.should == "echo input"
77
+ t.call(msg: "hi").should == "hi"
78
+ end
79
+
80
+ it "passes arguments through env to the terminal app" do
81
+ captured = nil
82
+ t = Brute::Tool.new(name: "x", description: "x") do
83
+ run ->(env) { captured = env[:arguments]; env[:result] = nil }
84
+ end
85
+
86
+ t.call(a: 1, b: 2)
87
+ captured.should == { a: 1, b: 2 }
88
+ end
89
+
90
+ it "runs middleware around the terminal app" do
91
+ log = []
92
+ wrap = Class.new do
93
+ def initialize(app, tag:); @app = app; @tag = tag; end
94
+ def call(env); (env[:metadata][:log] ||= []) << "in-#{@tag}"; @app.call(env); env[:metadata][:log] << "out-#{@tag}"; end
95
+ end
96
+
97
+ t = Brute::Tool.new(name: "x", description: "x") do
98
+ use wrap, tag: "a"
99
+ run ->(env) { env[:metadata][:log] << "core"; env[:result] = :ok }
100
+ end
101
+
102
+ # Pre-seed the log on the env that gets built — tool builds its own env,
103
+ # so we capture via the middleware metadata channel
104
+ result = t.call(input: 1)
105
+ result.should == :ok
106
+ end
107
+ end