activeagent 1.8.0 → 1.9.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1193 -0
  3. data/README.md +9 -1
  4. data/lib/active_agent/base.rb +22 -1
  5. data/lib/active_agent/concerns/delegation.rb +4 -0
  6. data/lib/active_agent/concerns/input_requests.rb +178 -0
  7. data/lib/active_agent/concerns/parameterized.rb +7 -4
  8. data/lib/active_agent/concerns/provider.rb +1 -0
  9. data/lib/active_agent/concerns/tooling.rb +7 -2
  10. data/lib/active_agent/delegation/runner.rb +31 -0
  11. data/lib/active_agent/evals/catalog.rb +305 -0
  12. data/lib/active_agent/evals/diagnosis.rb +3 -3
  13. data/lib/active_agent/evals/format.rb +115 -0
  14. data/lib/active_agent/evals/judge.rb +2 -0
  15. data/lib/active_agent/evals/report.rb +232 -31
  16. data/lib/active_agent/evals/report_html.rb +316 -149
  17. data/lib/active_agent/evals/result.rb +40 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +411 -0
  19. data/lib/active_agent/evals/runner.rb +6 -2
  20. data/lib/active_agent/evals/scenario.rb +10 -3
  21. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  22. data/lib/active_agent/evals.rb +2 -0
  23. data/lib/active_agent/generation.rb +62 -2
  24. data/lib/active_agent/generation_job.rb +14 -3
  25. data/lib/active_agent/input_request/resume.rb +199 -0
  26. data/lib/active_agent/input_request.rb +279 -0
  27. data/lib/active_agent/providers/_base_provider.rb +153 -31
  28. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  29. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  30. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  31. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  32. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  33. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  34. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  35. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  36. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  37. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  38. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  39. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  40. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  41. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  42. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  43. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  44. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  45. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  46. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  47. data/lib/active_agent/providers/open_router/options.rb +6 -2
  48. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  49. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  50. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  51. data/lib/active_agent/schema_tools.rb +12 -0
  52. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  53. data/lib/active_agent/version.rb +1 -1
  54. data/lib/active_agent.rb +2 -0
  55. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  56. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  57. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  58. metadata +32 -2
@@ -49,6 +49,37 @@ module ActiveAgent
49
49
  diagnosis&.dig("judge", "suggested_tool")
50
50
  end
51
51
 
52
+ # Where the replay's cost came from, as the caller recorded it in the
53
+ # replay metadata: "reported" (the caller priced it), "estimated"
54
+ # (tokens × a model rate), "no_usage" (nothing was generated, so $0.00)
55
+ # or "unpriced" (nothing to price it from). A cost with no record is
56
+ # the caller's own figure; no cost at all is unpriced.
57
+ def cost_source
58
+ recorded = replay_meta("cost_source").to_s
59
+ return recorded if recorded.present?
60
+
61
+ replay.cost.nil? ? "unpriced" : "reported"
62
+ end
63
+
64
+ def estimated_cost?
65
+ cost_source == "estimated"
66
+ end
67
+
68
+ # The rate an estimated cost was worked out at, `{ "input", "output",
69
+ # "source" }` in $ per million tokens, or nil.
70
+ def cost_rate
71
+ rate = replay_meta("cost_rate")
72
+ rate.is_a?(Hash) ? rate.transform_keys(&:to_s) : nil
73
+ end
74
+
75
+ # What the judge spent on this result — `{ "calls", "input_tokens",
76
+ # "output_tokens", "cost", "model", "by_kind", "source" }` — or nil
77
+ # when no judge was asked about it.
78
+ def judge_usage
79
+ usage = replay_meta("judge_usage")
80
+ usage.is_a?(Hash) ? usage.transform_keys(&:to_s) : nil
81
+ end
82
+
52
83
  def to_h
53
84
  {
54
85
  "scenario_key" => scenario.key,
@@ -70,9 +101,18 @@ module ActiveAgent
70
101
  "fault" => fault,
71
102
  "recommendation" => recommendation,
72
103
  "diagnosis" => diagnosis,
104
+ "judge_usage" => judge_usage,
73
105
  "metadata" => replay.metadata
74
106
  }.compact
75
107
  end
108
+
109
+ private
110
+
111
+ # A caller may key the replay metadata with symbols.
112
+ def replay_meta(key)
113
+ metadata = replay.metadata
114
+ metadata[key] || metadata[key.to_sym]
115
+ end
76
116
  end
77
117
  end
78
118
  end
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by an explicit `require "active_agent/evals/ruby_llm"` — never by
4
+ # `require "active_agent/evals"` — so the evaluation module itself stays free
5
+ # of the ruby_llm gem.
6
+ require "ruby_llm"
7
+ require_relative "../evals"
8
+
9
+ module ActiveAgent
10
+ module Evals
11
+ # The RubyLLM side of an evaluation: a Judge that asks a RubyLLM chat for
12
+ # its completions, and a Replay built from the messages a RubyLLM
13
+ # conversation produced. Both are the glue a host that drives RubyLLM
14
+ # chats would otherwise write itself.
15
+ #
16
+ # require "active_agent/evals/ruby_llm"
17
+ #
18
+ # judge = ActiveAgent::Evals::RubyLLM.judge(label: "claude-opus-5", model: "claude-opus-5",
19
+ # provider: :anthropic, correlation: correlation)
20
+ #
21
+ # report = correlation.with_run("suite" => "support") do |metadata|
22
+ # ActiveAgent::Evals::Runner.new(
23
+ # scenarios: scenarios, models: models, metadata: metadata, judge: judge,
24
+ # around_evaluation: correlation,
25
+ # replay: ->(scenario, spec) {
26
+ # chat = correlation.replay { SupportChat.run(scenario.prompt, model: spec.model) }
27
+ # ActiveAgent::Evals::RubyLLM.replay(chat.messages.order(:id), duration_ms: chat.elapsed_ms)
28
+ # }
29
+ # ).call.tap(&:verdict) # the verdict is a judge call too: settle it while the run is open
30
+ # end
31
+ module RubyLLM
32
+ # How much of a tool's error a replay keeps, in bytes — the cap the
33
+ # dashboard puts on a run's tool event detail.
34
+ DETAIL_LIMIT = 1_200
35
+
36
+ # `chat` keywords the judge sets itself, so they are not `chat_options`.
37
+ JUDGE_CHAT_KEYWORDS = %i[model provider assume_model_exists context].freeze
38
+
39
+ # An `error` key holding a String, as Ruby inspects it: RubyLLM 1.x
40
+ # keeps a tool result's `to_s`, so a tool that returned
41
+ # `{ error: "..." }` stores `{:error=>"..."}`, or `{error: "..."}` on
42
+ # Ruby 3.4. Tried only at the top level of a whole inspected Hash.
43
+ INSPECTED_ERROR_KEY = /\G\s*(?::error\s*=>|error:|"error"\s*=>)\s*("(?:[^"\\]|\\.)*")/m
44
+
45
+ class << self
46
+ # Builds a Judge whose completions come from a RubyLLM chat.
47
+ #
48
+ # `context` is anything answering to `#chat` the way RubyLLM does: the
49
+ # `::RubyLLM` module itself, or a `RubyLLM.context` built with the
50
+ # host's own keys (see `ActionAgent::ProviderKey.apply_to`). With a
51
+ # `correlation`, every call is traced through `Correlation#judge` under
52
+ # the kind it serves (`score`, `recommend`, `verdict`), so the judge
53
+ # trace lands on the result it graded.
54
+ #
55
+ # `chat_options` go to `chat` itself, so they must be keywords
56
+ # `RubyLLM::Chat.new` takes (`protocol:` on RubyLLM 2.x); anything else
57
+ # raises ArgumentError here rather than failing every judge call
58
+ # later. Configure the chat through `temperature:` or `configure:`:
59
+ #
60
+ # ActiveAgent::Evals::RubyLLM.judge(label: "judge", model: "gpt-5-mini", provider: :openai,
61
+ # temperature: 0,
62
+ # configure: ->(chat) { chat.with_headers("X-Request-Tag" => "eval") })
63
+ #
64
+ # @param label [String] how reports name the judge
65
+ # @param model [String] the judge model
66
+ # @param provider [Symbol, String] the RubyLLM provider serving it
67
+ # @param context [#chat] where `chat` is called; defaults to `::RubyLLM`
68
+ # @param correlation [Correlation, nil] traces each call as a judge call
69
+ # @param assume_model_exists [Boolean] skip RubyLLM's model registry check
70
+ # @param temperature [Numeric, nil] applied with `with_temperature`
71
+ # @param configure [#call, nil] called with each chat before it is asked,
72
+ # for any other `with_*` setting
73
+ # @param on_usage [#call, nil] called after each answered call with
74
+ # `{ "kind", "model", "input_tokens", "output_tokens", "cost" }`, for a
75
+ # host that meters its judge's spend; an error it raises is logged,
76
+ # not propagated, so bookkeeping never costs a grade
77
+ # @param chat_options [Hash] other `chat` keywords, such as `protocol:`
78
+ # @return [Judge]
79
+ # @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take
80
+ def judge(label:, model:, provider:, context: ::RubyLLM, correlation: nil, assume_model_exists: true,
81
+ temperature: nil, configure: nil, on_usage: nil, **chat_options)
82
+ check_chat_options!(chat_options)
83
+
84
+ Judge.new(label: label) do |instructions:, prompt:, kind:|
85
+ complete = lambda do
86
+ chat = context.chat(model: model, provider: provider.to_sym, assume_model_exists: assume_model_exists,
87
+ **chat_options)
88
+ chat.with_temperature(temperature) unless temperature.nil?
89
+ configure&.call(chat)
90
+ response = chat.with_instructions(instructions).ask(prompt)
91
+ report_usage(on_usage, kind, model, response)
92
+ response.content
93
+ end
94
+
95
+ correlation ? correlation.judge(kind.to_s, &complete) : complete.call
96
+ end
97
+ end
98
+
99
+ # Builds a Replay from the ordered messages of a RubyLLM conversation:
100
+ # `acts_as_chat` message records of either RubyLLM generation (read
101
+ # through their own `to_llm`), `RubyLLM::Message` values, or anything
102
+ # shaped like them (`role`, `content`, `tool_calls`, `tool_call_id`,
103
+ # and `tokens` or `input_tokens`/`output_tokens`).
104
+ #
105
+ # - **Tool calls** are every message's calls in the order the model made
106
+ # them, each `{ "name", "arguments", "error", "detail" }`. A call is
107
+ # errored when the `tool` message answering it (the next one with its
108
+ # `tool_call_id`) reports an error at its top level: JSON or a Hash
109
+ # whose `"error"` is a non-empty String, Hash or Array, or `true`; an
110
+ # MCP result with `"isError": true`; or the inspected Hash RubyLLM 1.x
111
+ # stores for a tool that returned `{ error: "..." }`. `detail` is the
112
+ # error, JSON-encoded unless it is a String, cut to DETAIL_LIMIT bytes.
113
+ # - **Tokens** sum the assistant messages. `input_tokens` is the whole
114
+ # prompt: RubyLLM's `input` plus its cache reads and writes, which it
115
+ # counts apart.
116
+ # - **Cost** sums each assistant message's RubyLLM `cost.total` when
117
+ # every one of them is priced, and is nil otherwise.
118
+ # - **The answer** is the content of the last assistant message after the
119
+ # last user message. A conversation that stopped before answering —
120
+ # its last message is a tool call, a tool result or the user's own
121
+ # message — has no answer, and the replay records why as its error
122
+ # rather than scoring an empty or earlier reply. On RubyLLM 1.x a tool
123
+ # that ends the turn with `halt` leaves its result last too: pass the
124
+ # content `ask` returned as `answer:` for such scenarios.
125
+ #
126
+ # `answer:`, `error:` and `cost:` override what the messages say.
127
+ #
128
+ # @param messages [Array, #to_a] the conversation's messages, oldest first
129
+ # @param answer [String, nil] overrides the last assistant message's content
130
+ # @param duration_ms [Numeric, nil]
131
+ # @param error [String, Exception, nil] when the run raised before answering
132
+ # @param cost [Numeric, nil] overrides the cost RubyLLM priced
133
+ # @param metadata [Hash] carried onto the Result
134
+ # @return [Replay]
135
+ def replay(messages, answer: nil, duration_ms: nil, error: nil, cost: nil, metadata: {})
136
+ messages = messages.to_a.map { |message| message.respond_to?(:to_llm) ? message.to_llm : message }
137
+ unfinished = unfinished_reason(messages)
138
+ tokens = token_totals(messages)
139
+
140
+ Replay.new(
141
+ answer: answer.nil? && unfinished.nil? ? final_answer(messages) : answer,
142
+ tool_calls: extract_tool_calls(messages),
143
+ duration_ms: duration_ms,
144
+ input_tokens: tokens[:input],
145
+ output_tokens: tokens[:output],
146
+ error: replay_error(error, answer, unfinished),
147
+ cost: cost.nil? ? conversation_cost(messages) : cost,
148
+ metadata: metadata
149
+ )
150
+ end
151
+
152
+ private
153
+
154
+ def check_chat_options!(options)
155
+ return if options.empty?
156
+
157
+ parameters = ::RubyLLM::Chat.instance_method(:initialize).parameters
158
+ return if parameters.any? { |type, _| type == :keyrest }
159
+
160
+ accepted = parameters.filter_map { |type, name| name if %i[key keyreq].include?(type) } - JUDGE_CHAT_KEYWORDS
161
+ unknown = options.keys.map(&:to_sym) - accepted
162
+ return if unknown.empty?
163
+
164
+ raise ArgumentError,
165
+ "RubyLLM::Chat.new (ruby_llm #{::RubyLLM::VERSION}) does not take #{unknown.map(&:inspect).join(', ')}; " \
166
+ "it takes #{accepted.map(&:inspect).join(', ').presence || 'no other keywords'}. " \
167
+ "Pass temperature: to judge, or configure the chat with configure: ->(chat) { chat.with_... }."
168
+ end
169
+
170
+ def report_usage(callback, kind, model, response)
171
+ return unless callback
172
+
173
+ callback.call(
174
+ "kind" => kind.to_s,
175
+ "model" => response_model(response) || model.to_s,
176
+ "input_tokens" => prompt_tokens(response),
177
+ "output_tokens" => token_count(response, :output),
178
+ "cost" => message_cost(response)
179
+ )
180
+ rescue StandardError => e
181
+ warn_failure("judge on_usage callback failed: #{e.class}")
182
+ end
183
+
184
+ def response_model(response)
185
+ model = response.model if response.respond_to?(:model)
186
+ model = response.model_id if model.nil? && response.respond_to?(:model_id)
187
+ model.presence&.to_s
188
+ end
189
+
190
+ # Each result is paired with the call it answers: the latest call with
191
+ # its id that no earlier result has answered, so an id a provider
192
+ # reuses in a later turn is still told apart.
193
+ def extract_tool_calls(messages)
194
+ open_calls = {}
195
+
196
+ messages.each_with_object([]) do |message, calls|
197
+ if role_of(message) == "tool"
198
+ call = open_calls.delete(message.tool_call_id) if message.respond_to?(:tool_call_id)
199
+ detail = call && tool_error(message.content)
200
+ call&.merge!("error" => true, "detail" => detail) if detail
201
+ else
202
+ ordered_tool_calls(message).each do |tool_call|
203
+ call = { "name" => tool_call.name.to_s, "arguments" => tool_call.arguments, "error" => false }
204
+ open_calls[tool_call.id] = call
205
+ calls << call
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ # A message's calls in the order the model made them. RubyLLM keys a
212
+ # message's calls by the provider's call id in the order they arrived,
213
+ # and those ids (`call_…`, `toolu_…`) say nothing about order; only
214
+ # record primary keys, which are all Integers, are sorted.
215
+ def ordered_tool_calls(message)
216
+ calls = tool_calls_of(message)
217
+ calls.all? { |call| call.id.is_a?(Integer) } ? calls.sort_by(&:id) : calls
218
+ end
219
+
220
+ # `tool_calls` is a Hash keyed by call id on a RubyLLM::Message (and on
221
+ # anything `to_llm` returns), a has_many elsewhere, and nil on a
222
+ # message with no calls.
223
+ def tool_calls_of(message)
224
+ return [] unless message.respond_to?(:tool_calls)
225
+
226
+ calls = message.tool_calls
227
+ calls = calls.values if calls.is_a?(Hash)
228
+ calls.nil? ? [] : calls.to_a
229
+ end
230
+
231
+ # The detail of the error a tool reported, or nil when it succeeded.
232
+ def tool_error(content)
233
+ content = content.text if !content.is_a?(String) && content.respond_to?(:text)
234
+ content = content.scrub if content.is_a?(String)
235
+ payload = content.is_a?(Hash) ? content : parse_json(content)
236
+ return error_from_payload(payload) if payload.is_a?(Hash)
237
+ return unless content.is_a?(String)
238
+
239
+ literal = inspected_error(content.strip)
240
+ truncate_detail(unquote(literal)) if literal
241
+ end
242
+
243
+ def error_from_payload(payload)
244
+ error = payload.key?("error") ? payload["error"] : payload[:error]
245
+ if (error.is_a?(String) || error.is_a?(Hash) || error.is_a?(Array)) && error.present?
246
+ return truncate_detail(error.is_a?(String) ? error : error.to_json)
247
+ end
248
+ if error == true
249
+ message = payload["message"] || payload[:message]
250
+ return truncate_detail(message.is_a?(String) && message.present? ? message : payload.to_json)
251
+ end
252
+ return unless (payload["isError"] || payload[:isError]) == true
253
+
254
+ texts = Array(payload["content"] || payload[:content]).filter_map do |part|
255
+ part["text"] || part[:text] if part.is_a?(Hash)
256
+ end
257
+ truncate_detail(texts.any? ? texts.join("\n") : payload.to_json)
258
+ end
259
+
260
+ # The String literal an inspected Hash holds under a top-level `error`
261
+ # key, or nil. Only a whole Hash (`{...}`) qualifies, and a key is
262
+ # tried only where one starts at the Hash's own level: after its
263
+ # opening brace or a comma outside any string or nested structure.
264
+ def inspected_error(text)
265
+ return unless text.start_with?("{") && text.end_with?("}")
266
+
267
+ depth = 0
268
+ quoted = false
269
+ index = 0
270
+ while index < text.length
271
+ char = text[index]
272
+ if quoted
273
+ index += 1 if char == "\\"
274
+ quoted = false if char == '"'
275
+ elsif char == '"'
276
+ quoted = true
277
+ elsif char == "{" || char == "["
278
+ depth += 1
279
+ elsif char == "}" || char == "]"
280
+ depth -= 1
281
+ end
282
+
283
+ if !quoted && depth == 1 && (char == "{" || char == ",")
284
+ literal = text.match(INSPECTED_ERROR_KEY, index + 1)&.[](1)
285
+ return literal if literal
286
+ end
287
+ index += 1
288
+ end
289
+ nil
290
+ end
291
+
292
+ # Reads back a Ruby String literal from an inspected Hash. `undump`
293
+ # takes only ASCII, so the characters beyond it are escaped first.
294
+ def unquote(literal)
295
+ literal.gsub(/[^\x00-\x7F]/) { |char| format("\\u{%x}", char.ord) }.undump.force_encoding(Encoding::UTF_8).scrub
296
+ rescue RuntimeError, EncodingError, ArgumentError
297
+ literal[1...-1].scrub
298
+ end
299
+
300
+ def truncate_detail(detail)
301
+ detail.to_s.truncate_bytes(DETAIL_LIMIT)
302
+ end
303
+
304
+ def token_totals(messages)
305
+ assistants = messages.select { |message| role_of(message) == "assistant" }
306
+ {
307
+ input: assistants.sum { |message| prompt_tokens(message) },
308
+ output: assistants.sum { |message| token_count(message, :output) }
309
+ }
310
+ end
311
+
312
+ # The whole prompt: RubyLLM counts cache reads and writes apart from
313
+ # `input` (`cache_read`/`cache_write` on 2.x, `cached`/`cache_creation`
314
+ # on 1.x), so they are added back.
315
+ def prompt_tokens(message)
316
+ tokens = message.tokens if message.respond_to?(:tokens)
317
+ if tokens.respond_to?(:input)
318
+ tokens.input.to_i + first_count(tokens, :cache_read, :cached) + first_count(tokens, :cache_write, :cache_creation)
319
+ else
320
+ token_count(message, :input) + first_count(message, :cached_tokens) + first_count(message, :cache_creation_tokens)
321
+ end
322
+ end
323
+
324
+ def token_count(message, direction)
325
+ tokens = message.tokens if message.respond_to?(:tokens)
326
+ return tokens.public_send(direction).to_i if tokens.respond_to?(direction)
327
+
328
+ first_count(message, :"#{direction}_tokens")
329
+ end
330
+
331
+ def first_count(object, *readers)
332
+ reader = readers.find { |name| object.respond_to?(name) }
333
+ reader ? object.public_send(reader).to_i : 0
334
+ end
335
+
336
+ def conversation_cost(messages)
337
+ costs = messages.select { |message| role_of(message) == "assistant" }.map { |message| message_cost(message) }
338
+ costs.sum if costs.any? && costs.none?(&:nil?)
339
+ end
340
+
341
+ def message_cost(message)
342
+ return unless message.respond_to?(:cost)
343
+
344
+ cost = message.cost
345
+ total = cost.respond_to?(:total) ? cost.total : cost
346
+ total.to_f if total.is_a?(Numeric)
347
+ rescue StandardError
348
+ nil
349
+ end
350
+
351
+ # Why the conversation has no answer, or nil when it has one: it
352
+ # stopped at a tool call or a tool's result, or on the user's own
353
+ # message. System messages do not count as the last word.
354
+ def unfinished_reason(messages)
355
+ last = messages.reverse.find { |message| role_of(message) != "system" }
356
+ return if last.nil?
357
+
358
+ case role_of(last)
359
+ when "user"
360
+ "The conversation ended on a user message without a reply"
361
+ when "tool", "assistant"
362
+ return if role_of(last) == "assistant" && tool_calls_of(last).none?
363
+
364
+ assistant = messages.reverse.find { |message| role_of(message) == "assistant" }
365
+ names = (assistant ? tool_calls_of(assistant) : []).map { |call| call.name.to_s }.uniq
366
+ "The conversation stopped at #{names.any? ? "a call to #{names.join(', ')}" : 'a tool result'} " \
367
+ "without a final answer"
368
+ end
369
+ end
370
+
371
+ def replay_error(error, answer, unfinished)
372
+ return "#{error.class}: #{error.message}" if error.is_a?(Exception)
373
+ return error unless error.nil?
374
+
375
+ unfinished if answer.nil?
376
+ end
377
+
378
+ # The last assistant message after the last user message, so an
379
+ # earlier turn's reply never stands in for this one.
380
+ def final_answer(messages)
381
+ last_prompt = messages.rindex { |message| role_of(message) == "user" }
382
+ turn = last_prompt ? messages.drop(last_prompt + 1) : messages
383
+ content = turn.reverse.find { |message| role_of(message) == "assistant" }&.content
384
+ content = content.text if !content.nil? && !content.is_a?(String) && content.respond_to?(:text)
385
+ content
386
+ end
387
+
388
+ def role_of(message)
389
+ message.role.to_s
390
+ end
391
+
392
+ def parse_json(content)
393
+ return nil unless content.is_a?(String) && content.present?
394
+
395
+ JSON.parse(content)
396
+ rescue JSON::ParserError
397
+ nil
398
+ end
399
+
400
+ def warn_failure(message)
401
+ message = "[ActiveAgent::Evals] #{message}"
402
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
403
+ Rails.logger.warn(message)
404
+ else
405
+ warn(message)
406
+ end
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
@@ -51,10 +51,13 @@ module ActiveAgent
51
51
  # @param require_judge_scores [Boolean] fail an otherwise passing result
52
52
  # when a requested task/LLM grade is unavailable, rather than falling
53
53
  # back to rule scores. Does not require a judge for rules-only runs.
54
+ # @param release [Hash, nil] the release of the agent under evaluation,
55
+ # `{ "digest", "revision", "label" }`, carried onto the Report so a
56
+ # published run names the code it scored (see Report.new)
54
57
  def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
55
58
  instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
56
59
  refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
57
- around_evaluation: nil, require_judge_scores: false, metadata: {})
60
+ around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil)
58
61
  @scenarios = scenarios
59
62
  @models = models
60
63
  @replay = replay
@@ -71,6 +74,7 @@ module ActiveAgent
71
74
  @around_evaluation = around_evaluation
72
75
  @require_judge_scores = require_judge_scores
73
76
  @metadata = metadata
77
+ @release = release
74
78
  @judge_calls = 0
75
79
  @scorer = Scorer.new(criteria: criteria, judge: judge)
76
80
  end
@@ -83,7 +87,7 @@ module ActiveAgent
83
87
  end
84
88
 
85
89
  Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
86
- threshold: @threshold, metadata: @metadata)
90
+ threshold: @threshold, metadata: @metadata, release: @release)
87
91
  end
88
92
 
89
93
  # Scores and diagnoses one replay. Public so a caller that has already run
@@ -20,8 +20,13 @@ module ActiveAgent
20
20
  # @return [Array<String>] substrings or patterns the answer must avoid
21
21
  # @!attribute production_only
22
22
  # @return [Boolean] whether a local environment cannot answer with real data
23
+ # @!attribute tags
24
+ # @return [Array<String>] labels a catalog files the scenario under ("billing")
25
+ # @!attribute params
26
+ # @return [Hash] free-form parameters a replay may read; kept as written
23
27
  Scenario = Struct.new(:key, :group, :group_name, :prompt, :expected_tools, :expected_patterns,
24
- :forbidden_patterns, :notes, :production_only, :position, keyword_init: true) do
28
+ :forbidden_patterns, :notes, :production_only, :position, :tags, :params,
29
+ keyword_init: true) do
25
30
  # Builds a scenario from a hash — ScenarioParser output, a suite entry, or
26
31
  # a persisted record's attributes. Expectations are read from an
27
32
  # `expectations`/`expect` sub-hash (`tools`, `contains`, `not_contains`)
@@ -40,7 +45,9 @@ module ActiveAgent
40
45
  forbidden_patterns: list(expect["not_contains"] || attrs["not_contains"]),
41
46
  notes: attrs["notes"].presence,
42
47
  production_only: attrs["production_only"] == true,
43
- position: attrs["position"]
48
+ position: attrs["position"],
49
+ tags: list(attrs["tags"]),
50
+ params: (attrs["params"] || {}).to_h.deep_stringify_keys
44
51
  )
45
52
  end
46
53
 
@@ -61,7 +68,7 @@ module ActiveAgent
61
68
  end
62
69
 
63
70
  def to_h
64
- super.compact
71
+ super.compact.reject { |field, value| %i[tags params].include?(field) && value.blank? }
65
72
  end
66
73
  end
67
74
  end
@@ -21,6 +21,18 @@ module ActiveAgent
21
21
  # Every scenario gets a key unique within the paste, derived from its group
22
22
  # and position ("history_3"), unless the line names one. The result is an
23
23
  # array of string-keyed hashes; `Scenario.from_hash` builds the structs.
24
+ #
25
+ # Two options shape the generated keys, and leave named keys alone:
26
+ #
27
+ # reserved_keys: keys already in use, such as the suite a paste is
28
+ # merged into. A generated key skips them, ignoring
29
+ # case, so a second paste's first "Orders" line becomes
30
+ # `orders_3` after a suite holding `orders_1` and
31
+ # `Orders_2`.
32
+ # key_prefix: a namespace in front of every generated key:
33
+ # `key_prefix: "Batch 2"` turns `orders_1` into
34
+ # `batch_2_orders_1`. A prefix with no letters or
35
+ # digits a key can use raises ParseError.
24
36
  class ScenarioParser
25
37
  class ParseError < ArgumentError; end
26
38
 
@@ -31,18 +43,24 @@ module ActiveAgent
31
43
  # that merely mentions `some_tool` is kept whole.
32
44
  BACKTICK_PROMPT = /\A`([^`]+)`/
33
45
  OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
46
+ MAX_KEY_PREFIX = 40
34
47
 
35
- def self.parse(text, include_production_only: true)
36
- new(text).parse(include_production_only: include_production_only)
48
+ def self.parse(text, include_production_only: true, reserved_keys: [], key_prefix: nil)
49
+ new(text, reserved_keys: reserved_keys, key_prefix: key_prefix).parse(include_production_only: include_production_only)
37
50
  end
38
51
 
39
52
  # Parses and builds Scenario structs in one step.
40
- def self.scenarios(text, include_production_only: true)
41
- parse(text, include_production_only: include_production_only).map { |attrs| Scenario.from_hash(attrs) }
53
+ def self.scenarios(text, include_production_only: true, reserved_keys: [], key_prefix: nil)
54
+ parse(text, include_production_only: include_production_only, reserved_keys: reserved_keys, key_prefix: key_prefix)
55
+ .map { |attrs| Scenario.from_hash(attrs) }
42
56
  end
43
57
 
44
- def initialize(text)
58
+ def initialize(text, reserved_keys: [], key_prefix: nil)
45
59
  @text = text.to_s
60
+ # Generated keys are lowercase, and a database whose collation
61
+ # ignores case holds `Orders_1` and `orders_1` as one key.
62
+ @reserved_keys = Array(reserved_keys).map { |key| key.to_s.downcase }.to_set
63
+ @key_prefix = normalize_key_prefix(key_prefix)
46
64
  end
47
65
 
48
66
  # @return [Array<Hash>] scenario attributes with string keys
@@ -63,6 +81,15 @@ module ActiveAgent
63
81
 
64
82
  private
65
83
 
84
+ def normalize_key_prefix(value)
85
+ return nil if value.blank?
86
+
87
+ prefix = value.to_s.parameterize(separator: "_").first(MAX_KEY_PREFIX).sub(/_+\z/, "")
88
+ raise ParseError, "key_prefix #{value.to_s.truncate(40).inspect} has no letters or digits a key can use" if prefix.empty?
89
+
90
+ prefix
91
+ end
92
+
66
93
  def json?(text)
67
94
  text.start_with?("[", "{")
68
95
  end
@@ -236,9 +263,10 @@ module ActiveAgent
236
263
  }
237
264
  end
238
265
 
239
- # A key named on a line is kept; a generated one never collides with a
240
- # named key anywhere in the paste; and a named key that repeats an
241
- # earlier line's is treated as missing, so no two scenarios share one.
266
+ # A key named on a line is kept, even when it is reserved; a generated
267
+ # one never collides with a named key anywhere in the paste or with a
268
+ # reserved key; and a named key that repeats an earlier line's is
269
+ # treated as missing, so no two scenarios share one.
242
270
  def assign_keys(scenarios)
243
271
  named = scenarios.filter_map { |s| s["key"].presence }.to_set
244
272
  taken = Set.new
@@ -250,11 +278,11 @@ module ActiveAgent
250
278
  key = nil if key && taken.include?(key)
251
279
 
252
280
  unless key
253
- base = scenario["group"].to_s.parameterize(separator: "_").first(30).presence || "scenario"
281
+ base = [ @key_prefix, scenario["group"].to_s.parameterize(separator: "_").first(30).presence || "scenario" ].compact.join("_")
254
282
  loop do
255
283
  counters[base] += 1
256
284
  key = "#{base}_#{counters[base]}"
257
- break unless named.include?(key) || taken.include?(key)
285
+ break unless named.include?(key) || taken.include?(key) || @reserved_keys.include?(key)
258
286
  end
259
287
  end
260
288
 
@@ -15,8 +15,10 @@ require "active_support/core_ext/enumerable"
15
15
  require_relative "evals/scenario"
16
16
  require_relative "evals/scenario_parser"
17
17
  require_relative "evals/suite"
18
+ require_relative "evals/catalog"
18
19
  require_relative "evals/model_spec"
19
20
  require_relative "evals/replay"
21
+ require_relative "evals/format"
20
22
  require_relative "evals/scorer"
21
23
  require_relative "evals/diagnosis"
22
24
  require_relative "evals/judge"