activeagent 1.8.1 → 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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1044 -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/ruby_llm.rb +411 -0
  13. data/lib/active_agent/evals/scenario.rb +10 -3
  14. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  15. data/lib/active_agent/evals.rb +1 -0
  16. data/lib/active_agent/generation.rb +62 -2
  17. data/lib/active_agent/generation_job.rb +14 -3
  18. data/lib/active_agent/input_request/resume.rb +199 -0
  19. data/lib/active_agent/input_request.rb +279 -0
  20. data/lib/active_agent/providers/_base_provider.rb +153 -31
  21. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  22. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  23. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  24. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  25. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  26. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  27. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  28. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  29. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  30. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  31. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  32. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  33. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  34. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  35. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  36. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  37. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  38. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  39. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  40. data/lib/active_agent/providers/open_router/options.rb +6 -2
  41. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  42. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  43. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  44. data/lib/active_agent/schema_tools.rb +12 -0
  45. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  46. data/lib/active_agent/version.rb +1 -1
  47. data/lib/active_agent.rb +2 -0
  48. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  49. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  50. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  51. metadata +31 -2
@@ -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
@@ -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,6 +15,7 @@ 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"
20
21
  require_relative "evals/format"
@@ -103,6 +103,65 @@ module ActiveAgent
103
103
  end
104
104
  alias generate_later prompt_later
105
105
 
106
+ # Continues a generation that paused for user input.
107
+ #
108
+ # Call it on the generation that paused, or on one built the same way —
109
+ # same agent, action, arguments and params — in another request or
110
+ # process. A new generation runs the action again, so tools, instructions
111
+ # and options are rebuilt. Either way the conversation is replaced with
112
+ # the checkpoint's, and each paused tool call is dispatched again with
113
+ # its answer readable through {InputRequest.answer_for}. Calls that
114
+ # finished before the pause keep their results.
115
+ #
116
+ # @param checkpoint [Hash] the paused response's
117
+ # {Providers::Common::Responses::Prompt#checkpoint checkpoint}
118
+ # @param answers [Hash{String => Object}] an answer per paused tool call
119
+ # id; `false` declines
120
+ # @return [ActiveAgent::Providers::Common::PromptResponse] which may itself
121
+ # be awaiting input again
122
+ # @raise [InputRequest::ResumeError] before any tool runs or any request
123
+ # is sent, when an answer is missing or does not fit its request, or the
124
+ # checkpoint was taken by another action, provider or model
125
+ #
126
+ # @example
127
+ # response = SupportAgent.with(ticket:).triage.generate_now
128
+ # if response.awaiting_input?
129
+ # request = response.input_requests.first
130
+ # SupportAgent.with(ticket:).triage.resume_now(
131
+ # checkpoint: response.checkpoint,
132
+ # answers: { request.tool_call_id => true }
133
+ # )
134
+ # end
135
+ def resume_now(checkpoint:, answers:)
136
+ agent.resume_prompt(checkpoint:, answers:)
137
+ end
138
+
139
+ # Queues {#resume_now} for background execution on the agent's
140
+ # generation job, which runs the action again from its arguments, params
141
+ # and actor.
142
+ #
143
+ # The checkpoint and answers become job arguments, stored by the queue
144
+ # backend for as long as the job lives. A `:secret` answer is therefore
145
+ # refused: resume a generation that waits on a secret inside your own
146
+ # job, which reads the answer from where your app keeps it.
147
+ #
148
+ # @param checkpoint [Hash] the paused response's checkpoint
149
+ # @param answers [Hash{String => Object}] an answer per paused tool call
150
+ # id; `false` declines
151
+ # @param options [Hash] job options (queue, priority, wait, etc.)
152
+ # @return [Object] enqueued job instance
153
+ # @raise [InputRequest::ResumeError] before enqueueing, when an answer is
154
+ # missing, does not fit its request, or answers a `:secret` request
155
+ # @raise [RuntimeError] if agent was accessed before queueing
156
+ def resume_later(checkpoint:, answers:, **options)
157
+ if InputRequest::Resume.new(checkpoint:, answers:).secret_answers.any?
158
+ ::Kernel.raise InputRequest::ResumeError, "resume_later cannot carry the answer to a :secret request, because job " \
159
+ "arguments are stored with the job. Call resume_now from your own job, reading the answer from where your app keeps it."
160
+ end
161
+
162
+ enqueue_generation :resume_now, options, resume: { "checkpoint" => checkpoint.as_json, "answers" => answers.as_json }
163
+ end
164
+
106
165
  # Generates a preview of the prompt without executing generation.
107
166
  #
108
167
  # Processes the agent action and renders the prompt configuration as
@@ -153,10 +212,11 @@ module ActiveAgent
153
212
  #
154
213
  # @param generation_method [Symbol, String]
155
214
  # @param options [Hash]
215
+ # @param job_arguments [Hash] further keyword arguments for the job
156
216
  # @return [Object] enqueued job
157
217
  # @raise [RuntimeError] when agent already processed to prevent data loss
158
218
  # @api private
159
- def enqueue_generation(generation_method, options = {})
219
+ def enqueue_generation(generation_method, options = {}, **job_arguments)
160
220
  if processed?
161
221
  ::Kernel.raise "You've accessed the agent before asking to " \
162
222
  "generate it later, so you may have made local changes that would " \
@@ -168,7 +228,7 @@ module ActiveAgent
168
228
  "method*, or 3. use a custom Active Job instead of #prompt_later."
169
229
  else
170
230
  agent_class.generation_job.set(options).perform_later(
171
- agent_class.name, action_name.to_s, generation_method.to_s, args: args, kwargs: kwargs
231
+ agent_class.name, action_name.to_s, generation_method.to_s, args: args, kwargs: kwargs, **job_arguments
172
232
  )
173
233
  end
174
234
  end