brute 0.4.0 → 1.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +24 -0
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +70 -23
  11. data/lib/brute/middleware/doom_loop_detection.rb +110 -7
  12. data/lib/brute/middleware/llm_call.rb +88 -1
  13. data/lib/brute/middleware/message_tracking.rb +140 -10
  14. data/lib/brute/middleware/otel/span.rb +32 -2
  15. data/lib/brute/middleware/otel/token_usage.rb +38 -0
  16. data/lib/brute/middleware/otel/tool_calls.rb +30 -1
  17. data/lib/brute/middleware/otel/tool_results.rb +29 -1
  18. data/lib/brute/middleware/otel.rb +5 -0
  19. data/lib/brute/middleware/reasoning_normalizer.rb +94 -0
  20. data/lib/brute/middleware/retry.rb +113 -1
  21. data/lib/brute/middleware/session_persistence.rb +46 -3
  22. data/lib/brute/middleware/token_tracking.rb +78 -0
  23. data/lib/brute/middleware/tool_error_tracking.rb +128 -1
  24. data/lib/brute/middleware/tool_use_guard.rb +64 -28
  25. data/lib/brute/middleware/tracing.rb +63 -2
  26. data/lib/brute/middleware.rb +18 -0
  27. data/lib/brute/orchestrator/turn.rb +105 -0
  28. data/lib/brute/patches/buffer_nil_guard.rb +5 -0
  29. data/lib/brute/pipeline.rb +86 -7
  30. data/lib/brute/prompts/build_switch.rb +29 -0
  31. data/lib/brute/prompts/environment.rb +43 -0
  32. data/lib/brute/prompts/identity.rb +29 -0
  33. data/lib/brute/prompts/instructions.rb +21 -0
  34. data/lib/brute/prompts/max_steps.rb +25 -0
  35. data/lib/brute/prompts/plan_reminder.rb +25 -0
  36. data/lib/brute/prompts/skills.rb +13 -0
  37. data/lib/brute/prompts.rb +28 -0
  38. data/lib/brute/providers/ollama.rb +135 -0
  39. data/lib/brute/providers/opencode_go.rb +5 -0
  40. data/lib/brute/providers/opencode_zen.rb +7 -2
  41. data/lib/brute/providers/shell.rb +2 -2
  42. data/lib/brute/providers/shell_response.rb +7 -2
  43. data/lib/brute/providers.rb +62 -0
  44. data/lib/brute/queue/base_queue.rb +222 -0
  45. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  46. data/lib/brute/queue/parallel_queue.rb +66 -0
  47. data/lib/brute/queue/sequential_queue.rb +63 -0
  48. data/lib/brute/{message_store.rb → store/message_store.rb} +155 -62
  49. data/lib/brute/store/session.rb +106 -0
  50. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  51. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  52. data/lib/brute/system_prompt.rb +101 -0
  53. data/lib/brute/tools/delegate.rb +59 -0
  54. data/lib/brute/tools/fs_patch.rb +54 -2
  55. data/lib/brute/tools/fs_read.rb +5 -0
  56. data/lib/brute/tools/fs_remove.rb +7 -2
  57. data/lib/brute/tools/fs_search.rb +5 -0
  58. data/lib/brute/tools/fs_undo.rb +7 -2
  59. data/lib/brute/tools/fs_write.rb +40 -2
  60. data/lib/brute/tools/net_fetch.rb +5 -0
  61. data/lib/brute/tools/question.rb +5 -0
  62. data/lib/brute/tools/shell.rb +5 -0
  63. data/lib/brute/tools/todo_read.rb +6 -1
  64. data/lib/brute/tools/todo_write.rb +6 -1
  65. data/lib/brute/tools.rb +31 -0
  66. data/lib/brute/version.rb +1 -1
  67. data/lib/brute.rb +40 -204
  68. metadata +31 -20
  69. data/lib/brute/agent_stream.rb +0 -63
  70. data/lib/brute/hooks.rb +0 -84
  71. data/lib/brute/orchestrator.rb +0 -391
  72. data/lib/brute/session.rb +0 -161
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brute
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -52,33 +52,33 @@ dependencies:
52
52
  - !ruby/object:Gem::Version
53
53
  version: '4.11'
54
54
  - !ruby/object:Gem::Dependency
55
- name: rake
55
+ name: scampi
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
- - - "~>"
58
+ - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '13.0'
61
- type: :development
60
+ version: '0'
61
+ type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
- - - "~>"
65
+ - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '13.0'
67
+ version: '0'
68
68
  - !ruby/object:Gem::Dependency
69
- name: rspec
69
+ name: rake
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '3.13'
74
+ version: '13.0'
75
75
  type: :development
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '3.13'
81
+ version: '13.0'
82
82
  description: Production-grade coding agent with tool execution, middleware pipeline,
83
83
  context compaction, session persistence, and multi-provider LLM support.
84
84
  executables: []
@@ -86,13 +86,15 @@ extensions: []
86
86
  extra_rdoc_files: []
87
87
  files:
88
88
  - lib/brute.rb
89
- - lib/brute/agent_stream.rb
90
- - lib/brute/compactor.rb
89
+ - lib/brute/agent.rb
91
90
  - lib/brute/diff.rb
92
- - lib/brute/doom_loop.rb
93
- - lib/brute/file_mutation_queue.rb
94
- - lib/brute/hooks.rb
95
- - lib/brute/message_store.rb
91
+ - lib/brute/loop/agent_stream.rb
92
+ - lib/brute/loop/agent_turn.rb
93
+ - lib/brute/loop/compactor.rb
94
+ - lib/brute/loop/doom_loop.rb
95
+ - lib/brute/loop/step.rb
96
+ - lib/brute/loop/tool_call_step.rb
97
+ - lib/brute/middleware.rb
96
98
  - lib/brute/middleware/base.rb
97
99
  - lib/brute/middleware/compaction_check.rb
98
100
  - lib/brute/middleware/doom_loop_detection.rb
@@ -110,10 +112,11 @@ files:
110
112
  - lib/brute/middleware/tool_error_tracking.rb
111
113
  - lib/brute/middleware/tool_use_guard.rb
112
114
  - lib/brute/middleware/tracing.rb
113
- - lib/brute/orchestrator.rb
115
+ - lib/brute/orchestrator/turn.rb
114
116
  - lib/brute/patches/anthropic_tool_role.rb
115
117
  - lib/brute/patches/buffer_nil_guard.rb
116
118
  - lib/brute/pipeline.rb
119
+ - lib/brute/prompts.rb
117
120
  - lib/brute/prompts/autonomy.rb
118
121
  - lib/brute/prompts/base.rb
119
122
  - lib/brute/prompts/build_switch.rb
@@ -155,16 +158,24 @@ files:
155
158
  - lib/brute/prompts/text/tool_usage/google.txt
156
159
  - lib/brute/prompts/tone_and_style.rb
157
160
  - lib/brute/prompts/tool_usage.rb
161
+ - lib/brute/providers.rb
158
162
  - lib/brute/providers/models_dev.rb
163
+ - lib/brute/providers/ollama.rb
159
164
  - lib/brute/providers/opencode_go.rb
160
165
  - lib/brute/providers/opencode_zen.rb
161
166
  - lib/brute/providers/shell.rb
162
167
  - lib/brute/providers/shell_response.rb
163
- - lib/brute/session.rb
168
+ - lib/brute/queue/base_queue.rb
169
+ - lib/brute/queue/file_mutation_queue.rb
170
+ - lib/brute/queue/parallel_queue.rb
171
+ - lib/brute/queue/sequential_queue.rb
164
172
  - lib/brute/skill.rb
165
- - lib/brute/snapshot_store.rb
173
+ - lib/brute/store/message_store.rb
174
+ - lib/brute/store/session.rb
175
+ - lib/brute/store/snapshot_store.rb
176
+ - lib/brute/store/todo_store.rb
166
177
  - lib/brute/system_prompt.rb
167
- - lib/brute/todo_store.rb
178
+ - lib/brute/tools.rb
168
179
  - lib/brute/tools/delegate.rb
169
180
  - lib/brute/tools/fs_patch.rb
170
181
  - lib/brute/tools/fs_read.rb
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Brute
4
- # Bridges llm.rb's streaming callbacks to the host application.
5
- #
6
- # Text and reasoning chunks fire immediately as the LLM generates them.
7
- # Tool calls are collected but NOT executed — execution is deferred to the
8
- # orchestrator after the stream completes. This ensures text is never
9
- # concurrent with tool execution.
10
- #
11
- # After the stream finishes, the orchestrator reads +pending_tools+ to
12
- # dispatch all tool calls concurrently, then fires +on_tool_call_start+
13
- # once with the full batch.
14
- #
15
- class AgentStream < LLM::Stream
16
- # Tool call metadata recorded during streaming, used by ToolUseGuard
17
- # when ctx.functions is empty (nil-choice bug in llm.rb).
18
- attr_reader :pending_tool_calls
19
-
20
- # Deferred tool/error pairs: [(LLM::Function, error_or_nil), ...]
21
- # The orchestrator reads these after the stream completes.
22
- attr_reader :pending_tools
23
-
24
- def initialize(on_content: nil, on_reasoning: nil, on_question: nil)
25
- @on_content = on_content
26
- @on_reasoning = on_reasoning
27
- @on_question = on_question
28
- @pending_tool_calls = []
29
- @pending_tools = []
30
- end
31
-
32
- # The on_question callback, needed by the orchestrator to set
33
- # thread/fiber-locals before tool execution.
34
- attr_reader :on_question
35
-
36
- def on_content(text)
37
- @on_content&.call(text)
38
- end
39
-
40
- def on_reasoning_content(text)
41
- @on_reasoning&.call(text)
42
- end
43
-
44
- # Called by llm.rb per tool as it arrives during streaming.
45
- # Records only — no execution, no threads, no queue pushes.
46
- def on_tool_call(tool, error)
47
- @pending_tool_calls << { id: tool.id, name: tool.name, arguments: tool.arguments }
48
- @pending_tools << [tool, error]
49
- end
50
-
51
- # Clear only the tool call metadata (used by ToolUseGuard after it
52
- # has consumed the data for synthetic message injection).
53
- def clear_pending_tool_calls!
54
- @pending_tool_calls.clear
55
- end
56
-
57
- # Clear the deferred execution queue after the orchestrator has
58
- # consumed and dispatched all tool calls.
59
- def clear_pending_tools!
60
- @pending_tools.clear
61
- end
62
- end
63
- end
data/lib/brute/hooks.rb DELETED
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Brute
4
- # Lifecycle hook system modeled after forgecode's Hook struct.
5
- #
6
- # Six lifecycle events fire during the orchestrator loop:
7
- # :start — conversation processing begins
8
- # :end — conversation processing ends
9
- # :request — before each LLM API call
10
- # :response — after each LLM response
11
- # :toolcall_start — before a tool executes
12
- # :toolcall_end — after a tool executes
13
- #
14
- # Hooks receive (event_name, context_hash) and can inspect or mutate
15
- # the orchestrator state via the context hash.
16
- module Hooks
17
- # Base class. Subclass and override #on_<event> methods.
18
- class Base
19
- def call(event, **data)
20
- method_name = :"on_#{event}"
21
- send(method_name, **data) if respond_to?(method_name, true)
22
- end
23
-
24
- private
25
-
26
- def on_start(**) = nil
27
- def on_end(**) = nil
28
- def on_request(**) = nil
29
- def on_response(**) = nil
30
- def on_toolcall_start(**) = nil
31
- def on_toolcall_end(**) = nil
32
- end
33
-
34
- # Composes multiple hooks into one, firing them in order.
35
- class Composite < Base
36
- def initialize(*hooks)
37
- @hooks = hooks
38
- end
39
-
40
- def call(event, **data)
41
- @hooks.each { |h| h.call(event, **data) }
42
- end
43
-
44
- def <<(hook)
45
- @hooks << hook
46
- self
47
- end
48
- end
49
-
50
- # Logs lifecycle events to a logger.
51
- class Logging < Base
52
- def initialize(logger)
53
- @logger = logger
54
- end
55
-
56
- private
57
-
58
- def on_start(**)
59
- @logger.info("[brute] Conversation started")
60
- end
61
-
62
- def on_end(**)
63
- @logger.info("[brute] Conversation ended")
64
- end
65
-
66
- def on_request(request_count: 0, **)
67
- @logger.debug("[brute] LLM request ##{request_count}")
68
- end
69
-
70
- def on_response(tokens: nil, **)
71
- @logger.debug("[brute] LLM response (tokens: #{tokens || "?"})")
72
- end
73
-
74
- def on_toolcall_start(tool_name: nil, **)
75
- @logger.info("[brute] Tool call: #{tool_name}")
76
- end
77
-
78
- def on_toolcall_end(tool_name: nil, error: false, **)
79
- status = error ? "FAILED" : "ok"
80
- @logger.info("[brute] Tool result: #{tool_name} [#{status}]")
81
- end
82
- end
83
- end
84
- end
@@ -1,391 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async"
4
- require "async/barrier"
5
-
6
- module Brute
7
- # The core agent loop. Drives the cycle of:
8
- #
9
- # prompt → LLM → tool calls → execute → send results → repeat
10
- #
11
- # All cross-cutting concerns (retry, compaction, doom loop detection,
12
- # token tracking, session persistence, tracing, reasoning) are implemented
13
- # as Rack-style middleware in the Pipeline. The orchestrator is now a
14
- # thin loop that:
15
- #
16
- # 1. Sends input through the pipeline (which wraps the LLM call)
17
- # 2. Executes any tool calls the LLM requested
18
- # 3. Repeats until done or a limit is hit
19
- #
20
- # Tool execution is always deferred until after the LLM response (including
21
- # streaming) completes. Tools then run concurrently with each other via
22
- # Async::Barrier. on_tool_call_start fires once with the full batch before
23
- # execution begins; on_tool_result fires per-tool as each finishes.
24
- #
25
- class Orchestrator
26
- MAX_REQUESTS_PER_TURN = 100
27
-
28
- attr_reader :context, :session, :pipeline, :env, :barrier, :message_store
29
-
30
- def initialize(
31
- provider:,
32
- model: nil,
33
- tools: Brute::TOOLS,
34
- cwd: Dir.pwd,
35
- session: nil,
36
- compactor_opts: {},
37
- reasoning: {},
38
- agent_name: nil,
39
- on_content: nil,
40
- on_reasoning: nil,
41
- on_tool_call_start: nil,
42
- on_tool_result: nil,
43
- on_question: nil,
44
- logger: nil
45
- )
46
- @provider = provider
47
- @model = model
48
- @agent_name = agent_name
49
- @tool_classes = tools
50
- @cwd = cwd
51
- @session = session || Session.new
52
- @logger = logger || Logger.new($stderr, level: Logger::INFO)
53
- @message_store = @session.message_store
54
-
55
- # Build system prompt via deferred builder
56
- @system_prompt_builder = SystemPrompt.default
57
- @system_prompt = @system_prompt_builder.prepare(
58
- provider_name: @provider&.name,
59
- model_name: @model || @provider&.default_model,
60
- cwd: @cwd,
61
- custom_rules: load_custom_rules,
62
- agent: @agent_name,
63
- ).to_s
64
-
65
- # Initialize the LLM context (with streaming when callbacks provided)
66
- @stream = if on_content || on_reasoning
67
- AgentStream.new(
68
- on_content: on_content,
69
- on_reasoning: on_reasoning,
70
- on_question: on_question,
71
- )
72
- end
73
- ctx_opts = { tools: @tool_classes }
74
- ctx_opts[:model] = @model if @model
75
- ctx_opts[:stream] = @stream if @stream
76
- @context = LLM::Context.new(@provider, **ctx_opts)
77
-
78
- # Build the middleware pipeline
79
- compactor = Compactor.new(provider, **compactor_opts)
80
- @pipeline = build_pipeline(
81
- compactor: compactor,
82
- session: @session,
83
- logger: @logger,
84
- reasoning: reasoning,
85
- message_store: @message_store,
86
- )
87
-
88
- # The shared env hash — passed to every pipeline.call()
89
- @env = {
90
- context: @context,
91
- provider: @provider,
92
- tools: @tool_classes,
93
- input: nil,
94
- params: {},
95
- metadata: {},
96
- tool_results: nil,
97
- streaming: !!@stream,
98
- callbacks: {
99
- on_content: on_content,
100
- on_reasoning: on_reasoning,
101
- on_tool_call_start: on_tool_call_start,
102
- on_tool_result: on_tool_result,
103
- on_question: on_question,
104
- },
105
- }
106
- end
107
-
108
- # Run a single user turn. Loops internally until the agent either
109
- # completes (no more tool calls) or hits a limit.
110
- #
111
- # Returns the final assistant response.
112
- def run(user_message)
113
- unless @provider
114
- raise "No LLM provider configured. Set LLM_API_KEY and optionally LLM_PROVIDER (default: anthropic)"
115
- end
116
-
117
- @request_count = 0
118
-
119
- # Build the initial prompt with system message on first turn
120
- input = if first_turn?
121
- @context.prompt do |p|
122
- p.system @system_prompt
123
- p.user user_message
124
- end
125
- else
126
- user_message
127
- end
128
-
129
- # --- First LLM call ---
130
- @env[:input] = input
131
- @env[:tool_results] = nil
132
- last_response = @pipeline.call(@env)
133
- sync_context!
134
-
135
- # --- Agent loop ---
136
- loop do
137
- # Collect pending tools from either source:
138
- # - Streaming: AgentStream deferred tools (collected during stream)
139
- # - Non-streaming: ctx.functions (populated by llm.rb after response)
140
- pending = collect_pending_tools
141
- break if pending.empty?
142
-
143
- # Fire on_tool_call_start ONCE with the full batch
144
- on_start = @env.dig(:callbacks, :on_tool_call_start)
145
- on_start&.call(pending.map { |tool, _| { name: tool.name, arguments: tool.arguments } })
146
-
147
- # Separate errors (tool not found) from executable tools
148
- errors = pending.select { |_, err| err }
149
- executable = pending.reject { |_, err| err }.map(&:first)
150
-
151
- # Execute tools concurrently, collect results
152
- results = execute_tool_calls(executable)
153
-
154
- # Append error results (tool not found, etc.)
155
- errors.each do |_, err|
156
- on_result = @env.dig(:callbacks, :on_tool_result)
157
- on_result&.call(err.name, result_value(err))
158
- results << err
159
- end
160
-
161
- # Send results back through the pipeline
162
- @env[:input] = results
163
- @env[:tool_results] = extract_tool_result_pairs(results)
164
- last_response = @pipeline.call(@env)
165
- sync_context!
166
-
167
- @request_count += 1
168
-
169
- # Check limits
170
- break if !has_pending_tools?
171
- break if @request_count >= MAX_REQUESTS_PER_TURN
172
- break if @env[:metadata][:tool_error_limit_reached]
173
- end
174
-
175
- last_response
176
- end
177
-
178
- private
179
-
180
- # ------------------------------------------------------------------
181
- # Pipeline construction
182
- # ------------------------------------------------------------------
183
-
184
- def build_pipeline(compactor:, session:, logger:, reasoning:, message_store:)
185
- sys_prompt = @system_prompt
186
- tools = @tool_classes
187
- stream = @stream
188
-
189
- Pipeline.new do
190
- # OTel span lifecycle (outermost — creates env[:span])
191
- use Middleware::OTel::Span
192
-
193
- # Timing and logging
194
- use Middleware::Tracing, logger: logger
195
-
196
- # OTel: record tool results being sent back (pre-call)
197
- use Middleware::OTel::ToolResults
198
-
199
- # Retry transient errors (wraps everything below)
200
- use Middleware::Retry
201
-
202
- # Save after each successful LLM call
203
- use Middleware::SessionPersistence, session: session
204
-
205
- # Record structured messages in OpenCode {info, parts} format
206
- use Middleware::MessageTracking, store: message_store
207
-
208
- # Track cumulative token usage
209
- use Middleware::TokenTracking
210
-
211
- # OTel: record token usage from response (post-call)
212
- use Middleware::OTel::TokenUsage
213
-
214
- # Check context size and compact if needed
215
- use Middleware::CompactionCheck,
216
- compactor: compactor,
217
- system_prompt: sys_prompt,
218
- tools: tools,
219
- stream: stream
220
-
221
- # Track per-tool errors
222
- use Middleware::ToolErrorTracking
223
-
224
- # Detect and break doom loops (pre-call)
225
- use Middleware::DoomLoopDetection
226
-
227
- # Handle reasoning params and model-switch normalization (pre-call)
228
- use Middleware::ReasoningNormalizer, **reasoning unless reasoning.empty?
229
-
230
- # Guard against tool-only responses dropping the assistant message
231
- use Middleware::ToolUseGuard
232
-
233
- # OTel: record tool calls the LLM requested (post-call, after ToolUseGuard)
234
- use Middleware::OTel::ToolCalls
235
-
236
- # Innermost: the actual LLM call
237
- run Middleware::LLMCall.new
238
- end
239
- end
240
-
241
- # ------------------------------------------------------------------
242
- # Pending tool collection
243
- # ------------------------------------------------------------------
244
-
245
- # Check whether there are pending tools without consuming them.
246
- def has_pending_tools?
247
- return true if @stream&.pending_tools&.any?
248
- return true if @context.functions.any?
249
- false
250
- end
251
-
252
- # Collect pending tools from the stream (streaming) or context (non-streaming).
253
- # Returns an array of [tool, error_or_nil] pairs.
254
- # Clears the stream's deferred state after consumption.
255
- def collect_pending_tools
256
- if @stream&.pending_tools&.any?
257
- tools = @stream.pending_tools.dup
258
- @stream.clear_pending_tools!
259
- tools
260
- elsif @context.functions.any?
261
- @context.functions.to_a.map { |fn| [fn, nil] }
262
- else
263
- []
264
- end
265
- end
266
-
267
- # ------------------------------------------------------------------
268
- # Tool execution
269
- # ------------------------------------------------------------------
270
-
271
- def execute_tool_calls(functions)
272
- return [] if functions.empty?
273
-
274
- # Questions block execution — they must complete before other tools
275
- # run, since the LLM may need the answer to inform subsequent work.
276
- # Execute any question tools first (sequentially), then dispatch
277
- # the remaining tools concurrently.
278
- questions, others = functions.partition { |fn| fn.name == "question" }
279
-
280
- results = []
281
- results.concat(execute_sequential(questions)) if questions.any?
282
- if others.size <= 1
283
- results.concat(execute_sequential(others))
284
- else
285
- results.concat(execute_parallel(others))
286
- end
287
- results
288
- end
289
-
290
- # Run a single tool call synchronously.
291
- def execute_sequential(functions)
292
- on_result = @env.dig(:callbacks, :on_tool_result)
293
- on_question = @env.dig(:callbacks, :on_question)
294
-
295
- functions.map do |fn|
296
- Thread.current[:on_question] = on_question
297
- result = fn.call
298
- on_result&.call(fn.name, result_value(result))
299
- result
300
- end
301
- end
302
-
303
- # Run all pending tool calls concurrently via Async::Barrier.
304
- #
305
- # Each tool runs in its own fiber. File-mutating tools are safe because
306
- # they go through FileMutationQueue, whose Mutex is fiber-scheduler-aware
307
- # in Ruby 3.4 — a fiber blocked on a per-file mutex yields to other
308
- # fibers instead of blocking the thread.
309
- #
310
- # The barrier is stored in @barrier so abort! can cancel in-flight tools.
311
- #
312
- def execute_parallel(functions)
313
- on_result = @env.dig(:callbacks, :on_tool_result)
314
- on_question = @env.dig(:callbacks, :on_question)
315
-
316
- results = Array.new(functions.size)
317
-
318
- Async do
319
- @barrier = Async::Barrier.new
320
-
321
- functions.each_with_index do |fn, i|
322
- @barrier.async do
323
- Thread.current[:on_question] = on_question
324
- results[i] = fn.call
325
- r = results[i]
326
- on_result&.call(r.name, result_value(r))
327
- end
328
- end
329
-
330
- @barrier.wait
331
- ensure
332
- @barrier&.stop
333
- @barrier = nil
334
- end
335
-
336
- results
337
- end
338
-
339
- public
340
-
341
- # Cancel any in-flight tool execution. Safe to call from a signal
342
- # handler, another thread, or an interface layer (TUI, web, RPC).
343
- #
344
- # When called, Async::Stop is raised in each running fiber, unwinding
345
- # through ensure blocks — so FileMutationQueue mutexes release cleanly
346
- # and SnapshotStore stays consistent.
347
- #
348
- def abort!
349
- @barrier&.stop
350
- end
351
-
352
- private
353
-
354
- # ------------------------------------------------------------------
355
- # Helpers
356
- # ------------------------------------------------------------------
357
-
358
- # After a pipeline call, the compaction middleware may have replaced
359
- # the context. Sync our local reference.
360
- def sync_context!
361
- @context = @env[:context]
362
- end
363
-
364
- def first_turn?
365
- @context.messages.to_a.empty?
366
- end
367
-
368
- def result_value(result)
369
- result.respond_to?(:value) ? result.value : result
370
- end
371
-
372
- # Build [name, value] pairs from tool results for ToolErrorTracking.
373
- def extract_tool_result_pairs(results)
374
- results.filter_map do |r|
375
- name = r.respond_to?(:name) ? r.name : "unknown"
376
- val = result_value(r)
377
- [name, val]
378
- end
379
- end
380
-
381
- # Load AGENTS.md or .brute/rules from the working directory.
382
- def load_custom_rules
383
- candidates = [
384
- File.join(@cwd, "AGENTS.md"),
385
- File.join(@cwd, ".brute", "rules.md"),
386
- ]
387
- found = candidates.find { |p| File.exist?(p) }
388
- found ? File.read(found) : nil
389
- end
390
- end
391
- end