actionagent 1.8.1 → 1.9.1
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 +53 -6
- 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 +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
- data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -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 +140 -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 +623 -18
- 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 +26 -2
- 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 +39 -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 +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- 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/interaction_feedback.rb +24 -0
- 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 +200 -3
- 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 +297 -14
- 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/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_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/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_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- 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 +1218 -83
- 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 +276 -22
- 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 +119 -16
- 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 +155 -1
- 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 +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -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/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -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
|
@@ -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
|