brute 1.0.0 → 2.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 +72 -6
- data/lib/brute/events/handler.rb +69 -0
- data/lib/brute/events/prefixed_terminal_output.rb +72 -0
- data/lib/brute/events/terminal_output_handler.rb +68 -0
- data/lib/brute/middleware/001_otel_span.rb +77 -0
- data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
- data/lib/brute/middleware/004_summarize.rb +139 -0
- data/lib/brute/middleware/005_tracing.rb +86 -0
- data/lib/brute/middleware/010_max_iterations.rb +73 -0
- data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
- data/lib/brute/middleware/020_system_prompt.rb +128 -0
- data/lib/brute/middleware/040_compaction_check.rb +155 -0
- data/lib/brute/middleware/060_questions.rb +41 -0
- data/lib/brute/middleware/070_tool_call.rb +247 -0
- data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
- data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
- data/lib/brute/middleware/100_llm_call.rb +62 -0
- data/lib/brute/middleware/event_handler.rb +25 -0
- data/lib/brute/middleware/user_queue.rb +35 -0
- data/lib/brute/pipeline.rb +44 -107
- data/lib/brute/prompts/skills.rb +2 -2
- data/lib/brute/prompts.rb +23 -23
- data/lib/brute/providers/shell.rb +6 -19
- data/lib/brute/providers/shell_response.rb +22 -30
- data/lib/brute/session.rb +52 -0
- data/lib/brute/store/snapshot_store.rb +21 -37
- data/lib/brute/sub_agent.rb +106 -0
- data/lib/brute/system_prompt.rb +1 -83
- data/lib/brute/tool.rb +107 -0
- data/lib/brute/tools/delegate.rb +61 -70
- data/lib/brute/tools/fs_patch.rb +9 -7
- data/lib/brute/tools/fs_read.rb +233 -20
- data/lib/brute/tools/fs_remove.rb +8 -9
- data/lib/brute/tools/fs_search.rb +98 -16
- data/lib/brute/tools/fs_undo.rb +8 -8
- data/lib/brute/tools/fs_write.rb +7 -5
- data/lib/brute/tools/net_fetch.rb +8 -8
- data/lib/brute/tools/question.rb +36 -24
- data/lib/brute/tools/shell.rb +74 -16
- data/lib/brute/tools/todo_read.rb +8 -8
- data/lib/brute/tools/todo_write.rb +25 -18
- data/lib/brute/tools.rb +8 -12
- data/lib/brute/truncation.rb +219 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +82 -45
- metadata +59 -46
- data/lib/brute/loop/agent_stream.rb +0 -118
- data/lib/brute/loop/agent_turn.rb +0 -520
- data/lib/brute/loop/compactor.rb +0 -107
- data/lib/brute/loop/doom_loop.rb +0 -86
- data/lib/brute/loop/step.rb +0 -332
- data/lib/brute/loop/tool_call_step.rb +0 -90
- data/lib/brute/middleware/base.rb +0 -27
- data/lib/brute/middleware/compaction_check.rb +0 -106
- data/lib/brute/middleware/doom_loop_detection.rb +0 -136
- data/lib/brute/middleware/llm_call.rb +0 -128
- data/lib/brute/middleware/message_tracking.rb +0 -339
- data/lib/brute/middleware/otel/span.rb +0 -105
- data/lib/brute/middleware/otel/token_usage.rb +0 -68
- data/lib/brute/middleware/otel/tool_calls.rb +0 -68
- data/lib/brute/middleware/otel/tool_results.rb +0 -65
- data/lib/brute/middleware/otel.rb +0 -34
- data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
- data/lib/brute/middleware/retry.rb +0 -157
- data/lib/brute/middleware/session_persistence.rb +0 -72
- data/lib/brute/middleware/token_tracking.rb +0 -124
- data/lib/brute/middleware/tool_error_tracking.rb +0 -179
- data/lib/brute/middleware/tool_use_guard.rb +0 -133
- data/lib/brute/middleware/tracing.rb +0 -124
- data/lib/brute/middleware.rb +0 -18
- data/lib/brute/orchestrator/turn.rb +0 -105
- data/lib/brute/patches/anthropic_tool_role.rb +0 -35
- data/lib/brute/patches/buffer_nil_guard.rb +0 -26
- data/lib/brute/providers/models_dev.rb +0 -111
- data/lib/brute/providers/ollama.rb +0 -135
- data/lib/brute/providers/opencode_go.rb +0 -43
- data/lib/brute/providers/opencode_zen.rb +0 -87
- data/lib/brute/providers.rb +0 -62
- data/lib/brute/queue/base_queue.rb +0 -222
- data/lib/brute/queue/parallel_queue.rb +0 -66
- data/lib/brute/queue/sequential_queue.rb +0 -63
- data/lib/brute/store/message_store.rb +0 -362
- data/lib/brute/store/session.rb +0 -106
- /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/truncation"
|
|
6
|
+
require "async"
|
|
7
|
+
require "async/barrier"
|
|
8
|
+
|
|
9
|
+
module Brute
|
|
10
|
+
module Middleware
|
|
11
|
+
# Executes pending tool calls from the LLM response.
|
|
12
|
+
#
|
|
13
|
+
# Existing features (ref: opencode tool.ts wrap / truncate.ts):
|
|
14
|
+
#
|
|
15
|
+
# 1. Universal output truncation — after every tool.call(), pass the
|
|
16
|
+
# result string through Brute::Truncation.truncate() which enforces
|
|
17
|
+
# a 2000-line / 50 KB cap. This is a safety net so no single tool
|
|
18
|
+
# result can blow up the context window, regardless of whether the
|
|
19
|
+
# tool itself has internal limits.
|
|
20
|
+
# 2. Overflow to disk — when truncating, the full output is saved to
|
|
21
|
+
# a temp file under the truncation directory. The path is included
|
|
22
|
+
# in the truncated result with a hint.
|
|
23
|
+
# 3. Configurable limits — MAX_LINES / MAX_BYTES default to 2000 / 50 KB.
|
|
24
|
+
# 4. Skip truncation when tool already truncated — if the tool result
|
|
25
|
+
# already contains the truncation marker (e.g. Shell or FSSearch
|
|
26
|
+
# truncated internally), don't double-truncate.
|
|
27
|
+
#
|
|
28
|
+
# == Concurrency model (Async)
|
|
29
|
+
#
|
|
30
|
+
# Tool calls are executed concurrently using the `async` gem's fiber-based
|
|
31
|
+
# scheduler. Each tool call is dispatched as an Async::Task inside an
|
|
32
|
+
# Async::Barrier, so all tools run in parallel and we wait for every task
|
|
33
|
+
# to complete before moving on.
|
|
34
|
+
#
|
|
35
|
+
# Key design decisions:
|
|
36
|
+
#
|
|
37
|
+
# - Sync {} (not Async{}.wait) — reuses an existing event loop if one is
|
|
38
|
+
# already running, or creates one on demand. Blocks the caller until all
|
|
39
|
+
# inner work completes, which is what the middleware stack requires.
|
|
40
|
+
#
|
|
41
|
+
# - Async::Barrier — the idiomatic fan-out / join primitive. Each tool call
|
|
42
|
+
# becomes a child task via barrier.async; barrier.wait blocks until every
|
|
43
|
+
# task finishes. This is preferable to Async::Queue for a fixed batch of
|
|
44
|
+
# work with no producer/consumer relationship.
|
|
45
|
+
#
|
|
46
|
+
# - Deterministic result ordering — tool results are collected into an array
|
|
47
|
+
# during concurrent execution, then sorted back into the original
|
|
48
|
+
# tools_to_run key order before appending to env[:messages]. This ensures
|
|
49
|
+
# the LLM always sees results in a stable order regardless of which tool
|
|
50
|
+
# finishes first.
|
|
51
|
+
#
|
|
52
|
+
# - Fiber-safe shared state — appending to the results array from multiple
|
|
53
|
+
# fibers is safe because Async fibers are cooperatively scheduled (only
|
|
54
|
+
# one fiber runs at a time within a Sync block). No mutex needed.
|
|
55
|
+
#
|
|
56
|
+
# - FileMutationQueue compatibility — tools that mutate files use
|
|
57
|
+
# Brute::Queue::FileMutationQueue.serialize, which uses Ruby 3.4's
|
|
58
|
+
# fiber-scheduler-aware Mutex. Operations on the same file are serialized;
|
|
59
|
+
# operations on different files proceed in parallel.
|
|
60
|
+
#
|
|
61
|
+
class ToolCall
|
|
62
|
+
def initialize(app)
|
|
63
|
+
@app = app
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def call(env)
|
|
67
|
+
@app.call(env)
|
|
68
|
+
|
|
69
|
+
tools_to_run = pending_tool_calls(env[:messages].last)
|
|
70
|
+
if tools_to_run.any?
|
|
71
|
+
available_tools = resolve_tools(env[:tools])
|
|
72
|
+
env[:events] << on_tool_call_start_event(tools_to_run)
|
|
73
|
+
|
|
74
|
+
results = []
|
|
75
|
+
|
|
76
|
+
Sync do
|
|
77
|
+
barrier = Async::Barrier.new
|
|
78
|
+
|
|
79
|
+
tools_to_run.each do |id, tool_call|
|
|
80
|
+
barrier.async do
|
|
81
|
+
tool = available_tools[tool_call.name.to_sym]
|
|
82
|
+
result = tool.call(tool_call.arguments)
|
|
83
|
+
|
|
84
|
+
# Coerce to String so RubyLLM::Message doesn't treat Hash results
|
|
85
|
+
# (e.g. Shell's {stdout:, stderr:, exit_code:}) as attachments.
|
|
86
|
+
content = result.is_a?(String) ? result : result.to_s
|
|
87
|
+
|
|
88
|
+
# Universal truncation safety net — skip if already truncated
|
|
89
|
+
unless Brute::Truncation.already_truncated?(content)
|
|
90
|
+
content = Brute::Truncation.truncate(content)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
results << [id, tool_call, content]
|
|
94
|
+
rescue => e
|
|
95
|
+
# Capture the error as a tool result so the LLM can see it
|
|
96
|
+
# and reason about the failure, rather than crashing the
|
|
97
|
+
# entire middleware chain.
|
|
98
|
+
env[:events] << { type: :error, data: { error: e, message: e.message } }
|
|
99
|
+
results << [id, tool_call, "Error: #{e.class}: #{e.message}"]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
barrier.wait
|
|
104
|
+
ensure
|
|
105
|
+
barrier&.cancel
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Append events and messages in the original tool_call order so the
|
|
109
|
+
# LLM sees a deterministic sequence regardless of completion order.
|
|
110
|
+
order = tools_to_run.keys
|
|
111
|
+
results.sort_by! { |id, _, _| order.index(id) }
|
|
112
|
+
|
|
113
|
+
results.each do |_id, tool_call, content|
|
|
114
|
+
env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
|
|
115
|
+
env[:messages] << RubyLLM::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return env
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def pending_tool_calls(message)
|
|
125
|
+
message.tool_calls.to_h.reject { |_id, tc| tc.name == "question" }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolve_tools(tools)
|
|
129
|
+
tools.each_with_object({}) do |tool, hash|
|
|
130
|
+
instance = tool.is_a?(Class) ? tool.new : tool
|
|
131
|
+
hash[instance.name.to_sym] = instance
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def on_tool_call_start_event(pending_tools)
|
|
136
|
+
{
|
|
137
|
+
type: :tool_call_start,
|
|
138
|
+
data: pending_tools.map { |_id, tc|
|
|
139
|
+
{
|
|
140
|
+
name: tc.name,
|
|
141
|
+
call_id: tc.id,
|
|
142
|
+
arguments: tc.arguments
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
test do
|
|
152
|
+
require "brute/session"
|
|
153
|
+
require "brute/truncation"
|
|
154
|
+
|
|
155
|
+
it "passes through when no tool calls pending" do
|
|
156
|
+
inner = ->(env) {
|
|
157
|
+
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "hi")
|
|
158
|
+
}
|
|
159
|
+
mw = Brute::Middleware::ToolCall.new(inner)
|
|
160
|
+
env = {
|
|
161
|
+
messages: Brute::Session.new,
|
|
162
|
+
tools: [],
|
|
163
|
+
events: [],
|
|
164
|
+
}
|
|
165
|
+
env[:messages].user("hello")
|
|
166
|
+
mw.call(env)
|
|
167
|
+
env[:messages].last.content.should == "hi"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# --- Universal output truncation ---
|
|
171
|
+
|
|
172
|
+
it "truncates large tool results via Truncation" do
|
|
173
|
+
# A fake tool that returns a huge string
|
|
174
|
+
big_tool = Class.new(RubyLLM::Tool) do
|
|
175
|
+
description "test tool"
|
|
176
|
+
param :input, type: "string", desc: "input"
|
|
177
|
+
def name; "big_tool"; end
|
|
178
|
+
def execute(input:)
|
|
179
|
+
"line\n" * 3000
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
call_id = "tc_1"
|
|
184
|
+
tool_calls = {
|
|
185
|
+
call_id => RubyLLM::ToolCall.new(
|
|
186
|
+
id: call_id,
|
|
187
|
+
name: "big_tool",
|
|
188
|
+
arguments: { "input" => "go" },
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
inner = ->(env) {
|
|
193
|
+
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
|
|
194
|
+
}
|
|
195
|
+
mw = Brute::Middleware::ToolCall.new(inner)
|
|
196
|
+
env = {
|
|
197
|
+
messages: Brute::Session.new,
|
|
198
|
+
tools: [big_tool],
|
|
199
|
+
events: [],
|
|
200
|
+
}
|
|
201
|
+
env[:messages].user("hello")
|
|
202
|
+
mw.call(env)
|
|
203
|
+
|
|
204
|
+
tool_msg = env[:messages].select { |m| m.role == :tool }.last
|
|
205
|
+
tool_msg.content.lines.size.should.be < 2100
|
|
206
|
+
tool_msg.content.should =~ /truncated/i
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# --- Skip double-truncation ---
|
|
210
|
+
|
|
211
|
+
it "does not double-truncate already-truncated output" do
|
|
212
|
+
# A fake tool that returns output already containing the truncation marker
|
|
213
|
+
pre_truncated_tool = Class.new(RubyLLM::Tool) do
|
|
214
|
+
description "test tool"
|
|
215
|
+
param :input, type: "string", desc: "input"
|
|
216
|
+
def name; "pre_truncated_tool"; end
|
|
217
|
+
def execute(input:)
|
|
218
|
+
"some result\n[Output truncated: showing 100 of 5000 lines]"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
call_id = "tc_2"
|
|
223
|
+
tool_calls = {
|
|
224
|
+
call_id => RubyLLM::ToolCall.new(
|
|
225
|
+
id: call_id,
|
|
226
|
+
name: "pre_truncated_tool",
|
|
227
|
+
arguments: { "input" => "go" },
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
inner = ->(env) {
|
|
232
|
+
env[:messages] << RubyLLM::Message.new(role: :assistant, content: "", tool_calls: tool_calls)
|
|
233
|
+
}
|
|
234
|
+
mw = Brute::Middleware::ToolCall.new(inner)
|
|
235
|
+
env = {
|
|
236
|
+
messages: Brute::Session.new,
|
|
237
|
+
tools: [pre_truncated_tool],
|
|
238
|
+
events: [],
|
|
239
|
+
}
|
|
240
|
+
env[:messages].user("hello")
|
|
241
|
+
mw.call(env)
|
|
242
|
+
|
|
243
|
+
tool_msg = env[:messages].select { |m| m.role == :tool }.last
|
|
244
|
+
# Should contain exactly one truncation marker, not two
|
|
245
|
+
tool_msg.content.scan(/Output truncated/).size.should == 1
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Middleware
|
|
8
|
+
# Records tool calls the LLM requested as span events.
|
|
9
|
+
#
|
|
10
|
+
# Runs POST-call: after the LLM responds, inspects ctx.functions
|
|
11
|
+
# for any tool calls the model wants to make, and adds a span event
|
|
12
|
+
# for each one with the tool name, call ID, and arguments.
|
|
13
|
+
#
|
|
14
|
+
class OtelToolCalls
|
|
15
|
+
def initialize(app)
|
|
16
|
+
@app = app
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
#response = @app.call(env)
|
|
21
|
+
|
|
22
|
+
#span = env[:span]
|
|
23
|
+
#if span
|
|
24
|
+
# functions = env[:pending_functions]
|
|
25
|
+
# if functions && !functions.empty?
|
|
26
|
+
# span.set_attribute("brute.tool_calls.count", functions.size)
|
|
27
|
+
|
|
28
|
+
# functions.each do |fn|
|
|
29
|
+
# attrs = {
|
|
30
|
+
# "tool.name" => fn.name.to_s,
|
|
31
|
+
# "tool.id" => fn.id.to_s,
|
|
32
|
+
# }
|
|
33
|
+
# args = fn.arguments
|
|
34
|
+
# attrs["tool.arguments"] = args.to_json if args
|
|
35
|
+
# span.add_event("tool_call", attributes: attrs)
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#end
|
|
39
|
+
|
|
40
|
+
#response
|
|
41
|
+
@app.call(env)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
test do
|
|
48
|
+
# not implemented
|
|
49
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Middleware
|
|
8
|
+
# Records tool results as span events.
|
|
9
|
+
#
|
|
10
|
+
# Tool results are now appended directly to env[:messages] as :tool
|
|
11
|
+
# role messages. This middleware can inspect the last messages to
|
|
12
|
+
# record them as span events.
|
|
13
|
+
#
|
|
14
|
+
class OtelToolResults
|
|
15
|
+
def initialize(app)
|
|
16
|
+
@app = app
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
#span = env[:span]
|
|
21
|
+
|
|
22
|
+
#if span && (results = env[:tool_results])
|
|
23
|
+
# span.set_attribute("brute.tool_results.count", results.size)
|
|
24
|
+
|
|
25
|
+
# results.each do |name, value|
|
|
26
|
+
# error = value.is_a?(Hash) && value[:error]
|
|
27
|
+
# attrs = { "tool.name" => name.to_s }
|
|
28
|
+
# if error
|
|
29
|
+
# attrs["tool.status"] = "error"
|
|
30
|
+
# attrs["tool.error"] = value[:error].to_s
|
|
31
|
+
# else
|
|
32
|
+
# attrs["tool.status"] = "ok"
|
|
33
|
+
# end
|
|
34
|
+
# span.add_event("tool_result", attributes: attrs)
|
|
35
|
+
# end
|
|
36
|
+
#end
|
|
37
|
+
|
|
38
|
+
@app.call(env)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
test do
|
|
45
|
+
# not implemented
|
|
46
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
hash[instance.name.to_sym] = instance
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
completion_options = {
|
|
20
|
+
model: RubyLLM.models.find(env[:model], env[:provider]),
|
|
21
|
+
tools: available_tools,
|
|
22
|
+
temperature: env.fetch(:temperature, 0.7),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
complete(completion_options, env).then do |response|
|
|
26
|
+
env[:messages] << response
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
env
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def complete(kwargs, env)
|
|
35
|
+
provider_client = RubyLLM::Provider.resolve(env[:provider]).new(Brute.config)
|
|
36
|
+
|
|
37
|
+
if env[:streaming] == true
|
|
38
|
+
provider_client.complete(env[:messages], **kwargs) do |chunk|
|
|
39
|
+
if chunk.content && !chunk.content.to_s.empty?
|
|
40
|
+
env[:events] << { type: :content, data: chunk.content.to_s }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if chunk.respond_to?(:thinking) && chunk.thinking&.respond_to?(:text) && chunk.thinking.text
|
|
44
|
+
env[:events] << { type: :reasoning, data: chunk.thinking.text }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
provider_client.complete(env[:messages], **kwargs).then do |response|
|
|
49
|
+
if response.content.present?
|
|
50
|
+
env[:events] << { type: :content, data: response.content }
|
|
51
|
+
end
|
|
52
|
+
response
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
test do
|
|
61
|
+
# not implemented
|
|
62
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'brute'
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Middleware
|
|
8
|
+
class EventHandler
|
|
9
|
+
def initialize(app, handler_class:, **opts)
|
|
10
|
+
@app = app
|
|
11
|
+
@handler_class = handler_class
|
|
12
|
+
@opts = opts
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
env[:events] = @handler_class.new(env[:events], **@opts)
|
|
17
|
+
@app.call(env)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test do
|
|
24
|
+
# not implemented
|
|
25
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Middleware
|
|
8
|
+
class UserQueue
|
|
9
|
+
|
|
10
|
+
# Useful for testing...
|
|
11
|
+
# App will keep looping till all inputs are drained.
|
|
12
|
+
#
|
|
13
|
+
def initialize(app, inputs: [])
|
|
14
|
+
@app = app
|
|
15
|
+
@inputs = inputs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
if @inputs.any?
|
|
20
|
+
while inputs.any?
|
|
21
|
+
inputs.shift.then do |input|
|
|
22
|
+
@app.call(env)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
@app.call
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test do
|
|
34
|
+
# not implemented
|
|
35
|
+
end
|
data/lib/brute/pipeline.rb
CHANGED
|
@@ -4,79 +4,48 @@ require "bundler/setup"
|
|
|
4
4
|
require "brute"
|
|
5
5
|
|
|
6
6
|
module Brute
|
|
7
|
-
#
|
|
7
|
+
# Generic middleware machinery. Builds a chain of middleware around
|
|
8
|
+
# a terminal app, exposes `call(env)` to invoke it.
|
|
8
9
|
#
|
|
9
|
-
#
|
|
10
|
+
# Subclasses (Agent, Tool) override `call` to translate their public
|
|
11
|
+
# arguments into an env hash, then delegate to super.
|
|
10
12
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# - Retry (call inner app multiple times)
|
|
18
|
-
#
|
|
19
|
-
# ## The env hash
|
|
20
|
-
#
|
|
21
|
-
# {
|
|
22
|
-
# provider: LLM::Provider, # the LLM provider
|
|
23
|
-
# model: String|nil, # model override
|
|
24
|
-
# input: <prompt/results>, # what to pass to LLM
|
|
25
|
-
# tools: [Tool, ...], # tool classes
|
|
26
|
-
# messages: [LLM::Message], # conversation history (Brute-owned)
|
|
27
|
-
# stream: AgentStream|nil, # streaming bridge
|
|
28
|
-
# params: {}, # extra LLM call params
|
|
29
|
-
# metadata: {}, # shared scratchpad for middleware state
|
|
30
|
-
# callbacks: {}, # :on_content, :on_tool_call_start, :on_tool_result
|
|
31
|
-
# tool_results: Array|nil, # tool results from previous iteration
|
|
32
|
-
# streaming: Boolean, # whether streaming is active
|
|
33
|
-
# should_exit: Hash|nil, # exit signal from middleware
|
|
34
|
-
# pending_functions: [LLM::Function], # tool calls from last LLM response
|
|
35
|
-
# }
|
|
36
|
-
#
|
|
37
|
-
# ## The response
|
|
38
|
-
#
|
|
39
|
-
# The return value of call(env) is the LLM::Message from context.talk().
|
|
40
|
-
#
|
|
41
|
-
# ## Building a pipeline
|
|
42
|
-
#
|
|
43
|
-
# pipeline = Brute::Pipeline.new do
|
|
44
|
-
# use Brute::Middleware::Tracing, logger: logger
|
|
45
|
-
# use Brute::Middleware::Retry, max_attempts: 3
|
|
46
|
-
# use Brute::Middleware::SessionPersistence, session: session
|
|
47
|
-
# run Brute::Middleware::LLMCall.new
|
|
13
|
+
# class MyPipeline < Brute::Pipeline
|
|
14
|
+
# def call(input)
|
|
15
|
+
# env = { input: input, output: nil }
|
|
16
|
+
# super(env)
|
|
17
|
+
# env[:output]
|
|
18
|
+
# end
|
|
48
19
|
# end
|
|
49
20
|
#
|
|
50
|
-
# response = pipeline.call(env)
|
|
51
|
-
#
|
|
52
21
|
class Pipeline
|
|
53
22
|
def initialize(&block)
|
|
54
23
|
@middlewares = []
|
|
55
24
|
@app = nil
|
|
56
|
-
instance_eval(&block) if
|
|
25
|
+
instance_eval(&block) if block_given?
|
|
57
26
|
end
|
|
58
27
|
|
|
59
28
|
# Register a middleware class.
|
|
60
29
|
# The class must implement `initialize(app, *args, **kwargs)` and `call(env)`.
|
|
61
30
|
def use(klass, *args, **kwargs, &block)
|
|
62
|
-
@middlewares << [klass, args, kwargs, block]
|
|
63
|
-
self
|
|
31
|
+
tap { @middlewares << [klass, args, kwargs, block] }
|
|
64
32
|
end
|
|
65
33
|
|
|
66
34
|
# Set the terminal app (innermost handler).
|
|
35
|
+
# Accepts an instance (anything responding to #call(env)) or a class.
|
|
67
36
|
def run(app)
|
|
68
|
-
@app = app
|
|
69
|
-
self
|
|
37
|
+
tap { @app = app }
|
|
70
38
|
end
|
|
71
39
|
|
|
72
|
-
#
|
|
40
|
+
# Invoke the chain. Subclasses typically override this to shape env
|
|
41
|
+
# and extract a return value.
|
|
73
42
|
def call(env)
|
|
74
43
|
build.call(env)
|
|
75
44
|
end
|
|
76
45
|
|
|
77
46
|
# Build the chain without calling it. Useful for inspection or caching.
|
|
78
47
|
def build
|
|
79
|
-
raise "
|
|
48
|
+
raise "Stack has no terminal app — call `run` first" unless @app
|
|
80
49
|
|
|
81
50
|
@middlewares.reverse.inject(@app) do |inner, (klass, args, kwargs, block)|
|
|
82
51
|
if block
|
|
@@ -86,75 +55,43 @@ module Brute
|
|
|
86
55
|
end
|
|
87
56
|
end
|
|
88
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
|
|
89
63
|
end
|
|
90
64
|
end
|
|
91
65
|
|
|
92
66
|
test do
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
streaming: false, should_exit: nil, pending_functions: [] }
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
it "full pipeline passes env through all middleware" do
|
|
103
|
-
provider = MockProvider.new
|
|
104
|
-
session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
|
|
105
|
-
compactor = Object.new
|
|
106
|
-
compactor.define_singleton_method(:should_compact?) { |_msgs, **_| false }
|
|
107
|
-
log_output = StringIO.new
|
|
108
|
-
|
|
109
|
-
pipeline = Brute::Pipeline.new
|
|
110
|
-
pipeline.use(Brute::Middleware::Tracing, logger: Logger.new(log_output))
|
|
111
|
-
pipeline.use(Brute::Middleware::Retry, max_attempts: 3, base_delay: 2)
|
|
112
|
-
pipeline.use(Brute::Middleware::SessionPersistence, session: session)
|
|
113
|
-
pipeline.use(Brute::Middleware::TokenTracking)
|
|
114
|
-
pipeline.use(Brute::Middleware::CompactionCheck, compactor: compactor, system_prompt: "sys")
|
|
115
|
-
pipeline.use(Brute::Middleware::ToolErrorTracking)
|
|
116
|
-
pipeline.use(Brute::Middleware::DoomLoopDetection, threshold: 3)
|
|
117
|
-
pipeline.use(Brute::Middleware::ToolUseGuard)
|
|
118
|
-
pipeline.run(Brute::Middleware::LLMCall.new)
|
|
119
|
-
|
|
120
|
-
env = make_env(provider: provider, input: "hello")
|
|
121
|
-
result = pipeline.call(env)
|
|
122
|
-
result.should.not.be.nil
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
it "pipeline populates timing metadata" do
|
|
126
|
-
provider = MockProvider.new
|
|
127
|
-
session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
|
|
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
|
|
128
73
|
|
|
129
|
-
pipeline = Brute::Pipeline.new
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
74
|
+
pipeline = Brute::Pipeline.new do
|
|
75
|
+
use inc, label: "outer"
|
|
76
|
+
use inc, label: "inner"
|
|
77
|
+
run ->(env) { env[:trace] << "core" }
|
|
78
|
+
end
|
|
134
79
|
|
|
135
|
-
env =
|
|
80
|
+
env = { trace: [] }
|
|
136
81
|
pipeline.call(env)
|
|
137
|
-
env[:
|
|
82
|
+
env[:trace].should == ["outer", "inner", "core", "inner-after", "outer-after"]
|
|
138
83
|
end
|
|
139
84
|
|
|
140
|
-
it "
|
|
141
|
-
|
|
142
|
-
session = Struct.new(:saved) { def save_messages(m); self.saved = m; end }.new
|
|
143
|
-
|
|
144
|
-
pipeline = Brute::Pipeline.new
|
|
145
|
-
pipeline.use(Brute::Middleware::Tracing, logger: Logger.new(StringIO.new))
|
|
146
|
-
pipeline.use(Brute::Middleware::SessionPersistence, session: session)
|
|
147
|
-
pipeline.use(Brute::Middleware::TokenTracking)
|
|
148
|
-
pipeline.run(Brute::Middleware::LLMCall.new)
|
|
149
|
-
|
|
150
|
-
env = make_env(provider: provider, input: "hello")
|
|
151
|
-
pipeline.call(env)
|
|
152
|
-
env[:metadata][:tokens][:total_input].should.be > 0
|
|
85
|
+
it "raises when run was never called" do
|
|
86
|
+
lambda { Brute::Pipeline.new.call({}) }.should.raise(RuntimeError)
|
|
153
87
|
end
|
|
154
88
|
|
|
155
|
-
it "
|
|
156
|
-
pipeline = Brute::Pipeline.new
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
159
96
|
end
|
|
160
97
|
end
|
data/lib/brute/prompts/skills.rb
CHANGED
|
@@ -8,10 +8,10 @@ module Brute
|
|
|
8
8
|
module Skills
|
|
9
9
|
def self.call(ctx)
|
|
10
10
|
cwd = ctx[:cwd] || Dir.pwd
|
|
11
|
-
skills = Skill.all(cwd: cwd)
|
|
11
|
+
skills = Brute::Skill.all(cwd: cwd)
|
|
12
12
|
return nil if skills.empty?
|
|
13
13
|
|
|
14
|
-
listing = Skill.fmt(skills)
|
|
14
|
+
listing = Brute::Skill.fmt(skills)
|
|
15
15
|
|
|
16
16
|
<<~TXT
|
|
17
17
|
Skills provide specialized instructions and workflows for specific tasks.
|