activeagent 1.3.1 → 1.5.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.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Scores one Replay against an evaluation's criteria and the scenario's
6
+ # expectations. Returns `criterion key => 0.0..1.0`, with nil for a
7
+ # criterion that could not be scored (an llm_judge criterion with no judge).
8
+ #
9
+ # Criteria are `{ "key", "type", "config" }` hashes:
10
+ #
11
+ # response_present — the answer is non-empty
12
+ # min_length — `config.chars` characters (partial credit below)
13
+ # max_latency_ms — `config.ms` budget (partial credit above)
14
+ # token_budget — `config.output_tokens` budget (partial credit above)
15
+ # contains — `config.pattern` (a substring, or a regex) is present
16
+ # not_contains — `config.pattern` is absent
17
+ # llm_judge — the judge scores the answer against `config.prompt`
18
+ #
19
+ # The scenario's own expectations add `expected_tools`, `expected_content`,
20
+ # `forbidden_content` (when declared) and `tools_succeeded` (when any tool
21
+ # was called).
22
+ class Scorer
23
+ RULE_CRITERION_TYPES = %w[response_present min_length max_latency_ms token_budget contains not_contains].freeze
24
+ CRITERION_TYPES = (RULE_CRITERION_TYPES + %w[llm_judge]).freeze
25
+
26
+ attr_reader :criteria, :judge
27
+
28
+ def initialize(criteria: [], judge: nil)
29
+ @criteria = Array(criteria).map { |criterion| criterion.to_h.deep_stringify_keys }
30
+ @judge = judge
31
+ end
32
+
33
+ def score(scenario, replay)
34
+ scores = {}
35
+ answer = replay.answer.to_s
36
+
37
+ @criteria.each do |criterion|
38
+ scores[criterion["key"]] = answer.present? ? score_criterion(criterion, scenario, replay) : 0.0
39
+ end
40
+
41
+ if scenario.expected_tools.any?
42
+ scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
43
+ end
44
+ if scenario.expected_patterns.any?
45
+ hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
46
+ scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
47
+ end
48
+ if scenario.forbidden_patterns.any?
49
+ hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
50
+ scores["forbidden_content"] = hit ? 0.0 : 1.0
51
+ end
52
+ if replay.tool_calls.any?
53
+ scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
54
+ end
55
+
56
+ scores
57
+ end
58
+
59
+ # The mean of the scored criteria, or nil when nothing could be scored.
60
+ def self.mean(scores)
61
+ scored = scores.values.compact
62
+ return nil if scored.empty?
63
+
64
+ (scored.sum / scored.size).round(3)
65
+ end
66
+
67
+ # How long one pattern may take to match one answer. Patterns are
68
+ # whatever the scenario's author typed, and a pathological one must not
69
+ # stall the evaluation.
70
+ PATTERN_TIMEOUT = 1.0
71
+
72
+ # Whether `pattern` occurs in `text`: as a plain substring, case
73
+ # insensitively, or else as a regex. A pattern that is not a valid
74
+ # regex, or that takes longer than PATTERN_TIMEOUT, only counts as a
75
+ # substring.
76
+ def self.matches_pattern?(text, pattern)
77
+ pattern = pattern.to_s
78
+ return false if pattern.blank?
79
+
80
+ text = text.to_s
81
+ return true if text.downcase.include?(pattern.downcase)
82
+
83
+ text.match?(Regexp.new(pattern, Regexp::IGNORECASE, timeout: PATTERN_TIMEOUT))
84
+ rescue RegexpError
85
+ false
86
+ end
87
+
88
+ private
89
+
90
+ def score_criterion(criterion, scenario, replay)
91
+ config = criterion["config"] || {}
92
+ answer = replay.answer.to_s
93
+
94
+ case criterion["type"]
95
+ when "response_present"
96
+ answer.present? ? 1.0 : 0.0
97
+ when "min_length"
98
+ min = config.fetch("chars", 40).to_i
99
+ [ answer.length.to_f / [ min, 1 ].max, 1.0 ].min
100
+ when "max_latency_ms"
101
+ budget = config.fetch("ms", 5_000).to_f
102
+ duration = replay.duration_ms.to_f
103
+ duration.zero? || duration <= budget ? 1.0 : [ budget / duration, 1.0 ].min
104
+ when "token_budget"
105
+ budget = config.fetch("output_tokens", 1_000).to_f
106
+ tokens = replay.output_tokens.to_f
107
+ tokens <= budget ? 1.0 : [ budget / tokens, 1.0 ].min
108
+ when "contains"
109
+ self.class.matches_pattern?(answer, config["pattern"]) ? 1.0 : 0.0
110
+ when "not_contains"
111
+ self.class.matches_pattern?(answer, config["pattern"]) ? 0.0 : 1.0
112
+ when "llm_judge"
113
+ @judge&.score_criterion(criterion: criterion, prompt: scenario.prompt, answer: answer)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # An evaluation suite read from one or more YAML documents, later documents
6
+ # layered over earlier ones: a scenario with a key already present replaces
7
+ # it, a new key is appended to its group, and a new group is appended to the
8
+ # suite. That is how an app keeps a shared suite and lets a deployment add or
9
+ # reword questions.
10
+ #
11
+ # suite: assistant_dashboard
12
+ # description: The V1 question catalog
13
+ # groups:
14
+ # - key: find_records
15
+ # name: Find record(s)
16
+ # scenarios:
17
+ # - key: find_records_1
18
+ # prompt: Which medical search terms are under client control?
19
+ # expect:
20
+ # tools: [find_records, count_records]
21
+ # notes: Uniform locally.
22
+ # production_only: false
23
+ class Suite
24
+ class NotFound < StandardError; end
25
+
26
+ attr_reader :name, :description, :groups
27
+
28
+ # Loads the documents at `paths` (missing files are skipped) and raises
29
+ # NotFound when none exist.
30
+ def self.load(*paths, name: nil)
31
+ existing = paths.flatten.map(&:to_s).select { |path| File.exist?(path) }
32
+ raise NotFound, "no evaluation suite at #{paths.flatten.join(', ')}" if existing.empty?
33
+
34
+ documents = existing.map { |path| YAML.safe_load_file(path, aliases: true) || {} }
35
+ new(documents, name: name || File.basename(existing.first, ".yml"))
36
+ end
37
+
38
+ # @param documents [Array<Hash>] parsed YAML documents, base first
39
+ def initialize(documents, name: nil)
40
+ documents = Array(documents).map(&:deep_stringify_keys)
41
+ @name = documents.filter_map { |doc| doc["suite"] }.last || name
42
+ @description = documents.filter_map { |doc| doc["description"] }.last
43
+ @groups = merge_groups(documents)
44
+ end
45
+
46
+ # Scenarios narrowed by group keys, scenario keys, or both; `production_only`
47
+ # scenarios are dropped unless `include_production_only` is true.
48
+ def scenarios(groups: nil, keys: nil, include_production_only: true)
49
+ selected = all_scenarios
50
+ selected = selected.select { |scenario| Array(groups).map(&:to_s).include?(scenario.group) } if groups.present?
51
+ selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) } if keys.present?
52
+ selected = selected.reject(&:production_only?) unless include_production_only
53
+ selected
54
+ end
55
+
56
+ def all_scenarios
57
+ @all_scenarios ||= @groups.flat_map do |group|
58
+ group["scenarios"].each_with_index.map do |entry, index|
59
+ Scenario.from_hash(entry.merge("position" => index), group: group["key"], group_name: group["name"])
60
+ end
61
+ end
62
+ end
63
+
64
+ def group_keys
65
+ @groups.map { |group| group["key"] }
66
+ end
67
+
68
+ def find(key)
69
+ all_scenarios.find { |scenario| scenario.key == key.to_s } ||
70
+ raise(NotFound, "no scenario #{key.inspect} in suite #{name}")
71
+ end
72
+
73
+ private
74
+
75
+ def merge_groups(documents)
76
+ documents.each_with_object([]) do |document, groups|
77
+ Array(document["groups"]).each do |incoming|
78
+ existing = groups.find { |group| group["key"] == incoming["key"] }
79
+
80
+ if existing
81
+ existing["name"] = incoming["name"] if incoming["name"].present?
82
+ existing["description"] = incoming["description"] if incoming["description"].present?
83
+ merge_scenarios(existing, Array(incoming["scenarios"]))
84
+ else
85
+ groups << incoming.merge("scenarios" => Array(incoming["scenarios"]))
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ def merge_scenarios(group, incoming)
92
+ incoming.each do |scenario|
93
+ index = group["scenarios"].index { |existing| existing["key"] == scenario["key"] }
94
+ index ? group["scenarios"][index] = scenario : group["scenarios"] << scenario
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "cgi"
5
+ require "yaml"
6
+ require "active_support"
7
+ require "active_support/core_ext/object/blank"
8
+ require "active_support/core_ext/string/filters"
9
+ require "active_support/core_ext/string/access"
10
+ require "active_support/core_ext/string/inflections"
11
+ require "active_support/core_ext/hash/keys"
12
+ require "active_support/core_ext/hash/indifferent_access"
13
+ require "active_support/core_ext/enumerable"
14
+
15
+ require_relative "evals/scenario"
16
+ require_relative "evals/scenario_parser"
17
+ require_relative "evals/suite"
18
+ require_relative "evals/model_spec"
19
+ require_relative "evals/replay"
20
+ require_relative "evals/scorer"
21
+ require_relative "evals/diagnosis"
22
+ require_relative "evals/judge"
23
+ require_relative "evals/result"
24
+ require_relative "evals/design_tokens"
25
+ require_relative "evals/report_html"
26
+ require_relative "evals/report"
27
+ require_relative "evals/runner"
28
+ require_relative "evals/publisher"
29
+
30
+ # Scenario evaluations for agents that answer with tools.
31
+ #
32
+ # `require "active_agent/evals"` loads this module on its own, without the
33
+ # rest of the framework, so an app that calls models some other way (RubyLLM,
34
+ # a plain HTTP client) can still run the same evaluations.
35
+ #
36
+ # The module owns everything about an evaluation except talking to the agent:
37
+ # parsing a pasted list of tasks (ScenarioParser) or a YAML suite (Suite),
38
+ # resolving candidate models (ModelSpec), scoring an answer against rule
39
+ # criteria and the scenario's expectations (Scorer), naming why a scenario
40
+ # fell short and what would fix it (Diagnosis, refined by an optional Judge),
41
+ # and rolling everything up per model (Report). Runner ties them together
42
+ # around one callable you supply: given a scenario and a model, run the
43
+ # agent and return a Replay.
44
+ #
45
+ # scenarios = ActiveAgent::Evals::ScenarioParser.scenarios(pasted_text)
46
+ # models = ActiveAgent::Evals::ModelSpec.parse_all(%w[gpt-5-mini qwen3:8b], default_provider: "openai")
47
+ #
48
+ # report = ActiveAgent::Evals::Runner.new(
49
+ # scenarios: scenarios,
50
+ # models: models,
51
+ # available_tools: { "find_records" => "Look up records by filters" },
52
+ # replay: ->(scenario, spec) { my_agent.run(scenario.prompt, model: spec.model, provider: spec.provider) }
53
+ # ).call
54
+ #
55
+ # puts report.to_markdown
56
+ module ActiveAgent
57
+ module Evals
58
+ # The score at or above which a scenario passes, 0.0..1.0.
59
+ PASS_THRESHOLD = 0.7
60
+ end
61
+ end
@@ -59,7 +59,8 @@ module ActiveAgent
59
59
  :tools_function, # Callback (Tools)
60
60
  :usage_stack, # Usage Tracking
61
61
  :stream_usage_index, # Usage Tracking (Streams)
62
- :max_tool_turns, :tool_turns # Tool-loop safety
62
+ :max_tool_turns, :tool_turns, # Tool-loop safety
63
+ :instrumentation_enabled # Per-generation privacy
63
64
 
64
65
  # Upper bound on tool-calling round-trips within one generation. A
65
66
  # model that keeps emitting tool calls otherwise recurses until the
@@ -117,6 +118,7 @@ module ActiveAgent
117
118
  self.tools_function = kwargs.delete(:tools_function)
118
119
  self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
119
120
  self.tool_turns = 0
121
+ self.instrumentation_enabled = kwargs.delete(:instrumentation) != false
120
122
  self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
121
123
  self.context = kwargs
122
124
  self.message_stack = []
@@ -175,6 +177,8 @@ module ActiveAgent
175
177
  # @yield block to instrument
176
178
  # @return [Object] block result
177
179
  def instrument(name, payload = {}, &block)
180
+ return block&.call(payload) unless instrumentation_enabled
181
+
178
182
  full_payload = { provider: service_name, provider_module: tag_name, trace_id: }.merge(payload)
179
183
  ActiveSupport::Notifications.instrument(name, full_payload, &block)
180
184
  end
@@ -21,8 +21,10 @@ module ActiveAgent
21
21
  if content_type == :text
22
22
  self.content = value
23
23
  else
24
- # For image/document, MockProvider doesn't support these, so ignore
25
- # (or could raise an error)
24
+ # No vision here: an image/document stands in as a text
25
+ # marker, so a media-only turn still has content to validate
26
+ # and to concatenate with its neighbours when serialized.
27
+ self.content ||= "[#{content_type}]"
26
28
  end
27
29
  end
28
30
  end
@@ -295,25 +295,43 @@ module ActiveAgent
295
295
  if message.respond_to?(:serialize)
296
296
  message.serialize
297
297
  elsif message.is_a?(Hash)
298
- # If it has a role, it's a message - convert :text to :content
298
+ # If it has a role, it's a message. Its :text becomes :content,
299
+ # and an :image / :document alongside it becomes a content
300
+ # part — the same `{role:, text:, image:}` shorthand the Chat
301
+ # API and Anthropic transforms accept, so a caller sending
302
+ # history plus a multimodal turn gets the same request shape
303
+ # from every provider.
299
304
  if message.key?(:role)
300
305
  normalized = message.dup
301
- if normalized.key?(:text) && !normalized.key?(:content)
302
- normalized[:content] = normalized.delete(:text)
306
+ # The shorthand keys always come off the message: left on,
307
+ # they reach the request body as unknown parameters and the
308
+ # API rejects the whole call. A blank one contributes no
309
+ # part rather than an empty input_image the API would
310
+ # refuse (or a nil document, which has no URL to send).
311
+ text = normalized.delete(:text)
312
+ image = normalized.delete(:image)
313
+ document = normalized.delete(:document)
314
+
315
+ unless normalized.key?(:content)
316
+ parts = []
317
+ parts << { type: "input_text", text: text } if text.present?
318
+ parts << { type: "input_image", image_url: image } if image.present?
319
+ parts << document_part(document) if document.present?
320
+
321
+ if parts.size == 1 && parts.first[:type] == "input_text"
322
+ normalized[:content] = parts.first[:text]
323
+ elsif parts.any?
324
+ normalized[:content] = parts
325
+ end
303
326
  end
304
327
  return normalized
305
328
  end
306
329
 
307
330
  # Expand shorthand formats to full structures for content items
308
- if message.key?(:image)
331
+ if message[:image].present?
309
332
  { type: "input_image", image_url: message[:image] }
310
- elsif message.key?(:document)
311
- document_value = message[:document]
312
- if document_value.start_with?("data:")
313
- { type: "input_file", filename: "document.pdf", file_data: document_value }
314
- else
315
- { type: "input_file", file_url: document_value }
316
- end
333
+ elsif message[:document].present?
334
+ document_part(message[:document])
317
335
  elsif message.key?(:text) && message.size == 1
318
336
  # Single :text key without :role - treat as user message
319
337
  { role: "user", content: message[:text] }
@@ -336,6 +354,19 @@ module ActiveAgent
336
354
  end
337
355
  end
338
356
 
357
+ # An input_file part for a document given as a URL or a data URI.
358
+ #
359
+ # @param document_value [String] URL or data URI
360
+ # @return [Hash] input_file content part
361
+ def document_part(document_value)
362
+ document_value = document_value.to_s
363
+ if document_value.start_with?("data:")
364
+ { type: "input_file", filename: "document.pdf", file_data: document_value }
365
+ else
366
+ { type: "input_file", file_url: document_value }
367
+ end
368
+ end
369
+
339
370
  # Cleans up serialized request for API submission
340
371
  #
341
372
  # Removes default values and simplifies input where possible.
@@ -11,6 +11,10 @@ module ActiveAgent
11
11
  # provider-specific API key attributes are needed here.
12
12
  class Options < Common::BaseModel
13
13
  attribute :model, :string
14
+ # Pins which RubyLLM backend serves the model (RubyLLM's provider:,
15
+ # e.g. :vertexai, :gemini, :bedrock). A model ID served by several
16
+ # backends otherwise resolves by RubyLLM's registry preference.
17
+ attribute :platform, :string
14
18
  attribute :temperature, :float
15
19
  attribute :max_tokens, :integer
16
20
 
@@ -12,6 +12,10 @@ module ActiveAgent
12
12
  # Provider for RubyLLM's unified API, supporting 15+ LLM providers
13
13
  # (OpenAI, Anthropic, Gemini, Bedrock, Azure, Ollama, etc.).
14
14
  #
15
+ # RubyLLM resolves which backend serves a request from the model ID; the
16
+ # platform option pins it when a model ID is served by more than one
17
+ # (e.g. Gemini models on the Gemini API vs Vertex AI).
18
+ #
15
19
  # Uses RubyLLM's provider-level API (provider.complete()) rather than
16
20
  # the high-level Chat object to avoid conflicts with ActiveAgent's own
17
21
  # conversation management and tool execution loop.
@@ -254,13 +258,22 @@ module ActiveAgent
254
258
  # Reuses the cached provider if the model hasn't changed (e.g., during
255
259
  # multi-turn tool calling loops).
256
260
  #
261
+ # The platform option is forwarded as RubyLLM's provider: so a model ID
262
+ # served by several backends (e.g. gemini-2.5-flash on the Gemini API
263
+ # and Vertex AI) can be pinned instead of resolving by RubyLLM's
264
+ # registry preference.
265
+ #
257
266
  # @param model_id [String] model identifier
258
267
  # @return [void]
259
268
  def resolve_ruby_llm_provider!(model_id)
260
269
  return if @ruby_llm_provider && @cached_model_id == model_id
261
270
 
262
271
  @cached_model_id = model_id
263
- @ruby_llm_model, @ruby_llm_provider = ::RubyLLM::Models.resolve(model_id, config: ::RubyLLM.config)
272
+ @ruby_llm_model, @ruby_llm_provider = ::RubyLLM::Models.resolve(
273
+ model_id,
274
+ provider: options.platform&.to_sym,
275
+ config: ::RubyLLM.config
276
+ )
264
277
  end
265
278
 
266
279
  # Converts ActiveAgent messages to RubyLLM message format.
@@ -0,0 +1 @@
1
+ require_relative "ruby_llm_provider"
@@ -32,8 +32,19 @@ module ActiveAgent
32
32
  @local_storage = false
33
33
  end
34
34
 
35
+ # Under local storage traces never leave the process, and the
36
+ # dashboard's Interactions and Evaluations views are built from the
37
+ # message bodies — so bodies are captured unless the app said
38
+ # otherwise. An explicit capture_bodies setting wins whichever order
39
+ # the two options were given in.
35
40
  def local_storage=(value)
36
41
  @local_storage = value == true
42
+ @capture_bodies = true if @local_storage && !@capture_bodies_explicit
43
+ end
44
+
45
+ def capture_bodies=(value)
46
+ @capture_bodies_explicit = true
47
+ super
37
48
  end
38
49
 
39
50
  def local_storage?
@@ -39,6 +39,7 @@ module ActiveAgent
39
39
  module GenerationInstrumentation
40
40
  # Wraps process_prompt with telemetry tracing.
41
41
  def process_prompt
42
+ return super if respond_to?(:prompt_options) && prompt_options.is_a?(Hash) && prompt_options[:instrumentation] == false
42
43
  return super unless Telemetry.enabled?
43
44
 
44
45
  # Reuse (or mint) the generation's trace id so the telemetry trace
@@ -73,7 +74,7 @@ module ActiveAgent
73
74
  rescue StandardError
74
75
  prompt_options[:instructions].is_a?(String) ? prompt_options[:instructions] : nil
75
76
  end
76
- if rendered_instructions.present?
77
+ if rendered_instructions.present? && telemetry_capture_bodies?
77
78
  prompt_span.set_attribute("prompt.input.instructions", telemetry_truncate(Array(rendered_instructions).join("\n\n")))
78
79
  end
79
80
 
@@ -101,8 +102,14 @@ module ActiveAgent
101
102
  # later, in prepare_prompt_parameters. Falling back to it means
102
103
  # the message the model actually received is on the trace either
103
104
  # way, which is what an evaluation scores.
104
- outbound = prompt_options[:messages]
105
- outbound = rendered_prompt_messages if outbound.blank?
105
+ # Bodies only when the configuration asks for them. (The
106
+ # messages.count above is independent of this switch, but is
107
+ # itself only present when explicit messages were passed — an
108
+ # agent rendering its user turn from a template has none at this
109
+ # point.) Rendering is skipped entirely when bodies are off —
110
+ # there would be nothing to record.
111
+ outbound = telemetry_capture_bodies? ? prompt_options[:messages] : nil
112
+ outbound = rendered_prompt_messages if outbound.blank? && telemetry_capture_bodies?
106
113
 
107
114
  if outbound.present?
108
115
  serialized = Array(outbound).map { |message|
@@ -174,7 +181,7 @@ module ActiveAgent
174
181
 
175
182
  # Carry the generation contents so dashboards can show what
176
183
  # came back, not just how many tokens it cost.
177
- if result.respond_to?(:message) && result.message.respond_to?(:content) && result.message.content.present?
184
+ if telemetry_capture_bodies? && result.respond_to?(:message) && result.message.respond_to?(:content) && result.message.content.present?
178
185
  llm_span.set_attribute("llm.output.message", telemetry_truncate(result.message.content))
179
186
  end
180
187
  if result.respond_to?(:finish_reason) && result.finish_reason.present?
@@ -204,6 +211,7 @@ module ActiveAgent
204
211
  # don't expose tool calls.
205
212
  def tools_function
206
213
  base = super
214
+ return base if respond_to?(:prompt_options) && prompt_options.is_a?(Hash) && prompt_options[:instrumentation] == false
207
215
  return base unless Telemetry.enabled?
208
216
 
209
217
  agent = self
@@ -218,13 +226,16 @@ module ActiveAgent
218
226
  # Records which MCP server (if any) serves this tool, so tool
219
227
  # traffic can be grouped by service downstream.
220
228
  ToolOrigin.annotate(tool_span, tool_name)
229
+ capture_bodies = agent.send(:telemetry_capture_bodies?)
221
230
  arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
222
- if arguments.present?
231
+ if arguments.present? && capture_bodies
223
232
  tool_span.set_attribute("tool.input.args", agent.send(:telemetry_truncate, JSON.generate(arguments)))
224
233
  end
225
234
  begin
226
235
  result = base.call(tool_name, *args, **kwargs)
227
- tool_span.set_attribute("tool.output.result", agent.send(:telemetry_truncate, result))
236
+ if capture_bodies
237
+ tool_span.set_attribute("tool.output.result", agent.send(:telemetry_truncate, result))
238
+ end
228
239
  tool_span.set_status(:ok)
229
240
  result
230
241
  rescue StandardError => e
@@ -238,6 +249,7 @@ module ActiveAgent
238
249
 
239
250
  # Wraps process_embed with telemetry tracing.
240
251
  def process_embed
252
+ return super if respond_to?(:embed_options) && embed_options.is_a?(Hash) && embed_options[:instrumentation] == false
241
253
  return super unless Telemetry.enabled?
242
254
 
243
255
  Telemetry.trace("#{self.class.name}.embed", span_type: :embedding) do |span|
@@ -269,6 +281,17 @@ module ActiveAgent
269
281
  # tool loop) can't bloat the trace payload.
270
282
  TELEMETRY_ATTRIBUTE_MAX_CHARS = 4_000
271
283
 
284
+ # Whether message bodies — the rendered system prompt, the outbound
285
+ # messages, the completion, and tool arguments and results — go on
286
+ # the spans at all. Off by default (the shared telemetry gem's
287
+ # contract, and what the docs promise), so an app reporting to a
288
+ # remote endpoint ships counts, names and tokens but not content
289
+ # unless it opted in. Configuration turns it on under local_storage,
290
+ # where bodies never leave the process.
291
+ def telemetry_capture_bodies?
292
+ Telemetry.configuration.capture_bodies?
293
+ end
294
+
272
295
  # The turns this generation will actually send, for an agent that
273
296
  # renders its user message from the action's template rather than
274
297
  # passing `messages:`. prepare_prompt_parameters is a pure function of
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.3.1"
2
+ VERSION = "1.5.0"
3
3
  end
data/lib/active_agent.rb CHANGED
@@ -108,6 +108,7 @@ module ActiveAgent
108
108
  autoload :Rescue, "active_agent/concerns/rescue"
109
109
  autoload :Tooling, "active_agent/concerns/tooling"
110
110
  autoload :View, "active_agent/concerns/view"
111
+ autoload :Evals
111
112
  autoload :Telemetry
112
113
 
113
114
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
@@ -430,6 +430,21 @@ files:
430
430
  - lib/active_agent/delegation/runner.rb
431
431
  - lib/active_agent/delegation/schema.rb
432
432
  - lib/active_agent/deprecator.rb
433
+ - lib/active_agent/evals.rb
434
+ - lib/active_agent/evals/design_tokens.rb
435
+ - lib/active_agent/evals/diagnosis.rb
436
+ - lib/active_agent/evals/judge.rb
437
+ - lib/active_agent/evals/model_spec.rb
438
+ - lib/active_agent/evals/publisher.rb
439
+ - lib/active_agent/evals/replay.rb
440
+ - lib/active_agent/evals/report.rb
441
+ - lib/active_agent/evals/report_html.rb
442
+ - lib/active_agent/evals/result.rb
443
+ - lib/active_agent/evals/runner.rb
444
+ - lib/active_agent/evals/scenario.rb
445
+ - lib/active_agent/evals/scenario_parser.rb
446
+ - lib/active_agent/evals/scorer.rb
447
+ - lib/active_agent/evals/suite.rb
433
448
  - lib/active_agent/generation.rb
434
449
  - lib/active_agent/generation_job.rb
435
450
  - lib/active_agent/inline_preview_interceptor.rb
@@ -543,6 +558,7 @@ files:
543
558
  - lib/active_agent/providers/ruby_llm/request.rb
544
559
  - lib/active_agent/providers/ruby_llm/tool_proxy.rb
545
560
  - lib/active_agent/providers/ruby_llm_provider.rb
561
+ - lib/active_agent/providers/rubyllm_provider.rb
546
562
  - lib/active_agent/railtie.rb
547
563
  - lib/active_agent/railtie/schema_generator_extension.rb
548
564
  - lib/active_agent/schema_generator.rb
@@ -591,14 +607,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
591
607
  requirements:
592
608
  - - ">="
593
609
  - !ruby/object:Gem::Version
594
- version: '0'
610
+ version: 3.2.0
595
611
  required_rubygems_version: !ruby/object:Gem::Requirement
596
612
  requirements:
597
613
  - - ">="
598
614
  - !ruby/object:Gem::Version
599
615
  version: '0'
600
616
  requirements: []
601
- rubygems_version: 4.0.6
617
+ rubygems_version: 4.0.16
602
618
  specification_version: 4
603
619
  summary: Rails AI Agents Framework
604
620
  test_files: []