brute 5.0.5 → 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 +170 -0
- data/lib/brute/compaction/middleware/strategy.rb +137 -0
- data/lib/brute/compaction/middleware/tool_results.rb +146 -0
- data/lib/brute/compaction/summarize.rb +349 -0
- data/lib/brute/compaction/transcript.rb +166 -0
- data/lib/brute/compaction.rb +57 -0
- 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 +83 -51
- data/lib/brute/env.rb +54 -0
- data/lib/brute/eval/case.rb +254 -0
- data/lib/brute/eval/suite.rb +181 -0
- data/lib/brute/eval/transcript.rb +147 -0
- data/lib/brute/eval/world.rb +106 -0
- data/lib/brute/eval.rb +48 -0
- 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 +89 -102
- 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 +6 -6
- data/lib/brute/middleware/002_session_log.rb +12 -4
- data/lib/brute/middleware/008_checkpoint.rb +27 -19
- 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 +356 -0
- data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
- 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/approximate.rb +54 -0
- data/lib/brute/token_counter/tiktoken.rb +80 -0
- data/lib/brute/token_counter.rb +150 -0
- data/lib/brute/tool.rb +10 -6
- data/lib/brute/tools/adapter.rb +57 -47
- 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 +46 -41
- data/lib/brute/tools/sub_agent.rb +2 -2
- 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 +123 -0
- 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 +25 -12
- data/lib/brute_cli/providers/shell.rb +32 -29
- data/lib/brute_cli/providers/shell_response.rb +20 -18
- metadata +48 -5
- data/lib/brute/middleware/040_compaction_check.rb +0 -157
- data/lib/brute/middleware/event_handler.rb +0 -27
|
@@ -44,34 +44,35 @@ module Brute
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def call(env)
|
|
47
|
-
|
|
47
|
+
env.emit_trace do |env|
|
|
48
|
+
env.emit(LLM_START_EVENT)
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
# llm.rb takes the last message as the prompt and the rest as history.
|
|
51
|
+
messages = Brute::MessageTransport::LLM.dump_all(env[:messages])
|
|
52
|
+
prompt = messages.pop
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
response = nil
|
|
55
|
+
env.emit(LLM_DURATION_EVENT) { response = client(env).complete(prompt, **params(env, messages)) }
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
if (usage = Brute::MessageTransport::LLM.usage_metrics(response))
|
|
58
|
+
(env[:metadata] ||= {})[:last_llm_usage] = usage
|
|
59
|
+
end
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
Brute::MessageTransport::LLM.wrap_each(response) do |message|
|
|
62
|
+
env[:messages] << message
|
|
63
|
+
end
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
emit(LLM_FAILURE_EVENT, env)
|
|
65
|
+
env.emit(LLM_END_EVENT)
|
|
66
|
+
rescue => error
|
|
67
|
+
env.emit(LLM_FAILURE_EVENT)
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
70
|
+
env.emit(FARADAY_ERROR_EVENT, error)
|
|
71
|
+
else
|
|
72
|
+
env.emit(STANDARD_ERROR_EVENT, error)
|
|
73
|
+
end
|
|
74
74
|
|
|
75
|
+
end
|
|
75
76
|
env
|
|
76
77
|
end
|
|
77
78
|
|
|
@@ -80,16 +81,17 @@ module Brute
|
|
|
80
81
|
attr_reader :options
|
|
81
82
|
|
|
82
83
|
def params(env, history)
|
|
83
|
-
|
|
84
|
+
{
|
|
84
85
|
role: nil,
|
|
85
86
|
model: option(env, :model),
|
|
86
87
|
messages: history,
|
|
87
88
|
temperature: temperature(env),
|
|
88
|
-
}.compact
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
}.compact.tap do |params|
|
|
90
|
+
functions = functions(env)
|
|
91
|
+
if functions.any?
|
|
92
|
+
params[:tools] = functions
|
|
93
|
+
end
|
|
94
|
+
end
|
|
93
95
|
end
|
|
94
96
|
|
|
95
97
|
def client(env)
|
|
@@ -154,8 +156,8 @@ describe "brute/completion/llmrb" do
|
|
|
154
156
|
seen = []
|
|
155
157
|
pipeline = Brute::Turn::Pipeline.new
|
|
156
158
|
pipeline.run Brute::Completion::LLMrb.new(client: client, temperature: 0.1)
|
|
157
|
-
pipeline.on(Brute::Hooks::
|
|
158
|
-
pipeline.on(Brute::Hooks::
|
|
159
|
+
pipeline.on(Brute::Hooks::LLM_START_EVENT) { |_env| seen << :before }
|
|
160
|
+
pipeline.on(Brute::Hooks::LLM_END_EVENT) { |_env| seen << :after }
|
|
159
161
|
pipeline.call(env)
|
|
160
162
|
|
|
161
163
|
env[:messages].last.role.should == :assistant
|
|
@@ -43,52 +43,53 @@ module Brute
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def call(env)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
46
|
+
env.emit_trace do |env|
|
|
47
|
+
env.emit(LLM_START_EVENT)
|
|
48
|
+
|
|
49
|
+
messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
|
|
50
|
+
|
|
51
|
+
::OpenRouter::Client.new(**@config).then do |client|
|
|
52
|
+
response = nil
|
|
53
|
+
env.emit(LLM_DURATION_EVENT) { response = client.complete(messages, options(env)) }
|
|
54
|
+
|
|
55
|
+
response.then do |response|
|
|
56
|
+
|
|
57
|
+
# Expose the provider's usage for downstream accounting
|
|
58
|
+
# (goal budgets, autonomous limits, compaction thresholds, usage
|
|
59
|
+
# attribution) — additive metadata only, normalised so a reader
|
|
60
|
+
# does not have to know which provider answered.
|
|
61
|
+
if (usage = Brute::MessageTransport::OpenRouter.usage_metrics(response))
|
|
62
|
+
(env[:metadata] ||= {})[:last_llm_usage] = usage
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# OpenRouter in fact only returns a single message...
|
|
66
|
+
# https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
|
|
67
|
+
Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
|
|
68
|
+
env[:messages] << message
|
|
69
|
+
end
|
|
68
70
|
end
|
|
69
71
|
end
|
|
70
|
-
end
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
emit(LLM_FAILURE_EVENT, env)
|
|
73
|
+
env.emit(LLM_END_EVENT)
|
|
74
|
+
# A provider call that raises is reported through the hooks rather than
|
|
75
|
+
# up the stack: :llm_failure with the turn env, then one hook naming the
|
|
76
|
+
# kind of failure. The classes are looked up defensively — the provider
|
|
77
|
+
# gem is only a dependency of the app that uses this middleware.
|
|
78
|
+
rescue => error
|
|
79
|
+
env.emit(LLM_FAILURE_EVENT)
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
82
|
+
env.emit(FARADAY_ERROR_EVENT, error)
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
|
|
85
|
+
env.emit(OPEN_ROUTER_SERVER_ERROR_EVENT, error)
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
else
|
|
88
|
+
env.emit(STANDARD_ERROR_EVENT, error)
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
end
|
|
91
91
|
|
|
92
|
+
end
|
|
92
93
|
env
|
|
93
94
|
end
|
|
94
95
|
|
|
@@ -100,7 +101,9 @@ module Brute
|
|
|
100
101
|
|
|
101
102
|
unless @options.tools?
|
|
102
103
|
tools = tool_definitions(env)
|
|
103
|
-
|
|
104
|
+
if tools.any?
|
|
105
|
+
overrides[:tools] = tools
|
|
106
|
+
end
|
|
104
107
|
end
|
|
105
108
|
|
|
106
109
|
if !@model_given && (model = env[:model])
|
|
@@ -266,7 +269,7 @@ describe "brute/completion/open_router" do
|
|
|
266
269
|
env[:messages].user("hi")
|
|
267
270
|
pipeline = Brute::Turn::Pipeline.new
|
|
268
271
|
pipeline.run Brute::Completion::OpenRouter.new
|
|
269
|
-
%i[llm_failure standard_error
|
|
272
|
+
%i[llm_failure standard_error llm_end].each do |event|
|
|
270
273
|
pipeline.on(event) { |hook_env, extra| seen << [event, hook_env, extra] }
|
|
271
274
|
end
|
|
272
275
|
returned = pipeline.call(env)
|
|
@@ -59,35 +59,36 @@ module Brute
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def call(env)
|
|
62
|
-
|
|
62
|
+
env.emit_trace do |env|
|
|
63
|
+
env.emit(LLM_START_EVENT)
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
messages = Brute::MessageTransport::RubyLLM.dump_all(env[:messages])
|
|
66
|
+
response = nil
|
|
67
|
+
env.emit(LLM_DURATION_EVENT) { response = complete(env, messages) }
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
unless response.nil?
|
|
70
|
+
if (usage = Brute::MessageTransport::RubyLLM.usage_metrics(response))
|
|
71
|
+
(env[:metadata] ||= {})[:last_llm_usage] = usage
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Brute::MessageTransport::RubyLLM.wrap_each(response) do |message|
|
|
75
|
+
env[:messages] << message
|
|
76
|
+
end
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
env.emit(LLM_END_EVENT)
|
|
80
|
+
# A provider call that raises is reported through the hooks rather than
|
|
81
|
+
# up the stack, the same way the OpenRouter completion does it.
|
|
82
|
+
rescue => error
|
|
83
|
+
env.emit(LLM_FAILURE_EVENT)
|
|
84
|
+
|
|
85
|
+
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
86
|
+
env.emit(FARADAY_ERROR_EVENT, error)
|
|
87
|
+
else
|
|
88
|
+
env.emit(STANDARD_ERROR_EVENT, error)
|
|
75
89
|
end
|
|
76
|
-
end
|
|
77
90
|
|
|
78
|
-
emit(AFTER_LLM_EVENT, env)
|
|
79
|
-
env
|
|
80
|
-
# A provider call that raises is reported through the hooks rather than
|
|
81
|
-
# up the stack, the same way the OpenRouter completion does it.
|
|
82
|
-
rescue => error
|
|
83
|
-
emit(LLM_FAILURE_EVENT, env)
|
|
84
|
-
|
|
85
|
-
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
86
|
-
emit(FARADAY_ERROR_EVENT, env, error)
|
|
87
|
-
else
|
|
88
|
-
emit(STANDARD_ERROR_EVENT, env, error)
|
|
89
91
|
end
|
|
90
|
-
|
|
91
92
|
env
|
|
92
93
|
end
|
|
93
94
|
|
|
@@ -104,7 +105,7 @@ module Brute
|
|
|
104
105
|
|
|
105
106
|
provider_client = client(option(env, :provider))
|
|
106
107
|
|
|
107
|
-
# Streaming reports through
|
|
108
|
+
# Streaming reports through :content and :reasoning chunk by chunk; the final
|
|
108
109
|
# message still comes back for the log.
|
|
109
110
|
if streaming?(env)
|
|
110
111
|
provider_client.complete(messages, **kwargs) do |chunk|
|
|
@@ -117,11 +118,11 @@ module Brute
|
|
|
117
118
|
|
|
118
119
|
def stream(env, chunk)
|
|
119
120
|
if chunk.content && !chunk.content.to_s.empty?
|
|
120
|
-
env
|
|
121
|
+
env.emit(CONTENT_EVENT, chunk.content.to_s)
|
|
121
122
|
end
|
|
122
123
|
|
|
123
124
|
if chunk.respond_to?(:thinking) && chunk.thinking.respond_to?(:text) && chunk.thinking&.text
|
|
124
|
-
env
|
|
125
|
+
env.emit(REASONING_EVENT, chunk.thinking.text)
|
|
125
126
|
end
|
|
126
127
|
end
|
|
127
128
|
|
|
@@ -214,8 +215,8 @@ describe "brute/completion/ruby_llm" do
|
|
|
214
215
|
|
|
215
216
|
seen = []
|
|
216
217
|
pipeline = running.call(Brute::Completion::RubyLLM.new(client: client, model: "use-this-model", temperature: 0.1))
|
|
217
|
-
pipeline.on(Brute::Hooks::
|
|
218
|
-
pipeline.on(Brute::Hooks::
|
|
218
|
+
pipeline.on(Brute::Hooks::LLM_START_EVENT) { |_env| seen << :before }
|
|
219
|
+
pipeline.on(Brute::Hooks::LLM_END_EVENT) { |_env| seen << :after }
|
|
219
220
|
pipeline.call(env)
|
|
220
221
|
|
|
221
222
|
env[:messages].last.role.should == :assistant
|
data/lib/brute/contrib/otel.rb
CHANGED
|
@@ -19,8 +19,8 @@ module Brute
|
|
|
19
19
|
# Every event it needs already exists:
|
|
20
20
|
#
|
|
21
21
|
# turn_start / turn_end the span itself
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# llm_end token usage, from env[:metadata][:last_llm_usage]
|
|
23
|
+
# tool_start / tool_end a span event per tool call and result
|
|
24
24
|
#
|
|
25
25
|
# The tracer is injectable; without one it asks OpenTelemetry, and when
|
|
26
26
|
# the SDK is not loaded `subscribe` does nothing at all.
|
|
@@ -35,33 +35,50 @@ module Brute
|
|
|
35
35
|
|
|
36
36
|
class << self
|
|
37
37
|
def subscribe(agent, tracer: default_tracer)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
if tracer.nil?
|
|
39
|
+
agent
|
|
40
|
+
else
|
|
41
|
+
agent
|
|
42
|
+
.on(Brute::Hooks::TURN_START_EVENT) { |env| start(env, tracer) }
|
|
43
|
+
.on(Brute::Hooks::TURN_END_EVENT) { |env| finish(env) }
|
|
44
|
+
.on(Brute::Hooks::LLM_END_EVENT) { |env| record_usage(env) }
|
|
45
|
+
.on(Brute::Hooks::TOOL_START_EVENT) { |env, call| tool_called(env, call) }
|
|
46
|
+
.on(Brute::Hooks::TOOL_END_EVENT) { |env, call| tool_returned(env, call) }
|
|
47
|
+
.on(Brute::Hooks::LLM_FAILURE_EVENT) { |env| failed(env) }
|
|
48
|
+
.on(Brute::Hooks::MIDDLEWARE_DURATION_EVENT) { |env, started, finished, layer| layer_finished(
|
|
49
|
+
env,
|
|
50
|
+
started,
|
|
51
|
+
finished,
|
|
52
|
+
layer,
|
|
53
|
+
) }
|
|
54
|
+
.on(Brute::Hooks::TURN_DURATION_EVENT) { |env, started, finished| timed(env, "turn", finished - started) }
|
|
55
|
+
.on(Brute::Hooks::LLM_DURATION_EVENT) { |env, started, finished| timed(env, "llm", finished - started) }
|
|
56
|
+
.on(Brute::Hooks::TOOL_DURATION_EVENT) { |env, started, finished, call| timed(
|
|
57
|
+
env,
|
|
58
|
+
"tool",
|
|
59
|
+
finished - started,
|
|
60
|
+
"tool.name" => call[:name].to_s,
|
|
61
|
+
) }
|
|
62
|
+
end
|
|
51
63
|
end
|
|
52
64
|
|
|
53
65
|
def default_tracer
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
if defined?(::OpenTelemetry)
|
|
67
|
+
::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
|
|
68
|
+
else
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
57
71
|
end
|
|
58
72
|
|
|
59
73
|
private
|
|
60
74
|
|
|
61
75
|
def start(env, tracer)
|
|
62
|
-
env[:span] = tracer.start_span(
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
env[:span] = tracer.start_span(
|
|
77
|
+
SPAN_NAME,
|
|
78
|
+
attributes: {
|
|
79
|
+
"brute.messages" => env[:messages]&.size,
|
|
80
|
+
}.compact,
|
|
81
|
+
)
|
|
65
82
|
end
|
|
66
83
|
|
|
67
84
|
def finish(env)
|
|
@@ -73,40 +90,55 @@ module Brute
|
|
|
73
90
|
# arrives under the same names, and what it did not report is
|
|
74
91
|
# absent rather than zero.
|
|
75
92
|
def record_usage(env)
|
|
76
|
-
span
|
|
77
|
-
usage = env.dig(:metadata, :last_llm_usage)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
span = env[:span]
|
|
94
|
+
usage = env.dig(:metadata, :last_llm_usage)
|
|
95
|
+
|
|
96
|
+
if span && usage
|
|
97
|
+
{
|
|
98
|
+
"gen_ai.usage.input_tokens" => usage.input,
|
|
99
|
+
"gen_ai.usage.output_tokens" => usage.output,
|
|
100
|
+
"gen_ai.usage.total_tokens" => usage.total,
|
|
101
|
+
"gen_ai.usage.reasoning_tokens" => usage.reasoning,
|
|
102
|
+
"gen_ai.usage.cache_read_tokens" => usage.cache_read,
|
|
103
|
+
"gen_ai.usage.cost" => usage.cost,
|
|
104
|
+
}.each do |name, value|
|
|
105
|
+
unless value.nil?
|
|
106
|
+
span.set_attribute(name, value)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
87
110
|
end
|
|
88
111
|
|
|
89
112
|
def tool_called(env, call)
|
|
90
|
-
env[:span]&.add_event(
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
env[:span]&.add_event(
|
|
114
|
+
"tool_call",
|
|
115
|
+
attributes: {
|
|
116
|
+
"tool.name" => call[:name].to_s,
|
|
117
|
+
"tool.arguments" => call[:arguments].to_s,
|
|
118
|
+
},
|
|
119
|
+
)
|
|
94
120
|
end
|
|
95
121
|
|
|
96
122
|
def tool_returned(env, call)
|
|
97
|
-
env[:span]&.add_event(
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
env[:span]&.add_event(
|
|
124
|
+
"tool_result",
|
|
125
|
+
attributes: {
|
|
126
|
+
"tool.name" => call[:name].to_s,
|
|
127
|
+
"tool.bytes" => call[:result].to_s.bytesize,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
101
130
|
end
|
|
102
131
|
|
|
103
|
-
# The layer's work is :
|
|
104
|
-
# with it rather than having to be correlated with :
|
|
132
|
+
# The layer's work is :middleware_duration's block, so start and finish arrive
|
|
133
|
+
# with it rather than having to be correlated with :middleware_start by hand.
|
|
105
134
|
def layer_finished(env, started, finished, layer)
|
|
106
|
-
env[:span]&.add_event(
|
|
107
|
-
"middleware
|
|
108
|
-
|
|
109
|
-
|
|
135
|
+
env[:span]&.add_event(
|
|
136
|
+
"middleware",
|
|
137
|
+
attributes: {
|
|
138
|
+
"middleware.name" => layer.class.name.to_s,
|
|
139
|
+
"middleware.duration" => finished - started,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
110
142
|
end
|
|
111
143
|
|
|
112
144
|
# Every pair in the turn has a timed middle, so each one's duration
|
|
@@ -157,7 +189,7 @@ describe "brute/contrib/otel" do
|
|
|
157
189
|
tool = { name: "echo", description: "", execute: ->(text:) { "ran:#{text}" } }
|
|
158
190
|
|
|
159
191
|
agent = Brute::Turn::AgentPipeline.new
|
|
160
|
-
agent.use Brute::Middleware::
|
|
192
|
+
agent.use Brute::Middleware::DefaultToolPipeline, tools: [tool]
|
|
161
193
|
agent.run(Object.new.tap do |terminal|
|
|
162
194
|
terminal.define_singleton_method(:call) do |env|
|
|
163
195
|
# A completion records usage and announces the call; `run` bound this
|
|
@@ -170,8 +202,8 @@ describe "brute/contrib/otel" do
|
|
|
170
202
|
tool_calls: [{ id: "tc1", name: "echo", arguments: { "text" => "hi" } }])
|
|
171
203
|
end
|
|
172
204
|
|
|
173
|
-
emit(Brute::Hooks::LLM_DURATION_EVENT
|
|
174
|
-
emit(Brute::Hooks::
|
|
205
|
+
env.emit(Brute::Hooks::LLM_DURATION_EVENT) { :provider_call }
|
|
206
|
+
env.emit(Brute::Hooks::LLM_END_EVENT)
|
|
175
207
|
env
|
|
176
208
|
end
|
|
177
209
|
end)
|
|
@@ -190,9 +222,9 @@ describe "brute/contrib/otel" do
|
|
|
190
222
|
span.events.map(&:first).should.include "tool_result"
|
|
191
223
|
span.events.find { |name, _| name == "tool_call" }.last["tool.name"].should == "echo"
|
|
192
224
|
|
|
193
|
-
# :
|
|
225
|
+
# :middleware_end is timed, so each layer reports its own duration.
|
|
194
226
|
layer_event = span.events.find { |name, _| name == "middleware" }
|
|
195
|
-
layer_event.last["middleware.name"].should == "Brute::Middleware::
|
|
227
|
+
layer_event.last["middleware.name"].should == "Brute::Middleware::DefaultToolPipeline"
|
|
196
228
|
layer_event.last["middleware.duration"].should.be >= 0
|
|
197
229
|
|
|
198
230
|
# Each pair's timed middle reports its own duration.
|
data/lib/brute/env.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
# What the turn came back with, asked of the env itself.
|
|
8
|
+
#
|
|
9
|
+
# env = agent.start("hello")
|
|
10
|
+
# env.extend(Brute::Env)
|
|
11
|
+
# env.reply.content if env.has_reply?
|
|
12
|
+
#
|
|
13
|
+
# A turn ends with whatever the last middleware left in env[:messages], and
|
|
14
|
+
# that is not always an answer: a turn that only ran tools, or that the
|
|
15
|
+
# provider failed, ends on something else. So the reply is the last message
|
|
16
|
+
# AND only when the assistant is the one who wrote it.
|
|
17
|
+
module Env
|
|
18
|
+
def has_reply?
|
|
19
|
+
!reply.nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reply
|
|
23
|
+
messages = self[:messages]
|
|
24
|
+
|
|
25
|
+
if messages.respond_to?(:last) && messages.last&.role == :assistant
|
|
26
|
+
messages.last
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
__END__
|
|
33
|
+
|
|
34
|
+
require "brute/messages"
|
|
35
|
+
|
|
36
|
+
describe "brute/env" do
|
|
37
|
+
it "answers whether the turn produced a reply, and what it was" do
|
|
38
|
+
env = { messages: Brute.log }.extend(Brute::Env)
|
|
39
|
+
|
|
40
|
+
env.has_reply?.should.be.false
|
|
41
|
+
|
|
42
|
+
env[:messages].user("hello")
|
|
43
|
+
env.has_reply?.should.be.false
|
|
44
|
+
|
|
45
|
+
env[:messages].assistant("hi back")
|
|
46
|
+
env.has_reply?.should.be.true
|
|
47
|
+
env.reply.content.should == "hi back"
|
|
48
|
+
|
|
49
|
+
env[:messages] << Brute::Message.new(role: :tool, content: "ran", tool_call_id: "tc1")
|
|
50
|
+
env.has_reply?.should.be.false
|
|
51
|
+
|
|
52
|
+
{}.extend(Brute::Env).has_reply?.should.be.false
|
|
53
|
+
end
|
|
54
|
+
end
|