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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
  18. data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
  19. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  20. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  21. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  22. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  23. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  24. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  25. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  26. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  27. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  28. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  29. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  30. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  31. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  32. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  33. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  34. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  35. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  36. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  37. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  38. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  39. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  40. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  41. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  42. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  43. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  44. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  45. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  46. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  47. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  48. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  49. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  50. data/app/jobs/action_agent/code_session_job.rb +26 -2
  51. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  52. data/app/jobs/action_agent/exploration_job.rb +80 -0
  53. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  54. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  55. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  56. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  57. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  58. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  59. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  60. data/app/models/action_agent/agent.rb +39 -26
  61. data/app/models/action_agent/agent_run.rb +134 -23
  62. data/app/models/action_agent/agent_template.rb +35 -1
  63. data/app/models/action_agent/api_key.rb +14 -0
  64. data/app/models/action_agent/catalog_scenario.rb +75 -0
  65. data/app/models/action_agent/code_session.rb +16 -4
  66. data/app/models/action_agent/draft_pull_request.rb +131 -0
  67. data/app/models/action_agent/evaluation.rb +84 -0
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/interaction_feedback.rb +24 -0
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +200 -3
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +297 -14
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  85. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  86. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  87. data/app/services/action_agent/agent_execution_service.rb +450 -72
  88. data/app/services/action_agent/agent_sync.rb +5 -2
  89. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  90. data/app/services/action_agent/agent_toolbox.rb +215 -9
  91. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  92. data/app/services/action_agent/browser_sidecar.rb +213 -0
  93. data/app/services/action_agent/browser_sign_in.rb +163 -0
  94. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  95. data/app/services/action_agent/claude_code_auth.rb +70 -4
  96. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  97. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  98. data/app/services/action_agent/evaluation_fix.rb +101 -0
  99. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  100. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  101. data/app/services/action_agent/evaluation_standing.rb +15 -8
  102. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  103. data/app/services/action_agent/github_client.rb +405 -64
  104. data/app/services/action_agent/live_updates.rb +31 -0
  105. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  106. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  107. data/app/services/action_agent/mcp_client.rb +6 -2
  108. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  109. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  110. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  111. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  112. data/app/services/action_agent/project_boot_fix.rb +309 -0
  113. data/app/services/action_agent/project_capabilities.rb +200 -0
  114. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  115. data/app/services/action_agent/project_preflight.rb +178 -0
  116. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  117. data/app/services/action_agent/project_setup.rb +429 -0
  118. data/app/services/action_agent/provider_credentials.rb +102 -0
  119. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  120. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  121. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  122. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  123. data/app/services/action_agent/sandbox_browser.rb +299 -0
  124. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  125. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  126. data/app/services/action_agent/sandbox_mail.rb +103 -0
  127. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  128. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  129. data/app/services/action_agent/sandbox_patch.rb +253 -0
  130. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  131. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  132. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  133. data/app/services/action_agent/secret_scrubber.rb +33 -4
  134. data/app/services/action_agent/session_index.rb +270 -0
  135. data/app/services/action_agent/session_recording_service.rb +11 -0
  136. data/app/services/action_agent/session_timeline.rb +505 -0
  137. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  138. data/app/views/action_agent/session_player/show.html.erb +19 -0
  139. data/config/routes.rb +155 -1
  140. data/lib/action_agent/claude_login_process.rb +107 -0
  141. data/lib/action_agent/engine.rb +55 -5
  142. data/lib/action_agent/secret_requests.rb +80 -0
  143. data/lib/action_agent/version.rb +1 -1
  144. data/lib/action_agent.rb +507 -22
  145. data/lib/generators/action_agent/install_generator.rb +37 -0
  146. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  147. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  148. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  149. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  150. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  151. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  152. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  153. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  154. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  155. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  156. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  157. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  158. data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
  159. data/lib/tasks/action_agent.rake +33 -0
  160. data/lib/tasks/browser.rake +22 -0
  161. data/lib/tasks/sandbox.rake +1 -1
  162. data/lib/tasks/scenario_catalogs.rake +71 -0
  163. 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 evaluations_run evaluation_runs_get evaluation_runs_compare
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
- return after.passed? ? "added_pass" : "added_fail" if before.nil?
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 = tool_argument(:evaluation_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, provider keys, the GitHub token, and each checkout
493
- # sandbox's runtime token. None of them belongs in these payloads; this
494
- # keeps one that leaked into a recorded output or a trace from being
495
- # handed on. A failed lookup masks nothing rather than failing the call.
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
- *owned(ProviderKey).limit(SECRET_LOOKUP_LIMIT).pluck(:credential, :api_key).flatten,
500
- *owned(GithubConnection).limit(SECRET_LOOKUP_LIMIT).pluck(:access_token),
501
- *owned(SandboxSession).where.not(runtime_mcp_token: nil).order(id: :desc).limit(SECRET_LOOKUP_LIMIT).pluck(:runtime_mcp_token)
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