activeagent 1.4.0 → 1.5.2

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.
@@ -8,8 +8,10 @@ module ActiveAgent
8
8
  # The one thing the runner does not know is how to talk to your agent;
9
9
  # `replay` is a callable `(scenario, model_spec) → Replay` (a Hash with the
10
10
  # same keys is accepted, and an exception becomes an errored Replay). A
11
- # scenario passes when its replay completed, met its expectations, and its
12
- # mean score reached `threshold`; anything else carries exactly one fault
11
+ # scenario passes when its replay completed, met its expectations, and both
12
+ # its mean score and the mean of its judge grades (task completion, or the
13
+ # configured llm_judge criteria) reached `threshold`;
14
+ # anything else carries exactly one fault
13
15
  # and a recommendation from Diagnosis, refined by the `judge` for the
14
16
  # faults in `refine_faults` (up to `judge_limit` calls per run).
15
17
  #
@@ -42,9 +44,17 @@ module ActiveAgent
42
44
  # @param instructions [String, nil] the agent's instructions, for the judge
43
45
  # @param agent_name [String] how recommendations refer to the agent
44
46
  # @param on_result [#call, nil] called with each Result as it lands
47
+ # @param around_evaluation [#call, nil] called with (scenario, spec) and a
48
+ # block that returns the Result. Establishes context for replay, scoring
49
+ # and recommendations; must return the block's result. Wrapper errors
50
+ # propagate to the caller. Applies to #call, not direct #evaluate calls.
51
+ # @param require_judge_scores [Boolean] fail an otherwise passing result
52
+ # when a requested task/LLM grade is unavailable, rather than falling
53
+ # back to rule scores. Does not require a judge for rules-only runs.
45
54
  def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
46
55
  instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
47
- refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil, metadata: {})
56
+ refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
57
+ around_evaluation: nil, require_judge_scores: false, metadata: {})
48
58
  @scenarios = scenarios
49
59
  @models = models
50
60
  @replay = replay
@@ -58,6 +68,8 @@ module ActiveAgent
58
68
  @refine_faults = refine_faults
59
69
  @judge_limit = judge_limit
60
70
  @on_result = on_result
71
+ @around_evaluation = around_evaluation
72
+ @require_judge_scores = require_judge_scores
61
73
  @metadata = metadata
62
74
  @judge_calls = 0
63
75
  @scorer = Scorer.new(criteria: criteria, judge: judge)
@@ -66,7 +78,7 @@ module ActiveAgent
66
78
  def call
67
79
  results = @scenarios.flat_map do |scenario|
68
80
  @models.map do |spec|
69
- evaluate(scenario, spec).tap { |result| @on_result&.call(result) }
81
+ evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
70
82
  end
71
83
  end
72
84
 
@@ -85,7 +97,9 @@ module ActiveAgent
85
97
  score = Scorer.mean(scores)
86
98
 
87
99
  diagnosis = Diagnosis.call(scenario: scenario, replay: replay, scores: scores, score: score,
88
- available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name)
100
+ available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name,
101
+ judge_keys: llm_judge_keys)
102
+ diagnosis ||= unavailable_judge_diagnosis(scores)
89
103
  diagnosis_hash = diagnosis&.to_h
90
104
  refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
91
105
 
@@ -102,10 +116,51 @@ module ActiveAgent
102
116
 
103
117
  private
104
118
 
119
+ def evaluate_with_context(scenario, spec)
120
+ return evaluate(scenario, spec) unless @around_evaluation
121
+
122
+ result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
123
+ # A wrapper written the natural way — do something, yield, do something
124
+ # after — returns that last value rather than the Result. Left alone it
125
+ # reaches on_result and the Report, and fails somewhere far from the
126
+ # wrapper that caused it. Name the wrapper here instead.
127
+ unless result.is_a?(Result)
128
+ raise ArgumentError, "around_evaluation must return the Result its block yields, got #{result.class}"
129
+ end
130
+
131
+ result
132
+ end
133
+
105
134
  def judge_task?
106
135
  @judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
107
136
  end
108
137
 
138
+ # The keys in `scores` a judge graded, so a low grade is not averaged
139
+ # away against rule checks.
140
+ def llm_judge_keys
141
+ @llm_judge_keys ||= @criteria.filter_map do |criterion|
142
+ value = criterion.to_h.stringify_keys
143
+ value["key"] if value["type"] == "llm_judge"
144
+ end
145
+ end
146
+
147
+ def unavailable_judge_diagnosis(scores)
148
+ return unless @require_judge_scores
149
+
150
+ keys = llm_judge_keys.dup
151
+ keys << "task_completion" if judge_task?
152
+ missing = keys.select { |key| scores[key].nil? }
153
+ return if missing.empty?
154
+
155
+ Diagnosis::Result.new(
156
+ fault: "judge_unavailable",
157
+ summary: "The evaluation judge did not return a usable score for #{missing.join(', ')}.",
158
+ recommendation: "Check the judge's credentials, model availability and JSON response, then re-run this evaluation. " \
159
+ "The available rule scores do not establish answer quality.",
160
+ evidence: { "unscored_criteria" => missing }
161
+ )
162
+ end
163
+
109
164
  # Whatever the callable raises becomes an errored Replay, so one model
110
165
  # rejecting a parameter fails its scenario rather than the whole run.
111
166
  # A return value that is neither a Replay nor a Hash is the caller's
@@ -15,11 +15,15 @@ module ActiveAgent
15
15
  # options on a line
16
16
  # - a JSON array of strings, or of objects with `prompt` (or `message`),
17
17
  # `group`, `key`, `notes`, `tools`, `contains`, `not_contains`
18
+ # - a grouped Suite document in YAML or JSON, retaining its expectations,
19
+ # group names, stable keys, notes and production-only flags
18
20
  #
19
21
  # Every scenario gets a key unique within the paste, derived from its group
20
22
  # and position ("blame_3"), unless the line names one. The result is an
21
23
  # array of string-keyed hashes; `Scenario.from_hash` builds the structs.
22
24
  class ScenarioParser
25
+ class ParseError < ArgumentError; end
26
+
23
27
  LIST_MARKER = /\A\s*(?:[-*•]|\d+[.)])\s+/
24
28
  HEADING = /\A\s*#+\s+(.+?)\s*\z/
25
29
  BOLD_HEADING = /\A\s*\*\*(.+?)\*\*:?\s*(?:—.*)?\z/
@@ -28,13 +32,13 @@ module ActiveAgent
28
32
  BACKTICK_PROMPT = /\A`([^`]+)`/
29
33
  OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
30
34
 
31
- def self.parse(text)
32
- new(text).parse
35
+ def self.parse(text, include_production_only: true)
36
+ new(text).parse(include_production_only: include_production_only)
33
37
  end
34
38
 
35
39
  # Parses and builds Scenario structs in one step.
36
- def self.scenarios(text)
37
- parse(text).map { |attrs| Scenario.from_hash(attrs) }
40
+ def self.scenarios(text, include_production_only: true)
41
+ parse(text, include_production_only: include_production_only).map { |attrs| Scenario.from_hash(attrs) }
38
42
  end
39
43
 
40
44
  def initialize(text)
@@ -42,12 +46,19 @@ module ActiveAgent
42
46
  end
43
47
 
44
48
  # @return [Array<Hash>] scenario attributes with string keys
45
- def parse
49
+ def parse(include_production_only: true)
46
50
  stripped = @text.strip
47
51
  return [] if stripped.empty?
48
52
 
49
- scenarios = json?(stripped) ? parse_json(stripped) : parse_lines(stripped)
50
- assign_keys(scenarios)
53
+ scenarios = if json?(stripped)
54
+ parse_json(stripped)
55
+ elsif stripped.match?(/^(?:suite|groups):(?:\s|$)/)
56
+ parse_suite_yaml(stripped)
57
+ else
58
+ parse_lines(stripped)
59
+ end
60
+ assigned = assign_keys(scenarios)
61
+ include_production_only ? assigned : assigned.reject { |entry| entry["production_only"] }
51
62
  end
52
63
 
53
64
  private
@@ -58,6 +69,8 @@ module ActiveAgent
58
69
 
59
70
  def parse_json(text)
60
71
  parsed = JSON.parse(text)
72
+ return parse_suite(parsed) if parsed.is_a?(Hash) && parsed.key?("groups")
73
+
61
74
  parsed = parsed["scenarios"] if parsed.is_a?(Hash) && parsed.key?("scenarios")
62
75
  parsed = [ parsed ] if parsed.is_a?(Hash)
63
76
 
@@ -71,6 +84,39 @@ module ActiveAgent
71
84
  parse_lines(text)
72
85
  end
73
86
 
87
+ def parse_suite_yaml(text)
88
+ document = YAML.safe_load(text, aliases: true)
89
+ raise ParseError, "evaluation suite must contain a groups array" unless document.is_a?(Hash) && document["groups"].is_a?(Array)
90
+
91
+ parse_suite(document)
92
+ rescue Psych::Exception => e
93
+ raise ParseError, "invalid evaluation suite YAML: #{e.message}"
94
+ end
95
+
96
+ def parse_suite(document)
97
+ raise ParseError, "evaluation suite must contain a groups array" unless document["groups"].is_a?(Array)
98
+
99
+ document["groups"].each do |group|
100
+ unless group.is_a?(Hash) && (group["scenarios"].nil? || group["scenarios"].is_a?(Array))
101
+ raise ParseError, "each evaluation group must contain a scenarios array"
102
+ end
103
+ Array(group["scenarios"]).each do |entry|
104
+ unless entry.is_a?(Hash) && entry["prompt"].is_a?(String) && entry["prompt"].present?
105
+ raise ParseError, "each evaluation scenario must contain a prompt"
106
+ end
107
+ expectations = entry["expectations"] || entry["expect"]
108
+ if expectations && !expectations.is_a?(Hash)
109
+ raise ParseError, "scenario expectations must be an object"
110
+ end
111
+ end
112
+ end
113
+
114
+ Suite.new([ document ]).all_scenarios.map do |item|
115
+ scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
116
+ notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
117
+ end
118
+ end
119
+
74
120
  def scenario_from_hash(entry)
75
121
  entry = entry.stringify_keys
76
122
  prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
@@ -84,9 +130,11 @@ module ActiveAgent
84
130
  scenario(
85
131
  prompt: prompt,
86
132
  group: entry["group"],
133
+ group_name: entry["group_name"],
87
134
  key: entry["key"],
88
135
  notes: entry["notes"],
89
- expectations: expectations
136
+ expectations: expectations,
137
+ production_only: entry["production_only"] == true
90
138
  )
91
139
  end
92
140
 
@@ -172,13 +220,15 @@ module ActiveAgent
172
220
  text.to_s.gsub(/\*\*|__|`/, "").strip
173
221
  end
174
222
 
175
- def scenario(prompt:, group: nil, key: nil, notes: nil, expectations: {})
223
+ def scenario(prompt:, group: nil, group_name: nil, key: nil, notes: nil, expectations: {}, production_only: false)
176
224
  {
177
225
  "prompt" => prompt.to_s.strip,
178
226
  "group" => group.presence&.to_s&.strip,
227
+ "group_name" => group_name.presence&.to_s&.strip,
179
228
  "key" => key.presence&.to_s&.strip,
180
229
  "notes" => notes.presence,
181
- "expectations" => (expectations || {}).reject { |_, value| value.blank? }
230
+ "expectations" => (expectations || {}).reject { |_, value| value.blank? },
231
+ "production_only" => production_only
182
232
  }
183
233
  end
184
234
 
@@ -25,6 +25,7 @@ require_relative "evals/design_tokens"
25
25
  require_relative "evals/report_html"
26
26
  require_relative "evals/report"
27
27
  require_relative "evals/runner"
28
+ require_relative "evals/publisher"
28
29
 
29
30
  # Scenario evaluations for agents that answer with tools.
30
31
  #
@@ -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.
@@ -114,6 +114,16 @@ module ActiveAgent
114
114
  initializer "active_agent.inflections" do
115
115
  ActiveSupport::Inflector.inflections do |inflect|
116
116
  inflect.acronym "AI"
117
+
118
+ # "MCP" alone does not give the plural: an acronym only matches the
119
+ # whole word, so `mcps` still camelizes to `Mcps`, and a constant
120
+ # spelled `MCPs` underscores back to `mc_ps` — a name that round-trips
121
+ # to something no file is called.
122
+ #
123
+ # Registering the plural as its own acronym makes both directions
124
+ # agree: mcps <-> MCPs, alongside mcp_catalog <-> MCPCatalog.
125
+ inflect.acronym "MCP"
126
+ inflect.acronym "MCPs"
117
127
  end
118
128
  end
119
129