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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +39 -10
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +72 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +207 -4
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +300 -16
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +153 -2
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -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", "@
|
|
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
|
|
|
@@ -22,8 +25,10 @@ module ActionAgent
|
|
|
22
25
|
# Per string inside an event: tool results can be whole files.
|
|
23
26
|
MAX_EVENT_STRING = 4_000
|
|
24
27
|
MAX_DIFF_BYTES = 500_000
|
|
28
|
+
RUNNERS = %w[claude_code codex].freeze
|
|
25
29
|
|
|
26
30
|
validates :prompt, presence: true, length: { maximum: MAX_PROMPT_CHARACTERS }
|
|
31
|
+
validates :runner, inclusion: { in: RUNNERS }
|
|
27
32
|
|
|
28
33
|
scope :recent, -> { order(created_at: :desc) }
|
|
29
34
|
|
|
@@ -47,11 +52,13 @@ module ActionAgent
|
|
|
47
52
|
finished_at.nil?
|
|
48
53
|
end
|
|
49
54
|
|
|
50
|
-
# The values that must never be stored: the checkout token
|
|
51
|
-
#
|
|
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.
|
|
52
58
|
def secrets
|
|
53
59
|
spec = sandbox_session.checkout_spec rescue nil
|
|
54
|
-
[ spec&.dig(:token), *sandbox_session.runtime_environment.values
|
|
60
|
+
[ spec&.dig(:token), *sandbox_session.runtime_environment(runner: runner).values,
|
|
61
|
+
*sandbox_session.project_scrub_values ].compact
|
|
55
62
|
end
|
|
56
63
|
|
|
57
64
|
# Appends one stream-json event, scrubbed and bounded. Past MAX_EVENTS
|
|
@@ -77,7 +84,8 @@ module ActionAgent
|
|
|
77
84
|
|
|
78
85
|
update!(
|
|
79
86
|
result: event["result"].to_s.presence && SecretScrubber.scrub(event["result"].to_s, secrets).truncate(MAX_EVENT_STRING * 4),
|
|
80
|
-
claude_session_id: event["session_id"],
|
|
87
|
+
claude_session_id: runner == "claude_code" ? event["session_id"] : nil,
|
|
88
|
+
runner_session_id: event["session_id"],
|
|
81
89
|
num_turns: event["num_turns"],
|
|
82
90
|
duration_ms: event["duration_ms"],
|
|
83
91
|
total_cost_usd: event["total_cost_usd"],
|
|
@@ -98,6 +106,13 @@ module ActionAgent
|
|
|
98
106
|
status: status,
|
|
99
107
|
prompt: prompt,
|
|
100
108
|
model: model,
|
|
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,
|
|
101
116
|
result: result,
|
|
102
117
|
error_message: error_message,
|
|
103
118
|
num_turns: num_turns,
|
|
@@ -123,7 +138,8 @@ module ActionAgent
|
|
|
123
138
|
events: events.drop(after),
|
|
124
139
|
events_offset: after,
|
|
125
140
|
dropped_events_count: dropped_events_count.to_i,
|
|
126
|
-
diff: finished? ? diff : nil
|
|
141
|
+
diff: finished? ? diff : nil,
|
|
142
|
+
verification: evaluation_run_id ? EvaluationFixComparison.new(self).summary : nil
|
|
127
143
|
)
|
|
128
144
|
end
|
|
129
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
|
|
@@ -34,6 +38,20 @@ module ActionAgent
|
|
|
34
38
|
validate :validate_criteria
|
|
35
39
|
|
|
36
40
|
scope :recent, -> { order(updated_at: :desc) }
|
|
41
|
+
# Archived evaluations keep their runs but leave the index and its
|
|
42
|
+
# tiles unless asked for (see #archive!). The mark lives in the config
|
|
43
|
+
# JSON, so the filter reads it with each database's own JSON path.
|
|
44
|
+
scope :archived, -> { where("#{archived_at_sql} IS NOT NULL") }
|
|
45
|
+
scope :unarchived, -> { where("#{archived_at_sql} IS NULL") }
|
|
46
|
+
|
|
47
|
+
# SQL reading config["archived_at"] on the connected database.
|
|
48
|
+
def self.archived_at_sql
|
|
49
|
+
case connection.adapter_name.to_s.downcase
|
|
50
|
+
when /postgres/ then "#{quoted_table_name}.config ->> 'archived_at'"
|
|
51
|
+
when /mysql|trilogy/ then "JSON_UNQUOTE(JSON_EXTRACT(#{quoted_table_name}.config, '$.archived_at'))"
|
|
52
|
+
else "json_extract(#{quoted_table_name}.config, '$.archived_at')"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
37
55
|
|
|
38
56
|
# MySQL cannot give a JSON column a default, so a row inserted there
|
|
39
57
|
# without `criteria` or `config` reads back nil. Both readers answer with
|
|
@@ -50,6 +68,56 @@ module ActionAgent
|
|
|
50
68
|
evaluation_runs.order(created_at: :desc).first
|
|
51
69
|
end
|
|
52
70
|
|
|
71
|
+
# The newest run that finished: the one the evaluation's pass rate
|
|
72
|
+
# describes. A newer run still pending, or one that failed, shows
|
|
73
|
+
# beside it and never in its place. Read from the loaded association
|
|
74
|
+
# when the index preloaded it.
|
|
75
|
+
def headline_run
|
|
76
|
+
if evaluation_runs.loaded?
|
|
77
|
+
evaluation_runs.select(&:complete?).max_by { |run| [ run.created_at, run.id ] }
|
|
78
|
+
else
|
|
79
|
+
evaluation_runs.complete.order(created_at: :desc, id: :desc).first
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- archiving ---------------------------------------------------------
|
|
84
|
+
#
|
|
85
|
+
# An evaluation nobody maintains — its suite superseded, its agent
|
|
86
|
+
# retired — keeps its history but stops counting: the index leaves it
|
|
87
|
+
# out, and so do the pooled pass rate and the agent's scorecard. The
|
|
88
|
+
# mark is config["archived_at"]; a new run or a published report clears
|
|
89
|
+
# it, since either says the evaluation is alive after all.
|
|
90
|
+
|
|
91
|
+
def archived_at
|
|
92
|
+
value = config["archived_at"]
|
|
93
|
+
value.present? ? Time.zone.parse(value.to_s) : nil
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def archived?
|
|
99
|
+
config["archived_at"].present?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def archive!
|
|
103
|
+
update!(config: config.merge("archived_at" => Time.current.iso8601))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def unarchive!
|
|
107
|
+
return unless archived?
|
|
108
|
+
|
|
109
|
+
update!(config: config.except("archived_at"))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Where this evaluation stands against the agent as it is now: whether
|
|
113
|
+
# its headline run scored the current version (EvaluationStanding).
|
|
114
|
+
# Memoized per instance; the index preloads what it needs.
|
|
115
|
+
def standing_info
|
|
116
|
+
@standing_info ||= EvaluationStanding.new(self)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
attr_writer :standing_info
|
|
120
|
+
|
|
53
121
|
def judge_defined?
|
|
54
122
|
judge_kind == "judge_defined"
|
|
55
123
|
end
|
|
@@ -127,12 +195,92 @@ module ActionAgent
|
|
|
127
195
|
scenarios.reload
|
|
128
196
|
end
|
|
129
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
|
+
|
|
130
274
|
def llm_criteria
|
|
131
275
|
criteria.select { |c| c["type"] == "llm_judge" }
|
|
132
276
|
end
|
|
133
277
|
|
|
134
278
|
private
|
|
135
279
|
|
|
280
|
+
def scenario_fields(attrs)
|
|
281
|
+
{ prompt: attrs["prompt"], group: attrs["group"], notes: attrs["notes"], expectations: attrs["expectations"] || {} }
|
|
282
|
+
end
|
|
283
|
+
|
|
136
284
|
def validate_criteria
|
|
137
285
|
if criteria.blank?
|
|
138
286
|
# judge_defined evaluations start empty — the judge authors the KPIs
|