actionagent 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 (159) 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 +53 -6
  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 +9 -4
  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 +140 -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 +623 -18
  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 +26 -2
  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 +39 -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 +16 -4
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +84 -0
  66. data/app/models/action_agent/exploration.rb +847 -0
  67. data/app/models/action_agent/github_installation.rb +159 -0
  68. data/app/models/action_agent/input_request.rb +291 -0
  69. data/app/models/action_agent/project.rb +789 -0
  70. data/app/models/action_agent/project_secret.rb +339 -0
  71. data/app/models/action_agent/provider_key.rb +200 -3
  72. data/app/models/action_agent/recording_event.rb +145 -0
  73. data/app/models/action_agent/recording_snapshot.rb +7 -7
  74. data/app/models/action_agent/sandbox_session.rb +297 -14
  75. data/app/models/action_agent/scenario_catalog.rb +172 -0
  76. data/app/models/action_agent/scenario_product.rb +57 -0
  77. data/app/models/action_agent/scenario_set.rb +138 -0
  78. data/app/models/action_agent/session_recording.rb +121 -14
  79. data/app/models/action_agent/telemetry_trace.rb +74 -19
  80. data/app/models/concerns/action_agent/ownable.rb +84 -13
  81. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  82. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  83. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  84. data/app/services/action_agent/agent_execution_service.rb +450 -72
  85. data/app/services/action_agent/agent_sync.rb +5 -2
  86. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  87. data/app/services/action_agent/agent_toolbox.rb +215 -9
  88. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  89. data/app/services/action_agent/browser_sidecar.rb +213 -0
  90. data/app/services/action_agent/browser_sign_in.rb +163 -0
  91. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  92. data/app/services/action_agent/claude_code_auth.rb +70 -4
  93. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  94. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  95. data/app/services/action_agent/evaluation_fix.rb +101 -0
  96. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  97. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  98. data/app/services/action_agent/evaluation_standing.rb +15 -8
  99. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  100. data/app/services/action_agent/github_client.rb +405 -64
  101. data/app/services/action_agent/live_updates.rb +31 -0
  102. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  103. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  104. data/app/services/action_agent/mcp_client.rb +6 -2
  105. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  106. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  107. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  108. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  109. data/app/services/action_agent/project_boot_fix.rb +309 -0
  110. data/app/services/action_agent/project_capabilities.rb +200 -0
  111. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  112. data/app/services/action_agent/project_preflight.rb +178 -0
  113. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  114. data/app/services/action_agent/project_setup.rb +429 -0
  115. data/app/services/action_agent/provider_credentials.rb +102 -0
  116. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  117. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  118. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  119. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  120. data/app/services/action_agent/sandbox_browser.rb +299 -0
  121. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  122. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  123. data/app/services/action_agent/sandbox_mail.rb +103 -0
  124. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  125. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  126. data/app/services/action_agent/sandbox_patch.rb +253 -0
  127. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  128. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  129. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  130. data/app/services/action_agent/secret_scrubber.rb +33 -4
  131. data/app/services/action_agent/session_index.rb +270 -0
  132. data/app/services/action_agent/session_recording_service.rb +11 -0
  133. data/app/services/action_agent/session_timeline.rb +505 -0
  134. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  135. data/app/views/action_agent/session_player/show.html.erb +19 -0
  136. data/config/routes.rb +152 -1
  137. data/lib/action_agent/claude_login_process.rb +107 -0
  138. data/lib/action_agent/engine.rb +55 -5
  139. data/lib/action_agent/secret_requests.rb +80 -0
  140. data/lib/action_agent/version.rb +1 -1
  141. data/lib/action_agent.rb +507 -22
  142. data/lib/generators/action_agent/install_generator.rb +37 -0
  143. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  144. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  145. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  146. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  147. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  148. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  149. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  150. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  151. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  152. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  153. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  154. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  155. data/lib/tasks/action_agent.rake +33 -0
  156. data/lib/tasks/browser.rake +22 -0
  157. data/lib/tasks/sandbox.rake +1 -1
  158. data/lib/tasks/scenario_catalogs.rake +71 -0
  159. metadata +96 -2
@@ -157,6 +157,40 @@ module ActionAgent
157
157
  icon: "🚀",
158
158
  featured: false
159
159
  },
160
+ {
161
+ name: "Conference Ticket Agent",
162
+ slug: "conference-ticket",
163
+ description: "Registers for a conference in the platform's browser and stops before paying: the run hands the ticket page to a person, who pays. Built for the SF Ruby Conference demo; works on any event page.",
164
+ category: "automation",
165
+ provider: "anthropic",
166
+ model: "claude-sonnet-5",
167
+ preset_type: "playwright",
168
+ appearance: { hat: "fedora", hatAccessory: "theaterMasks", heldItem: "browser" },
169
+ instruction_sets: [],
170
+ # playwright_mcp is the platform's browser (browser_navigate,
171
+ # browser_snapshot, browser_click) plus request_handoff.
172
+ tools: %w[playwright_mcp],
173
+ model_config: { temperature: 0.1, max_tokens: 4096 },
174
+ instructions: <<~INSTRUCTIONS.strip,
175
+ You register a person for a conference in a real browser, and you stop before anything is paid.
176
+
177
+ You are given the event URL, the attendee's name and email, and the ticket they want. The person who started this run is watching and pays themselves.
178
+
179
+ How to work:
180
+ 1. browser_navigate to the event URL, then browser_snapshot to read the page.
181
+ 2. Find the tickets or registration link (Tickets, Register, Get ticket, Buy). Ticket pages often live on a ticketing site such as Luma; follow the link.
182
+ 3. Choose the ticket you were given. If it is sold out or missing, stop and say so.
183
+ 4. Enter only the attendee details you were given: name, email, and anything else the person listed. Leave every other field empty.
184
+ 5. Take a browser_snapshot after each step and use element refs from the latest snapshot only.
185
+ 6. The moment the page asks for a card number, billing address, a wallet, a password, or a one-time code, call request_handoff with the current URL, what the page is asking for, and the values you entered. Then stop.
186
+
187
+ Never enter payment details. Never click Pay, Place order, Confirm purchase or Complete registration. Never accept terms on the person's behalf. Never invent details. Stay on the event's own pages and its ticketing site.
188
+
189
+ Report in three lines: where you stopped, what you entered, and what the person does next.
190
+ INSTRUCTIONS
191
+ icon: "🎟️",
192
+ featured: true
193
+ },
160
194
  {
161
195
  name: "PlaywrightMCP Demo",
162
196
  slug: "playwright-mcp-demo",
@@ -176,7 +210,7 @@ module ActionAgent
176
210
  {
177
211
  name: "playwright",
178
212
  command: "npx",
179
- args: [ "-y", "@anthropic/mcp-server-playwright" ]
213
+ args: [ "-y", "@playwright/mcp@latest" ]
180
214
  }
181
215
  ],
182
216
  model_config: { temperature: 0.2, max_tokens: 4096 },
@@ -36,6 +36,20 @@ module ActionAgent
36
36
  update_column(:last_used_at, Time.current) if last_used_at.nil? || last_used_at < 1.minute.ago
37
37
  end
38
38
 
39
+ # The user who created the key, whom the MCP facade treats as the caller
40
+ # of a request made with it. Nil when the install has no user model, or
41
+ # the key records no creator: keys created before keys recorded one, and
42
+ # keys the engine creates for itself.
43
+ def creator
44
+ return nil if user_id.nil?
45
+
46
+ user_class = ActionAgent.user_class&.safe_constantize
47
+ return nil if user_class.nil?
48
+
49
+ reflection = self.class.reflect_on_association(:user)
50
+ reflection&.class_name == user_class.name ? user : user_class.find_by(id: user_id)
51
+ end
52
+
39
53
  # Safe to display in the dashboard key list: "aa_3xam…k9Q2"
40
54
  def masked_token
41
55
  "#{token_prefix}…#{token.last(4)}"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # One scenario of a catalog set, as the document gave it. It becomes an
5
+ # EvaluationScenario of the set's evaluation when the set is materialized;
6
+ # tags and params stay here, for the catalog's readers, since the
7
+ # evaluation's scenarios do not carry them.
8
+ class CatalogScenario < ApplicationRecord
9
+ belongs_to :set, class_name: "ActionAgent::ScenarioSet", foreign_key: :scenario_set_id, inverse_of: :scenarios
10
+
11
+ validates :key, presence: true, length: { maximum: 200 }, uniqueness: { scope: :scenario_set_id }
12
+ validates :prompt, presence: true
13
+
14
+ scope :enabled, -> { where(enabled: true) }
15
+
16
+ def expectations
17
+ value = self[:expectations]
18
+ value.is_a?(Hash) ? value : {}
19
+ end
20
+
21
+ def tags
22
+ Array(self[:tags]).map(&:to_s)
23
+ end
24
+
25
+ def params
26
+ value = self[:params]
27
+ value.is_a?(Hash) ? value : {}
28
+ end
29
+
30
+ # @return [ActiveAgent::Evals::Scenario]
31
+ def to_scenario
32
+ ActiveAgent::Evals::Scenario.from_hash(to_document.merge("position" => position), group: set.key, group_name: set.name)
33
+ end
34
+
35
+ def to_document
36
+ {
37
+ "key" => key,
38
+ "prompt" => prompt,
39
+ "expect" => expectations.presence,
40
+ "notes" => notes,
41
+ "tags" => tags.presence,
42
+ "params" => params.presence,
43
+ "production_only" => (true if production_only?)
44
+ }.compact
45
+ end
46
+
47
+ # The attributes Evaluation#replace_scenarios! takes.
48
+ def evaluation_attributes(index)
49
+ {
50
+ "key" => key,
51
+ "prompt" => prompt,
52
+ "group" => set.key,
53
+ "notes" => notes,
54
+ "expectations" => expectations,
55
+ "position" => position || index,
56
+ "enabled" => enabled
57
+ }
58
+ end
59
+
60
+ def summary
61
+ {
62
+ id: id,
63
+ key: key,
64
+ prompt: prompt,
65
+ notes: notes,
66
+ expectations: expectations,
67
+ tags: tags,
68
+ params: params,
69
+ production_only: production_only?,
70
+ enabled: enabled?,
71
+ position: position
72
+ }
73
+ end
74
+ end
75
+ end
@@ -14,6 +14,9 @@ module ActionAgent
14
14
  owned_by :user, :account
15
15
 
16
16
  belongs_to :sandbox_session
17
+ belongs_to :evaluation_run, optional: true
18
+ belongs_to :verification_run, class_name: "EvaluationRun", optional: true
19
+ belongs_to :previous_code_session, class_name: "CodeSession", optional: true
17
20
 
18
21
  enum :status, { queued: 0, running: 1, succeeded: 2, failed: 3, cancelled: 4 }
19
22
 
@@ -49,11 +52,13 @@ module ActionAgent
49
52
  finished_at.nil?
50
53
  end
51
54
 
52
- # The values that must never be stored: the checkout token and the
53
- # Claude Code credential this session ran with.
55
+ # The values that must never be stored: the checkout token, the Claude
56
+ # Code credential this session ran with, and the secrets of the project
57
+ # the checkout was booted for. The session itself never receives those.
54
58
  def secrets
55
59
  spec = sandbox_session.checkout_spec rescue nil
56
- [ spec&.dig(:token), *sandbox_session.runtime_environment(runner: runner).values ].compact
60
+ [ spec&.dig(:token), *sandbox_session.runtime_environment(runner: runner).values,
61
+ *sandbox_session.project_scrub_values ].compact
57
62
  end
58
63
 
59
64
  # Appends one stream-json event, scrubbed and bounded. Past MAX_EVENTS
@@ -102,6 +107,12 @@ module ActionAgent
102
107
  prompt: prompt,
103
108
  model: model,
104
109
  runner: runner,
110
+ credential_mode: credential_mode,
111
+ evaluation_run_id: evaluation_run_id,
112
+ evaluation_id: evaluation_run&.evaluation_id,
113
+ fix_item: fix_item,
114
+ verification_run_id: verification_run_id,
115
+ verification_error: verification_error,
105
116
  result: result,
106
117
  error_message: error_message,
107
118
  num_turns: num_turns,
@@ -127,7 +138,8 @@ module ActionAgent
127
138
  events: events.drop(after),
128
139
  events_offset: after,
129
140
  dropped_events_count: dropped_events_count.to_i,
130
- diff: finished? ? diff : nil
141
+ diff: finished? ? diff : nil,
142
+ verification: evaluation_run_id ? EvaluationFixComparison.new(self).summary : nil
131
143
  )
132
144
  end
133
145
 
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A pull request opened from a checkout sandbox's changes (see
5
+ # DraftPullRequestPublisher), and how its last publish went.
6
+ #
7
+ # A publish is one of four operations:
8
+ #
9
+ # create a new branch and a draft pull request for it
10
+ # update a new commit on the branch of an opened pull request,
11
+ # with +commit_message+ as its message
12
+ # open_draft a draft pull request for a branch that was published
13
+ # without one (see #branch_only?)
14
+ # open_regular a regular pull request for such a branch, which only an
15
+ # explicit request opens
16
+ #
17
+ # +status+ is how the last one went: queued, publishing, published,
18
+ # draft_refused (the branch was pushed, the draft was not opened) or
19
+ # failed. A publish still queued or publishing STALL_AFTER after its last
20
+ # write is failed as stalled by .fail_stalled!. +state+ and +draft+ are
21
+ # what GitHub last reported about the pull request, read again at most
22
+ # once per STATUS_REFRESH_INTERVAL.
23
+ #
24
+ # The +user_id+ is the user who published, also where the owner is the
25
+ # account.
26
+ class DraftPullRequest < ApplicationRecord
27
+ include Ownable
28
+ owned_by :account, :user
29
+
30
+ OPERATIONS = %w[create update open_draft open_regular].freeze
31
+ STATUSES = %w[queued publishing published draft_refused failed].freeze
32
+ CREDENTIAL_KINDS = %w[app oauth].freeze
33
+ STATES = %w[open closed merged].freeze
34
+ STATUS_REFRESH_INTERVAL = 1.minute
35
+ STALL_AFTER = 15.minutes
36
+ DEFAULT_UPDATE_MESSAGE = "Update from the sandbox"
37
+ MAX_TITLE_CHARACTERS = 256
38
+ MAX_BODY_CHARACTERS = 20_000
39
+
40
+ belongs_to :sandbox_session
41
+
42
+ validates :repository, :branch, :base_commit, :title, presence: true
43
+ validates :title, length: { maximum: MAX_TITLE_CHARACTERS }
44
+ validates :body, :commit_message, length: { maximum: MAX_BODY_CHARACTERS }
45
+ validates :operation, inclusion: { in: OPERATIONS }
46
+ validates :status, inclusion: { in: STATUSES }
47
+ validates :credential_kind, inclusion: { in: CREDENTIAL_KINDS }, allow_nil: true
48
+ validates :state, inclusion: { in: STATES }, allow_nil: true
49
+
50
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
51
+
52
+ # Fails every publish of the relation that is still queued or publishing
53
+ # STALL_AFTER after it was last written: its job died, or never ran.
54
+ #
55
+ # @return [Integer] how many were failed
56
+ def self.fail_stalled!(now: Time.current)
57
+ where(status: %w[queued publishing]).where(updated_at: ...(now - STALL_AFTER)).update_all(
58
+ status: "failed", error_code: "stalled", updated_at: now,
59
+ error_message: "The publish did not finish within #{STALL_AFTER.inspect}. Check the branch on GitHub, then publish again"
60
+ )
61
+ end
62
+
63
+ # JSON columns carry no default on MySQL or SQLite (see the migration).
64
+ def files
65
+ Array(super)
66
+ end
67
+
68
+ # Whether the last publish is still to run or running.
69
+ def in_progress?
70
+ status.in?(%w[queued publishing])
71
+ end
72
+
73
+ # Whether the pull request exists on GitHub.
74
+ def opened?
75
+ number.present?
76
+ end
77
+
78
+ # Whether the branch is on GitHub with no pull request for it: GitHub
79
+ # refused the draft, or opening it failed after the branch was pushed.
80
+ def branch_only?
81
+ head_commit.present? && !opened?
82
+ end
83
+
84
+ # The user who published, under the host's user model, or nil.
85
+ def publisher
86
+ user_class = ActionAgent.user_class&.safe_constantize
87
+ user_class && user_id ? user_class.find_by(id: user_id) : nil
88
+ end
89
+
90
+ # Claims the next status refresh for the caller when the last one is at
91
+ # least STATUS_REFRESH_INTERVAL old: true for exactly one of any callers
92
+ # racing for it.
93
+ def claim_status_refresh!(now: Time.current)
94
+ return false unless opened?
95
+
96
+ claimed = self.class.where(id: id)
97
+ .where("last_checked_at IS NULL OR last_checked_at < ?", now - STATUS_REFRESH_INTERVAL)
98
+ .update_all(last_checked_at: now)
99
+ self.last_checked_at = now if claimed == 1
100
+ claimed == 1
101
+ end
102
+
103
+ def summary
104
+ {
105
+ id: id,
106
+ sandbox_session_id: sandbox_session&.session_id,
107
+ repository: repository,
108
+ base_branch: base_branch,
109
+ branch: branch,
110
+ base_commit: base_commit,
111
+ head_commit: head_commit,
112
+ title: title,
113
+ body: body,
114
+ files: files.map { |file| file.slice("path", "status", "mode") },
115
+ operation: operation,
116
+ status: status,
117
+ error_code: error_code,
118
+ error_message: error_message,
119
+ credential_kind: credential_kind,
120
+ number: number,
121
+ url: url,
122
+ compare_url: compare_url,
123
+ state: state,
124
+ draft: draft,
125
+ last_checked_at: last_checked_at&.iso8601,
126
+ created_at: created_at&.iso8601,
127
+ updated_at: updated_at&.iso8601
128
+ }
129
+ end
130
+ end
131
+ end
@@ -11,6 +11,10 @@ module ActionAgent
11
11
  # agent's traces (error rate, latency); the llm_judge type asks a judge
12
12
  # model to score each sample and requires a configured provider.
13
13
  class Evaluation < ApplicationRecord
14
+ # Raised by #merge_scenarios! for a merge that would leave the suite
15
+ # holding more scenarios than its limit.
16
+ class ScenarioLimitExceeded < StandardError; end
17
+
14
18
  belongs_to :agent
15
19
  has_many :evaluation_runs, dependent: :destroy
16
20
  has_many :scenarios, class_name: "EvaluationScenario", dependent: :destroy
@@ -191,12 +195,92 @@ module ActionAgent
191
195
  scenarios.reload
192
196
  end
193
197
 
198
+ # Adds the scenarios of +attributes+ (ActiveAgent::Evals::ScenarioParser
199
+ # output) whose keys the suite lacks, and updates the ones whose keys it
200
+ # holds. A scenario whose key +attributes+ do not name is never touched,
201
+ # unlike #replace_scenarios!, which treats its input as the whole suite.
202
+ #
203
+ # - An existing key gets the given prompt, group, notes and
204
+ # expectations, and a group, notes or expectations the attributes
205
+ # leave out clears the stored one. It keeps its record, results,
206
+ # position and enabled flag unless the attributes set "position" or
207
+ # "enabled".
208
+ # - A new key is appended after the suite's last position, in input
209
+ # order, enabled unless the attributes say otherwise.
210
+ #
211
+ # ScenarioParser output carries each scenario's position within its own
212
+ # paste, which an existing key would take; remove "position" from parser
213
+ # output to keep the suite's order.
214
+ #
215
+ # Nothing is written when the merge raises.
216
+ #
217
+ # @param limit [Integer, nil] the most scenarios the suite may hold
218
+ # afterwards
219
+ # @raise [ArgumentError] when a scenario has no key or two share one
220
+ # @raise [ScenarioLimitExceeded] when the suite would pass +limit+
221
+ # @return [Hash{Symbol => Array<String>}] the keys under :added, :updated
222
+ # (an existing scenario that changed) and :unchanged
223
+ def merge_scenarios!(attributes, limit: nil)
224
+ keys = attributes.map { |attrs| attrs["key"].to_s }
225
+ raise ArgumentError, "every merged scenario needs a key" if keys.any?(&:blank?)
226
+
227
+ repeated = keys.tally.select { |_, count| count > 1 }.keys
228
+ raise ArgumentError, "scenario keys repeat: #{repeated.join(', ')}" if repeated.any?
229
+
230
+ merged = { added: [], updated: [], unchanged: [] }
231
+ # Locked, so two merges into one suite cannot both pass the limit or
232
+ # both add the same key.
233
+ with_lock do
234
+ existing = scenarios.where(key: keys).index_by(&:key)
235
+ added = keys.count { |key| !existing.key?(key) }
236
+ if limit && (held = scenarios.count) + added > limit
237
+ raise ScenarioLimitExceeded, "Scenario limit reached (#{limit} per evaluation): the evaluation holds #{held} " \
238
+ "and this merge adds #{added}"
239
+ end
240
+
241
+ next_position = (scenarios.maximum(:position) || -1) + 1
242
+ attributes.each do |attrs|
243
+ scenario = existing[attrs["key"].to_s]
244
+ if scenario.nil?
245
+ scenarios.create!(scenario_fields(attrs).merge(key: attrs["key"].to_s, position: next_position,
246
+ enabled: attrs.fetch("enabled", true)))
247
+ next_position += 1
248
+ merged[:added] << attrs["key"].to_s
249
+ next
250
+ end
251
+
252
+ fields = scenario_fields(attrs)
253
+ # A row stored without expectations reads them as {}, and is not
254
+ # changed by being given {}.
255
+ fields.delete(:expectations) if scenario.expectations == fields[:expectations]
256
+ scenario.assign_attributes(fields)
257
+ scenario.position = attrs["position"] if attrs.key?("position")
258
+ scenario.enabled = attrs["enabled"] if attrs.key?("enabled")
259
+ if scenario.changed?
260
+ scenario.save!
261
+ merged[:updated] << scenario.key
262
+ else
263
+ merged[:unchanged] << scenario.key
264
+ end
265
+ end
266
+ end
267
+ merged
268
+ ensure
269
+ # create! adds each record to the association, and a rolled-back one
270
+ # stays there as a new record that the evaluation's next save writes.
271
+ scenarios.reset
272
+ end
273
+
194
274
  def llm_criteria
195
275
  criteria.select { |c| c["type"] == "llm_judge" }
196
276
  end
197
277
 
198
278
  private
199
279
 
280
+ def scenario_fields(attrs)
281
+ { prompt: attrs["prompt"], group: attrs["group"], notes: attrs["notes"], expectations: attrs["expectations"] || {} }
282
+ end
283
+
200
284
  def validate_criteria
201
285
  if criteria.blank?
202
286
  # judge_defined evaluations start empty — the judge authors the KPIs