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
|
@@ -14,6 +14,14 @@ module ActionAgent
|
|
|
14
14
|
# (Api::TraceReportsController). A record outside that scope reads as
|
|
15
15
|
# nonexistent.
|
|
16
16
|
#
|
|
17
|
+
# Two tools write: `evaluations_create` and `scenarios_merge`, so a
|
|
18
|
+
# harness that has read or browsed the app can seed the suite it then
|
|
19
|
+
# runs. Both ask ActionAgent.permitted? for :replace_scenarios as the
|
|
20
|
+
# key's user, and neither removes, disables or reorders a scenario it
|
|
21
|
+
# was not given. `explorations_submit` stores candidate scenarios for a
|
|
22
|
+
# person to review on the dashboard instead (Exploration), which needs
|
|
23
|
+
# only the key: accepting them there is what asks :replace_scenarios.
|
|
24
|
+
#
|
|
17
25
|
# Names are a noun family followed by a verb (`evaluations_list`,
|
|
18
26
|
# `traces_get`). Host schema tools are always `find_`, `count_` or `get_`
|
|
19
27
|
# plus a model name, and agent tools are `run_<slug>`, so no host model or
|
|
@@ -28,10 +36,16 @@ module ActionAgent
|
|
|
28
36
|
include EvaluationRunStarting
|
|
29
37
|
|
|
30
38
|
NAMES = %w[
|
|
31
|
-
evaluations_list evaluations_get
|
|
32
|
-
traces_search traces_get
|
|
39
|
+
evaluations_list evaluations_get evaluations_create scenarios_merge explorations_submit evaluations_run
|
|
40
|
+
evaluation_runs_get evaluation_runs_compare traces_search traces_get input_requests_list input_requests_answer
|
|
41
|
+
catalogs_list catalogs_import catalog_set_run
|
|
33
42
|
].freeze
|
|
34
43
|
|
|
44
|
+
# The kinds input_requests_answer answers. An approval and a secret are
|
|
45
|
+
# answered in the dashboard only: a secret must not pass through the
|
|
46
|
+
# client's model, and an approval is a person's decision.
|
|
47
|
+
MCP_ANSWERABLE_KINDS = %w[text choice].freeze
|
|
48
|
+
|
|
35
49
|
LIST_LIMIT = 20
|
|
36
50
|
MAX_LIST_LIMIT = 50
|
|
37
51
|
SCENARIO_LIMIT = 200
|
|
@@ -47,6 +61,10 @@ module ActionAgent
|
|
|
47
61
|
MAX_STRING = 1_000
|
|
48
62
|
# Owned credentials read for scrubbing, per kind.
|
|
49
63
|
SECRET_LOOKUP_LIMIT = 100
|
|
64
|
+
# The longest scenario key, scenario group or judge model a write tool
|
|
65
|
+
# accepts, as the report collector does. Each is a string column,
|
|
66
|
+
# VARCHAR(255) on MySQL.
|
|
67
|
+
MAX_LABEL = 200
|
|
50
68
|
|
|
51
69
|
SELECTION_PROPERTIES = {
|
|
52
70
|
scenario_ids: { type: "array", items: { type: "integer" }, description: "Replay only these scenarios, by id" },
|
|
@@ -60,6 +78,53 @@ module ActionAgent
|
|
|
60
78
|
}
|
|
61
79
|
}.freeze
|
|
62
80
|
|
|
81
|
+
# An expectation list of an explorations_submit candidate.
|
|
82
|
+
EXPLORATION_EXPECTATION_LIST = {
|
|
83
|
+
type: "array", maxItems: Exploration::MAX_ITEMS, items: { type: "string", maxLength: Exploration::MAX_LABEL }
|
|
84
|
+
}.freeze
|
|
85
|
+
|
|
86
|
+
# The scenarios evaluations_create and scenarios_merge accept, in the
|
|
87
|
+
# forms POST /api/evaluations accepts them.
|
|
88
|
+
SCENARIO_PROPERTIES = {
|
|
89
|
+
scenarios: {
|
|
90
|
+
type: "array",
|
|
91
|
+
description: "Scenarios as objects. Give `scenarios` or `scenarios_text`.",
|
|
92
|
+
items: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
prompt: { type: "string", description: "The message a user would send the agent" },
|
|
96
|
+
key: {
|
|
97
|
+
type: "string",
|
|
98
|
+
description: "A stable key. A scenario whose key the evaluation holds is replaced by this one, " \
|
|
99
|
+
"keeping its results, position and enabled flag; one without a key gets the next " \
|
|
100
|
+
"unused <group>_<n>"
|
|
101
|
+
},
|
|
102
|
+
group: { type: "string", description: "The group of related scenarios this one belongs to" },
|
|
103
|
+
notes: { type: "string", description: "What a good answer does: the judge grades the answer against it" },
|
|
104
|
+
tools: { type: "array", items: { type: "string" }, description: "A passing answer calls at least one of these tools" },
|
|
105
|
+
contains: { type: "array", items: { type: "string" }, description: "Patterns the answer must contain" },
|
|
106
|
+
not_contains: { type: "array", items: { type: "string" }, description: "Patterns the answer must not contain" }
|
|
107
|
+
},
|
|
108
|
+
required: [ "prompt" ]
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
scenarios_text: {
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "Scenarios as text: one message per line, `# Heading` lines starting a group, and " \
|
|
114
|
+
"`| tools: a, b | contains: x | not_contains: y | notes: n | key: k` options after a line; or a " \
|
|
115
|
+
"JSON array, or a YAML or JSON suite document with groups"
|
|
116
|
+
},
|
|
117
|
+
include_production_only: {
|
|
118
|
+
type: "boolean",
|
|
119
|
+
description: "Keep a suite document's production_only scenarios, which are left out by default"
|
|
120
|
+
},
|
|
121
|
+
key_prefix: {
|
|
122
|
+
type: "string",
|
|
123
|
+
description: "A namespace for generated keys: `batch2` makes the first generated Orders key batch2_orders_1. " \
|
|
124
|
+
"Keys you give are kept as they are"
|
|
125
|
+
}
|
|
126
|
+
}.freeze
|
|
127
|
+
|
|
63
128
|
DEFINITIONS = [
|
|
64
129
|
{
|
|
65
130
|
name: "evaluations_list",
|
|
@@ -83,6 +148,107 @@ module ActionAgent
|
|
|
83
148
|
required: [ "evaluation_id" ]
|
|
84
149
|
}
|
|
85
150
|
},
|
|
151
|
+
{
|
|
152
|
+
name: "evaluations_create",
|
|
153
|
+
description: "Create an evaluation of one of this key's agents: a scenario suite, which replays its scenarios " \
|
|
154
|
+
"through the agent, or, without scenarios, criteria scored against the agent's recorded " \
|
|
155
|
+
"generations. Runs nothing; start a run with evaluations_run. Add scenarios later with " \
|
|
156
|
+
"scenarios_merge.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
agent: { type: "string", description: "The agent's slug or id" },
|
|
161
|
+
name: { type: "string", description: "The evaluation's name, unique among the agent's evaluations" },
|
|
162
|
+
judge_kind: {
|
|
163
|
+
type: "string", enum: Evaluation::JUDGE_KINDS,
|
|
164
|
+
description: "rules (the default) scores with rule criteria alone; llm adds a judge model; " \
|
|
165
|
+
"judge_defined lets the judge write the criteria on the first run"
|
|
166
|
+
},
|
|
167
|
+
judge_model: { type: "string", description: "The model the judge runs on" },
|
|
168
|
+
criteria: {
|
|
169
|
+
type: "array",
|
|
170
|
+
description: "Criteria every result is scored on. Rule-based defaults (an answer is present, long " \
|
|
171
|
+
"enough, fast enough and within a token budget) when omitted",
|
|
172
|
+
items: {
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: {
|
|
175
|
+
key: { type: "string", description: "The criterion's name; its type when omitted" },
|
|
176
|
+
type: { type: "string", enum: Evaluation::CRITERION_TYPES },
|
|
177
|
+
config: { type: "object", description: "The type's settings, such as { \"chars\": 40 } for min_length" }
|
|
178
|
+
},
|
|
179
|
+
required: [ "type" ]
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
compare_models: { type: "array", items: { type: "string" }, description: "Candidate models a run compares" }
|
|
183
|
+
}.merge(SCENARIO_PROPERTIES),
|
|
184
|
+
required: %w[agent name]
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "scenarios_merge",
|
|
189
|
+
description: "Add scenarios to an evaluation, and update the ones whose keys it already holds. A scenario " \
|
|
190
|
+
"the call does not name is left exactly as it is, results included; nothing is removed or " \
|
|
191
|
+
"disabled. A scenario without a key gets the next <group>_<n> the evaluation does not use. " \
|
|
192
|
+
"Returns the added, updated and unchanged keys.",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: "object",
|
|
195
|
+
properties: { evaluation_id: { type: "integer", description: "The evaluation's id" } }.merge(SCENARIO_PROPERTIES),
|
|
196
|
+
required: [ "evaluation_id" ]
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "explorations_submit",
|
|
201
|
+
description: "Submit candidate scenarios you found by reading or browsing a project's app, for a person to " \
|
|
202
|
+
"review on the dashboard. Each is checked against the tools of the agent the project evaluates: " \
|
|
203
|
+
"the result gives each candidate's verdict (answerable, needs_tool with the missing tools, or " \
|
|
204
|
+
"unverified when the tools could not be read) so you can revise, and the review link. Nothing " \
|
|
205
|
+
"reaches the evaluation until a person accepts it. Pass exploration_id to add to an exploration " \
|
|
206
|
+
"you submitted earlier.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
project_id: { type: "integer", description: "The project whose app you explored" },
|
|
211
|
+
evaluation_id: { type: "integer", description: "An evaluation to propose scenarios for, without a project" },
|
|
212
|
+
exploration_id: {
|
|
213
|
+
type: "integer",
|
|
214
|
+
description: "An exploration submitted from outside the dashboard for this key's owner, to add these candidates to"
|
|
215
|
+
},
|
|
216
|
+
candidates: {
|
|
217
|
+
type: "array",
|
|
218
|
+
description: "At most #{Exploration::MAX_CANDIDATES} per exploration, and " \
|
|
219
|
+
"#{Exploration::MAX_BYTES / 1.megabyte} MiB of JSON per call and per exploration",
|
|
220
|
+
items: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
prompt: {
|
|
224
|
+
type: "string", maxLength: Exploration::MAX_STRING,
|
|
225
|
+
description: "A question a user of the app would ask its agent"
|
|
226
|
+
},
|
|
227
|
+
group: { type: "string", description: "The scenario group it belongs to" },
|
|
228
|
+
rubric: {
|
|
229
|
+
type: "string", maxLength: Exploration::MAX_STRING,
|
|
230
|
+
description: "What a good answer does; the judge grades the answer against it. Not click steps"
|
|
231
|
+
},
|
|
232
|
+
tools: { **EXPLORATION_EXPECTATION_LIST, description: "Tools a good answer calls" },
|
|
233
|
+
contains: { **EXPLORATION_EXPECTATION_LIST, description: "Patterns the answer must contain" },
|
|
234
|
+
not_contains: { **EXPLORATION_EXPECTATION_LIST, description: "Patterns the answer must not contain" },
|
|
235
|
+
provenance: {
|
|
236
|
+
type: "object",
|
|
237
|
+
description: "How you found it, shown to the reviewer and never to the judge",
|
|
238
|
+
properties: {
|
|
239
|
+
urls: { type: "array", items: { type: "string" } },
|
|
240
|
+
steps: { type: "array", items: { type: "string" } },
|
|
241
|
+
screenshots: { type: "array", items: { type: "string" } }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
required: [ "prompt" ]
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
required: [ "candidates" ]
|
|
250
|
+
}
|
|
251
|
+
},
|
|
86
252
|
{
|
|
87
253
|
name: "evaluations_run",
|
|
88
254
|
description: "Start a run of an evaluation. A scenario suite replays each scenario through the agent in the " \
|
|
@@ -153,9 +319,80 @@ module ActionAgent
|
|
|
153
319
|
properties: { trace_id: { type: "string", description: "The trace's id, its OpenTelemetry trace id, or that id's first 8 characters" } },
|
|
154
320
|
required: [ "trace_id" ]
|
|
155
321
|
}
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "input_requests_list",
|
|
325
|
+
description: "List the requests for input this key's paused agent runs are waiting on, newest first: each " \
|
|
326
|
+
"request's id, kind (text, choice, confirm or secret), prompt, options, agent, run and expiry.",
|
|
327
|
+
inputSchema: {
|
|
328
|
+
type: "object",
|
|
329
|
+
properties: {
|
|
330
|
+
run_id: { type: "integer", description: "Only this run's requests" },
|
|
331
|
+
limit: { type: "integer", description: "At most this many (default #{LIST_LIMIT}, max #{MAX_LIST_LIMIT})" }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: "input_requests_answer",
|
|
337
|
+
description: "Answer a text or choice request for input, which resumes its run once every request of the " \
|
|
338
|
+
"pause is answered. A choice answer must be one of the request's options. Confirm and secret " \
|
|
339
|
+
"requests are answered in the dashboard only.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
input_request_id: { type: "integer", description: "The request's id" },
|
|
344
|
+
answer: { type: "string", description: "The answer" }
|
|
345
|
+
},
|
|
346
|
+
required: [ "input_request_id", "answer" ]
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: "catalogs_list",
|
|
351
|
+
description: "The owner's scenario catalogs: YAML catalogs of evaluation scenarios, each a set of products " \
|
|
352
|
+
"(an agent or a project under test) with named sets of scenarios, with their keys, sources, " \
|
|
353
|
+
"digests and counts.",
|
|
354
|
+
inputSchema: { type: "object", properties: {} }
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: "catalogs_import",
|
|
358
|
+
description: "Import a scenario catalog from its YAML document (`catalog:`, `products:` with `sets:` of " \
|
|
359
|
+
"`scenarios:`; a suite document with `groups:` is one product). Products, sets and scenarios are " \
|
|
360
|
+
"matched by key and replaced, so importing the same document twice changes nothing. A product's " \
|
|
361
|
+
"`agent:` names one of the owner's agents; `repository:` or `project:` names a project. Needs the " \
|
|
362
|
+
"replace_scenarios permission.",
|
|
363
|
+
inputSchema: {
|
|
364
|
+
type: "object",
|
|
365
|
+
properties: {
|
|
366
|
+
document: { type: "string", description: "The catalog as YAML" },
|
|
367
|
+
name: { type: "string", description: "The catalog key when the document sets no `catalog:`" }
|
|
368
|
+
},
|
|
369
|
+
required: [ "document" ]
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "catalog_set_run",
|
|
374
|
+
description: "Run one set of a catalog as an evaluation of the agent under test: the product's agent, the " \
|
|
375
|
+
"agent given, or a project's agent, against that project's running sandbox and browser when " \
|
|
376
|
+
"project_id is given (boot the project on the dashboard first). The run comes back pending; poll " \
|
|
377
|
+
"evaluation_runs_get with the evaluation's id.",
|
|
378
|
+
inputSchema: {
|
|
379
|
+
type: "object",
|
|
380
|
+
properties: {
|
|
381
|
+
catalog: { type: "string", description: "The catalog's key" },
|
|
382
|
+
product: { type: "string", description: "The product's key" },
|
|
383
|
+
set: { type: "string", description: "The set's key" },
|
|
384
|
+
agent: { type: "string", description: "The agent to run against, by id, slug or name; the product's own when omitted" },
|
|
385
|
+
project_id: { type: "integer", description: "A project whose booted app and browser every replay reaches" },
|
|
386
|
+
keys: { type: "array", items: { type: "string" }, description: "Replay only these scenarios, by key" },
|
|
387
|
+
models: { type: "array", items: { type: "string" }, description: "Candidate models to compare; the agent's own model when omitted" }
|
|
388
|
+
},
|
|
389
|
+
required: %w[catalog product set]
|
|
390
|
+
}
|
|
156
391
|
}
|
|
157
392
|
].freeze
|
|
158
393
|
|
|
394
|
+
OBSERVED_AGENT_REFUSAL = "Observed agents are read-only — duplicate this agent to create an executable copy"
|
|
395
|
+
|
|
159
396
|
# Raised by a tool for a call the client can correct. Answered as a tool
|
|
160
397
|
# result with isError.
|
|
161
398
|
class ToolError < StandardError; end
|
|
@@ -181,11 +418,19 @@ module ActionAgent
|
|
|
181
418
|
case name
|
|
182
419
|
when "evaluations_list" then evaluations_list_tool
|
|
183
420
|
when "evaluations_get" then evaluations_get_tool
|
|
421
|
+
when "evaluations_create" then evaluations_create_tool
|
|
422
|
+
when "scenarios_merge" then scenarios_merge_tool
|
|
423
|
+
when "explorations_submit" then explorations_submit_tool
|
|
184
424
|
when "evaluations_run" then evaluations_run_tool
|
|
185
425
|
when "evaluation_runs_get" then evaluation_runs_get_tool
|
|
186
426
|
when "evaluation_runs_compare" then evaluation_runs_compare_tool
|
|
187
427
|
when "traces_search" then traces_search_tool
|
|
188
428
|
when "traces_get" then traces_get_tool
|
|
429
|
+
when "input_requests_list" then input_requests_list_tool
|
|
430
|
+
when "input_requests_answer" then input_requests_answer_tool
|
|
431
|
+
when "catalogs_list" then catalogs_list_tool
|
|
432
|
+
when "catalogs_import" then catalogs_import_tool
|
|
433
|
+
when "catalog_set_run" then catalog_set_run_tool
|
|
189
434
|
end
|
|
190
435
|
end
|
|
191
436
|
|
|
@@ -230,6 +475,232 @@ module ActionAgent
|
|
|
230
475
|
}
|
|
231
476
|
end
|
|
232
477
|
|
|
478
|
+
# Builds the evaluation as POST /api/evaluations does, scenarios
|
|
479
|
+
# before the save, because an evaluation with neither criteria nor
|
|
480
|
+
# scenarios is invalid. Runs nothing, so neither the execution switch
|
|
481
|
+
# nor the execution quota applies.
|
|
482
|
+
def evaluations_create_tool
|
|
483
|
+
agent = resolve_tool_agent!(required_tool_argument!(:agent))
|
|
484
|
+
name = required_tool_argument!(:name).to_s.strip
|
|
485
|
+
if name.length > EvaluationReportImport::MAX_EVALUATION_NAME
|
|
486
|
+
raise ToolError, "name is longer than #{EvaluationReportImport::MAX_EVALUATION_NAME} characters"
|
|
487
|
+
end
|
|
488
|
+
raise ToolError, "judge_model is longer than #{MAX_LABEL} characters" if tool_argument(:judge_model).to_s.length > MAX_LABEL
|
|
489
|
+
|
|
490
|
+
attributes = tool_scenario_attributes
|
|
491
|
+
if attributes.size > EvaluationReportImport::MAX_SCENARIOS_PER_EVALUATION
|
|
492
|
+
raise ToolError, "Scenario limit reached (#{EvaluationReportImport::MAX_SCENARIOS_PER_EVALUATION} per " \
|
|
493
|
+
"evaluation): this call gives #{attributes.size}"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
evaluation = build_tool_evaluation(agent, name, attributes)
|
|
497
|
+
authorize_scenario_write!(evaluation)
|
|
498
|
+
|
|
499
|
+
# Locked, so two creates for one agent cannot both pass the limit.
|
|
500
|
+
agent.with_lock do
|
|
501
|
+
if agent.evaluations.count >= EvaluationReportImport::MAX_EVALUATIONS_PER_AGENT
|
|
502
|
+
raise ToolError, "Evaluation limit reached (#{EvaluationReportImport::MAX_EVALUATIONS_PER_AGENT} for this " \
|
|
503
|
+
"agent); remove evaluations on the dashboard first"
|
|
504
|
+
end
|
|
505
|
+
raise ToolError, evaluation.errors.full_messages.to_sentence unless evaluation.save
|
|
506
|
+
|
|
507
|
+
# Checked once saved, because a host adapter resolver is handed a
|
|
508
|
+
# persisted evaluation; the refusal rolls the save back.
|
|
509
|
+
refuse_unexecutable_suite!(evaluation) if attributes.any?
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
{
|
|
513
|
+
evaluation: EvaluationSerializer.summary(evaluation),
|
|
514
|
+
added: evaluation.scenarios.ordered.pluck(:key)
|
|
515
|
+
}
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# A scenario given without a key gets the next <group>_<n> the suite
|
|
519
|
+
# does not hold, so a second batch never lands on the first one's keys.
|
|
520
|
+
def scenarios_merge_tool
|
|
521
|
+
evaluation = find_tool_evaluation!
|
|
522
|
+
authorize_scenario_write!(evaluation)
|
|
523
|
+
refuse_unexecutable_suite!(evaluation)
|
|
524
|
+
|
|
525
|
+
merged = evaluation.with_lock do
|
|
526
|
+
attributes = tool_scenario_attributes(reserved_keys: evaluation.scenarios.pluck(:key))
|
|
527
|
+
raise ToolError, "Give the scenarios to merge as scenarios or scenarios_text" if attributes.empty?
|
|
528
|
+
|
|
529
|
+
evaluation.merge_scenarios!(attributes.map { |attrs| attrs.except("position") },
|
|
530
|
+
limit: EvaluationReportImport::MAX_SCENARIOS_PER_EVALUATION)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
{
|
|
534
|
+
evaluation: { id: evaluation.id, name: evaluation.name },
|
|
535
|
+
added: merged[:added],
|
|
536
|
+
updated: merged[:updated],
|
|
537
|
+
unchanged: merged[:unchanged],
|
|
538
|
+
scenario_count: evaluation.scenarios.count
|
|
539
|
+
}
|
|
540
|
+
rescue Evaluation::ScenarioLimitExceeded, ActiveRecord::RecordInvalid => e
|
|
541
|
+
raise ToolError, e.message
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Candidates are stored as Exploration#add_candidates! stores them:
|
|
545
|
+
# scrubbed, bounded and checked against the target agent's tools.
|
|
546
|
+
def explorations_submit_tool
|
|
547
|
+
list = tool_argument(:candidates)
|
|
548
|
+
raise ToolError, "candidates must be an array of candidate objects" unless list.is_a?(Array)
|
|
549
|
+
|
|
550
|
+
list = list.map { |entry| entry.respond_to?(:to_unsafe_h) ? entry.to_unsafe_h : entry }
|
|
551
|
+
exploration = tool_exploration
|
|
552
|
+
stored = exploration.persisted? ? exploration.add_candidates!(list) : exploration.save_with_candidates!(list)
|
|
553
|
+
|
|
554
|
+
{
|
|
555
|
+
exploration: exploration.summary,
|
|
556
|
+
candidates: stored.map { |candidate| candidate.slice("id", "prompt", "verdict", "missing_tools", "state") },
|
|
557
|
+
review_url: "#{request.base_url}#{request.script_name}/explorations/#{exploration.id}"
|
|
558
|
+
}
|
|
559
|
+
rescue Exploration::InvalidCandidate, Exploration::CandidateLimitExceeded => e
|
|
560
|
+
raise ToolError, e.message
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# The external exploration exploration_id names, or a new one for the
|
|
564
|
+
# project or evaluation the call names.
|
|
565
|
+
def tool_exploration
|
|
566
|
+
if (id = tool_argument(:exploration_id)).present?
|
|
567
|
+
return owned(Exploration).find_by(id: id.to_s, source: "external") ||
|
|
568
|
+
raise(ToolError, "No exploration #{id.to_s.truncate(32)} submitted from outside the dashboard was found")
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
attributes = { source: "external", status: "review", started_at: Time.current, finished_at: Time.current }
|
|
572
|
+
if (project_id = tool_argument(:project_id)).present?
|
|
573
|
+
project = owned(Project).find_by(id: project_id.to_s) or
|
|
574
|
+
raise ToolError, "No project #{project_id.to_s.truncate(32)} was found"
|
|
575
|
+
Exploration.build_for(project: project, **attributes)
|
|
576
|
+
elsif tool_argument(:evaluation_id).present?
|
|
577
|
+
evaluation = find_tool_evaluation!
|
|
578
|
+
refuse_unexecutable_suite!(evaluation)
|
|
579
|
+
Exploration.build_for(evaluation: evaluation, user: current_user, account: current_account, **attributes)
|
|
580
|
+
else
|
|
581
|
+
raise MCPController::McpError.new("Give project_id, evaluation_id or exploration_id", MCPController::JSONRPC_INVALID_PARAMS)
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def build_tool_evaluation(agent, name, attributes)
|
|
586
|
+
judge_kind = tool_argument(:judge_kind).to_s.presence || "rules"
|
|
587
|
+
criteria = tool_criteria
|
|
588
|
+
criteria = EvaluationsController::DEFAULT_CRITERIA.deep_dup if criteria.empty? && judge_kind != "judge_defined"
|
|
589
|
+
compare_models = tool_compare_models
|
|
590
|
+
|
|
591
|
+
evaluation = Evaluation.new(
|
|
592
|
+
agent: agent,
|
|
593
|
+
name: name,
|
|
594
|
+
judge_kind: judge_kind,
|
|
595
|
+
judge_model: tool_argument(:judge_model).to_s.presence,
|
|
596
|
+
criteria: criteria,
|
|
597
|
+
config: compare_models.any? ? { "compare_models" => compare_models } : {}
|
|
598
|
+
)
|
|
599
|
+
attributes.each_with_index do |attrs, index|
|
|
600
|
+
evaluation.scenarios.build(
|
|
601
|
+
key: attrs["key"], prompt: attrs["prompt"], group: attrs["group"], notes: attrs["notes"],
|
|
602
|
+
expectations: attrs["expectations"] || {}, position: index
|
|
603
|
+
)
|
|
604
|
+
end
|
|
605
|
+
evaluation
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# The scenarios the call gives, parsed as POST /api/evaluations parses
|
|
609
|
+
# them; empty when it gives none. Generated keys skip +reserved_keys+.
|
|
610
|
+
def tool_scenario_attributes(reserved_keys: [])
|
|
611
|
+
list = tool_argument(:scenarios)
|
|
612
|
+
text = tool_argument(:scenarios_text).to_s
|
|
613
|
+
return [] if list.blank? && text.blank?
|
|
614
|
+
|
|
615
|
+
source =
|
|
616
|
+
if list.present?
|
|
617
|
+
raise ToolError, "scenarios must be an array of scenario objects" unless list.is_a?(Array)
|
|
618
|
+
|
|
619
|
+
list.map { |entry| entry.respond_to?(:to_unsafe_h) ? entry.to_unsafe_h : entry }.to_json
|
|
620
|
+
else
|
|
621
|
+
text
|
|
622
|
+
end
|
|
623
|
+
parsed = ActiveAgent::Evals::ScenarioParser.parse(
|
|
624
|
+
source,
|
|
625
|
+
include_production_only: boolean_argument(:include_production_only),
|
|
626
|
+
reserved_keys: reserved_keys,
|
|
627
|
+
key_prefix: tool_argument(:key_prefix).to_s
|
|
628
|
+
)
|
|
629
|
+
raise ToolError, "No scenarios matched the import. Check the text or the include_production_only selection." if parsed.empty?
|
|
630
|
+
|
|
631
|
+
parsed.each { |attrs| refuse_oversized_scenario!(attrs) }
|
|
632
|
+
refuse_oversized_batch!(parsed)
|
|
633
|
+
parsed
|
|
634
|
+
rescue ActiveAgent::Evals::ScenarioParser::ParseError => e
|
|
635
|
+
raise ToolError, e.message
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Raises for a scenario whose key or group is longer than MAX_LABEL
|
|
639
|
+
# characters, or whose prompt or notes are larger than a MySQL TEXT
|
|
640
|
+
# column holds.
|
|
641
|
+
def refuse_oversized_scenario!(attrs)
|
|
642
|
+
label = attrs["key"].to_s.truncate(64)
|
|
643
|
+
%w[key group].each do |field|
|
|
644
|
+
next if attrs[field].to_s.length <= MAX_LABEL
|
|
645
|
+
|
|
646
|
+
raise ToolError, "Scenario #{label}: #{field} is longer than #{MAX_LABEL} characters"
|
|
647
|
+
end
|
|
648
|
+
%w[prompt notes].each do |field|
|
|
649
|
+
next if attrs[field].to_s.bytesize <= EvaluationReportImport::TEXT_BYTES
|
|
650
|
+
|
|
651
|
+
raise ToolError, "Scenario #{label}: #{field} is larger than #{EvaluationReportImport::TEXT_BYTES} bytes"
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Raises when the scenarios one call gives total more than the report
|
|
656
|
+
# collector accepts in one report, measured as the JSON of the fields
|
|
657
|
+
# a scenario stores.
|
|
658
|
+
def refuse_oversized_batch!(parsed)
|
|
659
|
+
bytes = parsed.sum { |attrs| attrs.slice("key", "group", "prompt", "notes", "expectations").to_json.bytesize }
|
|
660
|
+
return if bytes <= EvaluationReportImport::MAX_BYTES
|
|
661
|
+
|
|
662
|
+
raise ToolError, "The scenarios in one call may total #{EvaluationReportImport::MAX_BYTES / 1.megabyte} MiB and " \
|
|
663
|
+
"these total #{(bytes.to_f / 1.megabyte).round(1)} MiB; give them over several scenarios_merge calls"
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def tool_criteria
|
|
667
|
+
raw = tool_argument(:criteria)
|
|
668
|
+
return [] if raw.blank?
|
|
669
|
+
unless raw.is_a?(Array) && raw.all? { |criterion| criterion.respond_to?(:permit) }
|
|
670
|
+
raise ToolError, "criteria must be an array of { key, type, config } objects"
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
raw.map do |criterion|
|
|
674
|
+
criterion.permit(:key, :type, config: {}).to_h.tap do |c|
|
|
675
|
+
c["key"] = c["key"].presence || c["type"]
|
|
676
|
+
c["config"] ||= {}
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def tool_compare_models
|
|
682
|
+
models = tool_argument(:compare_models)
|
|
683
|
+
models = models.to_s.split(",") unless models.is_a?(Array)
|
|
684
|
+
models.map(&:to_s).map(&:strip).reject(&:blank?)
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# Raises unless the key's user may change +evaluation+'s scenarios. With
|
|
688
|
+
# a permission checker set, a multi-tenant key that records no user is
|
|
689
|
+
# refused; with none, every key may (ActionAgent.permitted?).
|
|
690
|
+
def authorize_scenario_write!(evaluation)
|
|
691
|
+
return if ActionAgent.permitted?(current_user, :replace_scenarios, evaluation)
|
|
692
|
+
|
|
693
|
+
raise MCPController::McpError.new(
|
|
694
|
+
"This key's user may not change evaluation scenarios (replace_scenarios)", MCPController::JSONRPC_FORBIDDEN
|
|
695
|
+
)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Raises for a suite that could never run: its agent is observed and
|
|
699
|
+
# no host adapter replays it, the case evaluations_run refuses.
|
|
700
|
+
def refuse_unexecutable_suite!(evaluation)
|
|
701
|
+
raise ToolError, OBSERVED_AGENT_REFUSAL if unexecutable_scenario_run?(evaluation)
|
|
702
|
+
end
|
|
703
|
+
|
|
233
704
|
# Checked in the order Api::EvaluationsController#run checks a request:
|
|
234
705
|
# the execution switch, an executable agent, the execution quota, then
|
|
235
706
|
# the sandbox. Only a scenario suite executes the agent, so a sampling
|
|
@@ -239,9 +710,7 @@ module ActionAgent
|
|
|
239
710
|
evaluation = find_tool_evaluation!
|
|
240
711
|
if evaluation.scenario_suite?
|
|
241
712
|
raise MCPController::McpError.new("Agent execution is disabled on this dashboard") unless ActionAgent.execution_enabled?
|
|
242
|
-
if unexecutable_scenario_run?(evaluation)
|
|
243
|
-
raise ToolError, "Observed agents are read-only — duplicate this agent to create an executable copy"
|
|
244
|
-
end
|
|
713
|
+
raise ToolError, OBSERVED_AGENT_REFUSAL if unexecutable_scenario_run?(evaluation)
|
|
245
714
|
if (denial = ActionAgent.quota_denial(current_owner, :execution)).present?
|
|
246
715
|
raise MCPController::McpError.new(denial.is_a?(Hash) ? denial[:message] || denial["message"] : denial)
|
|
247
716
|
end
|
|
@@ -380,6 +849,40 @@ module ActionAgent
|
|
|
380
849
|
PayloadBounds.bound(payload, max_string: MAX_STRING, max_items: MAX_TRACE_SPANS)
|
|
381
850
|
end
|
|
382
851
|
|
|
852
|
+
def input_requests_list_tool
|
|
853
|
+
InputRequest.expire_overdue!(owner_input_requests)
|
|
854
|
+
scope = owner_input_requests.pending.for_listing.includes(:subject).recent
|
|
855
|
+
if (run_id = tool_argument(:run_id)).present?
|
|
856
|
+
scope = scope.where(subject_type: AgentRun.polymorphic_name, subject_id: run_id.to_s)
|
|
857
|
+
end
|
|
858
|
+
limit = integer_argument(:limit, default: LIST_LIMIT, min: 1, max: MAX_LIST_LIMIT)
|
|
859
|
+
|
|
860
|
+
{ input_requests: scope.limit(limit).map { |request| InputRequestSerializer.call(request) } }
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Answered as the key's user, under the permission check the dashboard
|
|
864
|
+
# applies.
|
|
865
|
+
def input_requests_answer_tool
|
|
866
|
+
id = tool_argument(:input_request_id)
|
|
867
|
+
raise MCPController::McpError.new("Missing required argument: input_request_id", MCPController::JSONRPC_INVALID_PARAMS) if id.blank?
|
|
868
|
+
raise MCPController::McpError.new("Agent execution is disabled on this dashboard") unless ActionAgent.execution_enabled?
|
|
869
|
+
|
|
870
|
+
request = owner_input_requests.find_by(id: id.to_s) or raise ToolError, "No input request #{id.to_s.truncate(32)} was found"
|
|
871
|
+
unless MCP_ANSWERABLE_KINDS.include?(request.kind)
|
|
872
|
+
raise ToolError, "Input request #{request.id} is a #{request.kind} request, which is answered in the dashboard, " \
|
|
873
|
+
"not over MCP: open the run #{request.subject_id} there to answer it"
|
|
874
|
+
end
|
|
875
|
+
raise ToolError, "You do not have permission to answer input request #{request.id}" unless request.answerable_by?(current_user)
|
|
876
|
+
|
|
877
|
+
begin
|
|
878
|
+
request.answer!(tool_argument(:answer), user: current_user)
|
|
879
|
+
rescue InputRequest::Conflict, InputRequest::InvalidAnswer => e
|
|
880
|
+
raise ToolError, e.message
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
{ input_request: InputRequestSerializer.call(request) }
|
|
884
|
+
end
|
|
885
|
+
|
|
383
886
|
# `trace_id` names the result's telemetry trace for traces_get, and is
|
|
384
887
|
# nil when telemetry recorded none for the replay.
|
|
385
888
|
def result_row(result, recorded_trace_ids)
|
|
@@ -422,10 +925,7 @@ module ActionAgent
|
|
|
422
925
|
# fixed, still_failing.
|
|
423
926
|
def comparison_change(before, after)
|
|
424
927
|
return "removed" if after.nil?
|
|
425
|
-
|
|
426
|
-
return after.passed? ? "unchanged_pass" : "regressed" if before.passed?
|
|
427
|
-
|
|
428
|
-
after.passed? ? "fixed" : "still_failing"
|
|
928
|
+
EvaluationFixComparison.change(before, after)
|
|
429
929
|
end
|
|
430
930
|
|
|
431
931
|
# Fix item links at the facade's absolute mount, since a client has no
|
|
@@ -438,9 +938,88 @@ module ActionAgent
|
|
|
438
938
|
Evaluation.joins(:agent).where(agent: owner_agents)
|
|
439
939
|
end
|
|
440
940
|
|
|
941
|
+
# The owner's scenario catalogs, as GET /api/scenario_catalogs lists them.
|
|
942
|
+
def catalogs_list_tool
|
|
943
|
+
catalogs = owned(ScenarioCatalog).ordered.includes(products: { sets: :scenarios }).limit(MAX_LIST_LIMIT)
|
|
944
|
+
{
|
|
945
|
+
catalogs: catalogs.map { |catalog| ScenarioCatalogSerializer.summary(catalog) },
|
|
946
|
+
storage_available: ScenarioCatalog.attachments_available?
|
|
947
|
+
}
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Imports a catalog document as POST /api/scenario_catalogs does: the
|
|
951
|
+
# owner's agents and projects resolve the products' targets.
|
|
952
|
+
def catalogs_import_tool
|
|
953
|
+
document = required_tool_argument!(:document).to_s
|
|
954
|
+
raise ToolError, "document is larger than #{ScenarioCatalog::MAX_BYTES} bytes" if document.bytesize > ScenarioCatalog::MAX_BYTES
|
|
955
|
+
|
|
956
|
+
authorize_catalog_write!(ScenarioCatalog.new)
|
|
957
|
+
catalog = ScenarioCatalogImport.new(
|
|
958
|
+
owner: current_owner, document: document, name: tool_argument(:name).to_s.presence,
|
|
959
|
+
source_kind: "api", agents: owner_agents, projects: owned(Project)
|
|
960
|
+
).call
|
|
961
|
+
|
|
962
|
+
{
|
|
963
|
+
catalog: ScenarioCatalogSerializer.summary(catalog),
|
|
964
|
+
products: catalog.products.map do |product|
|
|
965
|
+
{ key: product.key, agent: product.agent&.name, project_id: product.project_id,
|
|
966
|
+
sets: product.sets.map { |set| { key: set.key, scenario_count: set.scenarios.size } } }
|
|
967
|
+
end
|
|
968
|
+
}
|
|
969
|
+
rescue ScenarioCatalogImport::Invalid => e
|
|
970
|
+
raise ToolError, e.message
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# Runs one set as POST /api/scenario_catalogs/:id/sets/:set_id/run
|
|
974
|
+
# does, gated as evaluations_run is. A project's sandbox must already
|
|
975
|
+
# be running: booting one is a dashboard action, not a tool's.
|
|
976
|
+
def catalog_set_run_tool
|
|
977
|
+
catalog = owned(ScenarioCatalog).find_by(key: required_tool_argument!(:catalog).to_s) ||
|
|
978
|
+
raise(ToolError, "No catalog #{tool_argument(:catalog).inspect}; catalogs_list names them")
|
|
979
|
+
product = catalog.products.find_by(key: required_tool_argument!(:product).to_s) ||
|
|
980
|
+
raise(ToolError, "No product #{tool_argument(:product).inspect} in catalog #{catalog.key}")
|
|
981
|
+
set = product.sets.find_by(key: required_tool_argument!(:set).to_s) ||
|
|
982
|
+
raise(ToolError, "No set #{tool_argument(:set).inspect} in product #{product.key}")
|
|
983
|
+
authorize_catalog_write!(catalog)
|
|
984
|
+
|
|
985
|
+
project = tool_argument(:project_id).present? ? owned(Project).find_by(id: tool_argument(:project_id)) : nil
|
|
986
|
+
raise ToolError, "No project #{tool_argument(:project_id)}" if tool_argument(:project_id).present? && project.nil?
|
|
987
|
+
agent = tool_argument(:agent).present? ? resolve_tool_agent!(tool_argument(:agent)) : nil
|
|
988
|
+
agent ||= project&.target_agent || product.target_agent(owner_agents)
|
|
989
|
+
raise ToolError, "Choose the agent to run against (agent) or a project (project_id)" if agent.nil?
|
|
990
|
+
raise ToolError, OBSERVED_AGENT_REFUSAL if agent.observed?
|
|
991
|
+
raise MCPController::McpError.new("Agent execution is disabled on this dashboard") unless ActionAgent.execution_enabled?
|
|
992
|
+
if (denial = ActionAgent.quota_denial(current_owner, :execution)).present?
|
|
993
|
+
raise MCPController::McpError.new(denial.is_a?(Hash) ? denial[:message] || denial["message"] : denial)
|
|
994
|
+
end
|
|
995
|
+
if project && project.current_sandbox_session.nil?
|
|
996
|
+
raise ToolError, "Project #{project.name} has no running sandbox: boot it on the dashboard first"
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
selection = evaluation_run_selection(tool_arguments).except(:scenario_ids, :group)
|
|
1000
|
+
run = set.run!(agent: agent, project: project, mount_url: "#{request.base_url}#{request.script_name}", **selection)
|
|
1001
|
+
|
|
1002
|
+
{
|
|
1003
|
+
set: set.reload.summary,
|
|
1004
|
+
evaluation: { id: run.evaluation_id, name: run.evaluation.name },
|
|
1005
|
+
run: EvaluationSerializer.run_summary(run, number: EvaluationSerializer.run_number(run.evaluation, run))
|
|
1006
|
+
.merge(selection: run.selection),
|
|
1007
|
+
background: true
|
|
1008
|
+
}
|
|
1009
|
+
rescue ScenarioSet::NoAgent => e
|
|
1010
|
+
raise ToolError, e.message
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
def authorize_catalog_write!(catalog)
|
|
1014
|
+
return if ActionAgent.permitted?(current_user, :replace_scenarios, catalog)
|
|
1015
|
+
|
|
1016
|
+
raise MCPController::McpError.new(
|
|
1017
|
+
"This key's user may not change scenario catalogs (replace_scenarios)", MCPController::JSONRPC_FORBIDDEN
|
|
1018
|
+
)
|
|
1019
|
+
end
|
|
1020
|
+
|
|
441
1021
|
def find_tool_evaluation!
|
|
442
|
-
id =
|
|
443
|
-
raise MCPController::McpError.new("Missing required argument: evaluation_id", MCPController::JSONRPC_INVALID_PARAMS) if id.blank?
|
|
1022
|
+
id = required_tool_argument!(:evaluation_id)
|
|
444
1023
|
|
|
445
1024
|
dashboard_evaluations.find_by(id: id.to_s) or raise ToolError, "No evaluation #{id.to_s.truncate(32)} was found"
|
|
446
1025
|
end
|
|
@@ -472,6 +1051,14 @@ module ActionAgent
|
|
|
472
1051
|
tool_arguments[name]
|
|
473
1052
|
end
|
|
474
1053
|
|
|
1054
|
+
# The argument, or a JSON-RPC invalid-params error when it is blank.
|
|
1055
|
+
def required_tool_argument!(name)
|
|
1056
|
+
value = tool_argument(name)
|
|
1057
|
+
raise MCPController::McpError.new("Missing required argument: #{name}", MCPController::JSONRPC_INVALID_PARAMS) if value.blank?
|
|
1058
|
+
|
|
1059
|
+
value
|
|
1060
|
+
end
|
|
1061
|
+
|
|
475
1062
|
# An integer argument clamped into [min, max], or +default+ when absent
|
|
476
1063
|
# or not a number.
|
|
477
1064
|
def integer_argument(name, default:, min: nil, max: nil)
|
|
@@ -489,21 +1076,39 @@ module ActionAgent
|
|
|
489
1076
|
end
|
|
490
1077
|
|
|
491
1078
|
# Every credential the owner holds, masked out of each tool's output:
|
|
492
|
-
# this key's token
|
|
493
|
-
#
|
|
494
|
-
#
|
|
495
|
-
#
|
|
1079
|
+
# this key's token and the #owner_credentials (provider keys, the GitHub
|
|
1080
|
+
# token, runtime tokens, the owner's other API keys and ingest key),
|
|
1081
|
+
# plus every member's personal provider key, each checkout sandbox's
|
|
1082
|
+
# browser token, and the values of projects' secrets with their
|
|
1083
|
+
# encodings, which an app tool can echo into a recorded output. None of
|
|
1084
|
+
# them belongs in these payloads; this keeps one that leaked into a
|
|
1085
|
+
# recorded output or a trace from being handed on. A failed lookup
|
|
1086
|
+
# masks nothing rather than failing the call.
|
|
496
1087
|
def dashboard_tool_secrets
|
|
497
1088
|
@dashboard_tool_secrets ||= [
|
|
498
1089
|
@api_key&.token,
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*owned(SandboxSession).where.not(
|
|
1090
|
+
*owner_credentials,
|
|
1091
|
+
*ProviderCredentials.secrets_for(current_owner, limit: SECRET_LOOKUP_LIMIT),
|
|
1092
|
+
*owned(SandboxSession).where.not(browser_token: nil).order(id: :desc).limit(SECRET_LOOKUP_LIMIT).pluck(:browser_token),
|
|
1093
|
+
*project_secret_values
|
|
502
1094
|
].compact
|
|
503
1095
|
rescue StandardError => e
|
|
504
1096
|
Rails.logger.warn("[ActionAgent] MCP secret lookup failed: #{e.class}: #{e.message}")
|
|
505
1097
|
@dashboard_tool_secrets = [ @api_key&.token ].compact
|
|
506
1098
|
end
|
|
1099
|
+
|
|
1100
|
+
# The owner's entered project secrets, with their encodings and a
|
|
1101
|
+
# sign-in secret's parts (ProjectSecret#scrub_parts); a secret that
|
|
1102
|
+
# uses the organization's key is masked as that provider key.
|
|
1103
|
+
# Empty on an install that has not run the projects migration, so the
|
|
1104
|
+
# other credentials are still masked there.
|
|
1105
|
+
def project_secret_values
|
|
1106
|
+
secrets = owned(ProjectSecret).where(source: "entered").order(id: :desc).limit(SECRET_LOOKUP_LIMIT)
|
|
1107
|
+
SecretScrubber.with_encodings(secrets.flat_map(&:scrub_parts))
|
|
1108
|
+
rescue StandardError => e
|
|
1109
|
+
Rails.logger.warn("[ActionAgent] MCP project secret lookup failed: #{e.class}")
|
|
1110
|
+
[]
|
|
1111
|
+
end
|
|
507
1112
|
end
|
|
508
1113
|
end
|
|
509
1114
|
end
|