brute 0.4.1 → 1.0.1
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 +18 -28
- 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 +60 -146
- data/lib/brute/middleware/doom_loop_detection.rb +95 -92
- data/lib/brute/middleware/llm_call.rb +78 -80
- data/lib/brute/middleware/message_tracking.rb +115 -162
- data/lib/brute/middleware/otel/span.rb +25 -106
- data/lib/brute/middleware/otel/token_usage.rb +29 -84
- data/lib/brute/middleware/otel/tool_calls.rb +23 -107
- data/lib/brute/middleware/otel/tool_results.rb +22 -86
- data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
- data/lib/brute/middleware/retry.rb +95 -76
- data/lib/brute/middleware/session_persistence.rb +38 -37
- data/lib/brute/middleware/token_tracking.rb +64 -63
- data/lib/brute/middleware/tool_error_tracking.rb +108 -82
- data/lib/brute/middleware/tool_use_guard.rb +57 -90
- data/lib/brute/middleware/tracing.rb +53 -63
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/pipeline.rb +77 -133
- data/lib/brute/prompts/build_switch.rb +21 -25
- data/lib/brute/prompts/environment.rb +31 -35
- data/lib/brute/prompts/identity.rb +22 -29
- data/lib/brute/prompts/instructions.rb +15 -18
- data/lib/brute/prompts/max_steps.rb +18 -25
- data/lib/brute/prompts/plan_reminder.rb +18 -26
- data/lib/brute/prompts/skills.rb +8 -30
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +2 -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/store/message_store.rb +362 -0
- 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 +81 -194
- data/lib/brute/tools/delegate.rb +46 -116
- data/lib/brute/tools/fs_patch.rb +36 -37
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +29 -41
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -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 -181
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/message_store.rb +0 -463
- data/lib/brute/orchestrator.rb +0 -550
- data/lib/brute/session.rb +0 -161
data/lib/brute/agent_stream.rb
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
if __FILE__ == $0
|
|
4
|
-
require "bundler/setup"
|
|
5
|
-
require "brute"
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
module Brute
|
|
9
|
-
# Bridges llm.rb's streaming callbacks to the host application.
|
|
10
|
-
#
|
|
11
|
-
# Text and reasoning chunks fire immediately as the LLM generates them.
|
|
12
|
-
# Tool calls are collected but NOT executed — execution is deferred to the
|
|
13
|
-
# orchestrator after the stream completes. This ensures text is never
|
|
14
|
-
# concurrent with tool execution.
|
|
15
|
-
#
|
|
16
|
-
# After the stream finishes, the orchestrator reads +pending_tools+ to
|
|
17
|
-
# dispatch all tool calls concurrently, then fires +on_tool_call_start+
|
|
18
|
-
# once with the full batch.
|
|
19
|
-
#
|
|
20
|
-
class AgentStream < LLM::Stream
|
|
21
|
-
# Tool call metadata recorded during streaming, used by ToolUseGuard
|
|
22
|
-
# when ctx.functions is empty (nil-choice bug in llm.rb).
|
|
23
|
-
attr_reader :pending_tool_calls
|
|
24
|
-
|
|
25
|
-
# Deferred tool/error pairs: [(LLM::Function, error_or_nil), ...]
|
|
26
|
-
# The orchestrator reads these after the stream completes.
|
|
27
|
-
attr_reader :pending_tools
|
|
28
|
-
|
|
29
|
-
def initialize(on_content: nil, on_reasoning: nil, on_question: nil)
|
|
30
|
-
@on_content = on_content
|
|
31
|
-
@on_reasoning = on_reasoning
|
|
32
|
-
@on_question = on_question
|
|
33
|
-
@pending_tool_calls = []
|
|
34
|
-
@pending_tools = []
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# The on_question callback, needed by the orchestrator to set
|
|
38
|
-
# thread/fiber-locals before tool execution.
|
|
39
|
-
attr_reader :on_question
|
|
40
|
-
|
|
41
|
-
def on_content(text)
|
|
42
|
-
@on_content&.call(text)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def on_reasoning_content(text)
|
|
46
|
-
@on_reasoning&.call(text)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Called by llm.rb per tool as it arrives during streaming.
|
|
50
|
-
# Records only — no execution, no threads, no queue pushes.
|
|
51
|
-
def on_tool_call(tool, error)
|
|
52
|
-
@pending_tool_calls << { id: tool.id, name: tool.name, arguments: tool.arguments }
|
|
53
|
-
@pending_tools << [tool, error]
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Clear only the tool call metadata (used by ToolUseGuard after it
|
|
57
|
-
# has consumed the data for synthetic message injection).
|
|
58
|
-
def clear_pending_tool_calls!
|
|
59
|
-
@pending_tool_calls.clear
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Clear the deferred execution queue after the orchestrator has
|
|
63
|
-
# consumed and dispatched all tool calls.
|
|
64
|
-
def clear_pending_tools!
|
|
65
|
-
@pending_tools.clear
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
if __FILE__ == $0
|
|
71
|
-
require_relative "../../spec/spec_helper"
|
|
72
|
-
|
|
73
|
-
RSpec.describe Brute::AgentStream do
|
|
74
|
-
# Build a mock tool that quacks like LLM::Function.
|
|
75
|
-
def mock_tool(id:, name:, arguments: {})
|
|
76
|
-
instance_double(LLM::Function,
|
|
77
|
-
id: id,
|
|
78
|
-
name: name,
|
|
79
|
-
arguments: arguments,
|
|
80
|
-
)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
describe "#on_tool_call" do
|
|
84
|
-
it "records tool/error pair in pending_tools without spawning threads" do
|
|
85
|
-
stream = described_class.new
|
|
86
|
-
tool = mock_tool(id: "toolu_1", name: "read")
|
|
87
|
-
|
|
88
|
-
stream.on_tool_call(tool, nil)
|
|
89
|
-
|
|
90
|
-
expect(stream.pending_tools.size).to eq(1)
|
|
91
|
-
recorded_tool, recorded_error = stream.pending_tools.first
|
|
92
|
-
expect(recorded_tool).to eq(tool)
|
|
93
|
-
expect(recorded_error).to be_nil
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
it "records error tools in pending_tools" do
|
|
97
|
-
stream = described_class.new
|
|
98
|
-
tool = mock_tool(id: "toolu_err", name: "bad_tool")
|
|
99
|
-
error = LLM::Function::Return.new("toolu_err", "bad_tool", { error: true })
|
|
100
|
-
|
|
101
|
-
stream.on_tool_call(tool, error)
|
|
102
|
-
|
|
103
|
-
expect(stream.pending_tools.size).to eq(1)
|
|
104
|
-
_, recorded_error = stream.pending_tools.first
|
|
105
|
-
expect(recorded_error).to eq(error)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
it "records pending tool call metadata for ToolUseGuard" do
|
|
109
|
-
stream = described_class.new
|
|
110
|
-
tool = mock_tool(
|
|
111
|
-
id: "toolu_abc",
|
|
112
|
-
name: "read",
|
|
113
|
-
arguments: { "file_path" => "test.rb" },
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
stream.on_tool_call(tool, nil)
|
|
117
|
-
|
|
118
|
-
calls = stream.pending_tool_calls
|
|
119
|
-
expect(calls).not_to be_empty
|
|
120
|
-
expect(calls.first).to include(
|
|
121
|
-
id: "toolu_abc",
|
|
122
|
-
name: "read",
|
|
123
|
-
arguments: { "file_path" => "test.rb" },
|
|
124
|
-
)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
it "records metadata for multiple tool calls" do
|
|
128
|
-
stream = described_class.new
|
|
129
|
-
tool1 = mock_tool(id: "toolu_1", name: "read", arguments: { "file_path" => "a.rb" })
|
|
130
|
-
tool2 = mock_tool(id: "toolu_2", name: "write", arguments: { "file_path" => "b.rb" })
|
|
131
|
-
|
|
132
|
-
stream.on_tool_call(tool1, nil)
|
|
133
|
-
stream.on_tool_call(tool2, nil)
|
|
134
|
-
|
|
135
|
-
expect(stream.pending_tool_calls.size).to eq(2)
|
|
136
|
-
expect(stream.pending_tool_calls.map { |c| c[:id] }).to eq(["toolu_1", "toolu_2"])
|
|
137
|
-
|
|
138
|
-
expect(stream.pending_tools.size).to eq(2)
|
|
139
|
-
expect(stream.pending_tools.map { |t, _| t }).to eq([tool1, tool2])
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
describe "#clear_pending_tool_calls! and #clear_pending_tools!" do
|
|
144
|
-
it "empties both pending_tool_calls and pending_tools" do
|
|
145
|
-
stream = described_class.new
|
|
146
|
-
tool = mock_tool(id: "toolu_1", name: "read")
|
|
147
|
-
|
|
148
|
-
stream.on_tool_call(tool, nil)
|
|
149
|
-
expect(stream.pending_tool_calls).not_to be_empty
|
|
150
|
-
expect(stream.pending_tools).not_to be_empty
|
|
151
|
-
|
|
152
|
-
stream.clear_pending_tool_calls!
|
|
153
|
-
stream.clear_pending_tools!
|
|
154
|
-
expect(stream.pending_tool_calls).to be_empty
|
|
155
|
-
expect(stream.pending_tools).to be_empty
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
describe "#on_content" do
|
|
160
|
-
it "fires the content callback" do
|
|
161
|
-
received = nil
|
|
162
|
-
stream = described_class.new(on_content: ->(text) { received = text })
|
|
163
|
-
|
|
164
|
-
stream.on_content("hello")
|
|
165
|
-
|
|
166
|
-
expect(received).to eq("hello")
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
describe "#on_reasoning_content" do
|
|
171
|
-
it "fires the reasoning callback" do
|
|
172
|
-
received = nil
|
|
173
|
-
stream = described_class.new(on_reasoning: ->(text) { received = text })
|
|
174
|
-
|
|
175
|
-
stream.on_reasoning_content("thinking...")
|
|
176
|
-
|
|
177
|
-
expect(received).to eq("thinking...")
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
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
|