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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +421 -0
- data/lib/active_agent/evals/design_tokens.rb +130 -0
- data/lib/active_agent/evals/diagnosis.rb +258 -0
- data/lib/active_agent/evals/judge.rb +212 -0
- data/lib/active_agent/evals/model_spec.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +76 -0
- data/lib/active_agent/evals/replay.rb +63 -0
- data/lib/active_agent/evals/report.rb +450 -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 +204 -0
- data/lib/active_agent/evals/scenario.rb +68 -0
- data/lib/active_agent/evals/scenario_parser.rb +265 -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 +61 -0
- data/lib/active_agent/providers/_base_provider.rb +5 -1
- data/lib/active_agent/providers/mock/messages/base.rb +4 -2
- data/lib/active_agent/providers/open_ai/responses/transforms.rb +42 -11
- 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 +29 -6
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +19 -3
|
@@ -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
|
|
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
|
-
#
|
|
25
|
-
#
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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.
|
|
331
|
+
if message[:image].present?
|
|
309
332
|
{ type: "input_image", image_url: message[:image] }
|
|
310
|
-
elsif message.
|
|
311
|
-
|
|
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(
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
data/lib/active_agent/version.rb
CHANGED
data/lib/active_agent.rb
CHANGED
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.
|
|
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:
|
|
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.
|
|
617
|
+
rubygems_version: 4.0.16
|
|
602
618
|
specification_version: 4
|
|
603
619
|
summary: Rails AI Agents Framework
|
|
604
620
|
test_files: []
|