activeagent 1.9.6 → 1.9.7
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 +436 -28
- data/README.md +6 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +3 -3
- data/lib/active_agent/concerns/tooling.rb +9 -0
- data/lib/active_agent/evals/capture.rb +275 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/providers/_base_provider.rb +17 -1
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +16 -3
- data/lib/tasks/activeagent_tasks.rake +0 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# The bars a suite declares for a run: the score every answer must reach
|
|
6
|
+
# (the Runner's and the Report's threshold), the share of a model's
|
|
7
|
+
# answers that must pass, and the groups whose results are reported but
|
|
8
|
+
# not gated. Report#gates compares each group's pass rate against them.
|
|
9
|
+
#
|
|
10
|
+
# suite: support_desk
|
|
11
|
+
# pass:
|
|
12
|
+
# threshold: 0.7
|
|
13
|
+
# min_pass_rate: 0.8
|
|
14
|
+
# groups:
|
|
15
|
+
# - key: orders
|
|
16
|
+
# pass: { min_pass_rate: 1.0 }
|
|
17
|
+
# - key: experimental
|
|
18
|
+
# pass: { gated: false }
|
|
19
|
+
#
|
|
20
|
+
# A group's `min_pass_rate` falls back to the suite's, and the suite's to
|
|
21
|
+
# DEFAULT_MIN_PASS_RATE; a group is gated unless it says `gated: false`.
|
|
22
|
+
# Layered documents override key by key at both levels, so a document
|
|
23
|
+
# that sets one group's bar leaves the suite's threshold and the other
|
|
24
|
+
# groups alone.
|
|
25
|
+
class PassThresholds
|
|
26
|
+
DEFAULT_MIN_PASS_RATE = 1.0
|
|
27
|
+
SUITE_KEYS = %w[threshold min_pass_rate].freeze
|
|
28
|
+
GROUP_KEYS = %w[min_pass_rate gated].freeze
|
|
29
|
+
|
|
30
|
+
# Layers each document's top-level `pass` and each group's `pass`
|
|
31
|
+
# (groups under `groups`, keyed by their `key`), later documents
|
|
32
|
+
# winning key by key. A group without a key has no scenarios to gate,
|
|
33
|
+
# so a `pass` block on one is refused.
|
|
34
|
+
#
|
|
35
|
+
# @param documents [Array<Hash>] parsed YAML documents, base first
|
|
36
|
+
def self.from_documents(documents)
|
|
37
|
+
suite = {}
|
|
38
|
+
groups = {}
|
|
39
|
+
Array(documents).each do |document|
|
|
40
|
+
document = document.to_h.deep_stringify_keys
|
|
41
|
+
suite.merge!(pass_block(document["pass"], "the suite"))
|
|
42
|
+
Array(document["groups"]).each do |group|
|
|
43
|
+
group = group.to_h.deep_stringify_keys
|
|
44
|
+
key = group["key"].to_s
|
|
45
|
+
raise ArgumentError, "\"pass\" of a group without a key gates nothing" if key.empty? && !group["pass"].nil?
|
|
46
|
+
|
|
47
|
+
groups[key] = groups.fetch(key, {}).merge(pass_block(group["pass"], "group #{key}"))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
new(suite: suite, groups: groups)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.default
|
|
54
|
+
new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A `pass` block as a string-keyed Hash; nothing declared reads as {},
|
|
58
|
+
# and anything but a mapping raises ArgumentError naming `place`.
|
|
59
|
+
def self.pass_block(value, place)
|
|
60
|
+
return {} if value.nil?
|
|
61
|
+
raise ArgumentError, "\"pass\" of #{place} must be a mapping, got #{value.inspect}" unless value.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
value.deep_stringify_keys
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `pass` as a string-keyed Hash of the `allowed` keys (SUITE_KEYS or
|
|
67
|
+
# GROUP_KEYS), each refusal naming `place` — the suite, a group, or
|
|
68
|
+
# whatever a caller holds a block for: a bar is a number from 0 to 1,
|
|
69
|
+
# kept as a Float so `1` and `1.0` read, serialize and hash the same,
|
|
70
|
+
# and `gated` is true or false.
|
|
71
|
+
def self.validate(pass, allowed, place)
|
|
72
|
+
pass_block(pass, place).to_h do |key, value|
|
|
73
|
+
raise ArgumentError, "unknown key #{key.inspect} in \"pass\" of #{place}; expected one of #{allowed.join(', ')}" unless allowed.include?(key)
|
|
74
|
+
|
|
75
|
+
if key == "gated"
|
|
76
|
+
raise ArgumentError, "\"gated\" in \"pass\" of #{place} must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
|
|
77
|
+
|
|
78
|
+
[ key, value ]
|
|
79
|
+
elsif value.is_a?(Numeric) && value.finite? && value.between?(0, 1)
|
|
80
|
+
[ key, value.to_f ]
|
|
81
|
+
else
|
|
82
|
+
raise ArgumentError, "#{key.inspect} in \"pass\" of #{place} must be a number from 0 to 1, got #{value.inspect}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param suite [Hash] the suite's bars, `threshold` and `min_pass_rate`
|
|
88
|
+
# @param groups [Hash{String=>Hash}] each group's bars by group key,
|
|
89
|
+
# `min_pass_rate` and `gated`
|
|
90
|
+
def initialize(suite: {}, groups: {})
|
|
91
|
+
@suite = self.class.validate(suite, SUITE_KEYS, "the suite")
|
|
92
|
+
@groups = groups.to_h.deep_stringify_keys.filter_map do |key, pass|
|
|
93
|
+
declared = self.class.validate(pass, GROUP_KEYS, "group #{key}")
|
|
94
|
+
[ key.to_s, declared ] if declared.any?
|
|
95
|
+
end.to_h
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The score every answer must reach, 0..1.
|
|
99
|
+
def threshold
|
|
100
|
+
(@suite["threshold"] || PASS_THRESHOLD).to_f
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The share of a model's answers that must pass, 0..1: the group's own
|
|
104
|
+
# bar when it declared one, else the suite's, else DEFAULT_MIN_PASS_RATE.
|
|
105
|
+
def min_pass_rate(group = nil)
|
|
106
|
+
own = @groups.dig(group.to_s, "min_pass_rate") unless group.nil?
|
|
107
|
+
(own || @suite["min_pass_rate"] || DEFAULT_MIN_PASS_RATE).to_f
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# False only for a group that declared `gated: false`; nil and unknown
|
|
111
|
+
# groups are gated.
|
|
112
|
+
def gated?(group)
|
|
113
|
+
return true if group.nil?
|
|
114
|
+
|
|
115
|
+
@groups.dig(group.to_s, "gated") != false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The bars in effect, with each group's declared keys only.
|
|
119
|
+
def to_h
|
|
120
|
+
{
|
|
121
|
+
"threshold" => threshold,
|
|
122
|
+
"min_pass_rate" => min_pass_rate,
|
|
123
|
+
"groups" => @groups.transform_values(&:dup)
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ==(other)
|
|
128
|
+
other.is_a?(PassThresholds) && to_h == other.to_h
|
|
129
|
+
end
|
|
130
|
+
alias eql? ==
|
|
131
|
+
|
|
132
|
+
def hash
|
|
133
|
+
to_h.hash
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# Regexes for a figure an answer is expected to state, built from the data
|
|
6
|
+
# the scenario was seeded with: a count ("3 of 4 tickets") or a share of
|
|
7
|
+
# a total ("75%"). Each is a String that drops into a suite's
|
|
8
|
+
# `expect.contains` / `not_contains` or a `contains` criterion, which
|
|
9
|
+
# Scorer.matches_pattern? tries as a substring and then as a regex:
|
|
10
|
+
#
|
|
11
|
+
# "expect" => { "contains" => [ Patterns.number(3), Patterns.share(3, 4) ] }
|
|
12
|
+
module Patterns
|
|
13
|
+
MONTHS = %w[january february march april may june july august september october november december].freeze
|
|
14
|
+
|
|
15
|
+
# `text` as a pattern that matches it in any case, each letter a
|
|
16
|
+
# character class: any_case("Oct.") is "[Oo][Cc][Tt]\\.".
|
|
17
|
+
def self.any_case(text)
|
|
18
|
+
text.chars.map { |char| char.match?(/[a-z]/i) ? "[#{char.upcase}#{char.downcase}]" : Regexp.escape(char) }.join
|
|
19
|
+
end
|
|
20
|
+
private_class_method :any_case
|
|
21
|
+
|
|
22
|
+
# A day of the month written beside its month, in any case: "Oct 12",
|
|
23
|
+
# "Sept. 12" and "October 12" before the figure, "12 Oct" and "12th of
|
|
24
|
+
# October" after it. A lookbehind takes alternatives of one length only,
|
|
25
|
+
# so the spellings before the figure are one lookbehind per length, and
|
|
26
|
+
# each letter in it is a character class rather than `(?i:...)`: the
|
|
27
|
+
# dashes make the pattern UTF-8, where case folding lets "st" match the
|
|
28
|
+
# one-character ligature "st", and Ruby 3.2 refuses a lookbehind whose
|
|
29
|
+
# length that leaves open. "may" is left out after the figure, where it
|
|
30
|
+
# is usually the verb.
|
|
31
|
+
MONTH_BEFORE = MONTHS.flat_map { |month| [ month, month[0, 3], "#{month[0, 3]}." ] }.push("sept", "sept.").uniq
|
|
32
|
+
.group_by(&:length).sort.map { |_length, spellings| "(?<!#{spellings.map { |spelling| "\\b#{any_case("#{spelling} ")}" }.join('|')})" }
|
|
33
|
+
.join.freeze
|
|
34
|
+
MONTH_AFTER = (MONTHS - %w[may]).map { |month| "#{month[0, 3]}(?:#{Regexp.escape(month[3..])})?" }.push("sept").join("|").freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# The integer `count` as a figure on its own: number(10) matches
|
|
39
|
+
# "10 sessions", "(10)" and "10." at a sentence end, not "2026-10-01",
|
|
40
|
+
# "10/1", "10:00", "1-10" or "1–10", "110", "10.5", "10,000", "10%",
|
|
41
|
+
# "Oct 10" or "10 October". A ratio such as "10/22" does not match
|
|
42
|
+
# either, because "10/12" reads as a date as often as a count; expect
|
|
43
|
+
# "10 of 22". `count` is read as a whole number from its text, so 3 and
|
|
44
|
+
# "3" work and 1.5, 10.0, "a" or a negative count raise ArgumentError.
|
|
45
|
+
def number(count)
|
|
46
|
+
count = whole_number(count, "count")
|
|
47
|
+
"(?<![\\d/:.,\\-\u2013\u2014])#{MONTH_BEFORE}\\b#{count}\\b(?![/:\\-\u2013\u2014])(?![.,]\\d)(?! ?%)" \
|
|
48
|
+
"(?!(?i:(?:st|nd|rd|th)?,? (?:of )?(?:#{MONTH_AFTER})\\b))"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `count` of `total` as a percentage, whole or to one or two decimals,
|
|
52
|
+
# with an optional space before the sign: share(3, 4) matches "75%",
|
|
53
|
+
# "75 %", "75.0%" and "75.00%", not "175%", "7.5%", "75.5%" or "0.75".
|
|
54
|
+
# The whole percent rounds half up like Format.whole and the decimals
|
|
55
|
+
# like Float#round, so share(1, 8) matches "13%", "12.5%" and "12.50%",
|
|
56
|
+
# and share(12, 22) matches "55%", "54.5%" and "54.55%". Both arguments are read like
|
|
57
|
+
# `number`'s; a total below one and a count above the total raise
|
|
58
|
+
# ArgumentError.
|
|
59
|
+
def share(count, total)
|
|
60
|
+
count = whole_number(count, "count")
|
|
61
|
+
total = whole_number(total, "total", minimum: 1)
|
|
62
|
+
raise ArgumentError, "count must not exceed total" if count > total
|
|
63
|
+
|
|
64
|
+
percent = count * 100.0 / total
|
|
65
|
+
decimals = [ 2, 1 ].map { |places| Regexp.escape(format("%.#{places}f", percent.round(places))) }
|
|
66
|
+
"(?<![\\d.,])\\b(?:#{decimals.join('|')}|#{Format.whole(percent)}) ?%"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `value` as an Integer of at least `minimum`, read from its text so
|
|
70
|
+
# that 1.5 is rejected rather than truncated: 3 and "3" → 3; 1.5, "a"
|
|
71
|
+
# and a value below `minimum` raise ArgumentError.
|
|
72
|
+
def whole_number(value, name, minimum: 0)
|
|
73
|
+
number = Integer(value.to_s, 10)
|
|
74
|
+
raise ArgumentError, "#{name} must be #{minimum.positive? ? 'positive' : 'zero or positive'}" if number < minimum
|
|
75
|
+
|
|
76
|
+
number
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -20,12 +20,21 @@ module ActiveAgent
|
|
|
20
20
|
# ArgumentError before anything is sent.
|
|
21
21
|
class Publisher
|
|
22
22
|
DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
|
|
23
|
+
# The hosted dashboard people sign in to; the hosted collector answers
|
|
24
|
+
# on the API host, where a dashboard session does not reach.
|
|
25
|
+
HOSTED_DASHBOARD_URL = "https://activeagents.ai"
|
|
23
26
|
MAX_BYTES = 2 * 1024 * 1024
|
|
24
27
|
DETAIL_LIMIT = 200
|
|
25
28
|
|
|
26
29
|
# @return [String] the collector URL reports go to
|
|
27
30
|
attr_reader :endpoint
|
|
28
31
|
|
|
32
|
+
# @return [String] the address a receipt's "url" path resolves against: the
|
|
33
|
+
# dashboard_url given, or by default the endpoint's origin (its scheme,
|
|
34
|
+
# host and any non-default port, with no path), except that the hosted
|
|
35
|
+
# API host resolves to HOSTED_DASHBOARD_URL
|
|
36
|
+
attr_reader :dashboard_url
|
|
37
|
+
|
|
29
38
|
# Raised for every failed delivery. Only a network failure keeps the
|
|
30
39
|
# underlying error as its +cause+, so neither the response nor the
|
|
31
40
|
# report reaches a log through the exception chain.
|
|
@@ -68,7 +77,14 @@ module ActiveAgent
|
|
|
68
77
|
# The key is sent as a bearer token and filtered from the collector's
|
|
69
78
|
# explanation, so it is limited to visible ASCII: the sanitizer in
|
|
70
79
|
# +collector_detail+ never alters it, and an echo of it always matches.
|
|
71
|
-
|
|
80
|
+
#
|
|
81
|
+
# @param dashboard_url [String, nil] the address people open the dashboard at,
|
|
82
|
+
# where a receipt's "url" path resolves; by default the endpoint's origin,
|
|
83
|
+
# or HOSTED_DASHBOARD_URL for the hosted collector.
|
|
84
|
+
# The receipt's path, which carries the engine's mount, replaces any path on it
|
|
85
|
+
# @raise [ArgumentError] "Evaluation dashboard_url is not an http(s) URL" when it is anything else, or
|
|
86
|
+
# "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment" when it carries one of those
|
|
87
|
+
def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, dashboard_url: nil, timeout: 10, open_timeout: 10)
|
|
72
88
|
@uri = URI.parse(endpoint.to_s)
|
|
73
89
|
unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
|
|
74
90
|
raise ArgumentError, "Evaluation endpoint must be an HTTP(S) URL without credentials, query or fragment"
|
|
@@ -78,6 +94,8 @@ module ActiveAgent
|
|
|
78
94
|
end
|
|
79
95
|
@endpoint = @uri.to_s
|
|
80
96
|
|
|
97
|
+
@dashboard_url = dashboard_url.nil? ? default_dashboard_url(@uri) : checked_dashboard_url(dashboard_url)
|
|
98
|
+
|
|
81
99
|
@api_key = api_key.to_s.strip
|
|
82
100
|
raise ArgumentError, "Evaluation API key is required" if @api_key.empty?
|
|
83
101
|
unless @api_key.match?(/\A[\x21-\x7E]+\z/)
|
|
@@ -93,9 +111,42 @@ module ActiveAgent
|
|
|
93
111
|
raise ArgumentError, "Evaluation endpoint is not a valid URL"
|
|
94
112
|
end
|
|
95
113
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
114
|
+
# Returns a publisher built from +env+: ACTIVEAGENTS_API_KEY is the key,
|
|
115
|
+
# ACTIVEAGENTS_EVALUATIONS_ENDPOINT the collector (DEFAULT_ENDPOINT when
|
|
116
|
+
# blank) and ACTIVEAGENTS_DASHBOARD_URL the dashboard a receipt's "url"
|
|
117
|
+
# path resolves against (the endpoint's origin when blank), so
|
|
118
|
+
# publishing is one line:
|
|
119
|
+
#
|
|
120
|
+
# Publisher.from_env.call(report: report, source: "support-app", agent_name: "SupportBot", suite: "support_desk")
|
|
121
|
+
#
|
|
122
|
+
# @param env [#[]] the environment to read, ENV by default
|
|
123
|
+
# @raise [ArgumentError] "ACTIVEAGENTS_API_KEY is not set" for a blank key, and what +new+ raises for the other two
|
|
124
|
+
# @return [Publisher]
|
|
125
|
+
def self.from_env(env = ENV)
|
|
126
|
+
api_key = env["ACTIVEAGENTS_API_KEY"].to_s.strip
|
|
127
|
+
raise ArgumentError, "ACTIVEAGENTS_API_KEY is not set" if api_key.empty?
|
|
128
|
+
|
|
129
|
+
endpoint = env["ACTIVEAGENTS_EVALUATIONS_ENDPOINT"].to_s.strip
|
|
130
|
+
dashboard_url = env["ACTIVEAGENTS_DASHBOARD_URL"].to_s.strip
|
|
131
|
+
new(api_key: api_key, endpoint: endpoint.empty? ? DEFAULT_ENDPOINT : endpoint, dashboard_url: dashboard_url.empty? ? nil : dashboard_url)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns the collector's receipt for the completed +report+, a Report
|
|
135
|
+
# or its saved JSON hash. Full prompts, answers and tool results are
|
|
136
|
+
# included; applications should make publication opt-in.
|
|
137
|
+
#
|
|
138
|
+
# The receipt's "url" is the run's dashboard page, made absolute: a path
|
|
139
|
+
# beginning with "/" is joined onto +dashboard_url+. An absolute URL, a
|
|
140
|
+
# url that is neither (a relative reference without a leading slash, a
|
|
141
|
+
# network-path reference, or a path that is not a valid URI) and a
|
|
142
|
+
# receipt without one are left as the collector sent them.
|
|
143
|
+
#
|
|
144
|
+
# @param run_id [String, nil] the run's identity, kept across retries; by default
|
|
145
|
+
# the one the report's metadata records under "run_id" or :run_id, which
|
|
146
|
+
# Correlation#with_run puts there
|
|
147
|
+
# @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
|
|
148
|
+
def call(report:, run_id: nil, source:, agent_name:, suite:)
|
|
149
|
+
run_id = resolve_run_id(report, run_id)
|
|
99
150
|
envelope = self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite)
|
|
100
151
|
publish(envelope, accepted: %w[complete], receipt: "completion receipt")
|
|
101
152
|
end
|
|
@@ -104,13 +155,18 @@ module ActiveAgent
|
|
|
104
155
|
# which the collector shows as a running run under +run_id+. Each call
|
|
105
156
|
# replaces the results the previous one sent; +call+ with the finished
|
|
106
157
|
# report under the same run_id completes the run. The receipt's status
|
|
107
|
-
# is "running", or "complete" when the run already finished
|
|
158
|
+
# is "running", or "complete" when the run already finished, and its
|
|
159
|
+
# "url" is made absolute as +call+ makes it.
|
|
108
160
|
#
|
|
109
161
|
# Raises Error, without sending the report, when the collector does not
|
|
110
162
|
# list "progress" among its capabilities (see +supports_progress?+): a
|
|
111
163
|
# collector that predates progress would store the partial report as
|
|
112
164
|
# complete.
|
|
113
|
-
|
|
165
|
+
#
|
|
166
|
+
# @param run_id [String, nil] as for +call+: by default the one the report's metadata records
|
|
167
|
+
# @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
|
|
168
|
+
def progress(report:, run_id: nil, source:, agent_name:, suite:)
|
|
169
|
+
run_id = resolve_run_id(report, run_id)
|
|
114
170
|
unless supports_progress?
|
|
115
171
|
raise Error, "Evaluation collector does not accept progress; publish the finished report with call"
|
|
116
172
|
end
|
|
@@ -186,8 +242,54 @@ module ActiveAgent
|
|
|
186
242
|
|
|
187
243
|
private
|
|
188
244
|
|
|
245
|
+
# The run_id a delivery goes under: +run_id+ when given, otherwise the
|
|
246
|
+
# one the report's metadata records under a String or Symbol key, which
|
|
247
|
+
# Correlation#with_run puts there. Raises ArgumentError for a report
|
|
248
|
+
# that is neither a Report nor a hash, as +envelope+ does, and when
|
|
249
|
+
# neither +run_id+ nor the metadata names a nonempty string.
|
|
250
|
+
def resolve_run_id(report, run_id)
|
|
251
|
+
return run_id unless run_id.nil?
|
|
252
|
+
|
|
253
|
+
hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
|
|
254
|
+
raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
|
|
255
|
+
|
|
256
|
+
metadata = hash["metadata"]
|
|
257
|
+
recorded = metadata["run_id"] || metadata[:run_id] if metadata.is_a?(Hash)
|
|
258
|
+
return recorded if recorded.is_a?(String) && !recorded.strip.empty?
|
|
259
|
+
|
|
260
|
+
raise ArgumentError, "run_id must be given, or the report's metadata must carry one (Correlation#with_run records it)"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The hosted collector's API host serves no dashboard session, so its
|
|
264
|
+
# receipts resolve against the hosted dashboard; any other collector is
|
|
265
|
+
# its own dashboard.
|
|
266
|
+
def default_dashboard_url(uri)
|
|
267
|
+
uri.hostname == URI.parse(DEFAULT_ENDPOINT).hostname ? HOSTED_DASHBOARD_URL : origin(uri)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Returns the dashboard_url given as a String, raising ArgumentError
|
|
271
|
+
# unless it is an http(s) URL with a host and without credentials, a
|
|
272
|
+
# query or a fragment.
|
|
273
|
+
def checked_dashboard_url(value)
|
|
274
|
+
uri = URI.parse(value.to_s)
|
|
275
|
+
raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
276
|
+
if uri.userinfo || uri.query || uri.fragment
|
|
277
|
+
raise ArgumentError, "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
value.to_s
|
|
281
|
+
rescue URI::InvalidURIError
|
|
282
|
+
raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The origin of +uri+: its scheme, host and any non-default port, with no path.
|
|
286
|
+
def origin(uri)
|
|
287
|
+
uri.dup.tap { |without_path| without_path.path = "" }.to_s
|
|
288
|
+
end
|
|
289
|
+
|
|
189
290
|
# Delivers +envelope+ and returns the collector's receipt, which must name
|
|
190
|
-
# the envelope's run_id, a stored run, and one of the +accepted+ statuses
|
|
291
|
+
# the envelope's run_id, a stored run, and one of the +accepted+ statuses,
|
|
292
|
+
# with a "url" path made absolute on the dashboard.
|
|
191
293
|
def publish(envelope, accepted:, receipt:)
|
|
192
294
|
body = encode(envelope)
|
|
193
295
|
raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
|
|
@@ -201,10 +303,24 @@ module ActiveAgent
|
|
|
201
303
|
answer["id"] && answer["evaluation_id"]
|
|
202
304
|
raise Error.new("Evaluation collector returned an invalid #{receipt}; retain the report and run_id for retry", retryable: true)
|
|
203
305
|
end
|
|
204
|
-
answer
|
|
306
|
+
resolve_url(answer)
|
|
205
307
|
end
|
|
206
308
|
end
|
|
207
309
|
|
|
310
|
+
# Returns +receipt+ with a "url" that is a path beginning with "/" joined
|
|
311
|
+
# onto +dashboard_url+. Any other url (an absolute one, a relative
|
|
312
|
+
# reference without a leading slash, a network-path reference, a path
|
|
313
|
+
# that is not a valid URI) and a receipt without one are left as the
|
|
314
|
+
# collector sent them.
|
|
315
|
+
def resolve_url(receipt)
|
|
316
|
+
url = receipt["url"]
|
|
317
|
+
return receipt unless url.is_a?(String) && url.start_with?("/") && !url.start_with?("//")
|
|
318
|
+
|
|
319
|
+
receipt.merge("url" => URI.join(@dashboard_url, url).to_s)
|
|
320
|
+
rescue URI::InvalidURIError
|
|
321
|
+
receipt
|
|
322
|
+
end
|
|
323
|
+
|
|
208
324
|
# The string entries of a JSON object body's +capabilities+ array, or none.
|
|
209
325
|
def collector_capabilities(body)
|
|
210
326
|
parsed = JSON.parse(body.to_s)
|