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.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +14 -0
- data/lib/brute/diff.rb +24 -0
- data/lib/brute/loop/agent_stream.rb +118 -0
- data/lib/brute/loop/agent_turn.rb +520 -0
- data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
- data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
- data/lib/brute/loop/step.rb +332 -0
- data/lib/brute/loop/tool_call_step.rb +90 -0
- data/lib/brute/middleware/compaction_check.rb +70 -23
- data/lib/brute/middleware/doom_loop_detection.rb +110 -7
- data/lib/brute/middleware/llm_call.rb +88 -1
- data/lib/brute/middleware/message_tracking.rb +140 -10
- data/lib/brute/middleware/otel/span.rb +32 -2
- data/lib/brute/middleware/otel/token_usage.rb +38 -0
- data/lib/brute/middleware/otel/tool_calls.rb +30 -1
- data/lib/brute/middleware/otel/tool_results.rb +29 -1
- data/lib/brute/middleware/otel.rb +5 -0
- data/lib/brute/middleware/reasoning_normalizer.rb +94 -0
- data/lib/brute/middleware/retry.rb +113 -1
- data/lib/brute/middleware/session_persistence.rb +46 -3
- data/lib/brute/middleware/token_tracking.rb +78 -0
- data/lib/brute/middleware/tool_error_tracking.rb +128 -1
- data/lib/brute/middleware/tool_use_guard.rb +64 -28
- data/lib/brute/middleware/tracing.rb +63 -2
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/patches/buffer_nil_guard.rb +5 -0
- data/lib/brute/pipeline.rb +86 -7
- data/lib/brute/prompts/build_switch.rb +29 -0
- data/lib/brute/prompts/environment.rb +43 -0
- data/lib/brute/prompts/identity.rb +29 -0
- data/lib/brute/prompts/instructions.rb +21 -0
- data/lib/brute/prompts/max_steps.rb +25 -0
- data/lib/brute/prompts/plan_reminder.rb +25 -0
- data/lib/brute/prompts/skills.rb +13 -0
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/opencode_go.rb +5 -0
- data/lib/brute/providers/opencode_zen.rb +7 -2
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +7 -2
- data/lib/brute/providers.rb +62 -0
- data/lib/brute/queue/base_queue.rb +222 -0
- data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
- data/lib/brute/queue/parallel_queue.rb +66 -0
- data/lib/brute/queue/sequential_queue.rb +63 -0
- data/lib/brute/{message_store.rb → store/message_store.rb} +155 -62
- data/lib/brute/store/session.rb +106 -0
- data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
- data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
- data/lib/brute/system_prompt.rb +101 -0
- data/lib/brute/tools/delegate.rb +59 -0
- data/lib/brute/tools/fs_patch.rb +54 -2
- data/lib/brute/tools/fs_read.rb +5 -0
- data/lib/brute/tools/fs_remove.rb +7 -2
- data/lib/brute/tools/fs_search.rb +5 -0
- data/lib/brute/tools/fs_undo.rb +7 -2
- data/lib/brute/tools/fs_write.rb +40 -2
- data/lib/brute/tools/net_fetch.rb +5 -0
- data/lib/brute/tools/question.rb +5 -0
- data/lib/brute/tools/shell.rb +5 -0
- data/lib/brute/tools/todo_read.rb +6 -1
- data/lib/brute/tools/todo_write.rb +6 -1
- data/lib/brute/tools.rb +31 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +40 -204
- metadata +31 -20
- data/lib/brute/agent_stream.rb +0 -63
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/orchestrator.rb +0 -391
- 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
|
+
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:
|
|
55
|
+
name: scampi
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
|
-
- - "
|
|
58
|
+
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '
|
|
61
|
-
type: :
|
|
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: '
|
|
67
|
+
version: '0'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
|
-
name:
|
|
69
|
+
name: rake
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: '
|
|
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: '
|
|
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/
|
|
90
|
-
- lib/brute/compactor.rb
|
|
89
|
+
- lib/brute/agent.rb
|
|
91
90
|
- lib/brute/diff.rb
|
|
92
|
-
- lib/brute/
|
|
93
|
-
- lib/brute/
|
|
94
|
-
- lib/brute/
|
|
95
|
-
- lib/brute/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
data/lib/brute/agent_stream.rb
DELETED
|
@@ -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
|
data/lib/brute/orchestrator.rb
DELETED
|
@@ -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
|