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
data/lib/brute/truncation.rb
CHANGED
|
@@ -40,7 +40,13 @@ module Brute
|
|
|
40
40
|
MAX_LINE_LENGTH = 2000
|
|
41
41
|
TRUNCATION_MARKER = "[Output truncated:"
|
|
42
42
|
|
|
43
|
-
TRUNCATION_DIR = File.join(
|
|
43
|
+
TRUNCATION_DIR = File.join(
|
|
44
|
+
Dir.home,
|
|
45
|
+
".local",
|
|
46
|
+
"share",
|
|
47
|
+
"brute",
|
|
48
|
+
"tool-output",
|
|
49
|
+
)
|
|
44
50
|
|
|
45
51
|
# Truncate text to fit within line and byte limits.
|
|
46
52
|
#
|
|
@@ -55,38 +61,48 @@ module Brute
|
|
|
55
61
|
# @returns [String] the (possibly truncated) text
|
|
56
62
|
#
|
|
57
63
|
def self.truncate(text, max_lines: MAX_LINES, max_bytes: MAX_BYTES, direction: :head, truncation_dir: nil)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
64
|
+
if text.nil? || text.empty?
|
|
65
|
+
text
|
|
66
|
+
else
|
|
67
|
+
# Per-line truncation first — cap individual lines
|
|
68
|
+
lines = text.lines.map { |line| truncate_line(line) }
|
|
69
|
+
text = lines.join
|
|
70
|
+
|
|
71
|
+
if lines.size <= max_lines && text.bytesize <= max_bytes
|
|
72
|
+
text
|
|
73
|
+
else
|
|
74
|
+
# Determine how many lines we can keep within both caps
|
|
75
|
+
if direction == :tail
|
|
76
|
+
kept = lines.last(max_lines)
|
|
77
|
+
else
|
|
78
|
+
kept = lines.first(max_lines)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Enforce byte cap
|
|
82
|
+
result_lines = []
|
|
83
|
+
bytes = 0
|
|
84
|
+
kept.each do |line|
|
|
85
|
+
if bytes + line.bytesize > max_bytes
|
|
86
|
+
break
|
|
87
|
+
end
|
|
88
|
+
result_lines << line
|
|
89
|
+
bytes += line.bytesize
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result = result_lines.join
|
|
93
|
+
total = lines.size
|
|
94
|
+
shown = result_lines.size
|
|
95
|
+
|
|
96
|
+
# Overflow to disk — save the full output so it can be inspected later
|
|
97
|
+
saved_path = save_to_disk(text, truncation_dir)
|
|
98
|
+
|
|
99
|
+
hint = "\n#{TRUNCATION_MARKER} showing #{shown} of #{total} lines]"
|
|
100
|
+
if saved_path
|
|
101
|
+
hint += "\nFull output saved to: #{saved_path}. Use Read with offset/limit to view specific sections."
|
|
102
|
+
end
|
|
103
|
+
result + hint
|
|
104
|
+
end
|
|
88
105
|
end
|
|
89
|
-
result + hint
|
|
90
106
|
end
|
|
91
107
|
|
|
92
108
|
# Check whether text already contains a truncation marker.
|
|
@@ -96,27 +112,36 @@ module Brute
|
|
|
96
112
|
|
|
97
113
|
# Truncate a single line if it exceeds MAX_LINE_LENGTH.
|
|
98
114
|
def self.truncate_line(line, max: MAX_LINE_LENGTH)
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
if line.length <= max
|
|
116
|
+
line
|
|
117
|
+
else
|
|
118
|
+
line[0, max] + "... (line truncated to #{max} chars)\n"
|
|
119
|
+
end
|
|
101
120
|
end
|
|
102
121
|
|
|
103
122
|
# Purge files older than retention_days from the given directory.
|
|
104
123
|
def self.cleanup!(dir, retention_days: 7)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
if File.directory?(dir)
|
|
125
|
+
cutoff = Time.now - (retention_days * 86400)
|
|
126
|
+
Dir.glob(File.join(dir, "*")).each do |path|
|
|
127
|
+
if File.file?(path) && File.mtime(path) < cutoff
|
|
128
|
+
File.delete(path)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
nil
|
|
109
133
|
end
|
|
110
134
|
end
|
|
111
135
|
|
|
112
136
|
# Save text to a file in truncation_dir. Returns the file path, or nil.
|
|
113
137
|
def self.save_to_disk(text, truncation_dir)
|
|
114
138
|
dir = truncation_dir || TRUNCATION_DIR
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
if dir
|
|
140
|
+
FileUtils.mkdir_p(dir)
|
|
141
|
+
path = File.join(dir, "tool_#{SecureRandom.hex(8)}.txt")
|
|
142
|
+
File.write(path, text)
|
|
143
|
+
path
|
|
144
|
+
end
|
|
120
145
|
rescue => _e
|
|
121
146
|
nil
|
|
122
147
|
end
|
|
@@ -38,8 +38,10 @@ module Brute
|
|
|
38
38
|
# the newest message, only when it is a user message, before the rest
|
|
39
39
|
# of the stack.
|
|
40
40
|
def map(matcher, &block)
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
tap do
|
|
42
|
+
@commands ||= []
|
|
43
|
+
@commands << [check(matcher), block]
|
|
44
|
+
end
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# Every chain starts with the commands, registry or no registry: the
|
|
@@ -50,21 +52,25 @@ module Brute
|
|
|
50
52
|
end
|
|
51
53
|
alias_method :build, :to_app
|
|
52
54
|
|
|
53
|
-
def start(input = nil
|
|
54
|
-
|
|
55
|
+
def start(input = nil)
|
|
56
|
+
{
|
|
55
57
|
messages: coerce_messages(input),
|
|
56
|
-
events: events,
|
|
57
58
|
metadata: {},
|
|
58
59
|
current_iteration: 1,
|
|
59
60
|
commands: @commands || [],
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
}.tap do |turn|
|
|
62
|
+
Brute::Hooks::Trace.new(turn, hooks: hooks).emit_trace do |env|
|
|
63
|
+
env.emit(TURN_START_EVENT)
|
|
64
|
+
begin
|
|
65
|
+
env.emit(TURN_DURATION_EVENT) { build.call(env) }
|
|
66
|
+
rescue => error
|
|
67
|
+
env.emit(TURN_FAILURE_EVENT, error)
|
|
68
|
+
raise
|
|
69
|
+
ensure
|
|
70
|
+
env.emit(TURN_END_EVENT)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
66
73
|
end
|
|
67
|
-
env
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
private
|
|
@@ -86,7 +92,11 @@ module Brute
|
|
|
86
92
|
matcher
|
|
87
93
|
else
|
|
88
94
|
name = matcher.to_s
|
|
89
|
-
|
|
95
|
+
if name.start_with?("/")
|
|
96
|
+
name = name
|
|
97
|
+
else
|
|
98
|
+
name = "/#{name}"
|
|
99
|
+
end
|
|
90
100
|
/^#{::Regexp.escape(name)}.*/
|
|
91
101
|
end
|
|
92
102
|
end
|
|
@@ -40,18 +40,19 @@ module Brute
|
|
|
40
40
|
class CompactionPipeline
|
|
41
41
|
def initialize(&block)
|
|
42
42
|
@pipeline = Pipeline.new
|
|
43
|
-
|
|
43
|
+
if block
|
|
44
|
+
@pipeline.instance_eval(&block)
|
|
45
|
+
end
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# Answer a smaller conversation, or nil when nothing was given up.
|
|
47
|
-
def compact(messages, target:,
|
|
49
|
+
def compact(messages, target:, token_counter: nil)
|
|
48
50
|
env = {
|
|
49
51
|
conversation: messages,
|
|
50
52
|
target: target,
|
|
51
53
|
token_counter: token_counter,
|
|
52
54
|
applied: [],
|
|
53
55
|
messages: Brute.log,
|
|
54
|
-
events: events,
|
|
55
56
|
metadata: {},
|
|
56
57
|
}
|
|
57
58
|
@pipeline.call(env)
|
data/lib/brute/turn/pipeline.rb
CHANGED
|
@@ -21,32 +21,46 @@ module Brute
|
|
|
21
21
|
# }
|
|
22
22
|
#
|
|
23
23
|
# Every layer announces itself: :middleware_added when it goes on the
|
|
24
|
-
# stack, then :
|
|
25
|
-
# of those carry the middleware instance as self, and :
|
|
24
|
+
# stack, then :middleware_start and :middleware_end around its own work on each turn. Both
|
|
25
|
+
# of those carry the middleware instance as self, and :middleware_end fires from
|
|
26
26
|
# an ensure so a layer that raises is still reported.
|
|
27
27
|
def use(middleware, *args, &block)
|
|
28
28
|
tap do
|
|
29
29
|
super
|
|
30
|
-
hooks.emit(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
30
|
+
hooks.emit(
|
|
31
|
+
MIDDLEWARE_ADDED_EVENT,
|
|
32
|
+
{},
|
|
33
|
+
middleware,
|
|
34
|
+
*args,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@use.pop.then do |builder|
|
|
38
|
+
@use << lambda do |app|
|
|
39
|
+
builder.call(app).tap do |layer|
|
|
40
|
+
layer.define_singleton_method(:call) do |env|
|
|
41
|
+
result = nil
|
|
42
|
+
|
|
43
|
+
# A layer is one call: what it emits, and what the layers
|
|
44
|
+
# below it emit, belong to this trace.
|
|
45
|
+
env.emit_trace do |env|
|
|
46
|
+
env.emit(MIDDLEWARE_START_EVENT, self)
|
|
47
|
+
|
|
48
|
+
# The layer's work is :middleware_duration's block, so its subscribers
|
|
49
|
+
# are handed how long this layer took — its own work plus
|
|
50
|
+
# everything below it. :middleware_end then marks the layer done,
|
|
51
|
+
# from an ensure so it lands even when the work raised.
|
|
52
|
+
begin
|
|
53
|
+
env.emit(MIDDLEWARE_DURATION_EVENT, self) { result = super(env) }
|
|
54
|
+
rescue => error
|
|
55
|
+
env.emit(MIDDLEWARE_FAILURE_EVENT, self, error)
|
|
56
|
+
raise
|
|
57
|
+
ensure
|
|
58
|
+
env.emit(MIDDLEWARE_END_EVENT, self)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
result
|
|
47
63
|
end
|
|
48
|
-
|
|
49
|
-
result
|
|
50
64
|
end
|
|
51
65
|
end
|
|
52
66
|
end
|
|
@@ -55,11 +69,12 @@ module Brute
|
|
|
55
69
|
# Keeps a trailing hash flagged as keywords through `super`, the way
|
|
56
70
|
# Rack::Builder#use does — without it `use Mw, label: "x"` arrives at
|
|
57
71
|
# the middleware's constructor as a positional Hash.
|
|
58
|
-
|
|
72
|
+
if respond_to?(:ruby2_keywords, true)
|
|
73
|
+
ruby2_keywords(:use)
|
|
74
|
+
end
|
|
59
75
|
|
|
60
76
|
def run(app = nil, &block)
|
|
61
77
|
tap do
|
|
62
|
-
bind_emitter(app) unless app.nil?
|
|
63
78
|
super
|
|
64
79
|
end
|
|
65
80
|
end
|
|
@@ -69,62 +84,40 @@ module Brute
|
|
|
69
84
|
# Brute.agent
|
|
70
85
|
# .use(MaxProfit)
|
|
71
86
|
# .run(->(env) { ... })
|
|
72
|
-
# .on(:
|
|
73
|
-
# .on(:
|
|
87
|
+
# .on(:llm_start) { |env| ... }
|
|
88
|
+
# .on(:tool_approve) { |call| call[:name] != "exec" }
|
|
74
89
|
#
|
|
75
90
|
def on(...) = tap { hooks.on(...) }
|
|
76
91
|
|
|
77
92
|
private
|
|
78
93
|
|
|
79
|
-
# Bind an emit() to this builder's store, so a layer fires events at
|
|
80
|
-
# the pipeline it belongs to rather than fishing them out of env.
|
|
81
|
-
def bind_emitter(object)
|
|
82
|
-
if object.is_a?(Proc)
|
|
83
|
-
warn("brute: #{self.class} was given a lambda, which cannot be given an emit — its events will not fire")
|
|
84
|
-
return object
|
|
85
|
-
end
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
raise ArgumentError, "#{object.class} already defines #emit, so the pipeline will not bind its own over it"
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
store = hooks
|
|
92
|
-
object.define_singleton_method(:emit) do |event, env, *extras, &work|
|
|
93
|
-
store.emit(event, env, *extras, &work)
|
|
94
|
-
end
|
|
95
|
-
object
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
public
|
|
95
|
+
public
|
|
99
96
|
end
|
|
100
97
|
|
|
101
98
|
include Chainable
|
|
102
99
|
|
|
103
|
-
# Rack's `new_from_string` evaluates the script then returns `to_app` —
|
|
104
|
-
# the built callable. An agent, though, is the *builder*: you `.start` it,
|
|
105
|
-
# and it may be re-`use`d or served through the Rack adapter. So evaluate
|
|
106
|
-
# the script against a fresh builder and hand back the builder itself.
|
|
107
|
-
# This also backs `parse_file` (→ `load_file` → here), so
|
|
108
|
-
# `AgentPipeline.parse_file("agent.ru").start(prompt)` works as documented.
|
|
109
100
|
def self.new_from_string(builder_script, path = "(rackup)", **options)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
101
|
+
new(**options).tap do |builder|
|
|
102
|
+
eval(builder_script, ::Rack::BUILDER_TOPLEVEL_BINDING.call(builder), path) # rubocop:disable Security/Eval
|
|
103
|
+
end
|
|
113
104
|
end
|
|
114
105
|
|
|
115
|
-
# Brute's name for Rack's `to_app
|
|
116
|
-
# terminal app and return the runnable callable. Raises (via `to_app`)
|
|
117
|
-
# when `run` was never called.
|
|
106
|
+
# Brute's name for Rack's `to_app`
|
|
118
107
|
alias_method :build, :to_app
|
|
119
108
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
109
|
+
def initialize(default_app = nil, hooks: Brute::Hooks::Registry.new, **options, &block)
|
|
110
|
+
@hooks = hooks
|
|
111
|
+
super(default_app, **options, &block)
|
|
123
112
|
end
|
|
124
113
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
114
|
+
attr_reader :hooks
|
|
115
|
+
|
|
116
|
+
# Every way into a pipeline arrives at a Trace: what a caller hands over
|
|
117
|
+
# is wrapped once, and a pipeline entered from inside another keeps the
|
|
118
|
+
# trace it was already in.
|
|
119
|
+
def call(env)
|
|
120
|
+
env.is_a?(Brute::Hooks::Trace) ? super : super(Brute::Hooks::Trace.new(env, hooks: hooks))
|
|
128
121
|
end
|
|
129
122
|
end
|
|
130
123
|
end
|
|
@@ -135,11 +128,11 @@ __END__
|
|
|
135
128
|
describe "brute/turn/pipeline" do
|
|
136
129
|
it "announces a layer when it is added, then on the way in and out of every turn" do
|
|
137
130
|
seen = []
|
|
138
|
-
hooks = Brute::Hooks.new
|
|
131
|
+
hooks = Brute::Hooks::Registry.new
|
|
139
132
|
hooks.on(Brute::Hooks::MIDDLEWARE_ADDED_EVENT) { |env, mw, *args| seen << [:added, env, mw, args] }
|
|
140
|
-
hooks.on(Brute::Hooks::
|
|
141
|
-
hooks.on(Brute::Hooks::
|
|
142
|
-
hooks.on(Brute::Hooks::
|
|
133
|
+
hooks.on(Brute::Hooks::MIDDLEWARE_START_EVENT) { |_env, layer| seen << [:middleware_start, layer.class] }
|
|
134
|
+
hooks.on(Brute::Hooks::MIDDLEWARE_DURATION_EVENT) { |_env, started, finished, layer| seen << [:middleware_duration, layer.class, finished >= started] }
|
|
135
|
+
hooks.on(Brute::Hooks::MIDDLEWARE_END_EVENT) { |_env, layer| seen << [:middleware_end, layer.class] }
|
|
143
136
|
|
|
144
137
|
labeller = Class.new do
|
|
145
138
|
def initialize(app, label:); @app = app; @label = label; end
|
|
@@ -153,12 +146,12 @@ describe "brute/turn/pipeline" do
|
|
|
153
146
|
pipeline.call({ hooks: hooks })
|
|
154
147
|
|
|
155
148
|
seen.first.should == [:added, {}, labeller, [{ label: "outer" }]]
|
|
156
|
-
seen[1].should == [:
|
|
157
|
-
seen[2].should == [:
|
|
158
|
-
seen[3].should == [:
|
|
149
|
+
seen[1].should == [:middleware_start, labeller]
|
|
150
|
+
seen[2].should == [:middleware_duration, labeller, true]
|
|
151
|
+
seen[3].should == [:middleware_end, labeller]
|
|
159
152
|
end
|
|
160
153
|
|
|
161
|
-
it "reports a layer that raises through :
|
|
154
|
+
it "reports a layer that raises through :middleware_end anyway" do
|
|
162
155
|
seen = []
|
|
163
156
|
|
|
164
157
|
boom = Class.new do
|
|
@@ -167,7 +160,7 @@ describe "brute/turn/pipeline" do
|
|
|
167
160
|
end
|
|
168
161
|
|
|
169
162
|
pipeline = Brute::Turn::Pipeline.new
|
|
170
|
-
pipeline.on(Brute::Hooks::
|
|
163
|
+
pipeline.on(Brute::Hooks::MIDDLEWARE_END_EVENT) { |_env, layer| seen << layer.class }
|
|
171
164
|
pipeline.use boom
|
|
172
165
|
pipeline.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
|
|
173
166
|
begin
|
|
@@ -179,42 +172,40 @@ describe "brute/turn/pipeline" do
|
|
|
179
172
|
seen.should == [boom]
|
|
180
173
|
end
|
|
181
174
|
|
|
182
|
-
it "
|
|
175
|
+
it "wraps what it is called with in a trace, so a layer emits without being given anything" do
|
|
183
176
|
seen = []
|
|
184
177
|
quiet = Class.new do
|
|
185
178
|
def initialize(app); @app = app; end
|
|
186
|
-
def call(env); emit(:ping,
|
|
179
|
+
def call(env); env.emit(:ping, :from_layer); @app.call(env); end
|
|
187
180
|
end
|
|
188
181
|
|
|
189
182
|
pipeline = Brute::Turn::Pipeline.new
|
|
190
|
-
pipeline.on(:ping) { |env, extra| seen << [env, extra] }
|
|
183
|
+
pipeline.on(:ping) { |env, extra, id| seen << [env[:said], extra, id] }
|
|
191
184
|
pipeline.use quiet
|
|
192
185
|
pipeline.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
|
|
193
|
-
pipeline.call({})
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def initialize(app); @app = app; end
|
|
207
|
-
def emit(*); end
|
|
208
|
-
def call(env); env; end
|
|
186
|
+
pipeline.call({ said: "hello" })
|
|
187
|
+
|
|
188
|
+
said, extra, id = seen.first
|
|
189
|
+
said.should == "hello"
|
|
190
|
+
extra.should == :from_layer
|
|
191
|
+
id.should.not.be.nil
|
|
192
|
+
|
|
193
|
+
# A lambda is a layer like any other now: it is handed the trace as its env.
|
|
194
|
+
answered = []
|
|
195
|
+
Brute::Turn::Pipeline.new.tap do |lambdas|
|
|
196
|
+
lambdas.on(:ping) { |_env, extra| answered << extra }
|
|
197
|
+
lambdas.run ->(env) { env.emit(:ping, :from_lambda); env }
|
|
198
|
+
lambdas.call({})
|
|
209
199
|
end
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
200
|
+
answered.should == [:from_lambda]
|
|
201
|
+
|
|
202
|
+
# Given a registry it uses that one rather than building its own, so a
|
|
203
|
+
# nested pipeline reports to the agent that owns it.
|
|
204
|
+
shared = Brute::Hooks::Registry.new
|
|
205
|
+
Brute::Turn::Pipeline.new(hooks: shared).hooks.equal?(shared).should.be.true
|
|
216
206
|
end
|
|
217
207
|
|
|
208
|
+
|
|
218
209
|
it "builds and calls a chain" do
|
|
219
210
|
seen = []
|
|
220
211
|
inc = Class.new do
|
|
@@ -38,15 +38,16 @@ module Brute
|
|
|
38
38
|
@description = description
|
|
39
39
|
@params = params
|
|
40
40
|
@pipeline = Pipeline.new
|
|
41
|
-
|
|
41
|
+
if block
|
|
42
|
+
@pipeline.instance_eval(&block)
|
|
43
|
+
end
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
def call(
|
|
46
|
+
def call(**arguments)
|
|
45
47
|
env = {
|
|
46
48
|
name: @name,
|
|
47
49
|
arguments: arguments,
|
|
48
50
|
result: nil,
|
|
49
|
-
events: events,
|
|
50
51
|
metadata: {},
|
|
51
52
|
}
|
|
52
53
|
@pipeline.call(env)
|
|
@@ -11,20 +11,24 @@ module Brute
|
|
|
11
11
|
# reading of the response and it is reported as given.
|
|
12
12
|
module LLMrb
|
|
13
13
|
def self.detect(response)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
if response.respond_to?(:usage)
|
|
15
|
+
usage = response.usage
|
|
16
|
+
if usage.nil?
|
|
17
|
+
nil
|
|
18
|
+
else
|
|
19
|
+
Usage.new(
|
|
20
|
+
input: read(usage, :input_tokens),
|
|
21
|
+
output: read(usage, :output_tokens),
|
|
22
|
+
total: read(usage, :total_tokens),
|
|
23
|
+
reasoning: read(usage, :reasoning_tokens),
|
|
24
|
+
cache_read: read(usage, :cache_read_tokens),
|
|
25
|
+
cache_write: read(usage, :cache_write_tokens),
|
|
26
|
+
raw: usage,
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
else
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def self.read(usage, name) = usage.respond_to?(name) ? usage.public_send(name) : nil
|
|
@@ -11,23 +11,26 @@ module Brute
|
|
|
11
11
|
# arrive nested under completion_tokens_details.
|
|
12
12
|
module OpenRouter
|
|
13
13
|
def self.detect(response)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
14
|
+
if response.respond_to?(:usage)
|
|
15
|
+
usage = response.usage
|
|
16
|
+
if usage.nil?
|
|
17
|
+
nil
|
|
18
|
+
else
|
|
19
|
+
usage = usage.to_h
|
|
20
|
+
details = (usage["completion_tokens_details"] || usage[:completion_tokens_details] || {}).to_h
|
|
21
|
+
Usage.new(
|
|
22
|
+
input: field(usage, "prompt_tokens"),
|
|
23
|
+
output: field(usage, "completion_tokens"),
|
|
24
|
+
total: field(usage, "total_tokens"),
|
|
25
|
+
reasoning: field(details, "reasoning_tokens"),
|
|
26
|
+
cache_read: field(usage, "cached_tokens"),
|
|
27
|
+
cost: field(usage, "cost"),
|
|
28
|
+
raw: usage,
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
# OpenRouter hands back string keys; a hand-built response may not.
|
|
@@ -11,20 +11,24 @@ module Brute
|
|
|
11
11
|
# provider's own reported_cost when it gives one.
|
|
12
12
|
module RubyLLM
|
|
13
13
|
def self.detect(message)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
if message.respond_to?(:tokens)
|
|
15
|
+
tokens = message.tokens
|
|
16
|
+
if tokens.nil?
|
|
17
|
+
nil
|
|
18
|
+
else
|
|
19
|
+
Usage.new(
|
|
20
|
+
input: read(tokens, :input),
|
|
21
|
+
output: read(tokens, :output),
|
|
22
|
+
reasoning: read(tokens, :thinking),
|
|
23
|
+
cache_read: read(tokens, :cache_read),
|
|
24
|
+
cache_write: read(tokens, :cache_write),
|
|
25
|
+
cost: read(tokens, :reported_cost),
|
|
26
|
+
raw: tokens,
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
else
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def self.read(tokens, name) = tokens.respond_to?(name) ? tokens.public_send(name) : nil
|
|
@@ -19,7 +19,16 @@ module Brute
|
|
|
19
19
|
# turn are the env's business, not the reader's. A strategy answers nil when
|
|
20
20
|
# the provider reported nothing at all.
|
|
21
21
|
module UsageDetection
|
|
22
|
-
Usage = Data.define(
|
|
22
|
+
Usage = Data.define(
|
|
23
|
+
:input,
|
|
24
|
+
:output,
|
|
25
|
+
:total,
|
|
26
|
+
:reasoning,
|
|
27
|
+
:cache_read,
|
|
28
|
+
:cache_write,
|
|
29
|
+
:cost,
|
|
30
|
+
:raw,
|
|
31
|
+
) do
|
|
23
32
|
def initialize(input: nil, output: nil, total: nil, reasoning: nil,
|
|
24
33
|
cache_read: nil, cache_write: nil, cost: nil, raw: nil)
|
|
25
34
|
super
|