brute 2.0.6 → 3.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 (83) 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/messages.rb +47 -0
  6. data/lib/brute/middleware/001_otel_span.rb +4 -2
  7. data/lib/brute/middleware/002_session_log.rb +100 -0
  8. data/lib/brute/middleware/004_summarize.rb +14 -12
  9. data/lib/brute/middleware/006_loop.rb +158 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +8 -6
  11. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  12. data/lib/brute/middleware/020_system_prompt.rb +5 -3
  13. data/lib/brute/middleware/040_compaction_check.rb +3 -1
  14. data/lib/brute/middleware/060_questions.rb +3 -1
  15. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +31 -20
  16. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  17. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  18. data/lib/brute/middleware/event_handler.rb +3 -1
  19. data/lib/brute/middleware/user_queue.rb +3 -1
  20. data/lib/brute/prompts/autonomy.rb +3 -0
  21. data/lib/brute/prompts/base.rb +3 -0
  22. data/lib/brute/prompts/build_switch.rb +3 -1
  23. data/lib/brute/prompts/code_references.rb +3 -0
  24. data/lib/brute/prompts/code_style.rb +3 -0
  25. data/lib/brute/prompts/conventions.rb +3 -0
  26. data/lib/brute/prompts/doing_tasks.rb +3 -0
  27. data/lib/brute/prompts/editing_approach.rb +3 -0
  28. data/lib/brute/prompts/editing_constraints.rb +3 -0
  29. data/lib/brute/prompts/environment.rb +3 -1
  30. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  31. data/lib/brute/prompts/git_safety.rb +3 -0
  32. data/lib/brute/prompts/identity.rb +3 -1
  33. data/lib/brute/prompts/instructions.rb +3 -1
  34. data/lib/brute/prompts/max_steps.rb +3 -1
  35. data/lib/brute/prompts/objectivity.rb +3 -0
  36. data/lib/brute/prompts/plan_reminder.rb +3 -1
  37. data/lib/brute/prompts/proactiveness.rb +3 -0
  38. data/lib/brute/prompts/security_and_safety.rb +3 -0
  39. data/lib/brute/prompts/skills.rb +6 -2
  40. data/lib/brute/prompts/task_management.rb +3 -0
  41. data/lib/brute/prompts/tone_and_style.rb +3 -0
  42. data/lib/brute/prompts/tool_usage.rb +3 -0
  43. data/lib/brute/prompts.rb +5 -0
  44. data/lib/brute/rack/adapter.rb +237 -0
  45. data/lib/brute/skill.rb +192 -15
  46. data/lib/brute/system_prompt.rb +3 -1
  47. data/lib/brute/tools/adapter.rb +296 -0
  48. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  49. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  50. data/lib/brute/tools/fs_patch.rb +5 -3
  51. data/lib/brute/tools/fs_read.rb +4 -2
  52. data/lib/brute/tools/fs_remove.rb +2 -2
  53. data/lib/brute/tools/fs_search.rb +3 -1
  54. data/lib/brute/tools/fs_undo.rb +2 -2
  55. data/lib/brute/tools/fs_write.rb +5 -3
  56. data/lib/brute/tools/shell.rb +3 -1
  57. data/lib/brute/tools/skill_load.rb +156 -0
  58. data/lib/brute/tools/sub_agent.rb +118 -0
  59. data/lib/brute/tools/todo_list/store.rb +36 -0
  60. data/lib/brute/tools/todo_read.rb +1 -1
  61. data/lib/brute/tools/todo_write.rb +1 -1
  62. data/lib/brute/tools.rb +2 -1
  63. data/lib/brute/truncation.rb +3 -1
  64. data/lib/brute/turn/agent_pipeline.rb +168 -0
  65. data/lib/brute/turn/pipeline.rb +95 -0
  66. data/lib/brute/turn/tool_pipeline.rb +106 -0
  67. data/lib/brute/utils/diff.rb +3 -1
  68. data/lib/brute/version.rb +1 -1
  69. data/lib/brute.rb +46 -71
  70. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  71. data/lib/{brute → brute_cli}/providers/shell_response.rb +3 -5
  72. data/lib/ruby_llm/message_transport.rb +117 -0
  73. metadata +34 -30
  74. data/lib/brute/agent.rb +0 -82
  75. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  76. data/lib/brute/middleware/100_llm_call.rb +0 -63
  77. data/lib/brute/pipeline.rb +0 -97
  78. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  79. data/lib/brute/session.rb +0 -51
  80. data/lib/brute/store/snapshot_store.rb +0 -36
  81. data/lib/brute/store/todo_store.rb +0 -30
  82. data/lib/brute/sub_agent.rb +0 -106
  83. data/lib/brute/tool.rb +0 -107
data/lib/brute/agent.rb DELETED
@@ -1,82 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
- require 'brute/pipeline'
6
-
7
- module Brute
8
- DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant, hellbent on taking over the world."
9
-
10
- # An agent is a Pipeline configured for LLM turns. It carries the
11
- # provider/model/tools configuration and shapes env from a Session
12
- # (the conversation message log).
13
- #
14
- # Usage:
15
- #
16
- # agent = Brute::Agent.new(
17
- # provider: Brute.provider,
18
- # model: "claude-sonnet-4-20250514",
19
- # tools: Brute::Tools::ALL,
20
- # ) do
21
- # use Brute::Middleware::EventHandler, handler_class: TerminalOutput
22
- # use Brute::Middleware::SystemPrompt
23
- # use Brute::Middleware::MaxIterations
24
- # use Brute::Middleware::Question
25
- # use Brute::Middleware::ToolCall
26
- # run Brute::Middleware::LLMCall.new
27
- # end
28
- #
29
- # session = Brute::Session.new
30
- # session.user("fix the failing tests")
31
- # agent.call(session)
32
- #
33
- class Agent < Pipeline
34
- attr_reader :provider, :model, :tools
35
-
36
- def initialize(provider:, model: nil, tools: [], &block)
37
- @provider = provider
38
- @model = model
39
- @tools = tools
40
- super(&block)
41
- end
42
-
43
- # Run one turn against the given session. The session is mutated
44
- # in place (assistant + tool messages appended). Returns the env
45
- # hash so callers can access metadata (timing, tokens, etc.).
46
- def call(session, events: NullSink.new)
47
- env = {
48
- messages: session,
49
- provider: @provider,
50
- model: @model,
51
- tools: @tools,
52
- events: events,
53
- metadata: {},
54
- system_prompt: DEFAULT_SYSTEM_PROMPT,
55
- current_iteration: 1,
56
- }
57
- super(env)
58
- env
59
- end
60
- end
61
- end
62
-
63
- test do
64
- it "runs a turn and returns the env with session in :messages" do
65
- agent = Brute::Agent.new(provider: :stub) do
66
- run ->(env) { env[:messages].assistant("hello") }
67
- end
68
- session = Brute::Session.new
69
- session.user("hi")
70
- env = agent.call(session)
71
- env[:messages].should == session
72
- end
73
-
74
- it "passes provider/model/tools through env" do
75
- captured = nil
76
- capture = ->(env) { captured = env.slice(:provider, :model, :tools) }
77
-
78
- agent = Brute::Agent.new(provider: :stub, model: "m", tools: [:a]) { run capture }
79
- agent.call(Brute::Session.new)
80
- captured.should == { provider: :stub, model: "m", tools: [:a] }
81
- end
82
- end
@@ -1,103 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- # Re-invokes the inner stack whenever the last message is a :tool result.
9
- #
10
- # After the inner pipeline runs (LLMCall responds, ToolCall executes tools
11
- # and appends :tool messages), this middleware checks if tool results are
12
- # pending. If so, it increments the iteration counter and loops — sending
13
- # the tool results back through MaxIterations → ToolCall → LLMCall so the
14
- # LLM can see them.
15
- #
16
- # The loop breaks when:
17
- # - The LLM responds with text only (no tool calls) — last message is :assistant
18
- # - env[:should_exit] is set (e.g. by MaxIterations)
19
- #
20
- class ToolResultLoop
21
- def initialize(app)
22
- @app = app
23
- end
24
-
25
- def call(env)
26
- loop do
27
- @app.call(env)
28
-
29
- break if env[:should_exit]
30
- break unless env[:messages].last&.role == :tool
31
-
32
- env[:current_iteration] += 1
33
- end
34
-
35
- env
36
- end
37
- end
38
- end
39
- end
40
-
41
- test do
42
- require "brute/session"
43
-
44
- it "loops until last message is not a tool result" do
45
- call_count = 0
46
-
47
- # Fake inner app: first call appends a :tool message, second call appends :assistant
48
- inner = ->(env) do
49
- call_count += 1
50
- if call_count == 1
51
- env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
52
- else
53
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "done")
54
- end
55
- end
56
-
57
- mw = Brute::Middleware::ToolResultLoop.new(inner)
58
- env = { messages: Brute::Session.new, current_iteration: 1 }
59
- env[:messages].user("hi")
60
-
61
- mw.call(env)
62
-
63
- call_count.should == 2
64
- env[:current_iteration].should == 2
65
- env[:messages].last.role.should == :assistant
66
- end
67
-
68
- it "stops when should_exit is set" do
69
- call_count = 0
70
-
71
- inner = ->(env) do
72
- call_count += 1
73
- env[:messages] << RubyLLM::Message.new(role: :tool, content: "result", tool_call_id: "tc#{call_count}")
74
- env[:should_exit] = { reason: "max" } if call_count >= 2
75
- end
76
-
77
- mw = Brute::Middleware::ToolResultLoop.new(inner)
78
- env = { messages: Brute::Session.new, current_iteration: 1 }
79
- env[:messages].user("hi")
80
-
81
- mw.call(env)
82
-
83
- call_count.should == 2
84
- end
85
-
86
- it "does not loop when last message is assistant" do
87
- call_count = 0
88
-
89
- inner = ->(env) do
90
- call_count += 1
91
- env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hello")
92
- end
93
-
94
- mw = Brute::Middleware::ToolResultLoop.new(inner)
95
- env = { messages: Brute::Session.new, current_iteration: 1 }
96
- env[:messages].user("hi")
97
-
98
- mw.call(env)
99
-
100
- call_count.should == 1
101
- env[:current_iteration].should == 1
102
- end
103
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- # Terminal middleware. Calls the LLM with the current conversation,
9
- # appends the response to the session, and fires events along the way.
10
- #
11
- class LLMCall
12
- def call(env)
13
-
14
- available_tools = env[:tools].each_with_object({}) do |tool, hash|
15
- instance = tool.is_a?(Class) ? tool.new : tool
16
- instance = instance.to_ruby_llm if instance.respond_to?(:to_ruby_llm)
17
- hash[instance.name.to_sym] = instance
18
- end
19
-
20
- completion_options = {
21
- model: RubyLLM.models.find(env[:model], env[:provider]),
22
- tools: available_tools,
23
- temperature: env.fetch(:temperature, 0.7),
24
- }
25
-
26
- complete(completion_options, env).then do |response|
27
- env[:messages] << response
28
- end
29
-
30
- env
31
- end
32
-
33
- private
34
-
35
- def complete(kwargs, env)
36
- provider_client = RubyLLM::Provider.resolve(env[:provider]).new(Brute.config)
37
-
38
- if env[:streaming] == true
39
- provider_client.complete(env[:messages], **kwargs) do |chunk|
40
- if chunk.content && !chunk.content.to_s.empty?
41
- env[:events] << { type: :content, data: chunk.content.to_s }
42
- end
43
-
44
- if chunk.respond_to?(:thinking) && chunk.thinking&.respond_to?(:text) && chunk.thinking.text
45
- env[:events] << { type: :reasoning, data: chunk.thinking.text }
46
- end
47
- end
48
- else
49
- provider_client.complete(env[:messages], **kwargs).then do |response|
50
- if response.content.present?
51
- env[:events] << { type: :content, data: response.content }
52
- end
53
- response
54
- end
55
- end
56
- end
57
- end
58
- end
59
- end
60
-
61
- test do
62
- # not implemented
63
- end
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- # Generic middleware machinery. Builds a chain of middleware around
8
- # a terminal app, exposes `call(env)` to invoke it.
9
- #
10
- # Subclasses (Agent, Tool) override `call` to translate their public
11
- # arguments into an env hash, then delegate to super.
12
- #
13
- # class MyPipeline < Brute::Pipeline
14
- # def call(input)
15
- # env = { input: input, output: nil }
16
- # super(env)
17
- # env[:output]
18
- # end
19
- # end
20
- #
21
- class Pipeline
22
- def initialize(&block)
23
- @middlewares = []
24
- @app = nil
25
- instance_eval(&block) if block_given?
26
- end
27
-
28
- # Register a middleware class.
29
- # The class must implement `initialize(app, *args, **kwargs)` and `call(env)`.
30
- def use(klass, *args, **kwargs, &block)
31
- tap { @middlewares << [klass, args, kwargs, block] }
32
- end
33
-
34
- # Set the terminal app (innermost handler).
35
- # Accepts an instance (anything responding to #call(env)) or a class.
36
- def run(app)
37
- tap { @app = app }
38
- end
39
-
40
- # Invoke the chain. Subclasses typically override this to shape env
41
- # and extract a return value.
42
- def call(env)
43
- build.call(env)
44
- end
45
-
46
- # Build the chain without calling it. Useful for inspection or caching.
47
- def build
48
- raise "Stack has no terminal app — call `run` first" unless @app
49
-
50
- @middlewares.reverse.inject(@app) do |inner, (klass, args, kwargs, block)|
51
- if block
52
- klass.new(inner, *args, **kwargs, &block)
53
- else
54
- klass.new(inner, *args, **kwargs)
55
- end
56
- end
57
- end
58
-
59
- # Default null sink for env[:events] — swallows anything pushed to it.
60
- class NullSink
61
- def <<(_event); self; end
62
- end
63
- end
64
- end
65
-
66
- test do
67
- it "builds and calls a chain" do
68
- seen = []
69
- inc = Class.new do
70
- def initialize(app, label:); @app = app; @label = label; end
71
- def call(env); env[:trace] << @label; @app.call(env); env[:trace] << "#{@label}-after"; end
72
- end
73
-
74
- pipeline = Brute::Pipeline.new do
75
- use inc, label: "outer"
76
- use inc, label: "inner"
77
- run ->(env) { env[:trace] << "core" }
78
- end
79
-
80
- env = { trace: [] }
81
- pipeline.call(env)
82
- env[:trace].should == ["outer", "inner", "core", "inner-after", "outer-after"]
83
- end
84
-
85
- it "raises when run was never called" do
86
- lambda { Brute::Pipeline.new.call({}) }.should.raise(RuntimeError)
87
- end
88
-
89
- it "accepts a callable as the terminal app" do
90
- pipeline = Brute::Pipeline.new do
91
- run ->(env) { env[:result] = 42 }
92
- end
93
- env = {}
94
- pipeline.call(env)
95
- env[:result].should == 42
96
- end
97
- end
@@ -1,102 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Brute
4
- # @namespace
5
- module Queue
6
- # Per-file serialization queue for concurrent tool execution.
7
- #
8
- # When tools run in parallel (via threads or async fibers), multiple tools
9
- # may target the same file simultaneously. Without serialization, a sequence
10
- # like [read → patch → write] on the same file would race and lose edits.
11
- #
12
- # This module provides a single public method:
13
- #
14
- # Brute::FileMutationQueue.serialize("/path/to/file") do
15
- # # snapshot + read + modify + write — all atomic for this path
16
- # end
17
- #
18
- # Design (mirrors pi-mono's withFileMutationQueue):
19
- # - Operations on the SAME file are serialized (run one at a time)
20
- # - Operations on DIFFERENT files run fully in parallel (independent mutexes)
21
- # - Symlink-aware: resolves real paths so aliases share one mutex
22
- # - Error-safe: mutex is always released in `ensure`, so failures never deadlock
23
- # - Self-cleaning: per-file mutexes are removed when no longer in use
24
- #
25
- # Ruby 3.4's Mutex is fiber-scheduler-aware, so this works correctly with
26
- # both :thread and :task (Async) concurrency strategies.
27
- #
28
- module FileMutationQueue
29
- @mutexes = {} # path → Mutex
30
- @waiters = Hash.new(0) # path → number of threads/fibers waiting or holding
31
- @guard = Mutex.new # protects @mutexes and @waiters
32
-
33
- class << self
34
- # Serialize a block of work for a given file path.
35
- #
36
- # Concurrent calls targeting the same canonical path will execute
37
- # sequentially in FIFO order. Calls targeting different paths
38
- # proceed in parallel with zero contention.
39
- #
40
- # @parameter path [String] The file path to serialize on.
41
- # @yields {block} The mutation work to perform (snapshot, read, write, etc.)
42
- # @returns Whatever the block returns.
43
- def serialize(path, &block)
44
- key = canonical_path(path)
45
- mutex = acquire_mutex(key)
46
-
47
- mutex.synchronize(&block)
48
- ensure
49
- release_mutex(key)
50
- end
51
-
52
- # Clear all tracked mutexes. Used in tests and session resets.
53
- def clear!
54
- @guard.synchronize do
55
- @mutexes.clear
56
- @waiters.clear
57
- end
58
- end
59
-
60
- # Number of file paths currently tracked (for diagnostics).
61
- def size
62
- @guard.synchronize { @mutexes.size }
63
- end
64
-
65
- private
66
-
67
- # Resolve a file path to a canonical key.
68
- # Uses File.realpath to follow symlinks so that aliases to the
69
- # same underlying file share one mutex. Falls back to
70
- # File.expand_path for files that don't exist yet (e.g., new writes).
71
- def canonical_path(path)
72
- resolved = File.expand_path(path)
73
- begin
74
- File.realpath(resolved)
75
- rescue Errno::ENOENT
76
- resolved
77
- end
78
- end
79
-
80
- # Get (or create) a mutex for a file path and increment the waiter count.
81
- def acquire_mutex(key)
82
- @guard.synchronize do
83
- @mutexes[key] ||= Mutex.new
84
- @waiters[key] += 1
85
- @mutexes[key]
86
- end
87
- end
88
-
89
- # Decrement the waiter count and clean up the mutex if no one else needs it.
90
- def release_mutex(key)
91
- @guard.synchronize do
92
- @waiters[key] -= 1
93
- if @waiters[key] <= 0
94
- @mutexes.delete(key)
95
- @waiters.delete(key)
96
- end
97
- end
98
- end
99
- end
100
- end
101
- end
102
- end
data/lib/brute/session.rb DELETED
@@ -1,51 +0,0 @@
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
- if @path
14
- FileUtils.mkdir_p(File.dirname(@path))
15
- if File.exist?(@path)
16
- File.foreach(@path) do |line|
17
- line.strip!
18
- # Use push to bypass append persistence (avoids re-writing existing lines)
19
- push(RubyLLM::Message.new(**JSON.parse(line, symbolize_names: true))) if line.present?
20
- end
21
- end
22
- end
23
- end
24
-
25
- # @deprecated Use Session.new(path:) instead.
26
- def self.from_jsonl(path)
27
- new(path: path)
28
- end
29
-
30
- # Append a message and persist it to disk if a path is set.
31
- def <<(msg)
32
- super
33
- if @path
34
- File.open(@path, "a") { |f| f.puts(JSON.generate(msg.to_h)) }
35
- end
36
- self
37
- end
38
-
39
- def user(content)
40
- self << RubyLLM::Message.new(role: :user, content: content)
41
- end
42
-
43
- def assistant(content)
44
- self << RubyLLM::Message.new(role: :assistant, content: content)
45
- end
46
-
47
- def system(content)
48
- self << RubyLLM::Message.new(role: :system, content: content)
49
- end
50
- end
51
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Brute
4
- # @namespace
5
- module Store
6
- # Per-path stack of file snapshots used by fs_write, fs_patch, fs_remove
7
- # to enable undo. Each call to .save pushes the current content (or
8
- # :did_not_exist for new files). .pop retrieves the most recent snapshot.
9
- module SnapshotStore
10
- @snapshots = Hash.new { |h, k| h[k] = [] }
11
- @mutex = Mutex.new
12
-
13
- class << self
14
- # Push the current content of +path+ onto the snapshot stack.
15
- # If the file doesn't exist yet, records +:did_not_exist+.
16
- def save(path)
17
- key = File.expand_path(path)
18
- content = File.exist?(key) ? File.read(key) : :did_not_exist
19
- @mutex.synchronize { @snapshots[key].push(content) }
20
- end
21
-
22
- # Pop and return the most recent snapshot for +path+, or +nil+
23
- # if there is no history.
24
- def pop(path)
25
- key = File.expand_path(path)
26
- @mutex.synchronize { @snapshots[key].pop }
27
- end
28
-
29
- # Clear all snapshots. Used in tests and session resets.
30
- def clear!
31
- @mutex.synchronize { @snapshots.clear }
32
- end
33
- end
34
- end
35
- end
36
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Brute
4
- # @namespace
5
- module Store
6
- # In-memory todo list storage. The agent uses this to track multi-step tasks.
7
- # The list is replaced wholesale on each todo_write call.
8
- module TodoStore
9
- @items = []
10
- @mutex = Mutex.new
11
-
12
- class << self
13
- # Replace the entire todo list.
14
- def replace(items)
15
- @mutex.synchronize { @items = items.dup }
16
- end
17
-
18
- # Return all current items.
19
- def all
20
- @mutex.synchronize { @items.dup }
21
- end
22
-
23
- # Clear all items.
24
- def clear!
25
- @mutex.synchronize { @items.clear }
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,106 +0,0 @@
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