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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
  3. data/lib/brute/compaction/middleware/strategy.rb +137 -0
  4. data/lib/brute/compaction/middleware/tool_results.rb +146 -0
  5. data/lib/brute/compaction/summarize.rb +349 -0
  6. data/lib/brute/compaction/transcript.rb +166 -0
  7. data/lib/brute/compaction.rb +57 -0
  8. data/lib/brute/completion/lang_chain.rb +34 -31
  9. data/lib/brute/completion/llmrb.rb +31 -29
  10. data/lib/brute/completion/open_router.rb +43 -40
  11. data/lib/brute/completion/ruby_llm.rb +28 -27
  12. data/lib/brute/contrib/otel.rb +83 -51
  13. data/lib/brute/env.rb +54 -0
  14. data/lib/brute/eval/case.rb +254 -0
  15. data/lib/brute/eval/suite.rb +181 -0
  16. data/lib/brute/eval/transcript.rb +147 -0
  17. data/lib/brute/eval/world.rb +106 -0
  18. data/lib/brute/eval.rb +48 -0
  19. data/lib/brute/events/handler.rb +3 -1
  20. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  21. data/lib/brute/events/terminal_output_handler.rb +1 -1
  22. data/lib/brute/hooks.rb +89 -102
  23. data/lib/brute/message_transport/anthropic.rb +13 -3
  24. data/lib/brute/message_transport/llm.rb +8 -4
  25. data/lib/brute/message_transport/open_router.rb +30 -16
  26. data/lib/brute/message_transport/openai.rb +14 -7
  27. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  28. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  29. data/lib/brute/messages.rb +15 -8
  30. data/lib/brute/middleware/000_base.rb +6 -6
  31. data/lib/brute/middleware/002_session_log.rb +12 -4
  32. data/lib/brute/middleware/008_checkpoint.rb +27 -19
  33. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  34. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  35. data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
  36. data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
  37. data/lib/brute/prompt_template.rb +19 -15
  38. data/lib/brute/prompts/base.rb +19 -10
  39. data/lib/brute/prompts/environment.rb +3 -1
  40. data/lib/brute/prompts/instructions.rb +9 -7
  41. data/lib/brute/prompts/skills.rb +5 -3
  42. data/lib/brute/rack/adapter.rb +40 -23
  43. data/lib/brute/skill.rb +136 -89
  44. data/lib/brute/system_prompt.rb +9 -9
  45. data/lib/brute/token_counter/approximate.rb +54 -0
  46. data/lib/brute/token_counter/tiktoken.rb +80 -0
  47. data/lib/brute/token_counter.rb +150 -0
  48. data/lib/brute/tool.rb +10 -6
  49. data/lib/brute/tools/adapter.rb +57 -47
  50. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  51. data/lib/brute/tools/fs_patch.rb +16 -8
  52. data/lib/brute/tools/fs_read.rb +107 -80
  53. data/lib/brute/tools/fs_remove.rb +6 -2
  54. data/lib/brute/tools/fs_search.rb +14 -4
  55. data/lib/brute/tools/fs_undo.rb +6 -2
  56. data/lib/brute/tools/fs_write.rb +5 -1
  57. data/lib/brute/tools/net_fetch.rb +6 -2
  58. data/lib/brute/tools/question.rb +42 -39
  59. data/lib/brute/tools/shell.rb +20 -5
  60. data/lib/brute/tools/skill_load.rb +46 -41
  61. data/lib/brute/tools/sub_agent.rb +2 -2
  62. data/lib/brute/tools/todo_write.rb +19 -15
  63. data/lib/brute/truncation.rb +68 -43
  64. data/lib/brute/turn/agent_pipeline.rb +23 -13
  65. data/lib/brute/turn/compaction_pipeline.rb +123 -0
  66. data/lib/brute/turn/pipeline.rb +87 -96
  67. data/lib/brute/turn/tool_pipeline.rb +4 -3
  68. data/lib/brute/usage_detection/llmrb.rb +18 -14
  69. data/lib/brute/usage_detection/open_router.rb +20 -17
  70. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  71. data/lib/brute/usage_detection/usage.rb +10 -1
  72. data/lib/brute/utils/diff.rb +18 -10
  73. data/lib/brute/version.rb +1 -1
  74. data/lib/brute.rb +25 -12
  75. data/lib/brute_cli/providers/shell.rb +32 -29
  76. data/lib/brute_cli/providers/shell_response.rb +20 -18
  77. metadata +48 -5
  78. data/lib/brute/middleware/040_compaction_check.rb +0 -157
  79. 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
- emit(BEFORE_LLM_EVENT, env)
47
+ env.emit_trace do |env|
48
+ env.emit(LLM_START_EVENT)
48
49
 
49
- # llm.rb takes the last message as the prompt and the rest as history.
50
- messages = Brute::MessageTransport::LLM.dump_all(env[:messages])
51
- prompt = messages.pop
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
- response = nil
54
- emit(LLM_DURATION_EVENT, env) { response = client(env).complete(prompt, **params(env, messages)) }
54
+ response = nil
55
+ env.emit(LLM_DURATION_EVENT) { response = client(env).complete(prompt, **params(env, messages)) }
55
56
 
56
- if (usage = Brute::MessageTransport::LLM.usage_metrics(response))
57
- (env[:metadata] ||= {})[:last_llm_usage] = usage
58
- end
57
+ if (usage = Brute::MessageTransport::LLM.usage_metrics(response))
58
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
59
+ end
59
60
 
60
- Brute::MessageTransport::LLM.wrap_each(response) do |message|
61
- env[:messages] << message
62
- end
61
+ Brute::MessageTransport::LLM.wrap_each(response) do |message|
62
+ env[:messages] << message
63
+ end
63
64
 
64
- emit(AFTER_LLM_EVENT, env)
65
- env
66
- rescue => error
67
- emit(LLM_FAILURE_EVENT, env)
65
+ env.emit(LLM_END_EVENT)
66
+ rescue => error
67
+ env.emit(LLM_FAILURE_EVENT)
68
68
 
69
- if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
70
- emit(FARADAY_ERROR_EVENT, env, error)
71
- else
72
- emit(STANDARD_ERROR_EVENT, env, error)
73
- end
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
- params = {
84
+ {
84
85
  role: nil,
85
86
  model: option(env, :model),
86
87
  messages: history,
87
88
  temperature: temperature(env),
88
- }.compact
89
-
90
- functions = functions(env)
91
- params[:tools] = functions if functions.any?
92
- params
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::BEFORE_LLM_EVENT) { |_env| seen << :before }
158
- pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
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
- emit(BEFORE_LLM_EVENT, env)
47
-
48
- messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
49
-
50
- ::OpenRouter::Client.new(**@config).then do |client|
51
- response = nil
52
- emit(LLM_DURATION_EVENT, env) { response = client.complete(messages, options(env)) }
53
-
54
- response.then do |response|
55
-
56
- # Expose the provider's usage for downstream accounting
57
- # (goal budgets, autonomous limits, compaction thresholds, usage
58
- # attribution) additive metadata only, normalised so a reader
59
- # does not have to know which provider answered.
60
- if (usage = Brute::MessageTransport::OpenRouter.usage_metrics(response))
61
- (env[:metadata] ||= {})[:last_llm_usage] = usage
62
- end
63
-
64
- # OpenRouter in fact only returns a single message...
65
- # https://github.com/estiens/open_router_enhanced/blob/main/lib/open_router/response.rb
66
- Brute::MessageTransport::OpenRouter.wrap_each(response) do |message|
67
- env[:messages] << message
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
- emit(AFTER_LLM_EVENT, env)
73
- env
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
- 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
- if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
82
- emit(FARADAY_ERROR_EVENT, env, error)
81
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
82
+ env.emit(FARADAY_ERROR_EVENT, error)
83
83
 
84
- elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
85
- emit(OPEN_ROUTER_SERVER_ERROR_EVENT, env, error)
84
+ elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
85
+ env.emit(OPEN_ROUTER_SERVER_ERROR_EVENT, error)
86
86
 
87
- else
88
- emit(STANDARD_ERROR_EVENT, env, error)
87
+ else
88
+ env.emit(STANDARD_ERROR_EVENT, error)
89
89
 
90
- end
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
- overrides[:tools] = tools if tools.any?
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 after_llm].each do |event|
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
- emit(BEFORE_LLM_EVENT, env)
62
+ env.emit_trace do |env|
63
+ env.emit(LLM_START_EVENT)
63
64
 
64
- messages = Brute::MessageTransport::RubyLLM.dump_all(env[:messages])
65
- response = nil
66
- emit(LLM_DURATION_EVENT, env) { response = complete(env, messages) }
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
- unless response.nil?
69
- if (usage = Brute::MessageTransport::RubyLLM.usage_metrics(response))
70
- (env[:metadata] ||= {})[:last_llm_usage] = usage
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
- Brute::MessageTransport::RubyLLM.wrap_each(response) do |message|
74
- env[:messages] << message
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 env[:events] chunk by chunk; the final
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[:events] << { type: :content, data: chunk.content.to_s }
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[:events] << { type: :reasoning, data: chunk.thinking.text }
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::BEFORE_LLM_EVENT) { |_env| seen << :before }
218
- pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
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
@@ -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
- # after_llm token usage, from env[:metadata][:last_llm_usage]
23
- # before_tool / after_tool a span event per tool call and result
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
- return agent if tracer.nil?
39
-
40
- agent
41
- .on(Brute::Hooks::TURN_START_EVENT) { |env| start(env, tracer) }
42
- .on(Brute::Hooks::TURN_END_EVENT) { |env| finish(env) }
43
- .on(Brute::Hooks::AFTER_LLM_EVENT) { |env| record_usage(env) }
44
- .on(Brute::Hooks::BEFORE_TOOL_EVENT) { |env, call| tool_called(env, call) }
45
- .on(Brute::Hooks::AFTER_TOOL_EVENT) { |env, call| tool_returned(env, call) }
46
- .on(Brute::Hooks::LLM_FAILURE_EVENT) { |env| failed(env) }
47
- .on(Brute::Hooks::DURATION_EVENT) { |env, started, finished, layer| layer_finished(env, started, finished, layer) }
48
- .on(Brute::Hooks::TURN_DURATION_EVENT) { |env, started, finished| timed(env, "turn", finished - started) }
49
- .on(Brute::Hooks::LLM_DURATION_EVENT) { |env, started, finished| timed(env, "llm", finished - started) }
50
- .on(Brute::Hooks::TOOL_DURATION_EVENT) { |env, started, finished, call| timed(env, "tool", finished - started, "tool.name" => call[:name].to_s) }
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
- return nil unless defined?(::OpenTelemetry)
55
-
56
- ::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
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(SPAN_NAME, attributes: {
63
- "brute.messages" => env[:messages]&.size,
64
- }.compact)
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 = env[:span] or return
77
- usage = env.dig(:metadata, :last_llm_usage) or return
78
-
79
- {
80
- "gen_ai.usage.input_tokens" => usage.input,
81
- "gen_ai.usage.output_tokens" => usage.output,
82
- "gen_ai.usage.total_tokens" => usage.total,
83
- "gen_ai.usage.reasoning_tokens" => usage.reasoning,
84
- "gen_ai.usage.cache_read_tokens" => usage.cache_read,
85
- "gen_ai.usage.cost" => usage.cost,
86
- }.each { |name, value| span.set_attribute(name, value) unless value.nil? }
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("tool_call", attributes: {
91
- "tool.name" => call[:name].to_s,
92
- "tool.arguments" => call[:arguments].to_s,
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("tool_result", attributes: {
98
- "tool.name" => call[:name].to_s,
99
- "tool.bytes" => call[:result].to_s.bytesize,
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 :duration's block, so start and finish arrive
104
- # with it rather than having to be correlated with :enter by hand.
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("middleware", attributes: {
107
- "middleware.name" => layer.class.name.to_s,
108
- "middleware.duration" => finished - started,
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::ToolPipeline, tools: [tool]
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, env) { :provider_call }
174
- emit(Brute::Hooks::AFTER_LLM_EVENT, env)
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
- # :exit is timed, so each layer reports its own duration.
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::ToolPipeline"
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