brute 1.0.1 → 2.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 +74 -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 +94 -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
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
# The terminal "app" in the pipeline — performs the actual LLM call.
|
|
9
|
-
#
|
|
10
|
-
# Builds a fresh LLM::Context per call from env[:messages], makes the
|
|
11
|
-
# call, extracts new messages back into env[:messages], and stashes
|
|
12
|
-
# pending functions in env[:pending_functions].
|
|
13
|
-
#
|
|
14
|
-
# When streaming, on_content fires incrementally via AgentStream.
|
|
15
|
-
# When not streaming, fires on_content post-hoc with the full text.
|
|
16
|
-
#
|
|
17
|
-
class LLMCall
|
|
18
|
-
def call(env)
|
|
19
|
-
ctx = build_context(env)
|
|
20
|
-
|
|
21
|
-
# Load existing conversation history into the ephemeral context
|
|
22
|
-
ctx.messages.concat(env[:messages])
|
|
23
|
-
|
|
24
|
-
response = ctx.talk(env[:input])
|
|
25
|
-
|
|
26
|
-
# Extract new messages appended by talk() and store them
|
|
27
|
-
new_messages = ctx.messages.to_a.drop(env[:messages].size)
|
|
28
|
-
env[:messages].concat(new_messages)
|
|
29
|
-
|
|
30
|
-
# Stash pending functions for the agent loop
|
|
31
|
-
env[:pending_functions] = ctx.functions.to_a
|
|
32
|
-
|
|
33
|
-
# Only fire on_content post-hoc when NOT streaming
|
|
34
|
-
# (streaming delivers chunks incrementally via AgentStream)
|
|
35
|
-
unless env[:streaming]
|
|
36
|
-
if (cb = env.dig(:callbacks, :on_content)) && response
|
|
37
|
-
text = safe_content(response)
|
|
38
|
-
cb.call(text) if text
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
response
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def build_context(env)
|
|
48
|
-
params = {}
|
|
49
|
-
params[:tools] = env[:tools] if env[:tools]&.any?
|
|
50
|
-
params[:stream] = env[:stream] if env[:stream]
|
|
51
|
-
params[:model] = env[:model] if env[:model]
|
|
52
|
-
LLM::Context.new(env[:provider], **params)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Safely extract text content from an LLM response.
|
|
56
|
-
# Returns nil when the response contains only tool calls (no assistant text),
|
|
57
|
-
# which causes LLM::Contract::Completion#content to raise NoMethodError
|
|
58
|
-
# because messages.find(&:assistant?) returns nil.
|
|
59
|
-
def safe_content(response)
|
|
60
|
-
return nil unless response.respond_to?(:content)
|
|
61
|
-
response.content
|
|
62
|
-
rescue NoMethodError
|
|
63
|
-
nil
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
test do
|
|
70
|
-
require_relative "../../../spec/support/mock_provider"
|
|
71
|
-
require_relative "../../../spec/support/mock_response"
|
|
72
|
-
|
|
73
|
-
def build_env(**overrides)
|
|
74
|
-
{ provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
|
|
75
|
-
messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
|
|
76
|
-
tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
it "calls the provider and returns a response" do
|
|
80
|
-
provider = MockProvider.new
|
|
81
|
-
middleware = Brute::Middleware::LLMCall.new
|
|
82
|
-
env = build_env(provider: provider, input: "hello", streaming: false)
|
|
83
|
-
response = middleware.call(env)
|
|
84
|
-
response.should.not.be.nil
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
it "records a call on the provider" do
|
|
88
|
-
provider = MockProvider.new
|
|
89
|
-
middleware = Brute::Middleware::LLMCall.new
|
|
90
|
-
env = build_env(provider: provider, input: "hello", streaming: false)
|
|
91
|
-
middleware.call(env)
|
|
92
|
-
provider.calls.size.should == 1
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
it "appends new messages to env[:messages]" do
|
|
96
|
-
provider = MockProvider.new
|
|
97
|
-
middleware = Brute::Middleware::LLMCall.new
|
|
98
|
-
env = build_env(provider: provider, input: "hello", streaming: false)
|
|
99
|
-
middleware.call(env)
|
|
100
|
-
env[:messages].should.not.be.empty
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
it "populates env[:pending_functions] as an Array" do
|
|
104
|
-
provider = MockProvider.new
|
|
105
|
-
middleware = Brute::Middleware::LLMCall.new
|
|
106
|
-
env = build_env(provider: provider, input: "hello", streaming: false)
|
|
107
|
-
middleware.call(env)
|
|
108
|
-
env[:pending_functions].should.be.kind_of(Array)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
it "does not fire on_content callback when streaming" do
|
|
112
|
-
provider = MockProvider.new
|
|
113
|
-
middleware = Brute::Middleware::LLMCall.new
|
|
114
|
-
called = false
|
|
115
|
-
env = build_env(provider: provider, input: "hi", streaming: true, callbacks: { on_content: ->(_) { called = true } })
|
|
116
|
-
middleware.call(env)
|
|
117
|
-
called.should.be.false
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
it "preserves existing messages across calls" do
|
|
121
|
-
provider = MockProvider.new
|
|
122
|
-
middleware = Brute::Middleware::LLMCall.new
|
|
123
|
-
existing = LLM::Message.new(:user, "previous")
|
|
124
|
-
env = build_env(provider: provider, input: "hello", streaming: false, messages: [existing])
|
|
125
|
-
middleware.call(env)
|
|
126
|
-
env[:messages].first.should == existing
|
|
127
|
-
end
|
|
128
|
-
end
|
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
# Records every LLM exchange into a MessageStore in the OpenCode
|
|
9
|
-
# {info, parts} format so sessions can be viewed later.
|
|
10
|
-
#
|
|
11
|
-
# Lifecycle per pipeline call:
|
|
12
|
-
#
|
|
13
|
-
# 1. PRE-CALL — if this is the first call of a turn (env[:tool_results]
|
|
14
|
-
# is nil), record the user message.
|
|
15
|
-
# 2. POST-CALL — record the assistant message: text content as a "text"
|
|
16
|
-
# part, each tool call as a "tool" part in "running" state.
|
|
17
|
-
# 3. When the pipeline is called again with tool results, update the
|
|
18
|
-
# corresponding tool parts to "completed" (or "error").
|
|
19
|
-
#
|
|
20
|
-
# The middleware also stores itself in env[:message_tracking] so the
|
|
21
|
-
# agent loop can access the current assistant message ID for callbacks.
|
|
22
|
-
#
|
|
23
|
-
class MessageTracking < Base
|
|
24
|
-
attr_reader :store
|
|
25
|
-
|
|
26
|
-
def initialize(app, store:)
|
|
27
|
-
super(app)
|
|
28
|
-
@store = store
|
|
29
|
-
@current_user_id = nil
|
|
30
|
-
@current_assistant_id = nil
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def call(env)
|
|
34
|
-
env[:message_tracking] = self
|
|
35
|
-
|
|
36
|
-
# ── Pre-call: record user message or update tool results ──
|
|
37
|
-
if env[:tool_results].nil?
|
|
38
|
-
# New turn — record the user message
|
|
39
|
-
record_user_message(env)
|
|
40
|
-
else
|
|
41
|
-
# Tool results coming back — complete the tool parts
|
|
42
|
-
complete_tool_parts(env)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# ── LLM call ──
|
|
46
|
-
response = @app.call(env)
|
|
47
|
-
|
|
48
|
-
# ── Post-call: record assistant message ──
|
|
49
|
-
record_assistant_message(env, response)
|
|
50
|
-
|
|
51
|
-
response
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# The current assistant message ID (used by external callbacks).
|
|
55
|
-
def current_assistant_id
|
|
56
|
-
@current_assistant_id
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
private
|
|
60
|
-
|
|
61
|
-
# ── User message ───────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
def record_user_message(env)
|
|
64
|
-
text = extract_user_text(env)
|
|
65
|
-
return unless text
|
|
66
|
-
|
|
67
|
-
@current_user_id = @store.append_user(text: text)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def extract_user_text(env)
|
|
71
|
-
input = env[:input]
|
|
72
|
-
case input
|
|
73
|
-
when String
|
|
74
|
-
input
|
|
75
|
-
when Array
|
|
76
|
-
# llm.rb prompt format: array of message hashes
|
|
77
|
-
user_msg = input.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
|
|
78
|
-
user_msg&.content.to_s if user_msg
|
|
79
|
-
else
|
|
80
|
-
# Could be a prompt object — try to extract user content
|
|
81
|
-
if input.respond_to?(:messages)
|
|
82
|
-
msgs = input.messages.to_a
|
|
83
|
-
user_msg = msgs.reverse_each.find { |m| m.role.to_s == "user" }
|
|
84
|
-
user_msg&.content.to_s if user_msg
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# ── Assistant message ──────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
def record_assistant_message(env, response)
|
|
92
|
-
provider_name = env[:provider]&.class&.name&.split("::")&.last&.downcase
|
|
93
|
-
model_name = resolve_model_name(env)
|
|
94
|
-
|
|
95
|
-
@current_assistant_id = @store.append_assistant(
|
|
96
|
-
parent_id: @current_user_id,
|
|
97
|
-
model_id: model_name,
|
|
98
|
-
provider_id: provider_name,
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
# Text content
|
|
102
|
-
text = safe_content(response)
|
|
103
|
-
@store.add_text_part(message_id: @current_assistant_id, text: text) if text && !text.empty?
|
|
104
|
-
|
|
105
|
-
# Tool calls
|
|
106
|
-
record_tool_calls(env)
|
|
107
|
-
|
|
108
|
-
# Token usage
|
|
109
|
-
tokens = extract_tokens(env, response)
|
|
110
|
-
@store.complete_assistant(message_id: @current_assistant_id, tokens: tokens) if tokens
|
|
111
|
-
|
|
112
|
-
# Step finish
|
|
113
|
-
@store.add_step_finish(message_id: @current_assistant_id, tokens: tokens)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def record_tool_calls(env)
|
|
117
|
-
functions = env[:pending_functions]
|
|
118
|
-
return if functions.nil? || functions.empty?
|
|
119
|
-
|
|
120
|
-
functions.each do |fn|
|
|
121
|
-
@store.add_tool_part(
|
|
122
|
-
message_id: @current_assistant_id,
|
|
123
|
-
tool: fn.name,
|
|
124
|
-
call_id: fn.id,
|
|
125
|
-
input: fn.arguments,
|
|
126
|
-
)
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# ── Tool results ───────────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
def complete_tool_parts(env)
|
|
133
|
-
return unless @current_assistant_id
|
|
134
|
-
|
|
135
|
-
results = env[:tool_results]
|
|
136
|
-
return unless results.is_a?(Array)
|
|
137
|
-
|
|
138
|
-
results.each do |name, value|
|
|
139
|
-
# Find the tool part by name (tool results come as [name, value] pairs)
|
|
140
|
-
msg = @store.message(@current_assistant_id)
|
|
141
|
-
next unless msg
|
|
142
|
-
|
|
143
|
-
# Match by tool name — find the first running tool part with this name
|
|
144
|
-
part = msg[:parts]&.find do |p|
|
|
145
|
-
p[:type] == "tool" && p[:tool] == name && p.dig(:state, :status) == "running"
|
|
146
|
-
end
|
|
147
|
-
next unless part
|
|
148
|
-
|
|
149
|
-
call_id = part[:callID]
|
|
150
|
-
if value.is_a?(Hash) && value[:error]
|
|
151
|
-
@store.error_tool_part(
|
|
152
|
-
message_id: @current_assistant_id,
|
|
153
|
-
call_id: call_id,
|
|
154
|
-
error: value[:error],
|
|
155
|
-
)
|
|
156
|
-
else
|
|
157
|
-
output = value.is_a?(String) ? value : value.to_s
|
|
158
|
-
@store.complete_tool_part(
|
|
159
|
-
message_id: @current_assistant_id,
|
|
160
|
-
call_id: call_id,
|
|
161
|
-
output: output,
|
|
162
|
-
)
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# ── Helpers ────────────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
# Resolve the actual model used for the request.
|
|
170
|
-
# Prefers env[:model] (set by AgentTurn) and falls back to the
|
|
171
|
-
# provider's default_model.
|
|
172
|
-
def resolve_model_name(env)
|
|
173
|
-
model = env[:model]
|
|
174
|
-
return model.to_s if model
|
|
175
|
-
|
|
176
|
-
# Fall back to provider default
|
|
177
|
-
env[:provider]&.respond_to?(:default_model) ? env[:provider].default_model.to_s : nil
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def safe_content(response)
|
|
181
|
-
return nil unless response.respond_to?(:content)
|
|
182
|
-
response.content
|
|
183
|
-
rescue NoMethodError
|
|
184
|
-
nil
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def extract_tokens(env, response)
|
|
188
|
-
# Prefer the metadata accumulated by TokenTracking middleware
|
|
189
|
-
meta_tokens = env.dig(:metadata, :tokens, :last_call)
|
|
190
|
-
if meta_tokens
|
|
191
|
-
{
|
|
192
|
-
input: meta_tokens[:input] || 0,
|
|
193
|
-
output: meta_tokens[:output] || 0,
|
|
194
|
-
reasoning: 0,
|
|
195
|
-
cache: { read: 0, write: 0 },
|
|
196
|
-
}
|
|
197
|
-
elsif response.respond_to?(:usage) && (usage = response.usage)
|
|
198
|
-
{
|
|
199
|
-
input: usage.input_tokens.to_i,
|
|
200
|
-
output: usage.output_tokens.to_i,
|
|
201
|
-
reasoning: usage.reasoning_tokens.to_i,
|
|
202
|
-
cache: { read: 0, write: 0 },
|
|
203
|
-
}
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
test do
|
|
211
|
-
require_relative "../../../spec/support/mock_provider"
|
|
212
|
-
require_relative "../../../spec/support/mock_response"
|
|
213
|
-
require "tmpdir"
|
|
214
|
-
require "fileutils"
|
|
215
|
-
|
|
216
|
-
def build_env(**overrides)
|
|
217
|
-
{ provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
|
|
218
|
-
messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
|
|
219
|
-
tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def with_tracking
|
|
223
|
-
tmpdir = Dir.mktmpdir("brute_test_")
|
|
224
|
-
store = Brute::Store::MessageStore.new(session_id: "test-session", dir: tmpdir)
|
|
225
|
-
response = MockResponse.new(content: "Hello from the LLM")
|
|
226
|
-
inner_app = ->(_env) { response }
|
|
227
|
-
middleware = Brute::Middleware::MessageTracking.new(inner_app, store: store)
|
|
228
|
-
yield middleware, store, response
|
|
229
|
-
ensure
|
|
230
|
-
FileUtils.rm_rf(tmpdir)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
it "records a user message on first call of a turn" do
|
|
234
|
-
with_tracking do |mw, store, _|
|
|
235
|
-
mw.call(build_env(input: "What is Ruby?", tool_results: nil))
|
|
236
|
-
user_msg = store.messages.find { |m| m[:info][:role] == "user" }
|
|
237
|
-
user_msg[:parts][0][:text].should == "What is Ruby?"
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
it "records only one user message per turn" do
|
|
242
|
-
with_tracking do |mw, store, _|
|
|
243
|
-
env = build_env(input: "Hello", tool_results: nil)
|
|
244
|
-
mw.call(env)
|
|
245
|
-
env[:tool_results] = [["read", "contents"]]
|
|
246
|
-
mw.call(env)
|
|
247
|
-
store.messages.select { |m| m[:info][:role] == "user" }.size.should == 1
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
it "records an assistant message after LLM call" do
|
|
252
|
-
with_tracking do |mw, store, _|
|
|
253
|
-
mw.call(build_env(input: "Hello", tool_results: nil))
|
|
254
|
-
asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
255
|
-
asst.should.not.be.nil
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
it "captures text content as a text part" do
|
|
260
|
-
with_tracking do |mw, store, _|
|
|
261
|
-
mw.call(build_env(input: "Hello", tool_results: nil))
|
|
262
|
-
asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
263
|
-
text_parts = asst[:parts].select { |p| p[:type] == "text" }
|
|
264
|
-
text_parts[0][:text].should == "Hello from the LLM"
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
it "captures token usage from response" do
|
|
269
|
-
with_tracking do |mw, store, _|
|
|
270
|
-
mw.call(build_env(input: "Hello", tool_results: nil))
|
|
271
|
-
asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
272
|
-
asst[:info][:tokens][:input].should == 100
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
it "records tool calls as tool parts in running state" do
|
|
277
|
-
with_tracking do |mw, store, _|
|
|
278
|
-
fn = Struct.new(:id, :name, :arguments, keyword_init: true).new(id: "call_001", name: "read", arguments: { file_path: "/test" })
|
|
279
|
-
mw.call(build_env(input: "Read the file", tool_results: nil, pending_functions: [fn]))
|
|
280
|
-
asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
281
|
-
tool_parts = asst[:parts].select { |p| p[:type] == "tool" }
|
|
282
|
-
tool_parts[0][:state][:status].should == "running"
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
it "updates tool parts when results arrive" do
|
|
287
|
-
with_tracking do |mw, store, _|
|
|
288
|
-
fn = Struct.new(:id, :name, :arguments, keyword_init: true).new(id: "call_001", name: "read", arguments: { file_path: "/test" })
|
|
289
|
-
env = build_env(input: "Read the file", tool_results: nil, pending_functions: [fn])
|
|
290
|
-
mw.call(env)
|
|
291
|
-
env[:pending_functions] = []
|
|
292
|
-
env[:tool_results] = [["read", "file contents here"]]
|
|
293
|
-
mw.call(env)
|
|
294
|
-
first_asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
295
|
-
tool_part = first_asst[:parts].find { |p| p[:type] == "tool" }
|
|
296
|
-
tool_part[:state][:status].should == "completed"
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
it "records provider default_model when no override" do
|
|
301
|
-
with_tracking do |mw, store, _|
|
|
302
|
-
mw.call(build_env(input: "Hello", tool_results: nil))
|
|
303
|
-
asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
304
|
-
asst[:info][:modelID].should == "mock-model"
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
it "records overridden model when env[:model] is set" do
|
|
309
|
-
with_tracking do |mw, store, _|
|
|
310
|
-
mw.call(build_env(input: "Hello", tool_results: nil, model: "custom-haiku"))
|
|
311
|
-
asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
312
|
-
asst[:info][:modelID].should == "custom-haiku"
|
|
313
|
-
end
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
it "stores itself in env[:message_tracking]" do
|
|
317
|
-
with_tracking do |mw, _, _|
|
|
318
|
-
env = build_env(input: "Hello", tool_results: nil)
|
|
319
|
-
mw.call(env)
|
|
320
|
-
env[:message_tracking].should == mw
|
|
321
|
-
end
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
it "returns the inner app response unchanged" do
|
|
325
|
-
with_tracking do |mw, _, response|
|
|
326
|
-
result = mw.call(build_env(input: "Hello", tool_results: nil))
|
|
327
|
-
result.should == response
|
|
328
|
-
end
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
it "adds a step-finish part to assistant messages" do
|
|
332
|
-
with_tracking do |mw, store, _|
|
|
333
|
-
mw.call(build_env(input: "Hello", tool_results: nil))
|
|
334
|
-
asst = store.messages.find { |m| m[:info][:role] == "assistant" }
|
|
335
|
-
step_finish = asst[:parts].find { |p| p[:type] == "step-finish" }
|
|
336
|
-
step_finish[:reason].should == "stop"
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
end
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
module OTel
|
|
9
|
-
# Outermost OTel middleware. Creates a span per LLM pipeline call
|
|
10
|
-
# and passes it through env[:span] for inner OTel middlewares to
|
|
11
|
-
# decorate with events and attributes.
|
|
12
|
-
#
|
|
13
|
-
# When opentelemetry-sdk is not loaded, this is a pure pass-through.
|
|
14
|
-
#
|
|
15
|
-
# Pipeline position: outermost (wraps everything including retries).
|
|
16
|
-
#
|
|
17
|
-
# use Brute::Middleware::OTel::Span
|
|
18
|
-
# use Brute::Middleware::OTel::ToolResults
|
|
19
|
-
# use Brute::Middleware::OTel::ToolCalls
|
|
20
|
-
# use Brute::Middleware::OTel::TokenUsage
|
|
21
|
-
# # ... existing middleware ...
|
|
22
|
-
# run Brute::Middleware::LLMCall.new
|
|
23
|
-
#
|
|
24
|
-
class Span < Base
|
|
25
|
-
def call(env)
|
|
26
|
-
return @app.call(env) unless defined?(::OpenTelemetry::SDK)
|
|
27
|
-
|
|
28
|
-
provider_name = provider_type(env[:provider])
|
|
29
|
-
model = env[:model] || (env[:provider].default_model rescue nil)
|
|
30
|
-
span_name = model ? "llm.call #{model}" : "llm.call"
|
|
31
|
-
|
|
32
|
-
attributes = {
|
|
33
|
-
"brute.provider" => provider_name,
|
|
34
|
-
"brute.streaming" => !!env[:streaming],
|
|
35
|
-
"brute.context_messages" => env[:messages].size,
|
|
36
|
-
}
|
|
37
|
-
attributes["brute.model"] = model.to_s if model
|
|
38
|
-
attributes["brute.session_id"] = env[:metadata][:session_id].to_s if env.dig(:metadata, :session_id)
|
|
39
|
-
|
|
40
|
-
tracer.in_span(span_name, attributes: attributes, kind: :internal) do |span|
|
|
41
|
-
env[:span] = span
|
|
42
|
-
response = @app.call(env)
|
|
43
|
-
|
|
44
|
-
# Record response model if it differs from request model
|
|
45
|
-
resp_model = begin; response.model; rescue; nil; end
|
|
46
|
-
span.set_attribute("brute.response_model", resp_model.to_s) if resp_model && resp_model != model
|
|
47
|
-
|
|
48
|
-
response
|
|
49
|
-
rescue ::StandardError => e
|
|
50
|
-
span.record_exception(e)
|
|
51
|
-
span.status = ::OpenTelemetry::Trace::Status.error(e.message)
|
|
52
|
-
raise
|
|
53
|
-
ensure
|
|
54
|
-
env.delete(:span)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def tracer
|
|
61
|
-
@tracer ||= ::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def provider_type(provider)
|
|
65
|
-
name = provider.class.name.to_s.downcase
|
|
66
|
-
if name.include?("anthropic") then "anthropic"
|
|
67
|
-
elsif name.include?("openai") then "openai"
|
|
68
|
-
elsif name.include?("google") || name.include?("gemini") then "google"
|
|
69
|
-
elsif name.include?("deepseek") then "deepseek"
|
|
70
|
-
elsif name.include?("ollama") then "ollama"
|
|
71
|
-
elsif name.include?("xai") then "xai"
|
|
72
|
-
else "unknown"
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
test do
|
|
81
|
-
require_relative "../../../../spec/support/mock_provider"
|
|
82
|
-
require_relative "../../../../spec/support/mock_response"
|
|
83
|
-
|
|
84
|
-
def build_env(**overrides)
|
|
85
|
-
{ provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
|
|
86
|
-
messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
|
|
87
|
-
tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
it "passes through when OpenTelemetry::SDK is not defined" do
|
|
91
|
-
response = MockResponse.new(content: "hello from LLM")
|
|
92
|
-
middleware = Brute::Middleware::OTel::Span.new(->(_env) { response })
|
|
93
|
-
env = build_env
|
|
94
|
-
result = middleware.call(env)
|
|
95
|
-
result.should == response
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
it "env[:span] is nil when OTel is not defined" do
|
|
99
|
-
response = MockResponse.new(content: "hello from LLM")
|
|
100
|
-
middleware = Brute::Middleware::OTel::Span.new(->(_env) { response })
|
|
101
|
-
env = build_env
|
|
102
|
-
middleware.call(env)
|
|
103
|
-
env[:span].should.be.nil
|
|
104
|
-
end
|
|
105
|
-
end
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Middleware
|
|
8
|
-
module OTel
|
|
9
|
-
# Records token usage from the LLM response as span attributes.
|
|
10
|
-
#
|
|
11
|
-
# Runs POST-call: reads token counts from the response usage object
|
|
12
|
-
# and sets them as attributes on the span.
|
|
13
|
-
#
|
|
14
|
-
class TokenUsage < Base
|
|
15
|
-
def call(env)
|
|
16
|
-
response = @app.call(env)
|
|
17
|
-
|
|
18
|
-
span = env[:span]
|
|
19
|
-
if span && response.respond_to?(:usage) && (usage = response.usage)
|
|
20
|
-
span.set_attribute("gen_ai.usage.input_tokens", usage.input_tokens.to_i)
|
|
21
|
-
span.set_attribute("gen_ai.usage.output_tokens", usage.output_tokens.to_i)
|
|
22
|
-
span.set_attribute("gen_ai.usage.total_tokens", usage.total_tokens.to_i)
|
|
23
|
-
|
|
24
|
-
reasoning = usage.reasoning_tokens.to_i
|
|
25
|
-
span.set_attribute("gen_ai.usage.reasoning_tokens", reasoning) if reasoning > 0
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
response
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
test do
|
|
36
|
-
require_relative "../../../../spec/support/mock_provider"
|
|
37
|
-
require_relative "../../../../spec/support/mock_response"
|
|
38
|
-
|
|
39
|
-
def build_env(**overrides)
|
|
40
|
-
{ provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
|
|
41
|
-
messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
|
|
42
|
-
tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def make_response
|
|
46
|
-
MockResponse.new(content: "hello",
|
|
47
|
-
usage: LLM::Usage.new(input_tokens: 100, output_tokens: 50, reasoning_tokens: 10, total_tokens: 160))
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
it "passes the response through unchanged" do
|
|
51
|
-
response = make_response
|
|
52
|
-
middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { response })
|
|
53
|
-
result = middleware.call(build_env)
|
|
54
|
-
result.should == response
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
it "passes through without error when span is nil" do
|
|
58
|
-
response = make_response
|
|
59
|
-
middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { response })
|
|
60
|
-
lambda { middleware.call(build_env) }.should.not.raise
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
it "handles a response without usage gracefully" do
|
|
64
|
-
no_usage = Object.new
|
|
65
|
-
middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { no_usage })
|
|
66
|
-
lambda { middleware.call(build_env) }.should.not.raise
|
|
67
|
-
end
|
|
68
|
-
end
|