activeagent 1.9.6 → 1.9.8

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +612 -28
  3. data/README.md +9 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +50 -7
  6. data/lib/active_agent/concerns/tooling.rb +11 -0
  7. data/lib/active_agent/evals/capture.rb +286 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/model_capabilities.rb +1 -1
  27. data/lib/active_agent/providers/_base_provider.rb +18 -2
  28. data/lib/active_agent/providers/anthropic_provider.rb +10 -0
  29. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  30. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  31. data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
  32. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  33. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  34. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  35. data/lib/active_agent/providers/common/usage.rb +6 -1
  36. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  37. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  38. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  39. data/lib/active_agent/providers/ollama/options.rb +66 -2
  40. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  41. data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
  42. data/lib/active_agent/providers/requesty_provider.rb +8 -0
  43. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
  44. data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
  45. data/lib/active_agent/providers/runcrate/_types.rb +17 -0
  46. data/lib/active_agent/providers/runcrate/options.rb +39 -0
  47. data/lib/active_agent/providers/runcrate_provider.rb +72 -0
  48. data/lib/active_agent/railtie.rb +4 -0
  49. data/lib/active_agent/version.rb +1 -1
  50. data/lib/tasks/active_agent/evals.rake +28 -0
  51. metadata +27 -5
  52. data/lib/tasks/activeagent_tasks.rake +0 -4
data/README.md CHANGED
@@ -48,6 +48,9 @@ bundle add openai
48
48
  # Anthropic
49
49
  bundle add anthropic
50
50
 
51
+ # Atlas Cloud (uses OpenAI-compatible API)
52
+ bundle add openai
53
+
51
54
  # Ollama (uses OpenAI-compatible API)
52
55
  bundle add openai
53
56
 
@@ -60,6 +63,12 @@ bundle add openai
60
63
  # Opper (uses OpenAI-compatible API)
61
64
  bundle add openai
62
65
 
66
+ # Cheaper Inference (uses OpenAI-compatible API)
67
+ bundle add openai
68
+
69
+ # Runcrate (uses OpenAI-compatible API)
70
+ bundle add openai
71
+
63
72
  # DeepSeek (uses OpenAI-compatible API)
64
73
  bundle add openai
65
74
 
@@ -336,6 +336,7 @@ module ActiveAgent
336
336
  exception_handler:,
337
337
  stream_broadcaster:,
338
338
  tools_function:,
339
+ tool_observer:,
339
340
  action_name: generation_action_name,
340
341
  input_request_resume: @_input_request_resume,
341
342
  announce_input_requests: !input_requests_silenced?
@@ -86,8 +86,8 @@ module ActiveAgent
86
86
  p.on("--keys LIST", "Replay only these scenario keys, comma-separated") { |v| arguments[:keys] = list_option(v) }
87
87
  p.on("--group GROUP", "Replay only this scenario group") { |v| arguments[:group] = v }
88
88
  p.on("--sandbox ID", "Replay against this ready checkout sandbox") { |v| arguments[:sandbox_id] = v }
89
- p.on("--wait", "Wait for the run to finish") { wait = true }
90
- p.on("--min-pass-rate RATE", "Fail (exit #{EXIT_GATE}) below this pass rate: 0.8 or 80%; implies --wait") do |v|
89
+ p.on("--wait", "Wait for the run to finish; fail (exit #{EXIT_GATE}) when it failed or missed a pass gate") { wait = true }
90
+ p.on("--min-pass-rate RATE", "Also fail below this whole-run pass rate: 0.8 or 80%; implies --wait") do |v|
91
91
  min_pass_rate = pass_rate_option(v)
92
92
  wait = true
93
93
  end
@@ -138,11 +138,12 @@ module ActiveAgent
138
138
 
139
139
  # Posts a saved report to the collector. FILE is a version-1 envelope
140
140
  # (what `evals report --format json` writes) or a bare Report#to_h
141
- # hash, whose identities then come from the options.
141
+ # hash, whose identities then come from the options; the publisher
142
+ # reads the run_id from the report's metadata when neither names one.
142
143
  def evals_import(argv)
143
144
  identities = {}
144
145
  argv = parse(argv, "evals import FILE [--run-id ID --source NAME --agent NAME --suite NAME]") do |p|
145
- p.on("--run-id ID", "The report's run_id (kept across retries)") { |v| identities["run_id"] = v }
146
+ p.on("--run-id ID", "The report's run_id, kept across retries; by default the one its metadata records") { |v| identities["run_id"] = v }
146
147
  p.on("--source NAME", "The application that ran it") { |v| identities["source"] = v }
147
148
  p.on("--agent NAME", "The agent's name") { |v| identities["agent_name"] = v }
148
149
  p.on("--suite NAME", "The suite's name") { |v| identities["suite"] = v }
@@ -156,7 +157,6 @@ module ActiveAgent
156
157
  raise Error.new("#{path} is a version #{envelope["version"].inspect} envelope; this CLI sends version 1", exit_code: EXIT_USAGE)
157
158
  end
158
159
  envelope = envelope.merge(identities)
159
- envelope["run_id"] ||= envelope["report"].is_a?(Hash) ? envelope["report"].dig("metadata", "run_id") : nil
160
160
 
161
161
  receipt = client.publisher.call(report: envelope["report"], run_id: envelope["run_id"], source: envelope["source"],
162
162
  agent_name: envelope["agent_name"], suite: envelope["suite"])
@@ -199,18 +199,28 @@ module ActiveAgent
199
199
  end
200
200
  end
201
201
 
202
+ # A finished run fails the gate when it failed, when its whole pass
203
+ # rate is under --min-pass-rate, or when it was held to per-group pass
204
+ # gates (the run's "gates", from the suite's or catalog set's `pass`
205
+ # bars) and the dashboard says it did not meet every one. A run
206
+ # without gates is gated on its status and rate alone, as it was.
202
207
  def gate(payload, min_pass_rate)
203
208
  run = payload.fetch("run", {})
204
209
  rate = pass_rate(run)
205
210
  failed = run["status"] == "failed"
206
211
  below = min_pass_rate && (rate.nil? || rate < min_pass_rate)
207
- verdict = failed || below ? "fail" : "pass"
212
+ gates = run_gates(run)
213
+ missed = !gates.nil? && run["gates_met"] != true
214
+ verdict = failed || below || missed ? "fail" : "pass"
208
215
  failures = payload.fetch("results", [])
216
+ summary = { "pass_rate" => rate, "min_pass_rate" => min_pass_rate }
217
+ summary["gates"] = gates if gates
209
218
 
210
- emit(payload.merge("pass_rate" => rate, "min_pass_rate" => min_pass_rate, "verdict" => verdict)) do
219
+ emit(payload.merge(summary).merge("verdict" => verdict)) do
211
220
  @out.puts "Run #{run["id"]} #{run["status"]}: #{passed(run) || "no results"}" \
212
221
  "#{rate ? format(" (%.1f%%)", rate * 100) : ""}#{min_pass_rate ? format(", threshold %.1f%%", min_pass_rate * 100) : ""}."
213
222
  @out.puts "Error: #{run["error_message"]}" if run["error_message"]
223
+ print_gates(gates, missed) if gates
214
224
  unless failures.empty?
215
225
  @out.puts "Failed results:"
216
226
  table(failures, {
@@ -227,6 +237,39 @@ module ActiveAgent
227
237
  verdict == "pass" ? EXIT_SUCCESS : EXIT_GATE
228
238
  end
229
239
 
240
+ # The run's per-group pass gates as the dashboard worked them out
241
+ # (EvaluationRun#gates), or nil for a run held to none.
242
+ def run_gates(run)
243
+ rows = run["gates"]
244
+ rows.select { |row| row.is_a?(Hash) } if rows.is_a?(Array)
245
+ end
246
+
247
+ def print_gates(gates, missed)
248
+ unmet = gates.reject { |gate| gate["met"] == true }
249
+ unless missed
250
+ @out.puts "Gates: every gate met (#{gates.size})."
251
+ return
252
+ end
253
+
254
+ @out.puts(unmet.empty? ? "Gates: no gate was reached." : "Gates not met:")
255
+ unmet.each { |gate| @out.puts " #{gate_sentence(gate)}" }
256
+ end
257
+
258
+ # A gate the run missed, as ActiveAgent::Evals::Report::Gate#to_s
259
+ # reads it: "Orders · gpt-5-mini: 1/2 (50%) passed, below 100%".
260
+ def gate_sentence(gate)
261
+ passed = gate["passed"].to_i
262
+ total = gate["total"].to_i
263
+ passes = total.positive? ? "#{passed}/#{total} (#{whole_percent(passed.to_f / total)})" : "no results"
264
+ label = cell(gate["label"]) unless gate["label"].to_s.empty?
265
+ "#{cell(gate["name"] || gate["group"] || "All")}#{label ? " · #{label}" : ""}: #{passes} passed, below #{whole_percent(gate["min_pass_rate"])}"
266
+ end
267
+
268
+ def whole_percent(fraction)
269
+ value = Float(fraction, exception: false)
270
+ value ? "#{(value * 100).round}%" : "?"
271
+ end
272
+
230
273
  def pass_rate(run)
231
274
  evaluated = run["samples_evaluated"].to_i
232
275
  evaluated.positive? ? run["samples_passed"].to_f / evaluated : nil
@@ -26,5 +26,16 @@ module ActiveAgent
26
26
  raise_without_input_request_secrets(error)
27
27
  end
28
28
  end
29
+
30
+ # A callback the provider runs the calls it serves from an MCP server
31
+ # around, which never reach {#tools_function}. It receives the tool name,
32
+ # its arguments and a block that makes the call, and returns the block's
33
+ # result. Nil unless the agent defines one, to audit its MCP calls say,
34
+ # or a host that records a generation's tool calls sets it on the
35
+ # instance. Evals::Capture keeps an agent's own observer and records the
36
+ # call that observer makes.
37
+ #
38
+ # @return [Proc, nil]
39
+ def tool_observer = nil
29
40
  end
30
41
  end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Runs one agent generation and records what the agent did, as a Replay:
6
+ # the answer, every tool it called (arguments, result or error, timing),
7
+ # how long the generation took and the tokens it used. It is the glue
8
+ # between `MyAgent.with(...).action` and the Runner's `replay` callable,
9
+ # and what TestHelper#run_eval returns.
10
+ #
11
+ # replay = ActiveAgent::Evals::Capture.run(SupportAgent.with(question: "Where is order 4821?").help)
12
+ # replay.answer # => "Order 4821 shipped on Monday."
13
+ # replay.tool_names # => ["lookup_order"]
14
+ # replay.tool_calls # => [{ "name" => "lookup_order", "arguments" => { "number" => "4821" }, ... }]
15
+ #
16
+ # Tool calls are captured on the agent instance's `tools_function`, the
17
+ # proc every provider calls to run a tool, which is also where the
18
+ # telemetry tool spans hang. A tool served by a client-side MCP server
19
+ # (`mcp_strategy: :client`, or a provider with no MCP of its own) is
20
+ # dispatched before that proc, so the provider reports it through the
21
+ # agent's `tool_observer` instead, with a failure the server reported
22
+ # marked as one. An observer the agent defines itself still runs, around
23
+ # the call that is recorded. A tool a provider runs on its own side
24
+ # (Anthropic's or OpenAI's hosted MCP) never reaches this process and is
25
+ # not recorded.
26
+ #
27
+ # `model:` and `provider:` replay the same action under another model,
28
+ # the way a comparison run does. A model is an option on the agent
29
+ # instance. A provider is a subclass of the agent configured with
30
+ # `generate_with provider, model:`, named as its parent so its templates
31
+ # still resolve; the parent's `instructions:` option is carried, and the
32
+ # rest of its options follow `generate_with`'s own rule for a subclass
33
+ # (kept under the same service, dropped under another).
34
+ #
35
+ # A block runs the agent some other way; it may return a PromptResponse,
36
+ # a Replay, a Hash of Replay attributes, or the answer as a String. Tool
37
+ # calls are then whatever the block reports, since nothing wrapped the
38
+ # agent.
39
+ #
40
+ # An exception becomes an errored Replay, as it does under Runner, so a
41
+ # scenario that raised is scored and diagnosed like any other;
42
+ # `raise_errors: true` re-raises it instead, for the backtrace.
43
+ class Capture
44
+ # How much of a tool's result a tool call keeps as its "detail".
45
+ DETAIL_LIMIT = 500
46
+
47
+ # @param generation [ActiveAgent::Generation, nil] the action to run; nil with a block
48
+ # @param model [String, nil] the model to run under instead of the agent's own
49
+ # @param provider [Symbol, String, nil] the provider to run under instead of the agent's own
50
+ # @param metadata [Hash] carried onto the Replay's metadata
51
+ # @param raise_errors [Boolean] re-raise instead of returning an errored Replay
52
+ # @return [Replay]
53
+ def self.run(generation = nil, model: nil, provider: nil, metadata: {}, raise_errors: false, &block)
54
+ new(generation, model: model, provider: provider, metadata: metadata, raise_errors: raise_errors).call(&block)
55
+ end
56
+
57
+ def initialize(generation = nil, model: nil, provider: nil, metadata: {}, raise_errors: false)
58
+ @generation = generation
59
+ @model = model
60
+ @provider = provider
61
+ @metadata = (metadata || {}).to_h.transform_keys(&:to_s)
62
+ @raise_errors = raise_errors
63
+ @tool_calls = []
64
+ @agent = nil
65
+ end
66
+
67
+ def call(&block)
68
+ raise ArgumentError, "give Capture a generation to run, or a block that runs the agent" unless @generation || block
69
+
70
+ started = self.class.monotonic_ms
71
+ value =
72
+ begin
73
+ @generation ? run_generation : block.call
74
+ rescue StandardError => e
75
+ raise if @raise_errors
76
+
77
+ return Replay.failed(e, tool_calls: @tool_calls, duration_ms: self.class.elapsed_ms(started), metadata: base_metadata)
78
+ end
79
+
80
+ replay_for(value, duration_ms: self.class.elapsed_ms(started))
81
+ end
82
+
83
+ # The generation with `model:` and `provider:` applied: the one given
84
+ # when neither is set, otherwise a copy that instantiates the retargeted
85
+ # agent. Public so a host that runs generations itself can retarget the
86
+ # same way.
87
+ def self.retarget(generation, model: nil, provider: nil)
88
+ return generation unless model || provider
89
+
90
+ copy = generation.dup
91
+ # The memoized agent, if the generation was already touched, belongs
92
+ # to the original class and action.
93
+ copy.instance_variable_set(:@agent, nil)
94
+ copy.agent_class = provider ? subclass_under(generation.agent_class, provider, model) : generation.agent_class
95
+ copy
96
+ end
97
+
98
+ # A subclass of `agent_class` that generates with `provider` (and
99
+ # `model`, when given), named as its parent so `agent_name`, the view
100
+ # prefixes and the telemetry span names stay the parent's.
101
+ def self.subclass_under(agent_class, provider, model)
102
+ parent_name = agent_class.name
103
+ parent_options = agent_class.prompt_options || {}
104
+
105
+ Class.new(agent_class).tap do |klass|
106
+ klass.define_singleton_method(:name) { parent_name }
107
+ klass.generate_with(provider, **{ model: model }.compact)
108
+ # generate_with never inherits a parent's instructions. A replay is
109
+ # the same agent under another model, so they come along.
110
+ klass.prompt_options[:instructions] = parent_options[:instructions] if parent_options.key?(:instructions)
111
+ end
112
+ end
113
+
114
+ # Runs one tool call and appends its record to +calls+: the name, the
115
+ # arguments, a preview of the result and the time it took. A call that
116
+ # raises is recorded as an error and re-raised. So is one an MCP server
117
+ # reported as failed: it comes back as an MCPBridge::ErrorResult rather
118
+ # than an exception, so the model can recover, and is returned to the
119
+ # provider unchanged.
120
+ #
121
+ # @api private
122
+ # @return [Object] what the call returned
123
+ def self.record_call(calls, name, arguments)
124
+ call = { "name" => name.to_s }
125
+ call["arguments"] = jsonable(arguments) unless arguments.nil?
126
+ started = monotonic_ms
127
+
128
+ begin
129
+ result = yield
130
+ if mcp_error?(result)
131
+ call["error"] = true
132
+ call["detail"] = preview(result.message)
133
+ else
134
+ call["detail"] = preview(result)
135
+ end
136
+ result
137
+ rescue StandardError => e
138
+ call["error"] = true
139
+ call["detail"] = "#{e.class}: #{e.message}"
140
+ raise
141
+ ensure
142
+ call["duration_ms"] = elapsed_ms(started)
143
+ calls << call
144
+ end
145
+ end
146
+
147
+ def self.mcp_error?(result)
148
+ defined?(ActiveAgent::Providers::MCPBridge::ErrorResult) && result.is_a?(ActiveAgent::Providers::MCPBridge::ErrorResult)
149
+ end
150
+ private_class_method :mcp_error?
151
+
152
+ def self.monotonic_ms
153
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
154
+ end
155
+
156
+ def self.elapsed_ms(started)
157
+ monotonic_ms - started
158
+ end
159
+
160
+ # Tool arguments and results as JSON would carry them (string keys,
161
+ # plain values), so a recorded call compares with what a test writes.
162
+ def self.jsonable(value)
163
+ JSON.parse(JSON.generate(value))
164
+ rescue StandardError
165
+ value.to_s
166
+ end
167
+
168
+ def self.preview(value)
169
+ text = value.is_a?(String) ? value : (JSON.generate(value) rescue value.to_s)
170
+ text.length > DETAIL_LIMIT ? "#{text[0, DETAIL_LIMIT]}…" : text
171
+ end
172
+
173
+ private
174
+
175
+ def run_generation
176
+ generation = self.class.retarget(@generation, model: @model, provider: @provider)
177
+ # Generation#agent is private: nothing outside should touch the agent
178
+ # before prompt_later. A capture runs it now, and needs the instance
179
+ # to hook its tool dispatch, so it reaches in on purpose.
180
+ @agent = generation.send(:agent)
181
+ @agent.prompt_options[:model] = @model if @model && !@provider
182
+ record_tool_calls(@agent)
183
+ generation.generate_now
184
+ end
185
+
186
+ # Wraps the agent's tools_function so each dispatch lands in
187
+ # @tool_calls. The singleton method takes precedence over the telemetry
188
+ # module prepended on the class, and `base` is that module's wrapped
189
+ # proc, so tool spans still fire.
190
+ #
191
+ # A tool served by a client-side MCP server never reaches tools_function:
192
+ # the provider runs that call inside the agent's `tool_observer`, which
193
+ # records it the same way. The two paths are disjoint, so a call is
194
+ # recorded once, and the observer sees the call without making it again.
195
+ #
196
+ # An observer the agent already has is kept, as tools_function's `base`
197
+ # is: it runs around the call this records, so it sees the same name,
198
+ # arguments, result and exception, and a call it does not make is not
199
+ # recorded as made.
200
+ def record_tool_calls(agent)
201
+ calls = @tool_calls
202
+ base = agent.tools_function
203
+ observer = agent.tool_observer
204
+ capture = self.class
205
+
206
+ agent.define_singleton_method(:tools_function) do
207
+ proc do |name, *args, **kwargs|
208
+ arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
209
+ capture.record_call(calls, name, arguments) { base.call(name, *args, **kwargs) }
210
+ end
211
+ end
212
+
213
+ agent.define_singleton_method(:tool_observer) do
214
+ record = proc { |name, arguments, &call| capture.record_call(calls, name, arguments.presence, &call) }
215
+ next record unless observer
216
+
217
+ proc { |name, arguments, &call| observer.call(name, arguments) { record.call(name, arguments, &call) } }
218
+ end
219
+ end
220
+
221
+ def replay_for(value, duration_ms:)
222
+ case value
223
+ when Replay
224
+ attributes = value.to_h.transform_keys(&:to_sym).merge(
225
+ tool_calls: value.tool_calls.presence || @tool_calls,
226
+ duration_ms: value.duration_ms || duration_ms,
227
+ metadata: base_metadata.merge(value.metadata)
228
+ )
229
+ Replay.new(**attributes)
230
+ when Hash
231
+ replay_for(Replay.new(**value.to_h.transform_keys(&:to_sym)), duration_ms: duration_ms)
232
+ when String, nil
233
+ Replay.new(answer: value, tool_calls: @tool_calls, duration_ms: duration_ms, metadata: base_metadata)
234
+ else
235
+ unless value.respond_to?(:messages)
236
+ raise ArgumentError, "expected the agent run to return a PromptResponse, a Replay, a Hash or a String, got #{value.class}"
237
+ end
238
+
239
+ replay_from_response(value, duration_ms: duration_ms)
240
+ end
241
+ end
242
+
243
+ def replay_from_response(response, duration_ms:)
244
+ usage = response.respond_to?(:usage) ? response.usage : nil
245
+ messages = Array(response.messages)
246
+
247
+ Replay.new(
248
+ answer: final_answer(messages),
249
+ tool_calls: @tool_calls,
250
+ duration_ms: duration_ms,
251
+ input_tokens: usage&.input_tokens,
252
+ output_tokens: usage&.output_tokens,
253
+ metadata: base_metadata.merge({ "prompt" => user_prompt(messages) }.compact)
254
+ )
255
+ end
256
+
257
+ # The agent's last assistant turn. A tool loop leaves tool calls and
258
+ # results before it; the final text comes last.
259
+ def final_answer(messages)
260
+ message = messages.reverse.find { |candidate| candidate.respond_to?(:role) && candidate.role.to_s == "assistant" }
261
+ return nil unless message
262
+
263
+ message.respond_to?(:text) ? message.text : message.content.to_s
264
+ end
265
+
266
+ # What the user asked, for the scenario a test scores the replay
267
+ # against: the first user turn, when it is plain text.
268
+ def user_prompt(messages)
269
+ message = messages.find { |candidate| candidate.respond_to?(:role) && candidate.role.to_s == "user" }
270
+ content = message&.content
271
+ content if content.is_a?(String) && content.present?
272
+ end
273
+
274
+ def base_metadata
275
+ options = @agent&.prompt_options || {}
276
+ {
277
+ "agent" => @agent&.class&.name,
278
+ "action" => @generation&.action_name&.to_s,
279
+ "provider" => @provider&.to_s,
280
+ "service" => options[:service]&.to_s,
281
+ "model" => (@model || options[:model])&.to_s
282
+ }.compact.merge(@metadata)
283
+ end
284
+ end
285
+ end
286
+ end