actionagent 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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -2
@@ -0,0 +1,847 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A walk through a project's running app, and the candidate evaluation
5
+ # scenarios it proposed for a person to review.
6
+ #
7
+ # The engine's explorer agent (source "explorer") or an agent outside the
8
+ # dashboard (source "external", through the MCP facade's
9
+ # `explorations_submit` tool) proposes candidates with #add_candidates!,
10
+ # the only path that stores them. A reviewer edits or rejects them with
11
+ # #update_candidate! and accepts them with #accept!, which merges them into
12
+ # the target evaluation under keys x<exploration id>_<candidate id> and
13
+ # never touches another scenario.
14
+ #
15
+ # Each candidate is a JSON object:
16
+ #
17
+ # id unique within the exploration, counting from 1
18
+ # prompt the question a user would ask the target agent
19
+ # group the scenario group it joins, DEFAULT_GROUP when none
20
+ # notes the rubric: what a good answer does. The judge grades
21
+ # the answer against it.
22
+ # expectations { tools, contains, not_contains }
23
+ # verdict answerable; needs_tool, with the expected tools the
24
+ # target agent lacks in missing_tools; or unverified, when
25
+ # its tools could not be read (see #tool_roster)
26
+ # missing_tools see verdict
27
+ # state proposed, edited, accepted or rejected
28
+ # scenario_key the key it was last accepted under, or nil
29
+ # provenance { urls, steps, recording_id, range: { from_ms, to_ms },
30
+ # screenshots }: how it was found. Shown to the reviewer,
31
+ # and never to the judge. recording_id and range are kept
32
+ # only when they name this exploration's own
33
+ # session_recording. The range is in milliseconds from
34
+ # the recording's start.
35
+ #
36
+ # An explorer exploration is walked by ExplorerExecutionService within its
37
+ # budget: { minutes, steps, cost }, where steps counts browser tool calls
38
+ # and cost is in US dollars. Its usage counts the same. Running out of any
39
+ # of them, or Stop and review, ends it in review with what it found.
40
+ # Status moves from pending to running to review, or to failed. Review
41
+ # becomes closed once no candidate awaits a decision, and review again
42
+ # when one does.
43
+ class Exploration < ApplicationRecord
44
+ include Ownable
45
+ owned_by :account, :user
46
+
47
+ # Raised for a batch over MAX_BYTES, and for a batch or an edit that
48
+ # would take the exploration past MAX_CANDIDATES or MAX_BYTES.
49
+ class CandidateLimitExceeded < StandardError; end
50
+
51
+ # Raised for a candidate that cannot be stored or changed as given.
52
+ class InvalidCandidate < ArgumentError; end
53
+
54
+ # Raised by #accept! when nothing can be accepted. #problems maps the id
55
+ # of each refused candidate to why.
56
+ class AcceptRefused < StandardError
57
+ attr_reader :problems
58
+
59
+ def initialize(message, problems = {})
60
+ super(message)
61
+ @problems = problems
62
+ end
63
+ end
64
+
65
+ STATUSES = %w[pending running review closed failed].freeze
66
+ SOURCES = %w[explorer external].freeze
67
+ # The states of a candidate still awaiting a decision.
68
+ OPEN_STATES = %w[proposed edited].freeze
69
+ EXPECTATION_KEYS = %w[tools contains not_contains].freeze
70
+ BUDGET_KEYS = %w[minutes steps cost].freeze
71
+ # The tag a scenario accepted into a catalog set (#accept_into_set!) carries.
72
+ EXPLORED_TAG = "explored"
73
+ MAX_CANDIDATES = 200
74
+ # A candidate's prompt and rubric hold at most MAX_STRING characters,
75
+ # and each expectation list at most MAX_ITEMS entries of at most
76
+ # MAX_LABEL characters. A longer one is refused, because accepting the
77
+ # candidate writes them into a scenario as they are. The group is a
78
+ # label, cut to MAX_LABEL. Provenance is only shown to the reviewer, so
79
+ # PayloadBounds cuts its strings to MAX_PROVENANCE_STRING characters
80
+ # and its lists to MAX_ITEMS entries.
81
+ MAX_STRING = 4_000
82
+ MAX_ITEMS = 50
83
+ MAX_LABEL = 200
84
+ MAX_PROVENANCE_STRING = 2_048
85
+ # The candidates one call submits, and all an exploration stores, are
86
+ # at most this much JSON.
87
+ MAX_BYTES = EvaluationReportImport::MAX_BYTES
88
+ DEFAULT_GROUP = "Explored"
89
+ # The explorer's budget when a start names none, and the most one may set.
90
+ DEFAULT_BUDGET = { "minutes" => 15, "steps" => 150 }.freeze
91
+ MAX_BUDGET = { "minutes" => 120, "steps" => 1_000, "cost" => 100 }.freeze
92
+ # Credentials of each kind read for scrubbing.
93
+ SECRET_LOOKUP_LIMIT = 100
94
+ # A pattern containing one of these does not survive the suite editor,
95
+ # whose line format splits on " | ", "," and ";".
96
+ UNSAVEABLE_PATTERN = /[,;\r\n]|\s\|\s/
97
+
98
+ belongs_to :project, class_name: "ActionAgent::Project", optional: true
99
+ belongs_to :evaluation, class_name: "ActionAgent::Evaluation", optional: true
100
+ belongs_to :agent_run, class_name: "ActionAgent::AgentRun", optional: true
101
+ belongs_to :sandbox_session, class_name: "ActionAgent::SandboxSession", optional: true
102
+ belongs_to :session_recording, class_name: "ActionAgent::SessionRecording", optional: true
103
+
104
+ validates :status, inclusion: { in: STATUSES }
105
+ validates :source, inclusion: { in: SOURCES }
106
+ validates :start_url, length: { maximum: 2048 }, allow_nil: true
107
+ validate :project_or_evaluation
108
+
109
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
110
+
111
+ # An unsaved exploration of +project+, or of +evaluation+ when there is
112
+ # no project. An evaluation that is a project's own evaluation makes it
113
+ # that project's exploration. A project's exploration carries the
114
+ # project's owner columns, and an evaluation's carries +user+'s and
115
+ # +account+'s, the caller's.
116
+ def self.build_for(project: nil, evaluation: nil, user: nil, account: nil, **attributes)
117
+ if project.nil? && evaluation
118
+ owning = Project.for_evaluation(evaluation)
119
+ project = owning if owning && owning.evaluation_id == evaluation.id
120
+ end
121
+ evaluation = nil if project && evaluation&.id != project.evaluation_id
122
+
123
+ exploration = new(project: project, evaluation: evaluation, start_url: project&.start_url, **attributes)
124
+ if project
125
+ exploration.account_id = project.account_id
126
+ exploration.user_id = project.user_id
127
+ else
128
+ exploration.user_id = user.id if ActionAgent.user_class.present? && user.respond_to?(:id)
129
+ exploration.account_id = account.id if account
130
+ end
131
+ exploration
132
+ end
133
+
134
+ # The verdict for a candidate expecting +tools+, as
135
+ # { "verdict", "missing_tools" }, against +roster+ (see #tool_roster).
136
+ # A tool missing from a roster that lacks a failed server's tools is
137
+ # unverified rather than missing.
138
+ def self.verdict(tools, roster)
139
+ return { "verdict" => "unverified", "missing_tools" => [] } if roster.nil?
140
+
141
+ missing = Array(tools) - roster[:names].to_a
142
+ return { "verdict" => "answerable", "missing_tools" => [] } if missing.empty?
143
+ return { "verdict" => "unverified", "missing_tools" => [] } unless roster[:complete]
144
+
145
+ { "verdict" => "needs_tool", "missing_tools" => missing }
146
+ end
147
+
148
+ # The line ScenarioSuitePanel's Save writes for +entry+ (a scenario's
149
+ # key, prompt, notes and expectations), which ScenarioParser reads back.
150
+ # Mirrors scenarioLine in frontend/utils/scenarioSuiteText.mjs; both are
151
+ # checked against frontend/test/fixtures/suite-editor-lines.json.
152
+ def self.suite_editor_line(entry)
153
+ expectations = entry["expectations"] || {}
154
+ options = EXPECTATION_KEYS.filter_map do |field|
155
+ values = Array(expectations[field])
156
+ "#{field}: #{values.join(', ')}" if values.any?
157
+ end
158
+ options << "notes: #{entry['notes']}" if entry["notes"].present?
159
+ options << "key: #{entry['key']}"
160
+ "#{entry['prompt']} | #{options.join(' | ')}"
161
+ end
162
+
163
+ # MySQL cannot give a JSON column a default, so an unset column reads nil.
164
+ def budget
165
+ super || {}
166
+ end
167
+
168
+ def usage
169
+ super || {}
170
+ end
171
+
172
+ def candidates
173
+ super || []
174
+ end
175
+
176
+ # The agent candidates are checked against and accepted scenarios replay
177
+ # through: the project's target agent, or the evaluation's agent.
178
+ def target_agent
179
+ project ? project.target_agent : evaluation&.agent
180
+ end
181
+
182
+ # The evaluation accepted candidates merge into: the project's
183
+ # evaluation on its target agent, or this exploration's when it has no
184
+ # project. Nil when a project has none, because no target agent is
185
+ # chosen, its evaluation was deleted or it is on another agent. The
186
+ # project's next accept then creates one.
187
+ def target_evaluation
188
+ return evaluation unless project
189
+
190
+ current = project.evaluation
191
+ current if current && current.agent_id == project.target_agent_id
192
+ end
193
+
194
+ # The project whose app the candidates come from: this exploration's,
195
+ # or the project whose target agent its evaluation belongs to (see
196
+ # Project.for_evaluation). Nil for an evaluation of any other agent.
197
+ #
198
+ # @return [Project, nil]
199
+ def app_project
200
+ project || Project.for_evaluation(evaluation)
201
+ end
202
+
203
+ # The target agent's tools as verdicts read them: { names:, complete: },
204
+ # where complete is false when one of the agent's own servers failed
205
+ # discovery. An exploration of a project's app (see #app_project) reads
206
+ # the app's tools from the project's current sandbox, and the browser's
207
+ # while the sandbox runs one, as the project's evaluation runs reach it.
208
+ #
209
+ # Nil when the tools cannot be read: there is no target agent, the
210
+ # project has no sandbox, or its sandbox is not live or did not answer.
211
+ #
212
+ # @return [Hash, nil]
213
+ def tool_roster
214
+ agent = target_agent or return nil
215
+ extra = []
216
+ if (source = app_project)
217
+ sandbox = source.current_sandbox_session or return nil
218
+ extra << sandbox.runtime_server_key
219
+ extra << sandbox.browser_server_key if sandbox.browser_running?
220
+ end
221
+
222
+ roster = RuntimeToolRoster.new(agent, extra_server_keys: extra)
223
+ { names: roster.tools.keys.to_set, complete: roster.discovery_errors.empty? }
224
+ rescue StandardError => e
225
+ Rails.logger.warn("[ActionAgent] exploration #{id || 'new'}: could not read the target agent's tools: #{e.class}")
226
+ nil
227
+ end
228
+
229
+ # Saves this new exploration with +list+ as its first candidates (see
230
+ # #add_candidates!). Nothing is saved when the candidates are refused.
231
+ #
232
+ # @return [Array<Hash>] the stored candidates
233
+ def save_with_candidates!(list)
234
+ roster = tool_roster
235
+ transaction do
236
+ save!
237
+ add_candidates!(list, roster: roster)
238
+ end
239
+ end
240
+
241
+ # Stores +list+ as proposed candidates and returns them as stored. Each
242
+ # entry is a candidate's prompt, group, notes (or rubric), expectations
243
+ # (or top-level tools, contains and not_contains) and provenance. Each
244
+ # is scrubbed of the values #scrub_secrets lists, checked against the
245
+ # size limits (see MAX_STRING) and given a verdict against +roster+.
246
+ #
247
+ # Nothing is stored when an entry is invalid, +list+ is more than
248
+ # MAX_BYTES of JSON, or the exploration would hold more than
249
+ # MAX_CANDIDATES or MAX_BYTES.
250
+ #
251
+ # @param roster [Hash, nil, :read] see #tool_roster; :read reads it
252
+ # @raise [InvalidCandidate]
253
+ # @raise [CandidateLimitExceeded]
254
+ # @return [Array<Hash>]
255
+ def add_candidates!(list, roster: :read)
256
+ raise InvalidCandidate, "candidates must be a list of objects" unless list.is_a?(Array)
257
+ raise InvalidCandidate, "give at least one candidate" if list.empty?
258
+ raise CandidateLimitExceeded, "An exploration holds at most #{MAX_CANDIDATES} candidates" if list.size > MAX_CANDIDATES
259
+ if list.to_json.bytesize > MAX_BYTES
260
+ raise CandidateLimitExceeded, "The candidates in one call may total #{MAX_BYTES / 1.megabyte} MiB of JSON: " \
261
+ "give them over several calls"
262
+ end
263
+
264
+ secrets = scrub_secrets
265
+ prepared = list.each_with_index.map { |raw, index| prepare_candidate(raw, label: "Candidate #{index + 1}", secrets: secrets) }
266
+ roster = tool_roster if roster == :read
267
+
268
+ stored = with_lock do
269
+ current = candidates
270
+ if current.size + prepared.size > MAX_CANDIDATES
271
+ raise CandidateLimitExceeded, "An exploration holds at most #{MAX_CANDIDATES} candidates: this one holds " \
272
+ "#{current.size} and these are #{prepared.size} more"
273
+ end
274
+
275
+ next_id = current.map { |candidate| candidate["id"].to_i }.max.to_i + 1
276
+ rows = prepared.each_with_index.map do |candidate, offset|
277
+ candidate.merge("id" => next_id + offset, "state" => "proposed", "scenario_key" => nil)
278
+ .merge(self.class.verdict(candidate.dig("expectations", "tools"), roster))
279
+ end
280
+ refuse_oversized_list!(current + rows, "submit these as a new exploration")
281
+ self.candidates = current + rows
282
+ self.status = "review" if status == "closed"
283
+ save!
284
+ rows
285
+ end
286
+ broadcast_change
287
+ stored
288
+ end
289
+
290
+ # Changes candidate +id+ and returns it. +attributes+ may carry:
291
+ #
292
+ # prompt, group, notes (or rubric), tools, contains, not_contains
293
+ # an edit, stored the way #add_candidates! stores a candidate.
294
+ # The state becomes "edited", and changed tools are checked
295
+ # against the roster again. An accepted candidate keeps its
296
+ # scenario key, and accepting it again updates that scenario.
297
+ # state
298
+ # "rejected", or "proposed" to reconsider a rejected candidate.
299
+ # An accepted candidate cannot be rejected: its scenario is in the
300
+ # evaluation, and is disabled or deleted there.
301
+ #
302
+ # @raise [ActiveRecord::RecordNotFound] when there is no candidate +id+
303
+ # @raise [InvalidCandidate]
304
+ # @raise [CandidateLimitExceeded]
305
+ # @return [Hash]
306
+ def update_candidate!(id, attributes)
307
+ attributes = plain_hash(attributes)
308
+ state = attributes.delete("state")&.to_s
309
+ if state && !%w[rejected proposed].include?(state)
310
+ raise InvalidCandidate, "state must be rejected, or proposed to reconsider a rejected candidate"
311
+ end
312
+
313
+ edits = attributes.slice("prompt", "group", "notes", "rubric", "expectations", *EXPECTATION_KEYS)
314
+ secrets = edits.any? ? scrub_secrets : []
315
+ roster = edits_tools?(edits) ? tool_roster : nil
316
+
317
+ updated = with_lock do
318
+ rows = candidates.deep_dup
319
+ candidate = rows.find { |row| row["id"] == id.to_i } or raise ActiveRecord::RecordNotFound, "No candidate #{id}"
320
+
321
+ if edits.any?
322
+ apply_edit!(candidate, edits, roster: roster, secrets: secrets)
323
+ refuse_oversized_list!(rows, "shorten the edit")
324
+ end
325
+ case state
326
+ when "rejected"
327
+ if candidate["scenario_key"].present?
328
+ raise InvalidCandidate, "Candidate #{candidate['id']} was accepted as #{candidate['scenario_key']}: disable or " \
329
+ "delete that scenario in the evaluation instead"
330
+ end
331
+ candidate["state"] = "rejected"
332
+ when "proposed"
333
+ candidate["state"] = "proposed" if candidate["state"] == "rejected"
334
+ end
335
+
336
+ self.candidates = rows
337
+ settle_status
338
+ save!
339
+ candidate
340
+ end
341
+ broadcast_change
342
+ updated
343
+ end
344
+
345
+ # Merges candidates +ids+ into the target evaluation, marks them
346
+ # accepted and returns Evaluation#merge_scenarios!'s keys with the
347
+ # evaluation under :evaluation. Each becomes the scenario
348
+ # x<exploration id>_<candidate id>, written as the suite editor saves it
349
+ # (see #suite_entry): the rubric as its notes, folded onto one line.
350
+ # Accepting a candidate again updates its scenario. A project's first
351
+ # accept creates the project's evaluation on its target agent.
352
+ #
353
+ # +edits+ maps a candidate id to an edit, as #update_candidate! takes
354
+ # one, applied first.
355
+ #
356
+ # Nothing is written when any candidate is refused: a rejected one
357
+ # (reconsider it first), one the suite editor cannot save unchanged, or
358
+ # one whose key a scenario from outside this exploration already holds.
359
+ #
360
+ # @raise [AcceptRefused]
361
+ # @raise [InvalidCandidate] for an edit, as #update_candidate! raises it
362
+ # @raise [CandidateLimitExceeded]
363
+ # @raise [Evaluation::ScenarioLimitExceeded]
364
+ # @return [Hash]
365
+ def accept!(ids, edits: {})
366
+ ids = Array(ids).map { |id| Integer(id.to_s, exception: false) }
367
+ raise AcceptRefused, "Choose the candidates to accept" if ids.empty?
368
+ raise AcceptRefused, "Candidate ids are numbers" if ids.include?(nil)
369
+
370
+ ids = ids.uniq
371
+ edits = plain_hash(edits).each_with_object({}) do |(id, edit), map|
372
+ number = Integer(id.to_s, exception: false) or raise AcceptRefused, "Candidate ids are numbers"
373
+ map[number] = plain_hash(edit).slice("prompt", "group", "notes", "rubric", "expectations", *EXPECTATION_KEYS)
374
+ end
375
+ secrets = edits.any? ? scrub_secrets : []
376
+ roster = edits.values.any? { |edit| edits_tools?(edit) } ? tool_roster : nil
377
+
378
+ result = with_lock do
379
+ agent = target_agent or raise AcceptRefused, project ? "Choose the agent to evaluate first" : "The evaluation no longer exists"
380
+
381
+ rows = candidates.deep_dup
382
+ by_id = rows.index_by { |row| row["id"] }
383
+ unknown = ids.reject { |id| by_id.key?(id) }
384
+ raise AcceptRefused, "No candidate #{unknown.join(', ')}" if unknown.any?
385
+
386
+ problems = {}
387
+ entries = ids.filter_map do |id|
388
+ candidate = by_id[id]
389
+ if candidate["state"] == "rejected"
390
+ problems[id] = "it was rejected: reconsider it first"
391
+ next
392
+ end
393
+
394
+ apply_edit!(candidate, edits[id], roster: roster, secrets: secrets) if edits[id].present?
395
+ entry, problem = suite_entry(candidate)
396
+ problems[id] = problem if problem
397
+ entry
398
+ end
399
+ raise AcceptRefused.new("Some candidates cannot be accepted", problems) if problems.any?
400
+
401
+ refuse_oversized_list!(rows, "shorten the edits") if edits.any?
402
+
403
+ evaluation = accept_evaluation!(agent)
404
+ refuse_held_keys!(evaluation, entries, by_id)
405
+ merged = evaluation.merge_scenarios!(entries, limit: EvaluationReportImport::MAX_SCENARIOS_PER_EVALUATION)
406
+
407
+ entries.each do |entry|
408
+ candidate = by_id.fetch(entry["candidate_id"])
409
+ candidate["state"] = "accepted"
410
+ candidate["scenario_key"] = entry["key"]
411
+ end
412
+ self.candidates = rows
413
+ self.evaluation_id ||= evaluation.id
414
+ settle_status
415
+ save!
416
+ merged.merge(evaluation: evaluation)
417
+ end
418
+ broadcast_change
419
+ result
420
+ end
421
+
422
+ # Accepts candidates +ids+ into +set+, a scenario set of the owner's
423
+ # scenario catalogs, rather than into an evaluation: each becomes the
424
+ # set's scenario x<exploration id>_<candidate id>, with the rubric as its
425
+ # notes, and accepting it again updates that scenario. The catalog's
426
+ # document is rewritten from its records. Returns
427
+ # { added:, updated:, unchanged:, set: }.
428
+ #
429
+ # +edits+ is applied first, as #accept! applies it. Nothing is written
430
+ # when a candidate is refused (a rejected one), or when the set holds
431
+ # the key as a scenario this exploration did not write.
432
+ #
433
+ # @raise [AcceptRefused]
434
+ # @raise [InvalidCandidate] for an edit, as #update_candidate! raises it
435
+ # @raise [CandidateLimitExceeded]
436
+ # @return [Hash]
437
+ def accept_into_set!(ids, set:, edits: {})
438
+ ids = Array(ids).map { |id| Integer(id.to_s, exception: false) }
439
+ raise AcceptRefused, "Choose the candidates to accept" if ids.empty?
440
+ raise AcceptRefused, "Candidate ids are numbers" if ids.include?(nil)
441
+
442
+ ids = ids.uniq
443
+ edits = plain_hash(edits).each_with_object({}) do |(id, edit), map|
444
+ number = Integer(id.to_s, exception: false) or raise AcceptRefused, "Candidate ids are numbers"
445
+ map[number] = plain_hash(edit).slice("prompt", "group", "notes", "rubric", "expectations", *EXPECTATION_KEYS)
446
+ end
447
+ secrets = edits.any? ? scrub_secrets : []
448
+ roster = edits.values.any? { |edit| edits_tools?(edit) } ? tool_roster : nil
449
+ catalog = set.catalog
450
+ result = { added: [], updated: [], unchanged: [] }
451
+
452
+ with_lock do
453
+ rows = candidates.deep_dup
454
+ by_id = rows.index_by { |row| row["id"] }
455
+ unknown = ids.reject { |id| by_id.key?(id) }
456
+ raise AcceptRefused, "No candidate #{unknown.join(', ')}" if unknown.any?
457
+
458
+ problems = {}
459
+ ids.each do |id|
460
+ candidate = by_id[id]
461
+ if candidate["state"] == "rejected"
462
+ problems[id] = "it was rejected: reconsider it first"
463
+ next
464
+ end
465
+ apply_edit!(candidate, edits[id], roster: roster, secrets: secrets) if edits[id].present?
466
+ problems[id] = "it has no prompt" if candidate["prompt"].blank?
467
+ end
468
+ raise AcceptRefused.new("Some candidates cannot be accepted", problems) if problems.any?
469
+
470
+ refuse_oversized_list!(rows, "shorten the edits") if edits.any?
471
+ if catalog.scenario_count + ids.size > ScenarioCatalog::MAX_SCENARIOS
472
+ raise AcceptRefused, "The catalog would hold more than #{ScenarioCatalog::MAX_SCENARIOS} scenarios"
473
+ end
474
+
475
+ ScenarioCatalog.transaction do
476
+ next_position = (set.scenarios.maximum(:position) || -1) + 1
477
+ ids.each do |id|
478
+ candidate = by_id.fetch(id)
479
+ key = "x#{self.id}_#{id}"
480
+ record = set.scenarios.find_or_initialize_by(key: key)
481
+ written_here = candidate["scenario_set_id"] == set.id && candidate["scenario_key"] == key
482
+ if record.persisted? && !written_here
483
+ raise AcceptRefused.new("Some candidates cannot be accepted", id => "the set already holds a scenario #{key}")
484
+ end
485
+
486
+ expectations = candidate["expectations"].to_h.slice(*EXPECTATION_KEYS).reject { |_, value| value.blank? }
487
+ record.assign_attributes(prompt: candidate["prompt"], notes: candidate["notes"].presence,
488
+ expectations: expectations, tags: (record.tags | [ EXPLORED_TAG ]))
489
+ record.position ||= (next_position += 1) - 1
490
+ bucket = record.new_record? ? :added : (record.changed? ? :updated : :unchanged)
491
+ record.save!
492
+ result[bucket] << key
493
+
494
+ candidate["state"] = "accepted"
495
+ candidate["scenario_key"] = key
496
+ candidate["scenario_set_id"] = set.id
497
+ end
498
+ catalog.refresh_document!
499
+ end
500
+
501
+ self.candidates = rows
502
+ settle_status
503
+ save!
504
+ end
505
+ catalog.sync_to_storage!
506
+ broadcast_change
507
+ result.merge(set: set)
508
+ rescue ActiveAgent::Evals::Catalog::InvalidDocument, ActiveRecord::RecordInvalid => e
509
+ raise AcceptRefused, e.respond_to?(:record) ? e.record.errors.full_messages.to_sentence : e.message
510
+ end
511
+
512
+ # Ends a pending or running exploration, keeping the candidates found so
513
+ # far for review. It is closed when none awaits a decision.
514
+ #
515
+ # @return [Boolean] whether it was pending or running
516
+ def stop!(reason: "stopped")
517
+ stopped = with_lock do
518
+ next false unless %w[pending running].include?(status)
519
+
520
+ assign_attributes(status: "review", stop_reason: reason, finished_at: Time.current)
521
+ settle_status
522
+ save!
523
+ true
524
+ end
525
+ broadcast_change if stopped
526
+ stopped
527
+ end
528
+
529
+ # Whether the exploration may still add candidates on its own.
530
+ def active?
531
+ %w[pending running].include?(status)
532
+ end
533
+
534
+ # The explorer budget +raw+ asks for (minutes, steps and an optional
535
+ # cost), with DEFAULT_BUDGET's limits where it names none.
536
+ #
537
+ # @raise [InvalidCandidate] for a limit that is not a positive number,
538
+ # or above MAX_BUDGET
539
+ # @return [Hash{String => Numeric}]
540
+ def self.budget_from(raw)
541
+ raw = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
542
+ raw = raw.is_a?(Hash) ? raw.stringify_keys.slice(*BUDGET_KEYS).compact_blank : {}
543
+ budget = DEFAULT_BUDGET.merge(raw.to_h do |key, value|
544
+ number = key == "cost" ? Float(value.to_s, exception: false) : Integer(value.to_s, exception: false)
545
+ unless number&.positive? && number <= MAX_BUDGET.fetch(key)
546
+ raise InvalidCandidate, "The #{key} budget must be a number above 0 and at most #{MAX_BUDGET.fetch(key)}"
547
+ end
548
+
549
+ [ key, number ]
550
+ end)
551
+ budget
552
+ end
553
+
554
+ # Marks the explorer's walk started, with nothing used yet.
555
+ def start_walk!
556
+ update!(status: "running", started_at: Time.current, usage: { "minutes" => 0, "steps" => 0, "cost" => 0 })
557
+ broadcast_change
558
+ end
559
+
560
+ # Stores the walk's usage so far ({ minutes, steps, cost }).
561
+ def record_usage!(minutes:, steps:, cost:)
562
+ update_columns(usage: { "minutes" => minutes.round(2), "steps" => steps, "cost" => cost.round(4) }, updated_at: Time.current)
563
+ end
564
+
565
+ # Ends a running walk for review with what it found, unless it was
566
+ # stopped meanwhile. +reason+ becomes the stop_reason: "finished",
567
+ # "budget_minutes", "budget_steps", "budget_cost" or "stopped".
568
+ #
569
+ # @return [Boolean] whether it was running
570
+ def finish_walk!(reason:)
571
+ stop!(reason: reason)
572
+ end
573
+
574
+ # Ends a pending or running walk as failed, keeping its candidates for
575
+ # review. +message+ is stored scrubbed of the values #scrub_secrets lists.
576
+ def fail_walk!(message)
577
+ failed = with_lock do
578
+ next false unless active?
579
+
580
+ update!(status: "failed", stop_reason: "error", error_message: SecretScrubber.scrub(message.to_s, scrub_secrets).truncate(2_000),
581
+ finished_at: Time.current)
582
+ true
583
+ end
584
+ broadcast_change if failed
585
+ failed
586
+ end
587
+
588
+ # Whether the walk is still meant to go on: false once it was stopped,
589
+ # failed or reviewed. Reads the stored status.
590
+ def walking?
591
+ self.class.where(id: id, status: "running").exists?
592
+ end
593
+
594
+ def candidate_counts
595
+ list = candidates
596
+ {
597
+ total: list.size,
598
+ open: list.count { |candidate| OPEN_STATES.include?(candidate["state"]) },
599
+ accepted: list.count { |candidate| candidate["state"] == "accepted" },
600
+ rejected: list.count { |candidate| candidate["state"] == "rejected" },
601
+ answerable: list.count { |candidate| candidate["verdict"] == "answerable" },
602
+ needs_tool: list.count { |candidate| candidate["verdict"] == "needs_tool" },
603
+ unverified: list.count { |candidate| candidate["verdict"] == "unverified" }
604
+ }
605
+ end
606
+
607
+ def summary
608
+ {
609
+ id: id,
610
+ project_id: project_id,
611
+ evaluation_id: evaluation_id,
612
+ agent_run_id: agent_run_id,
613
+ session_recording_id: session_recording_id,
614
+ source: source,
615
+ status: status,
616
+ stop_reason: stop_reason,
617
+ error_message: error_message,
618
+ start_url: start_url,
619
+ budget: budget.slice(*BUDGET_KEYS),
620
+ usage: usage.slice(*BUDGET_KEYS),
621
+ counts: candidate_counts,
622
+ started_at: started_at&.iso8601,
623
+ finished_at: finished_at&.iso8601,
624
+ created_at: created_at&.iso8601,
625
+ updated_at: updated_at&.iso8601
626
+ }
627
+ end
628
+
629
+ # The values candidate text is scrubbed of: the secrets of the project
630
+ # whose app it comes from (see #app_project) with their encodings, the
631
+ # owner's provider keys, GitHub token and API keys, and the runtime and
632
+ # browser tokens of that project's sandboxes.
633
+ #
634
+ # @return [Array<String>]
635
+ def scrub_secrets
636
+ owner_record = owner
637
+ source = app_project
638
+ sandbox_tokens = source ? SandboxSession.where(project_id: source.id).order(id: :desc).limit(SECRET_LOOKUP_LIMIT)
639
+ .pluck(:runtime_mcp_token, :browser_token).flatten.compact : []
640
+
641
+ [
642
+ *(source ? source.scrub_values : []),
643
+ *ProviderKey.for_owner(owner_record).limit(SECRET_LOOKUP_LIMIT).pluck(:credential, :api_key).flatten,
644
+ *GithubConnection.for_owner(owner_record).limit(SECRET_LOOKUP_LIMIT).pluck(:access_token),
645
+ *ApiKey.for_owner(owner_record).limit(SECRET_LOOKUP_LIMIT).pluck(:token),
646
+ *sandbox_tokens
647
+ ].compact
648
+ end
649
+
650
+ private
651
+
652
+ def project_or_evaluation
653
+ errors.add(:base, "An exploration needs a project or an evaluation") if project_id.nil? && evaluation_id.nil?
654
+ end
655
+
656
+ def broadcast_change
657
+ LiveUpdates.broadcast("exploration_#{id}", type: "exploration", id: id, status: status)
658
+ end
659
+
660
+ # review and closed follow the candidates: closed once none awaits a
661
+ # decision.
662
+ def settle_status
663
+ return unless %w[review closed].include?(status)
664
+
665
+ self.status = candidates.any? { |candidate| OPEN_STATES.include?(candidate["state"]) } ? "review" : "closed"
666
+ end
667
+
668
+ def plain_hash(value)
669
+ value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
670
+ value.is_a?(Hash) ? value.deep_stringify_keys : {}
671
+ end
672
+
673
+ def edits_tools?(edit)
674
+ edit.key?("tools") || (edit["expectations"].is_a?(Hash) && edit["expectations"].stringify_keys.key?("tools"))
675
+ end
676
+
677
+ # Applies +edit+ to +candidate+ in place, as a reviewer's edit. The
678
+ # provenance is not editable and is kept as stored.
679
+ def apply_edit!(candidate, edit, roster:, secrets:)
680
+ before = candidate.dig("expectations", "tools")
681
+ edit = edit.merge("notes" => edit["rubric"]) if edit.key?("rubric") && !edit.key?("notes")
682
+ merged = candidate.slice("prompt", "group", "notes", "expectations").merge(edit.except("expectations", "rubric"))
683
+ if edit["expectations"].is_a?(Hash)
684
+ merged["expectations"] = candidate["expectations"].to_h.merge(edit["expectations"].stringify_keys)
685
+ end
686
+ prepared = prepare_candidate(merged, label: "Candidate #{candidate['id']}", secrets: secrets).except("provenance")
687
+
688
+ candidate.merge!(prepared)
689
+ candidate.merge!(self.class.verdict(prepared.dig("expectations", "tools"), roster)) if prepared.dig("expectations", "tools") != before
690
+ candidate["state"] = "edited"
691
+ candidate
692
+ end
693
+
694
+ # A candidate from +raw+, scrubbed of +secrets+ and checked against the
695
+ # size limits (see MAX_STRING). The group and provenance are cut after
696
+ # scrubbing, so a cut never leaves part of a secret behind.
697
+ def prepare_candidate(raw, label:, secrets:)
698
+ raw = plain_hash(raw) if raw.respond_to?(:to_unsafe_h) || raw.is_a?(Hash)
699
+ raise InvalidCandidate, "#{label} must be an object" unless raw.is_a?(Hash)
700
+
701
+ expectations = raw["expectations"].is_a?(Hash) ? raw["expectations"].stringify_keys : {}
702
+ candidate = SecretScrubber.scrub({
703
+ "prompt" => text(raw["prompt"]).strip,
704
+ "group" => text(raw["group"]).strip,
705
+ "notes" => text(raw.key?("notes") ? raw["notes"] : raw["rubric"]).strip.presence,
706
+ "expectations" => EXPECTATION_KEYS.index_with { |field| string_list(raw.key?(field) ? raw[field] : expectations[field]) },
707
+ "provenance" => provenance(raw["provenance"])
708
+ }, secrets)
709
+ raise InvalidCandidate, "#{label} has no prompt" if candidate["prompt"].empty?
710
+
711
+ refuse_oversized_fields!(candidate, label)
712
+ candidate.merge(
713
+ "group" => candidate["group"].first(MAX_LABEL).presence || DEFAULT_GROUP,
714
+ "provenance" => PayloadBounds.bound(candidate["provenance"], max_string: MAX_PROVENANCE_STRING, max_items: MAX_ITEMS)
715
+ )
716
+ end
717
+
718
+ def refuse_oversized_fields!(candidate, label)
719
+ { "prompt" => "prompt", "notes" => "rubric" }.each do |field, name|
720
+ next if candidate[field].to_s.length <= MAX_STRING
721
+
722
+ raise InvalidCandidate, "#{label}: the #{name} is longer than #{MAX_STRING} characters"
723
+ end
724
+ candidate["expectations"].each do |field, values|
725
+ name = field.tr("_", " ")
726
+ raise InvalidCandidate, "#{label} has more than #{MAX_ITEMS} #{name} entries" if values.size > MAX_ITEMS
727
+ if (long = values.find { |value| value.length > MAX_LABEL })
728
+ raise InvalidCandidate, "#{label}: #{name} “#{long.truncate(60)}” is longer than #{MAX_LABEL} characters"
729
+ end
730
+ end
731
+ end
732
+
733
+ # Raises when +rows+, all of an exploration's candidates, are more than
734
+ # MAX_BYTES of JSON. The message ends with +remedy+.
735
+ def refuse_oversized_list!(rows, remedy)
736
+ return if rows.to_json.bytesize <= MAX_BYTES
737
+
738
+ raise CandidateLimitExceeded, "An exploration's candidates may total #{MAX_BYTES / 1.megabyte} MiB of JSON, and " \
739
+ "this would take this one past it: #{remedy}"
740
+ end
741
+
742
+ # A submitter names a recording by id, so one that is not this
743
+ # exploration's is dropped with its range: a replay link must only ever
744
+ # open the recording of this walk.
745
+ def provenance(raw)
746
+ raw = plain_hash(raw)
747
+ return {} if raw.empty?
748
+
749
+ recording_id = integer(raw["recording_id"])
750
+ recording_id = nil unless recording_id && recording_id == session_recording_id
751
+ range = recording_id ? plain_hash(raw["range"]) : {}
752
+ {
753
+ "urls" => string_list(raw.key?("urls") ? raw["urls"] : raw["url"]),
754
+ "steps" => string_list(raw["steps"]),
755
+ "recording_id" => recording_id,
756
+ "range" => { "from_ms" => integer(range["from_ms"]), "to_ms" => integer(range["to_ms"]) }.compact.presence,
757
+ "screenshots" => string_list(raw["screenshots"])
758
+ }.compact
759
+ end
760
+
761
+ def text(value)
762
+ value.is_a?(String) || value.is_a?(Numeric) ? value.to_s : ""
763
+ end
764
+
765
+ def string_list(value)
766
+ Array(value).filter_map { |item| text(item).strip.presence }.uniq
767
+ end
768
+
769
+ def integer(value)
770
+ return value if value.is_a?(Integer)
771
+
772
+ value.to_s.match?(/\A\d+\z/) ? value.to_i : nil
773
+ end
774
+
775
+ # [entry, problem]: the scenario attributes +candidate+ is accepted as,
776
+ # or why it cannot be. The suite editor saves a suite as one line per
777
+ # scenario and ScenarioParser reads the lines back, so the entry is
778
+ # written the way that round trip leaves it: the prompt, group and rubric
779
+ # folded onto one line with " | " written " / ", and the prompt without
780
+ # the Markdown the parser drops. A pattern the parser would split, and
781
+ # anything else the round trip would change, is refused.
782
+ def suite_entry(candidate)
783
+ expectations = {}
784
+ EXPECTATION_KEYS.each do |field|
785
+ values = Array(candidate.dig("expectations", field))
786
+ if (bad = values.find { |value| value.match?(UNSAVEABLE_PATTERN) })
787
+ return [ nil, "#{field.tr('_', ' ')} “#{bad.truncate(60)}” has a comma, semicolon, | or line break, which the " \
788
+ "suite editor splits on" ]
789
+ end
790
+ expectations[field] = values if values.any?
791
+ end
792
+
793
+ prompt = suite_text(candidate["prompt"], markup: true)
794
+ return [ nil, "the prompt is empty" ] if prompt.empty?
795
+
796
+ entry = {
797
+ "key" => "x#{id}_#{candidate['id']}",
798
+ "prompt" => prompt,
799
+ "group" => suite_text(candidate["group"]).presence || DEFAULT_GROUP,
800
+ "notes" => suite_text(candidate["notes"]).presence,
801
+ "expectations" => expectations
802
+ }
803
+ unless survives_suite_editor?(entry)
804
+ return [ nil, "the suite editor would change it when saving: check the prompt and rubric for Markdown or a " \
805
+ "line that reads as a heading" ]
806
+ end
807
+
808
+ [ entry.merge("candidate_id" => candidate["id"]), nil ]
809
+ end
810
+
811
+ def suite_text(value, markup: false)
812
+ folded = value.to_s.gsub(/\s+/, " ").strip.gsub(" | ", " / ")
813
+ return folded unless markup
814
+
815
+ folded.gsub(/\*\*|__|`/, "").sub(ActiveAgent::Evals::ScenarioParser::LIST_MARKER, "").sub(/\A#+\s+/, "").strip
816
+ end
817
+
818
+ def survives_suite_editor?(entry)
819
+ text = "# #{entry['group']}\n#{self.class.suite_editor_line(entry)}"
820
+ parsed = ActiveAgent::Evals::ScenarioParser.parse(text)
821
+ parsed.size == 1 &&
822
+ parsed.first.values_at("key", "prompt", "group", "notes", "expectations") ==
823
+ entry.values_at("key", "prompt", "group", "notes", "expectations")
824
+ rescue ActiveAgent::Evals::ScenarioParser::ParseError
825
+ false
826
+ end
827
+
828
+ def accept_evaluation!(agent)
829
+ return evaluation || raise(AcceptRefused, "The evaluation no longer exists") unless project
830
+
831
+ project.scenario_evaluation!(agent)
832
+ end
833
+
834
+ # Raises when a scenario this exploration did not accept holds one of
835
+ # the keys +entries+ would write.
836
+ def refuse_held_keys!(evaluation, entries, by_id)
837
+ held = evaluation.scenarios.where(key: entries.map { |entry| entry["key"] }).pluck(:key).map(&:downcase).to_set
838
+ problems = entries.each_with_object({}) do |entry, map|
839
+ next unless held.include?(entry["key"].downcase)
840
+ next if by_id.fetch(entry["candidate_id"])["scenario_key"].to_s.casecmp?(entry["key"])
841
+
842
+ map[entry["candidate_id"]] = "the evaluation already has a scenario #{entry['key']} that this exploration did not add"
843
+ end
844
+ raise AcceptRefused.new("Some candidates cannot be accepted", problems) if problems.any?
845
+ end
846
+ end
847
+ end