activeagent 1.3.0 → 1.4.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,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,60 @@
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
+
29
+ # Scenario evaluations for agents that answer with tools.
30
+ #
31
+ # `require "active_agent/evals"` loads this module on its own, without the
32
+ # rest of the framework, so an app that calls models some other way (RubyLLM,
33
+ # a plain HTTP client) can still run the same evaluations.
34
+ #
35
+ # The module owns everything about an evaluation except talking to the agent:
36
+ # parsing a pasted list of tasks (ScenarioParser) or a YAML suite (Suite),
37
+ # resolving candidate models (ModelSpec), scoring an answer against rule
38
+ # criteria and the scenario's expectations (Scorer), naming why a scenario
39
+ # fell short and what would fix it (Diagnosis, refined by an optional Judge),
40
+ # and rolling everything up per model (Report). Runner ties them together
41
+ # around one callable you supply: given a scenario and a model, run the
42
+ # agent and return a Replay.
43
+ #
44
+ # scenarios = ActiveAgent::Evals::ScenarioParser.scenarios(pasted_text)
45
+ # models = ActiveAgent::Evals::ModelSpec.parse_all(%w[gpt-5-mini qwen3:8b], default_provider: "openai")
46
+ #
47
+ # report = ActiveAgent::Evals::Runner.new(
48
+ # scenarios: scenarios,
49
+ # models: models,
50
+ # available_tools: { "find_records" => "Look up records by filters" },
51
+ # replay: ->(scenario, spec) { my_agent.run(scenario.prompt, model: spec.model, provider: spec.provider) }
52
+ # ).call
53
+ #
54
+ # puts report.to_markdown
55
+ module ActiveAgent
56
+ module Evals
57
+ # The score at or above which a scenario passes, 0.0..1.0.
58
+ PASS_THRESHOLD = 0.7
59
+ end
60
+ end
@@ -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?
@@ -73,7 +73,7 @@ module ActiveAgent
73
73
  rescue StandardError
74
74
  prompt_options[:instructions].is_a?(String) ? prompt_options[:instructions] : nil
75
75
  end
76
- if rendered_instructions.present?
76
+ if rendered_instructions.present? && telemetry_capture_bodies?
77
77
  prompt_span.set_attribute("prompt.input.instructions", telemetry_truncate(Array(rendered_instructions).join("\n\n")))
78
78
  end
79
79
 
@@ -101,8 +101,14 @@ module ActiveAgent
101
101
  # later, in prepare_prompt_parameters. Falling back to it means
102
102
  # the message the model actually received is on the trace either
103
103
  # way, which is what an evaluation scores.
104
- outbound = prompt_options[:messages]
105
- outbound = rendered_prompt_messages if outbound.blank?
104
+ # Bodies only when the configuration asks for them. (The
105
+ # messages.count above is independent of this switch, but is
106
+ # itself only present when explicit messages were passed — an
107
+ # agent rendering its user turn from a template has none at this
108
+ # point.) Rendering is skipped entirely when bodies are off —
109
+ # there would be nothing to record.
110
+ outbound = telemetry_capture_bodies? ? prompt_options[:messages] : nil
111
+ outbound = rendered_prompt_messages if outbound.blank? && telemetry_capture_bodies?
106
112
 
107
113
  if outbound.present?
108
114
  serialized = Array(outbound).map { |message|
@@ -174,7 +180,7 @@ module ActiveAgent
174
180
 
175
181
  # Carry the generation contents so dashboards can show what
176
182
  # came back, not just how many tokens it cost.
177
- if result.respond_to?(:message) && result.message.respond_to?(:content) && result.message.content.present?
183
+ if telemetry_capture_bodies? && result.respond_to?(:message) && result.message.respond_to?(:content) && result.message.content.present?
178
184
  llm_span.set_attribute("llm.output.message", telemetry_truncate(result.message.content))
179
185
  end
180
186
  if result.respond_to?(:finish_reason) && result.finish_reason.present?
@@ -218,13 +224,16 @@ module ActiveAgent
218
224
  # Records which MCP server (if any) serves this tool, so tool
219
225
  # traffic can be grouped by service downstream.
220
226
  ToolOrigin.annotate(tool_span, tool_name)
227
+ capture_bodies = agent.send(:telemetry_capture_bodies?)
221
228
  arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
222
- if arguments.present?
229
+ if arguments.present? && capture_bodies
223
230
  tool_span.set_attribute("tool.input.args", agent.send(:telemetry_truncate, JSON.generate(arguments)))
224
231
  end
225
232
  begin
226
233
  result = base.call(tool_name, *args, **kwargs)
227
- tool_span.set_attribute("tool.output.result", agent.send(:telemetry_truncate, result))
234
+ if capture_bodies
235
+ tool_span.set_attribute("tool.output.result", agent.send(:telemetry_truncate, result))
236
+ end
228
237
  tool_span.set_status(:ok)
229
238
  result
230
239
  rescue StandardError => e
@@ -269,6 +278,17 @@ module ActiveAgent
269
278
  # tool loop) can't bloat the trace payload.
270
279
  TELEMETRY_ATTRIBUTE_MAX_CHARS = 4_000
271
280
 
281
+ # Whether message bodies — the rendered system prompt, the outbound
282
+ # messages, the completion, and tool arguments and results — go on
283
+ # the spans at all. Off by default (the shared telemetry gem's
284
+ # contract, and what the docs promise), so an app reporting to a
285
+ # remote endpoint ships counts, names and tokens but not content
286
+ # unless it opted in. Configuration turns it on under local_storage,
287
+ # where bodies never leave the process.
288
+ def telemetry_capture_bodies?
289
+ Telemetry.configuration.capture_bodies?
290
+ end
291
+
272
292
  # The turns this generation will actually send, for an agent that
273
293
  # renders its user message from the action's template rather than
274
294
  # passing `messages:`. prepare_prompt_parameters is a pure function of
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.3.0"
2
+ VERSION = "1.4.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,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: actionpack
@@ -430,6 +431,20 @@ files:
430
431
  - lib/active_agent/delegation/runner.rb
431
432
  - lib/active_agent/delegation/schema.rb
432
433
  - lib/active_agent/deprecator.rb
434
+ - lib/active_agent/evals.rb
435
+ - lib/active_agent/evals/design_tokens.rb
436
+ - lib/active_agent/evals/diagnosis.rb
437
+ - lib/active_agent/evals/judge.rb
438
+ - lib/active_agent/evals/model_spec.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
@@ -584,6 +600,7 @@ metadata:
584
600
  documentation_uri: https://docs.activeagents.ai
585
601
  source_code_uri: https://github.com/activeagents/activeagent
586
602
  rubygems_mfa_required: 'true'
603
+ post_install_message:
587
604
  rdoc_options: []
588
605
  require_paths:
589
606
  - lib
@@ -591,14 +608,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
591
608
  requirements:
592
609
  - - ">="
593
610
  - !ruby/object:Gem::Version
594
- version: '0'
611
+ version: 3.2.0
595
612
  required_rubygems_version: !ruby/object:Gem::Requirement
596
613
  requirements:
597
614
  - - ">="
598
615
  - !ruby/object:Gem::Version
599
616
  version: '0'
600
617
  requirements: []
601
- rubygems_version: 4.0.6
618
+ rubygems_version: 3.5.22
619
+ signing_key:
602
620
  specification_version: 4
603
621
  summary: Rails AI Agents Framework
604
622
  test_files: []