brute 5.1.0 → 6.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/compaction/middleware/sliding_window.rb +1 -1
- data/lib/brute/compaction/middleware/strategy.rb +0 -2
- data/lib/brute/compaction/summarize.rb +30 -21
- data/lib/brute/completion/lang_chain.rb +34 -31
- data/lib/brute/completion/llmrb.rb +31 -29
- data/lib/brute/completion/open_router.rb +43 -40
- data/lib/brute/completion/ruby_llm.rb +28 -27
- data/lib/brute/contrib/otel.rb +81 -49
- data/lib/brute/eval/case.rb +7 -2
- data/lib/brute/eval/suite.rb +10 -5
- data/lib/brute/eval/transcript.rb +3 -3
- data/lib/brute/eval/world.rb +2 -2
- data/lib/brute/eval.rb +1 -1
- data/lib/brute/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +1 -1
- data/lib/brute/hooks.rb +88 -112
- data/lib/brute/message_transport/anthropic.rb +13 -3
- data/lib/brute/message_transport/llm.rb +8 -4
- data/lib/brute/message_transport/open_router.rb +30 -16
- data/lib/brute/message_transport/openai.rb +14 -7
- data/lib/brute/message_transport/ruby_llm.rb +21 -17
- data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
- data/lib/brute/messages.rb +15 -8
- data/lib/brute/middleware/000_base.rb +5 -5
- data/lib/brute/middleware/002_session_log.rb +12 -4
- data/lib/brute/middleware/008_checkpoint.rb +26 -18
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/040_default_compaction_pipeline.rb +49 -45
- data/lib/brute/middleware/070_default_tool_pipeline.rb +44 -53
- data/lib/brute/prompt_template.rb +19 -15
- data/lib/brute/prompts/base.rb +19 -10
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/instructions.rb +9 -7
- data/lib/brute/prompts/skills.rb +5 -3
- data/lib/brute/rack/adapter.rb +40 -23
- data/lib/brute/skill.rb +136 -89
- data/lib/brute/system_prompt.rb +9 -9
- data/lib/brute/token_counter.rb +3 -1
- data/lib/brute/tool.rb +10 -6
- data/lib/brute/tools/adapter.rb +53 -43
- data/lib/brute/tools/fs/snapshot_store.rb +5 -1
- data/lib/brute/tools/fs_patch.rb +16 -8
- data/lib/brute/tools/fs_read.rb +107 -80
- data/lib/brute/tools/fs_remove.rb +6 -2
- data/lib/brute/tools/fs_search.rb +14 -4
- data/lib/brute/tools/fs_undo.rb +6 -2
- data/lib/brute/tools/fs_write.rb +5 -1
- data/lib/brute/tools/net_fetch.rb +6 -2
- data/lib/brute/tools/question.rb +42 -39
- data/lib/brute/tools/shell.rb +20 -5
- data/lib/brute/tools/skill_load.rb +44 -39
- data/lib/brute/tools/todo_write.rb +19 -15
- data/lib/brute/truncation.rb +68 -43
- data/lib/brute/turn/agent_pipeline.rb +23 -13
- data/lib/brute/turn/compaction_pipeline.rb +4 -3
- data/lib/brute/turn/pipeline.rb +87 -96
- data/lib/brute/turn/tool_pipeline.rb +4 -3
- data/lib/brute/usage_detection/llmrb.rb +18 -14
- data/lib/brute/usage_detection/open_router.rb +20 -17
- data/lib/brute/usage_detection/ruby_llm.rb +18 -14
- data/lib/brute/usage_detection/usage.rb +10 -1
- data/lib/brute/utils/diff.rb +18 -10
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +10 -11
- data/lib/brute_cli/providers/shell.rb +32 -29
- data/lib/brute_cli/providers/shell_response.rb +20 -18
- metadata +30 -5
- data/lib/brute/middleware/040_compaction_check.rb +0 -73
- data/lib/brute/middleware/070_tool_pipeline.rb +0 -61
- data/lib/brute/middleware/event_handler.rb +0 -27
|
@@ -46,10 +46,10 @@ module Brute
|
|
|
46
46
|
# env[:tools] is not set yet: pass `tools:` to have them counted then.
|
|
47
47
|
#
|
|
48
48
|
# Compaction is lossy, and this layer keeps no record of what it gave up:
|
|
49
|
-
# it rewrites env[:messages] and says so with :
|
|
49
|
+
# it rewrites env[:messages] and says so with :compact_end. An application
|
|
50
50
|
# that keeps a transcript preserves it from there.
|
|
51
51
|
#
|
|
52
|
-
# agent.on(:
|
|
52
|
+
# agent.on(:compact_end) { |env, payload| archive(env, payload) }
|
|
53
53
|
#
|
|
54
54
|
class DefaultCompactionPipeline < Brute::Middleware::Base
|
|
55
55
|
def initialize(app, window:, summariser: nil, compactor: nil, keep_steps: 2,
|
|
@@ -101,22 +101,25 @@ module Brute
|
|
|
101
101
|
def compact(env)
|
|
102
102
|
context = estimate(env)
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
env
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
env
|
|
118
|
-
|
|
119
|
-
|
|
104
|
+
env.emit_trace do |env|
|
|
105
|
+
if context >= @window * @compact_at
|
|
106
|
+
env.emit(COMPACT_START_EVENT, { context: context })
|
|
107
|
+
|
|
108
|
+
before = @token_counter.count(env[:messages])
|
|
109
|
+
compacted = attempt(env, target(env))
|
|
110
|
+
|
|
111
|
+
unless compacted.nil?
|
|
112
|
+
env[:messages].replace(compacted)
|
|
113
|
+
# The reported total describes a conversation that no longer
|
|
114
|
+
# exists, so the next estimate must not build on it. The call
|
|
115
|
+
# below refreshes it; one that fails leaves the estimate to be
|
|
116
|
+
# counted from scratch instead of anchored to a fiction.
|
|
117
|
+
env[:metadata]&.delete(:last_llm_usage)
|
|
118
|
+
env.emit(
|
|
119
|
+
COMPACT_END_EVENT,
|
|
120
|
+
{ context: context, before: before, after: @token_counter.count(compacted) },
|
|
121
|
+
)
|
|
122
|
+
end
|
|
120
123
|
end
|
|
121
124
|
end
|
|
122
125
|
end
|
|
@@ -145,18 +148,17 @@ module Brute
|
|
|
145
148
|
def attempt(env, target)
|
|
146
149
|
compacted = nil
|
|
147
150
|
|
|
148
|
-
emit(COMPACT_DURATION_EVENT, env
|
|
151
|
+
env.emit(COMPACT_DURATION_EVENT, env[:compactor]) do
|
|
149
152
|
compacted = env[:compactor].compact(
|
|
150
153
|
env[:messages],
|
|
151
|
-
target:
|
|
152
|
-
events: env[:events] || Brute::Turn::Pipeline::NullSink.new,
|
|
154
|
+
target: target,
|
|
153
155
|
token_counter: @token_counter,
|
|
154
156
|
)
|
|
155
157
|
end
|
|
156
158
|
|
|
157
159
|
compacted
|
|
158
160
|
rescue => error
|
|
159
|
-
(
|
|
161
|
+
env.emit(COMPACT_FAILURE_EVENT, error)
|
|
160
162
|
nil
|
|
161
163
|
end
|
|
162
164
|
|
|
@@ -194,6 +196,10 @@ describe "brute/middleware/040_default_compaction_pipeline" do
|
|
|
194
196
|
end
|
|
195
197
|
end
|
|
196
198
|
|
|
199
|
+
def traced(env, hooks = Brute::Hooks::Registry.new)
|
|
200
|
+
Brute::Hooks::Trace.new(env, hooks: hooks)
|
|
201
|
+
end
|
|
202
|
+
|
|
197
203
|
def compactor(&block)
|
|
198
204
|
Object.new.tap do |double|
|
|
199
205
|
double.define_singleton_method(:compact) { |messages, target:, **| block.call(messages, target) }
|
|
@@ -207,6 +213,10 @@ describe "brute/middleware/040_default_compaction_pipeline" do
|
|
|
207
213
|
declines = compactor { |_messages, target| asked << target; nil }
|
|
208
214
|
raises = compactor { |_messages, _target| raise IOError, "the summariser is down" }
|
|
209
215
|
|
|
216
|
+
hooks = Brute::Hooks::Registry.new
|
|
217
|
+
%i[compact_end compact_failure].each { |event| hooks.on(event) { |_env, *extras| events << [event, *extras.first(1)] } }
|
|
218
|
+
hooks.on(:compact_duration) { |_env, *| events << [:compact_duration] }
|
|
219
|
+
|
|
210
220
|
layer = lambda do |compactor|
|
|
211
221
|
Brute::Middleware::DefaultCompactionPipeline.new(
|
|
212
222
|
->(env) { env },
|
|
@@ -214,18 +224,16 @@ describe "brute/middleware/040_default_compaction_pipeline" do
|
|
|
214
224
|
window: 1_000,
|
|
215
225
|
compact_at: 0.7,
|
|
216
226
|
compact_to: 0.4,
|
|
217
|
-
)
|
|
218
|
-
it.define_singleton_method(:emit) { |event, _env, *extras, &work| work&.call; events << [event, *extras] }
|
|
219
|
-
end
|
|
227
|
+
)
|
|
220
228
|
end
|
|
221
229
|
|
|
222
|
-
env = { messages: conversation, metadata: usage(900) }
|
|
230
|
+
env = traced({ messages: conversation, metadata: usage(900) }, hooks)
|
|
223
231
|
layer.call(shrinks).call(env)
|
|
224
232
|
|
|
225
233
|
asked.should == [400]
|
|
226
234
|
env[:messages].map(&:role).should == [:user]
|
|
227
235
|
env[:compactor].equal?(shrinks).should.be.true
|
|
228
|
-
events.select { |e, _| e == :
|
|
236
|
+
events.select { |e, _| e == :compact_end }.should == [[:compact_end, { context: 900, before: 514, after: 256 }]]
|
|
229
237
|
# The attempt is timed, so the compactor that spends a model call is visible.
|
|
230
238
|
events.select { |e, _| e == :compact_duration }.length.should == 1
|
|
231
239
|
|
|
@@ -233,27 +241,29 @@ describe "brute/middleware/040_default_compaction_pipeline" do
|
|
|
233
241
|
# asked and nothing is said.
|
|
234
242
|
asked.clear
|
|
235
243
|
events.clear
|
|
236
|
-
layer.call(shrinks).call({ messages: conversation, metadata: usage(600) })
|
|
244
|
+
layer.call(shrinks).call(traced({ messages: conversation, metadata: usage(600) }, hooks))
|
|
237
245
|
asked.should == []
|
|
238
246
|
events.should == []
|
|
239
247
|
|
|
240
248
|
# ...but what landed since it answered is counted on top of it, and that
|
|
241
249
|
# is what tips this one over.
|
|
242
250
|
tail = conversation.tap { |log| log.tool("z" * 400, tool_call_id: "tc1") }
|
|
243
|
-
layer.call(declines).call({ messages: tail, metadata: usage(600) })
|
|
251
|
+
layer.call(declines).call(traced({ messages: tail, metadata: usage(600) }, hooks))
|
|
244
252
|
asked.should == [400]
|
|
245
253
|
|
|
246
254
|
# With nothing reported at all, the whole conversation is counted here.
|
|
247
255
|
asked.clear
|
|
248
|
-
layer.call(declines).call({ messages: conversation })
|
|
256
|
+
layer.call(declines).call(traced({ messages: conversation }, hooks))
|
|
249
257
|
asked.should == []
|
|
250
258
|
|
|
251
259
|
# A compactor that raises is a compactor that declined: reported, not
|
|
252
260
|
# fatal. An agent that cannot shrink its context carries on with it.
|
|
253
|
-
|
|
261
|
+
events.clear
|
|
262
|
+
failing = traced({ messages: conversation, metadata: usage(900) }, hooks)
|
|
254
263
|
should.not.raise(IOError) { layer.call(raises).call(failing) }
|
|
255
264
|
failing[:messages].length.should == 2
|
|
256
|
-
|
|
265
|
+
events.select { |e, _| e == :compact_failure }.map { |_, error| error.message }
|
|
266
|
+
.should == ["the summariser is down"]
|
|
257
267
|
|
|
258
268
|
# A target at or above the trigger would compact on every single step.
|
|
259
269
|
should.raise(ArgumentError) do
|
|
@@ -272,16 +282,11 @@ describe "brute/middleware/040_default_compaction_pipeline" do
|
|
|
272
282
|
ladder = Brute::Middleware::DefaultCompactionPipeline.compactor(summariser: summariser, keep_steps: 1)
|
|
273
283
|
ladder.should.be.kind_of Brute::Turn::CompactionPipeline
|
|
274
284
|
|
|
275
|
-
events = []
|
|
276
|
-
sink = Object.new.tap { |it| it.define_singleton_method(:<<) { |e| events << e; it } }
|
|
277
|
-
|
|
278
285
|
messages = history
|
|
279
286
|
before = Brute::Compaction::Transcript.tokens(messages)
|
|
280
|
-
out = ladder.compact(messages, target: 1_500
|
|
287
|
+
out = ladder.compact(messages, target: 1_500)
|
|
281
288
|
|
|
282
289
|
Brute::Compaction::Transcript.tokens(out).should.be < before
|
|
283
|
-
events.select { |e| e[:type] == :compacted }.map { |e| e[:data][:strategy] }
|
|
284
|
-
.should.include "sliding_window"
|
|
285
290
|
|
|
286
291
|
# Given no summariser the terminal declines, and the free layers are all
|
|
287
292
|
# the agent gets -- a policy said out loud rather than configured.
|
|
@@ -290,8 +295,7 @@ describe "brute/middleware/040_default_compaction_pipeline" do
|
|
|
290
295
|
|
|
291
296
|
# One layer, so it still owns the *when* as well as the what.
|
|
292
297
|
layer = Brute::Middleware::DefaultCompactionPipeline.new(->(e) { e }, window: 1_000, summariser: summariser)
|
|
293
|
-
|
|
294
|
-
env = { messages: history, metadata: {} }
|
|
298
|
+
env = traced({ messages: history, metadata: {} })
|
|
295
299
|
layer.call(env)
|
|
296
300
|
env[:messages].length.should.be < 11
|
|
297
301
|
end
|
|
@@ -319,33 +323,33 @@ describe "brute/middleware/040_default_compaction_pipeline" do
|
|
|
319
323
|
said = -> { Brute.log.tap { |log| log.user("x" * 2_578) } }
|
|
320
324
|
Brute::TokenCounter.default.count(said.call).should == 650
|
|
321
325
|
|
|
322
|
-
layer.call.call({ messages: said.call, metadata: {} })
|
|
326
|
+
layer.call.call(traced({ messages: said.call, metadata: {} }))
|
|
323
327
|
asked.should == []
|
|
324
328
|
|
|
325
329
|
# The same conversation ships with tool schemas in every request, and
|
|
326
330
|
# counting what they occupy is what tips it over. What is left of the
|
|
327
331
|
# target after them is what the compactor is asked for -- the schemas are
|
|
328
332
|
# not room the conversation may have.
|
|
329
|
-
layer.call(tools: [tool]).call({ messages: said.call, metadata: {} })
|
|
333
|
+
layer.call(tools: [tool]).call(traced({ messages: said.call, metadata: {} }))
|
|
330
334
|
asked.should == [400 - schemas]
|
|
331
335
|
|
|
332
336
|
# Reported usage already covers the schemas, so the warm path must not
|
|
333
337
|
# count them again -- 600 reported and nothing said since is 600, tools
|
|
334
338
|
# or no tools.
|
|
335
339
|
asked.clear
|
|
336
|
-
layer.call(tools: [tool]).call({ messages: conversation, metadata: usage(600) })
|
|
340
|
+
layer.call(tools: [tool]).call(traced({ messages: conversation, metadata: usage(600) }))
|
|
337
341
|
asked.should == []
|
|
338
342
|
|
|
339
343
|
# What the provider counted describes the conversation as it was. Once
|
|
340
344
|
# compaction has given part of it up that number is a fiction, so it goes
|
|
341
345
|
# -- the next estimate counts from scratch rather than building on it.
|
|
342
|
-
compacted = { messages: conversation, metadata: usage(900) }
|
|
346
|
+
compacted = traced({ messages: conversation, metadata: usage(900) })
|
|
343
347
|
layer.call.call(compacted)
|
|
344
348
|
compacted[:messages].length.should == 1
|
|
345
349
|
compacted[:metadata].key?(:last_llm_usage).should.be.false
|
|
346
350
|
|
|
347
351
|
# Nothing given up, nothing invalidated.
|
|
348
|
-
kept = { messages: conversation, metadata: usage(900) }
|
|
352
|
+
kept = traced({ messages: conversation, metadata: usage(900) })
|
|
349
353
|
layer.call(compactor: compactor { |_messages, _target| nil }).call(kept)
|
|
350
354
|
kept[:metadata].key?(:last_llm_usage).should.be.true
|
|
351
355
|
end
|
|
@@ -33,7 +33,7 @@ module Brute
|
|
|
33
33
|
tools_to_run = Array(tools_to_run).reject { |tc| tc.name == "question" }
|
|
34
34
|
|
|
35
35
|
available_tools = Brute::Tools::Adapter.wrap_all(env[:tools])
|
|
36
|
-
env
|
|
36
|
+
env.emit(TOOL_CALLS_EVENT, tools_to_run)
|
|
37
37
|
|
|
38
38
|
results = []
|
|
39
39
|
|
|
@@ -44,47 +44,58 @@ module Brute
|
|
|
44
44
|
barrier = Async::Barrier.new
|
|
45
45
|
|
|
46
46
|
tools_to_run.each do |tool_call|
|
|
47
|
-
barrier.async do
|
|
47
|
+
barrier.async do
|
|
48
48
|
name = tool_call.name.to_sym
|
|
49
49
|
args = tool_call.arguments
|
|
50
50
|
|
|
51
|
-
# Lifecycle hooks (Brute::Hooks):
|
|
52
|
-
# :arguments or short-circuit with a :result;
|
|
53
|
-
# denies on a false (or String) return;
|
|
51
|
+
# Lifecycle hooks (Brute::Hooks): tool_start may rewrite
|
|
52
|
+
# :arguments or short-circuit with a :result; tool_approve
|
|
53
|
+
# denies on a false (or String) return; tool_end may
|
|
54
54
|
# rewrite :result.
|
|
55
55
|
call_env = {
|
|
56
56
|
name: name.to_s,
|
|
57
57
|
arguments: args,
|
|
58
58
|
result: nil,
|
|
59
59
|
denied: nil,
|
|
60
|
-
events: env[:events],
|
|
61
60
|
metadata: {},
|
|
62
61
|
turn_env: env,
|
|
63
62
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
env.emit_trace do |env|
|
|
64
|
+
# A subscriber takes part by mutating the call env: set
|
|
65
|
+
# :result to answer without executing, set :denied to refuse.
|
|
66
|
+
env.emit(TOOL_START_EVENT, call_env)
|
|
67
|
+
|
|
68
|
+
if call_env[:result].nil?
|
|
69
|
+
env.emit(TOOL_APPROVE_EVENT, call_env)
|
|
70
|
+
|
|
71
|
+
if (denial = call_env[:denied])
|
|
72
|
+
if denial.is_a?(String)
|
|
73
|
+
call_env[:result] = denial
|
|
74
|
+
else
|
|
75
|
+
call_env[:result] = %(Tool call to "#{name}" was denied.)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
73
78
|
end
|
|
74
|
-
end
|
|
75
79
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
# Only the tool's own execution is timed: a call that
|
|
81
|
+
# tool_start answered, or tool_approve denied, never ran.
|
|
82
|
+
result = call_env[:result]
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
if result.nil?
|
|
85
|
+
env.emit(TOOL_DURATION_EVENT, call_env) do
|
|
86
|
+
result = available_tools[name].call(call_env[:arguments])
|
|
87
|
+
end
|
|
83
88
|
end
|
|
84
|
-
end
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
call_env[:result] = result
|
|
91
|
+
env.emit(TOOL_END_EVENT, call_env)
|
|
92
|
+
rescue => error
|
|
93
|
+
# Capture the error as a tool result so the LLM can see it
|
|
94
|
+
# and reason about the failure, rather than crashing the
|
|
95
|
+
# entire middleware chain.
|
|
96
|
+
env.emit(TOOL_FAILURE_EVENT, call_env, error)
|
|
97
|
+
call_env[:result] = "Error: #{error.class}: #{error.message}"
|
|
98
|
+
end
|
|
88
99
|
result = call_env[:result]
|
|
89
100
|
|
|
90
101
|
# Coerce to String so Hash results (e.g. Shell's
|
|
@@ -101,12 +112,6 @@ module Brute
|
|
|
101
112
|
end
|
|
102
113
|
|
|
103
114
|
results << [tool_call, content]
|
|
104
|
-
rescue => e
|
|
105
|
-
# Capture the error as a tool result so the LLM can see it
|
|
106
|
-
# and reason about the failure, rather than crashing the
|
|
107
|
-
# entire middleware chain.
|
|
108
|
-
env[:events] << { type: :error, data: { error: e, message: e.message } }
|
|
109
|
-
results << [tool_call, "Error: #{e.class}: #{e.message}"]
|
|
110
115
|
end
|
|
111
116
|
end
|
|
112
117
|
|
|
@@ -120,7 +125,6 @@ module Brute
|
|
|
120
125
|
results.sort_by! { |tool_call, _| tools_to_run.index(tool_call) }
|
|
121
126
|
|
|
122
127
|
results.each do |tool_call, content|
|
|
123
|
-
env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } }
|
|
124
128
|
env[:messages] << Brute::Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
|
|
125
129
|
end
|
|
126
130
|
end
|
|
@@ -129,19 +133,6 @@ module Brute
|
|
|
129
133
|
end
|
|
130
134
|
|
|
131
135
|
private
|
|
132
|
-
|
|
133
|
-
def on_tool_call_start_event(pending_tools)
|
|
134
|
-
{
|
|
135
|
-
type: :tool_call_start,
|
|
136
|
-
data: pending_tools.map { |tc|
|
|
137
|
-
{
|
|
138
|
-
name: tc.name,
|
|
139
|
-
call_id: tc.id,
|
|
140
|
-
arguments: tc.arguments
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
end
|
|
145
136
|
end
|
|
146
137
|
end
|
|
147
138
|
end
|
|
@@ -193,7 +184,7 @@ describe "brute/middleware/070_default_tool_pipeline" do
|
|
|
193
184
|
{ messages: Brute.log, events: [] }
|
|
194
185
|
end
|
|
195
186
|
|
|
196
|
-
it "
|
|
187
|
+
it "tool_start may rewrite arguments and short-circuit with a result" do
|
|
197
188
|
tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
|
|
198
189
|
inner = ->(env) do
|
|
199
190
|
env[:messages] << Brute::Message.new(role: :assistant, content: "",
|
|
@@ -201,41 +192,41 @@ describe "brute/middleware/070_default_tool_pipeline" do
|
|
|
201
192
|
end
|
|
202
193
|
|
|
203
194
|
pipeline = hooked(inner, tools: [tool]) do |p|
|
|
204
|
-
p.on(:
|
|
195
|
+
p.on(:tool_start) { |_env, call| call[:arguments] = { text: "rewritten" } }
|
|
205
196
|
end
|
|
206
197
|
env = hook_env
|
|
207
198
|
env[:messages].user("hi")
|
|
208
199
|
pipeline.call(env)
|
|
209
200
|
env[:messages].last.content.should == "ran:rewritten"
|
|
210
201
|
|
|
211
|
-
canned = hooked(inner, tools: [tool]) { |p| p.on(:
|
|
202
|
+
canned = hooked(inner, tools: [tool]) { |p| p.on(:tool_start) { |_env, call| call[:result] = "canned" } }
|
|
212
203
|
env2 = hook_env
|
|
213
204
|
env2[:messages].user("hi")
|
|
214
205
|
canned.call(env2)
|
|
215
206
|
env2[:messages].last.content.should == "canned" # never executed
|
|
216
207
|
end
|
|
217
208
|
|
|
218
|
-
it "
|
|
209
|
+
it "tool_approve denies on false (generic message) or String (custom)" do
|
|
219
210
|
tool = { name: "exec", description: "", execute: ->(**) { "ran" } }
|
|
220
211
|
inner = ->(env) do
|
|
221
212
|
env[:messages] << Brute::Message.new(role: :assistant, content: "",
|
|
222
213
|
tool_calls: [{ id: "tc1", name: "exec", arguments: {} }])
|
|
223
214
|
end
|
|
224
215
|
|
|
225
|
-
denied = hooked(inner, tools: [tool]) { |p| p.on(:
|
|
216
|
+
denied = hooked(inner, tools: [tool]) { |p| p.on(:tool_approve) { |_env, call| call[:denied] = true } }
|
|
226
217
|
env = hook_env
|
|
227
218
|
env[:messages].user("hi")
|
|
228
219
|
denied.call(env)
|
|
229
220
|
env[:messages].last.content.should == %(Tool call to "exec" was denied.)
|
|
230
221
|
|
|
231
|
-
by_policy = hooked(inner, tools: [tool]) { |p| p.on(:
|
|
222
|
+
by_policy = hooked(inner, tools: [tool]) { |p| p.on(:tool_approve) { |_env, call| call[:denied] = "denied by policy" } }
|
|
232
223
|
env2 = hook_env
|
|
233
224
|
env2[:messages].user("hi")
|
|
234
225
|
by_policy.call(env2)
|
|
235
226
|
env2[:messages].last.content.should == "denied by policy"
|
|
236
227
|
end
|
|
237
228
|
|
|
238
|
-
it "
|
|
229
|
+
it "tool_end may rewrite the result" do
|
|
239
230
|
tool = { name: "echo", description: "", execute: ->(**) { "raw" } }
|
|
240
231
|
inner = ->(env) do
|
|
241
232
|
env[:messages] << Brute::Message.new(role: :assistant, content: "",
|
|
@@ -243,7 +234,7 @@ describe "brute/middleware/070_default_tool_pipeline" do
|
|
|
243
234
|
end
|
|
244
235
|
|
|
245
236
|
pipeline = hooked(inner, tools: [tool]) do |p|
|
|
246
|
-
p.on(:
|
|
237
|
+
p.on(:tool_end) { |_env, call| call[:result] = "rewrote(#{call[:result]})" }
|
|
247
238
|
end
|
|
248
239
|
env = hook_env
|
|
249
240
|
env[:messages].user("hi")
|
|
@@ -56,25 +56,29 @@ module Brute
|
|
|
56
56
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
def locals(ctx)
|
|
60
|
+
@section_keys.to_h do |key|
|
|
61
|
+
value = self[key]
|
|
62
|
+
if value.is_a?(Proc)
|
|
63
|
+
resolved = (value.arity.zero? ? value.call : value.call(ctx))
|
|
64
|
+
else
|
|
65
|
+
resolved = value
|
|
66
|
+
end
|
|
67
|
+
[key, resolved]
|
|
68
|
+
end.merge(ctx: ctx)
|
|
69
|
+
end
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
def render(values)
|
|
72
|
+
context = binding
|
|
73
|
+
values.each { |key, value| context.local_variable_set(key, value) }
|
|
74
|
+
ERB.new(template_source, trim_mode: "-").result(context)
|
|
75
|
+
end
|
|
72
76
|
|
|
73
77
|
# A path that exists is re-read every time; anything else is treated as
|
|
74
78
|
# an inline ERB source string.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
def template_source
|
|
80
|
+
File.exist?(@template.to_s) ? File.read(@template) : @template.to_s
|
|
81
|
+
end
|
|
78
82
|
end
|
|
79
83
|
end
|
|
80
84
|
|
data/lib/brute/prompts/base.rb
CHANGED
|
@@ -14,9 +14,14 @@ module Brute
|
|
|
14
14
|
def self.read(section, provider_name)
|
|
15
15
|
provider = provider_name.to_s
|
|
16
16
|
path = File.join(TEXT_DIR, section, "#{provider}.txt")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
unless File.exist?(path)
|
|
18
|
+
path = File.join(TEXT_DIR, section, "default.txt")
|
|
19
|
+
end
|
|
20
|
+
if File.exist?(path)
|
|
21
|
+
File.read(path)
|
|
22
|
+
else
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
# Read a named agent prompt (e.g. "explore", "compaction").
|
|
@@ -33,9 +38,11 @@ module Brute
|
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
def method_missing(name, *args)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
if args.empty? && @ctx.key?(name)
|
|
42
|
+
@ctx[name]
|
|
43
|
+
else
|
|
44
|
+
super
|
|
45
|
+
end
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
def respond_to_missing?(name, include_private = false)
|
|
@@ -69,10 +76,12 @@ module Brute
|
|
|
69
76
|
path = [provider, "default"]
|
|
70
77
|
.map { |variant| File.join(TEXT_DIR, section, "#{variant}.erb") }
|
|
71
78
|
.find { |candidate| File.exist?(candidate) }
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
if path
|
|
80
|
+
erb = TEMPLATES[path] ||= ERB.new(File.read(path), trim_mode: "-")
|
|
81
|
+
erb.result(Context.new(ctx).get_binding)
|
|
82
|
+
else
|
|
83
|
+
read(section, provider)
|
|
84
|
+
end
|
|
76
85
|
end
|
|
77
86
|
end
|
|
78
87
|
end
|
|
@@ -12,7 +12,9 @@ module Brute
|
|
|
12
12
|
git = File.exist?(File.join(cwd, ".git"))
|
|
13
13
|
|
|
14
14
|
parts = []
|
|
15
|
-
|
|
15
|
+
unless model.empty?
|
|
16
|
+
parts << "You are powered by the model named #{model}."
|
|
17
|
+
end
|
|
16
18
|
parts << ""
|
|
17
19
|
parts << "Here is some useful information about the environment you are running in:"
|
|
18
20
|
parts << "<env>"
|
|
@@ -8,13 +8,15 @@ module Brute
|
|
|
8
8
|
module Instructions
|
|
9
9
|
def self.call(ctx)
|
|
10
10
|
rules = ctx[:custom_rules]
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
if rules.nil? || rules.strip.empty?
|
|
12
|
+
nil
|
|
13
|
+
else
|
|
14
|
+
<<~TXT
|
|
15
|
+
# Project-Specific Rules
|
|
16
|
+
|
|
17
|
+
#{rules}
|
|
18
|
+
TXT
|
|
19
|
+
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
end
|
data/lib/brute/prompts/skills.rb
CHANGED
|
@@ -16,9 +16,11 @@ module Brute
|
|
|
16
16
|
def self.call(ctx)
|
|
17
17
|
skills = ctx[:skills] || Brute::Skill.all(cwd: ctx[:cwd] || Dir.pwd)
|
|
18
18
|
visible = skills.reject(&:disable_model_invocation?)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
if visible.empty?
|
|
20
|
+
nil
|
|
21
|
+
else
|
|
22
|
+
Prompts.render("skills", ctx.merge(skills: visible))
|
|
23
|
+
end
|
|
22
24
|
end
|
|
23
25
|
end
|
|
24
26
|
end
|
data/lib/brute/rack/adapter.rb
CHANGED
|
@@ -38,7 +38,9 @@ module Brute
|
|
|
38
38
|
# @parameter agent [#start] Anything with `start(prompt) -> env` — an
|
|
39
39
|
# AgentPipeline, a SubAgent, or any turn-shaped callable.
|
|
40
40
|
def initialize(agent)
|
|
41
|
-
|
|
41
|
+
unless agent.respond_to?(:start)
|
|
42
|
+
raise ArgumentError, "agent must respond to #start"
|
|
43
|
+
end
|
|
42
44
|
|
|
43
45
|
@agent = agent
|
|
44
46
|
end
|
|
@@ -48,10 +50,12 @@ module Brute
|
|
|
48
50
|
# (client's fault); anything the turn raises is a 500.
|
|
49
51
|
def call(env)
|
|
50
52
|
prompt = prompt_from(env)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
if prompt.nil? || prompt.empty?
|
|
54
|
+
response_for(env, 400, "No prompt provided.")
|
|
55
|
+
else
|
|
56
|
+
turn = @agent.start(prompt)
|
|
57
|
+
response_for(env, 200, output_of(turn))
|
|
58
|
+
end
|
|
55
59
|
rescue => error
|
|
56
60
|
response_for(env, 500, error.message)
|
|
57
61
|
end
|
|
@@ -68,24 +72,29 @@ module Brute
|
|
|
68
72
|
request = ::Rack::Request.new(env)
|
|
69
73
|
|
|
70
74
|
if (query = request.GET["prompt"]) && !query.empty?
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
query
|
|
76
|
+
else
|
|
77
|
+
body = read_body(request)
|
|
78
|
+
if body.nil? || body.strip.empty?
|
|
79
|
+
nil
|
|
80
|
+
else
|
|
81
|
+
from_json = nil
|
|
82
|
+
if json?(request.media_type)
|
|
83
|
+
case data = parse_json(body)
|
|
84
|
+
when ::Hash then from_json = PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
|
|
85
|
+
when ::String then from_json = data
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if from_json
|
|
90
|
+
from_json
|
|
91
|
+
elsif request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
|
|
92
|
+
field
|
|
93
|
+
else
|
|
94
|
+
body
|
|
95
|
+
end
|
|
81
96
|
end
|
|
82
97
|
end
|
|
83
|
-
|
|
84
|
-
if request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
|
|
85
|
-
return field
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
body
|
|
89
98
|
end
|
|
90
99
|
|
|
91
100
|
# output -> [status, headers, body]. Content-negotiated: JSON in (or an
|
|
@@ -96,7 +105,11 @@ module Brute
|
|
|
96
105
|
text = output.to_s
|
|
97
106
|
|
|
98
107
|
if wants_json?(env)
|
|
99
|
-
|
|
108
|
+
if status == 200
|
|
109
|
+
key = :response
|
|
110
|
+
else
|
|
111
|
+
key = :error
|
|
112
|
+
end
|
|
100
113
|
[status, {"content-type" => "application/json"}, [::JSON.generate(key => text)]]
|
|
101
114
|
else
|
|
102
115
|
[status, {"content-type" => "text/plain; charset=utf-8"}, [text]]
|
|
@@ -107,7 +120,11 @@ module Brute
|
|
|
107
120
|
|
|
108
121
|
# The agent's answer is the last message it appended to the log.
|
|
109
122
|
def output_of(turn)
|
|
110
|
-
|
|
123
|
+
if turn.is_a?(::Hash)
|
|
124
|
+
messages = turn[:messages]
|
|
125
|
+
else
|
|
126
|
+
messages = turn
|
|
127
|
+
end
|
|
111
128
|
messages&.last&.content.to_s
|
|
112
129
|
end
|
|
113
130
|
|