brute 0.2.0 → 0.4.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.
- checksums.yaml +4 -4
- data/lib/brute/agent_stream.rb +29 -30
- data/lib/brute/orchestrator.rb +76 -22
- data/lib/brute/pipeline.rb +1 -1
- data/lib/brute/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e6fa4c53a825578634b110724522c021f089595e75e80faea05b5c53697010dd
|
|
4
|
+
data.tar.gz: 1cff09cf5e255928aada4f09a11c2f77ccf873839ee4f6d0ba24bc12beaefeba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 795a6b851f47daba23755f8791f98c4c54f1c738704748767e70ff0bf25b797dca15fc25892642b7b46c7f6c8acab83d5dd110b0741e4252e8e8b1ce8798ffa1
|
|
7
|
+
data.tar.gz: 827d9628e7d5142fe1eaabc5e3de47cf04468afa5e1985a9af6b7ccc16e471ce35236953d3b746e988ef34a779df3cd4b1e6821ca9cd45815fc302785d8d1a00
|
data/lib/brute/agent_stream.rb
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Brute
|
|
4
|
-
# Bridges llm.rb's streaming callbacks to
|
|
4
|
+
# Bridges llm.rb's streaming callbacks to the host application.
|
|
5
5
|
#
|
|
6
6
|
# Text and reasoning chunks fire immediately as the LLM generates them.
|
|
7
|
-
# Tool calls
|
|
8
|
-
#
|
|
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.
|
|
9
14
|
#
|
|
10
15
|
class AgentStream < LLM::Stream
|
|
11
16
|
# Tool call metadata recorded during streaming, used by ToolUseGuard
|
|
12
17
|
# when ctx.functions is empty (nil-choice bug in llm.rb).
|
|
13
|
-
# Cleared by the guard after consumption to prevent stale data from
|
|
14
|
-
# causing duplicate synthetic assistant messages on subsequent calls.
|
|
15
18
|
attr_reader :pending_tool_calls
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
20
23
|
|
|
21
|
-
def initialize(on_content: nil, on_reasoning: nil,
|
|
24
|
+
def initialize(on_content: nil, on_reasoning: nil, on_question: nil)
|
|
22
25
|
@on_content = on_content
|
|
23
26
|
@on_reasoning = on_reasoning
|
|
24
|
-
@on_tool_call = on_tool_call
|
|
25
|
-
@on_tool_result = on_tool_result
|
|
26
27
|
@on_question = on_question
|
|
27
28
|
@pending_tool_calls = []
|
|
29
|
+
@pending_tools = []
|
|
28
30
|
end
|
|
29
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
|
+
|
|
30
36
|
def on_content(text)
|
|
31
37
|
@on_content&.call(text)
|
|
32
38
|
end
|
|
@@ -35,30 +41,23 @@ module Brute
|
|
|
35
41
|
@on_reasoning&.call(text)
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# Called by llm.rb per tool as it arrives during streaming.
|
|
45
|
+
# Records only — no execution, no threads, no queue pushes.
|
|
38
46
|
def on_tool_call(tool, error)
|
|
39
47
|
@pending_tool_calls << { id: tool.id, name: tool.name, arguments: tool.arguments }
|
|
40
|
-
@
|
|
41
|
-
|
|
42
|
-
if error
|
|
43
|
-
queue << error
|
|
44
|
-
@on_tool_result&.call(tool.name, error.value)
|
|
45
|
-
else
|
|
46
|
-
queue << LLM::Function::Task.new(spawn_with_callback(tool))
|
|
47
|
-
end
|
|
48
|
+
@pending_tools << [tool, error]
|
|
48
49
|
end
|
|
49
50
|
|
|
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
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
Thread.new do
|
|
57
|
-
Thread.current[:on_question] = on_question
|
|
58
|
-
result = tool.call
|
|
59
|
-
on_result&.call(name, result.respond_to?(:value) ? result.value : result)
|
|
60
|
-
result
|
|
61
|
-
end
|
|
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
|
|
62
61
|
end
|
|
63
62
|
end
|
|
64
63
|
end
|
data/lib/brute/orchestrator.rb
CHANGED
|
@@ -17,6 +17,11 @@ module Brute
|
|
|
17
17
|
# 2. Executes any tool calls the LLM requested
|
|
18
18
|
# 3. Repeats until done or a limit is hit
|
|
19
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
|
+
#
|
|
20
25
|
class Orchestrator
|
|
21
26
|
MAX_REQUESTS_PER_TURN = 100
|
|
22
27
|
|
|
@@ -33,7 +38,7 @@ module Brute
|
|
|
33
38
|
agent_name: nil,
|
|
34
39
|
on_content: nil,
|
|
35
40
|
on_reasoning: nil,
|
|
36
|
-
|
|
41
|
+
on_tool_call_start: nil,
|
|
37
42
|
on_tool_result: nil,
|
|
38
43
|
on_question: nil,
|
|
39
44
|
logger: nil
|
|
@@ -62,8 +67,6 @@ module Brute
|
|
|
62
67
|
AgentStream.new(
|
|
63
68
|
on_content: on_content,
|
|
64
69
|
on_reasoning: on_reasoning,
|
|
65
|
-
on_tool_call: on_tool_call,
|
|
66
|
-
on_tool_result: on_tool_result,
|
|
67
70
|
on_question: on_question,
|
|
68
71
|
)
|
|
69
72
|
end
|
|
@@ -95,7 +98,7 @@ module Brute
|
|
|
95
98
|
callbacks: {
|
|
96
99
|
on_content: on_content,
|
|
97
100
|
on_reasoning: on_reasoning,
|
|
98
|
-
|
|
101
|
+
on_tool_call_start: on_tool_call_start,
|
|
99
102
|
on_tool_result: on_tool_result,
|
|
100
103
|
on_question: on_question,
|
|
101
104
|
},
|
|
@@ -131,15 +134,28 @@ module Brute
|
|
|
131
134
|
|
|
132
135
|
# --- Agent loop ---
|
|
133
136
|
loop do
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
143
159
|
end
|
|
144
160
|
|
|
145
161
|
# Send results back through the pipeline
|
|
@@ -151,7 +167,7 @@ module Brute
|
|
|
151
167
|
@request_count += 1
|
|
152
168
|
|
|
153
169
|
# Check limits
|
|
154
|
-
break if
|
|
170
|
+
break if !has_pending_tools?
|
|
155
171
|
break if @request_count >= MAX_REQUESTS_PER_TURN
|
|
156
172
|
break if @env[:metadata][:tool_error_limit_reached]
|
|
157
173
|
end
|
|
@@ -222,24 +238,62 @@ module Brute
|
|
|
222
238
|
end
|
|
223
239
|
end
|
|
224
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
|
+
|
|
225
267
|
# ------------------------------------------------------------------
|
|
226
268
|
# Tool execution
|
|
227
269
|
# ------------------------------------------------------------------
|
|
228
270
|
|
|
229
|
-
def execute_tool_calls
|
|
230
|
-
|
|
231
|
-
|
|
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" }
|
|
232
279
|
|
|
233
|
-
|
|
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
|
|
234
288
|
end
|
|
235
289
|
|
|
236
290
|
# Run a single tool call synchronously.
|
|
237
291
|
def execute_sequential(functions)
|
|
238
|
-
on_call = @env.dig(:callbacks, :on_tool_call)
|
|
239
292
|
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
293
|
+
on_question = @env.dig(:callbacks, :on_question)
|
|
240
294
|
|
|
241
295
|
functions.map do |fn|
|
|
242
|
-
|
|
296
|
+
Thread.current[:on_question] = on_question
|
|
243
297
|
result = fn.call
|
|
244
298
|
on_result&.call(fn.name, result_value(result))
|
|
245
299
|
result
|
|
@@ -256,8 +310,8 @@ module Brute
|
|
|
256
310
|
# The barrier is stored in @barrier so abort! can cancel in-flight tools.
|
|
257
311
|
#
|
|
258
312
|
def execute_parallel(functions)
|
|
259
|
-
on_call = @env.dig(:callbacks, :on_tool_call)
|
|
260
313
|
on_result = @env.dig(:callbacks, :on_tool_result)
|
|
314
|
+
on_question = @env.dig(:callbacks, :on_question)
|
|
261
315
|
|
|
262
316
|
results = Array.new(functions.size)
|
|
263
317
|
|
|
@@ -266,7 +320,7 @@ module Brute
|
|
|
266
320
|
|
|
267
321
|
functions.each_with_index do |fn, i|
|
|
268
322
|
@barrier.async do
|
|
269
|
-
|
|
323
|
+
Thread.current[:on_question] = on_question
|
|
270
324
|
results[i] = fn.call
|
|
271
325
|
r = results[i]
|
|
272
326
|
on_result&.call(r.name, result_value(r))
|
data/lib/brute/pipeline.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Brute
|
|
|
22
22
|
# tools: [Tool, ...], # tool classes
|
|
23
23
|
# params: {}, # extra LLM call params (reasoning config, etc.)
|
|
24
24
|
# metadata: {}, # shared scratchpad for middleware state
|
|
25
|
-
# callbacks: {}, # :on_content, :
|
|
25
|
+
# callbacks: {}, # :on_content, :on_tool_call_start, :on_tool_result
|
|
26
26
|
# }
|
|
27
27
|
#
|
|
28
28
|
# ## The response
|
data/lib/brute/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brute
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brute Contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 1980-01-
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: async
|