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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/lib/active_agent/evals/design_tokens.rb +130 -0
- data/lib/active_agent/evals/diagnosis.rb +238 -0
- data/lib/active_agent/evals/judge.rb +205 -0
- data/lib/active_agent/evals/model_spec.rb +80 -0
- data/lib/active_agent/evals/replay.rb +63 -0
- data/lib/active_agent/evals/report.rb +447 -0
- data/lib/active_agent/evals/report_html.rb +634 -0
- data/lib/active_agent/evals/result.rb +78 -0
- data/lib/active_agent/evals/runner.rb +149 -0
- data/lib/active_agent/evals/scenario.rb +68 -0
- data/lib/active_agent/evals/scenario_parser.rb +215 -0
- data/lib/active_agent/evals/scorer.rb +118 -0
- data/lib/active_agent/evals/suite.rb +99 -0
- data/lib/active_agent/evals.rb +60 -0
- data/lib/active_agent/providers/ruby_llm/options.rb +4 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +14 -1
- data/lib/active_agent/providers/rubyllm_provider.rb +1 -0
- data/lib/active_agent/telemetry/configuration.rb +11 -0
- data/lib/active_agent/telemetry/instrumentation.rb +26 -6
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +22 -4
|
@@ -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(
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
data/lib/active_agent/version.rb
CHANGED
data/lib/active_agent.rb
CHANGED
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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: []
|