actionagent 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) 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 +71 -12
  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 +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  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/model_pricing.rb +214 -34
  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 +207 -4
  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 +300 -16
  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/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -2
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A bounded, server-built brief for one persisted fix card. Request bodies
5
+ # choose an existing card and a subset of its scope; they cannot inject a
6
+ # different prompt, agent, scenario or model into the automatic fix loop.
7
+ class EvaluationFix
8
+ class Invalid < StandardError; end
9
+ attr_reader :run, :item
10
+
11
+ def initialize(run, requested)
12
+ @run = run
13
+ raise Invalid, "Only completed scenario runs have implementable fixes" unless run.complete? && run.evaluation.scenario_suite?
14
+ requested = requested.to_h.deep_stringify_keys
15
+ original = EvaluationSerializer.fix_items(run).find do |candidate|
16
+ candidate = candidate.deep_stringify_keys
17
+ candidate["kind"] == requested["kind"] &&
18
+ (requested["kind"] == "instruction" ? candidate["quote"] == requested["quote"] : candidate["fault"] == requested["fault"])
19
+ end
20
+ raise Invalid, "This fix card no longer exists on the selected run" unless original && %w[fault instruction].include?(requested["kind"])
21
+
22
+ @item = original.deep_stringify_keys.slice("kind", "fault", "quote", "recommendation", "scenario_keys", "models")
23
+ keys = Array(requested["scenario_keys"])
24
+ allowed_models = Array(@item["models"]).presence || model_labels
25
+ models = Array(requested["models"]).presence || allowed_models
26
+ unless keys.any? && (keys - Array(@item["scenario_keys"])).empty? && models.any? && (models - allowed_models).empty?
27
+ raise Invalid, "Choose scenarios and models from this fix card"
28
+ end
29
+ @item.merge!("scenario_keys" => keys.uniq, "models" => models.uniq)
30
+ @item["scenarios"] = run.scenario_results.includes(:scenario).filter_map do |result|
31
+ snapshot = result.evaluated_scenario.slice("key", "prompt", "expectations", "notes", "enabled")
32
+ snapshot if keys.include?(snapshot["key"])
33
+ end.uniq
34
+ self.class.check_scenarios!(run.evaluation, @item)
35
+ end
36
+
37
+ def self.check_scenarios!(evaluation, item)
38
+ Array(item["scenarios"]).each do |snapshot|
39
+ current = evaluation.scenarios.find_by(key: snapshot.fetch("key"))
40
+ unless current&.enabled? && current.as_json_summary.stringify_keys.slice(*snapshot.keys) == snapshot
41
+ raise Invalid, "The selected scenarios changed since this run. Run the evaluation again before implementing a fix."
42
+ end
43
+ end
44
+ end
45
+
46
+ def model_labels
47
+ run.scenario_results.map { |result| [ result.provider, result.model ].compact_blank.join("/") }.uniq
48
+ end
49
+
50
+ # How much of each piece of evidence the brief quotes, at full size. A
51
+ # card with many scenarios and models, or a retry, shrinks them all
52
+ # (see #prompt) rather than refusing the card.
53
+ EXCERPTS = { prompt: 1000, expectations: 1000, diagnosis: 1200, answer: 1000,
54
+ previous_result: 1500, previous_diff: 5000, previous_results: 3000 }.freeze
55
+ SCALES = [ 1, 0.5, 0.25, 0.1 ].freeze
56
+
57
+ def prompt(previous: nil)
58
+ SCALES.each do |scale|
59
+ text = brief(previous, EXCERPTS.transform_values { |size| (size * scale).to_i })
60
+ return text if text.length <= CodeSession::MAX_PROMPT_CHARACTERS
61
+ end
62
+ raise Invalid, "This fix has too much context for one session; run the evaluation on fewer scenarios or models"
63
+ end
64
+
65
+ private
66
+
67
+ def brief(previous, limit)
68
+ agent = run.evaluation.agent
69
+ name = agent.agent_class_name.presence || agent.telemetry_agent_class
70
+ path = name.to_s.underscore
71
+ lines = [ "# Implement one evaluation fix", "", "Agent: #{name}", "Evaluation: #{run.evaluation.name} · run #{run.id}",
72
+ "", "## What to fix", item["fault"], item["recommendation"], item["quote"],
73
+ "", "## Where to look", "- app/agents/#{path}.rb and its tools.",
74
+ "- app/views/agents/#{path.delete_suffix('_agent')}/ and app/views/#{path}/ for instructions and prompts.",
75
+ "- Follow the checkout's own instructions and locate the source of a synced agent before editing.",
76
+ "", "## Failing scenarios" ]
77
+ run.scenario_results.includes(:scenario).each do |result|
78
+ scenario = result.evaluated_scenario
79
+ next unless item["scenario_keys"].include?(scenario["key"])
80
+ label = [ result.provider, result.model ].compact_blank.join("/")
81
+ next unless item["models"].include?(label) || item["models"].include?(result.model)
82
+ lines += [ "", "### #{scenario['key']} · #{label}",
83
+ "Prompt: #{scenario['prompt'].to_s.truncate(limit[:prompt])}", "Expectations: #{scenario['expectations'].to_json.truncate(limit[:expectations])}",
84
+ "Before: #{result.status} · score #{result.score} · fault #{result.fault}",
85
+ "Diagnosis: #{result.diagnosis.to_json.truncate(limit[:diagnosis])}", "Answer: #{result.output.to_s.truncate(limit[:answer])}" ]
86
+ end
87
+ if previous
88
+ lines += [ "", "## Previous attempt", previous.result.to_s.truncate(limit[:previous_result]),
89
+ "Previous diff:", previous.diff.to_s.truncate(limit[:previous_diff]),
90
+ "Verification results: #{EvaluationFixComparison.new(previous).rows.to_json.truncate(limit[:previous_results])}" ]
91
+ end
92
+ lines += [ "", "## Verify", "Change the agent, not the scenarios or their expectations.",
93
+ "Run the relevant checkout tests. Do not commit or push.",
94
+ "The dashboard will re-run these exact scenarios and models against this sandbox when you finish.",
95
+ "Do not call dashboard URLs or dashboard MCP tools from this sandbox.",
96
+ "Never read or copy the sandbox's Claude configuration or credentials.",
97
+ "Summarize the change and any remaining limitations." ]
98
+ lines.compact.join("\n")
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class EvaluationFixComparison
5
+ def initialize(session)
6
+ @session = session
7
+ end
8
+
9
+ def self.change(before, after)
10
+ return "pending" unless after
11
+ return after.passed? ? "added_pass" : "added_fail" unless before
12
+ return after.passed? ? "unchanged_pass" : "regressed" if before.passed?
13
+ after.passed? ? "fixed" : "still_failing"
14
+ end
15
+
16
+ def rows
17
+ before = indexed(@session.evaluation_run)
18
+ after = indexed(@session.verification_run)
19
+ (before.keys | after.keys).map do |key|
20
+ first, last = before[key], after[key]
21
+ { scenario_key: key[0], model: key[1], change: self.class.change(first, last),
22
+ before: first&.status, after: last&.status, before_score: first&.score&.to_f, after_score: last&.score&.to_f,
23
+ score_change: first&.score && last&.score ? (last.score - first.score).to_f.round(3) : nil,
24
+ before_fault: first&.fault, fault: last&.fault }
25
+ end
26
+ end
27
+
28
+ def summary
29
+ { run_id: @session.verification_run_id, status: @session.verification_run&.status,
30
+ error: @session.verification_error || @session.verification_run&.error_message,
31
+ counts: rows.group_by { |row| row[:change] }.transform_values(&:size), rows: rows }
32
+ end
33
+
34
+ # The before/after section a fix's draft pull request carries, cut to
35
+ # +limit+ characters from the end (the session summary goes first), so
36
+ # appending it never pushes a body over DraftPullRequest's maximum.
37
+ def pull_request_body(mount:, limit: DraftPullRequest::MAX_BODY_CHARACTERS)
38
+ base = @session.evaluation_run
39
+ title = @session.fix_item["quote"].presence || @session.fix_item["fault"].to_s.humanize
40
+ lines = [ "## Evaluation fix", title, "",
41
+ "[Original run](#{mount}/evaluations/#{base.evaluation_id}/runs/#{base.id})",
42
+ "[Verification run](#{mount}/evaluations/#{base.evaluation_id}/runs/#{@session.verification_run_id})", "",
43
+ "| Scenario / model | Before | After | Score change | Fault |", "| --- | --- | --- | --- | --- |" ]
44
+ rows.each do |row|
45
+ cells = [ "#{row[:scenario_key]} / #{row[:model]}", row[:before], row[:after] || "pending", row[:score_change], row[:fault] || "—" ]
46
+ lines << "| #{cells.map { |cell| cell.to_s.gsub('|', '\\|').gsub(/[\r\n]/, ' ') }.join(' | ')} |"
47
+ end
48
+ lines += [ "", "## Session summary", @session.result.to_s, "", "Code session ##{@session.id}. Review the diff and full-suite regressions before merging." ]
49
+ SecretScrubber.scrub(lines.join("\n"), @session.secrets).truncate([ limit, 0 ].max, omission: "\n…")
50
+ end
51
+
52
+ private
53
+
54
+ def indexed(run)
55
+ return {} unless run
56
+ item = @session.fix_item || {}
57
+ run.scenario_results.includes(:scenario).each_with_object({}) do |result, index|
58
+ key = result.evaluated_scenario["key"]
59
+ model = [ result.provider, result.model ].compact_blank.join("/")
60
+ next unless Array(item["scenario_keys"]).include?(key) &&
61
+ (Array(item["models"]).include?(model) || Array(item["models"]).include?(result.model))
62
+ index[[ key, model ]] = result
63
+ end
64
+ end
65
+ end
66
+ end
@@ -22,7 +22,12 @@ module ActionAgent
22
22
  #
23
23
  # The run's per-model summary, criterion scores and recommendations are
24
24
  # computed from the stored results, not taken from the report. The judge's
25
- # verdict and label are taken from it.
25
+ # verdict and label are taken from it, and so is what the judge spent —
26
+ # per result (`result.judge_usage`, kept beside the result's diagnosis) and
27
+ # for the run (`report.judge_usage.run`) — and the release the report says
28
+ # it evaluated (`report.release`), which pins the run to that version of
29
+ # the agent (Agent#find_or_record_release!). A report naming no release
30
+ # leaves the run unrecorded rather than claiming the dashboard's latest.
26
31
  #
27
32
  # An identical retry returns the stored run, before anything is asked of the
28
33
  # `admit` callable. Different content under a run_id already stored raises
@@ -70,6 +75,11 @@ module ActionAgent
70
75
  IDENTIFIER_PATTERN = /\A[^[:cntrl:]]{1,200}\z/
71
76
  AGENT_NAME_PATTERN = /\A[^[:cntrl:]]{2,100}\z/
72
77
  TRACE_PATTERN = /\A[a-zA-Z0-9_-]{1,128}\z/
78
+ DIGEST_PATTERN = /\A[a-zA-Z0-9._-]{1,64}\z/
79
+ # The judge usage keys a report may send, per result and for the run.
80
+ JUDGE_USAGE_KEYS = %w[calls input_tokens output_tokens cost model by_kind source].freeze
81
+ JUDGE_USAGE_SOURCES = %w[reported traces meter].freeze
82
+ MAX_JUDGE_KINDS = 10
73
83
  SCOPE_PATTERN = %r{\A[\w .:/@-]{1,100}\z}
74
84
  STATUSES = %w[passed failed errored].freeze
75
85
  # Report metadata that tells one evaluation of a suite from another, in the
@@ -253,6 +263,7 @@ module ActionAgent
253
263
  external_tenant: tenant_key,
254
264
  external_run_id: @payload["run_id"],
255
265
  external_report_digest: digest,
266
+ agent_version: reported_release_version(agent),
256
267
  status: :complete,
257
268
  selection: selection,
258
269
  scores: recorded_scores,
@@ -267,6 +278,15 @@ module ActionAgent
267
278
  run
268
279
  end
269
280
 
281
+ # The agent version for the release the report names, or nil for a
282
+ # report that names none.
283
+ def reported_release_version(agent)
284
+ release = report["release"]
285
+ return nil unless release.is_a?(Hash) && release["digest"].present?
286
+
287
+ agent.find_or_record_release!(digest: release["digest"], revision: release["revision"].presence, label: release["label"].presence)
288
+ end
289
+
270
290
  # --- ownership -------------------------------------------------------------
271
291
 
272
292
  # Who a newly observed agent belongs to. Whatever the host's
@@ -492,11 +512,28 @@ module ActionAgent
492
512
  "key" => result["scenario_key"], "group" => result["group"], "prompt" => result["prompt"],
493
513
  "position" => scenario.position, "expectations" => {}
494
514
  }
495
- ),
515
+ ).merge(bounded_judge_usage(result["judge_usage"]).then { |usage| usage ? { "_judge_usage" => usage } : {} }),
496
516
  error_message: truncated(result["error"], TEXT_BYTES)
497
517
  )
498
518
  end
499
519
 
520
+ # A judge usage as the engine stores it: the keys the dashboard reads,
521
+ # each cut to its type, or nil for none.
522
+ def bounded_judge_usage(usage)
523
+ return nil unless usage.is_a?(Hash)
524
+
525
+ by_kind = usage["by_kind"].is_a?(Hash) ? usage["by_kind"].first(MAX_JUDGE_KINDS).to_h { |kind, count| [ kind.to_s.first(40), count.to_i ] } : {}
526
+ {
527
+ "calls" => usage["calls"].to_i,
528
+ "input_tokens" => usage["input_tokens"].to_i,
529
+ "output_tokens" => usage["output_tokens"].to_i,
530
+ "cost" => usage["cost"].is_a?(Numeric) ? usage["cost"].to_f : nil,
531
+ "model" => usage["model"].is_a?(String) ? usage["model"].first(200).presence : nil,
532
+ "by_kind" => by_kind,
533
+ "source" => JUDGE_USAGE_SOURCES.include?(usage["source"]) ? usage["source"] : "reported"
534
+ }
535
+ end
536
+
500
537
  # The first +bytes+ bytes of +text+, dropping a character the cut splits,
501
538
  # or nil for blank text.
502
539
  def truncated(text, bytes)
@@ -521,14 +558,18 @@ module ActionAgent
521
558
  {
522
559
  "_verdict" => report["verdict"],
523
560
  "_selection" => selection,
524
- "_metadata" => metadata
561
+ "_metadata" => metadata,
562
+ "_judge_usage_run" => bounded_judge_usage(report.dig("judge_usage", "run"))
525
563
  }.compact.merge("_judge_label" => judge_label)
526
564
  end
527
565
 
528
566
  # The run's scores in the shape the Evaluations view renders
529
- # (ScenarioEvaluationRunner#scores_for), summarized from the stored results.
567
+ # (ScenarioEvaluationRunner#scores_for), summarized from the stored
568
+ # results as the application reported them — no cost is estimated here,
569
+ # so the summaries stored stay the application's own figures; the API
570
+ # adds the estimates when it serves the run (EvaluationRun#model_summaries).
530
571
  def summarized_scores(run)
531
- rebuilt = run.to_report
572
+ rebuilt = run.to_report(estimate: false)
532
573
  rebuilt.criterion_scores.merge(
533
574
  "_models" => rebuilt.summary_by_model,
534
575
  "_recommendations" => rebuilt.recommendations
@@ -559,6 +600,9 @@ module ActionAgent
559
600
 
560
601
  judge_trace_ids!(metadata["judge_trace_ids"])
561
602
  string!(report["judge"], "report.judge", 200)
603
+ validate_release!
604
+ validate_judge_usage!(report.dig("judge_usage", "run"), "report.judge_usage.run") if report["judge_usage"].is_a?(Hash)
605
+ optional_object!(report["judge_usage"], "report.judge_usage")
562
606
  object!(report["models"], "report.models")
563
607
  raise Invalid, "report.models must contain 1-#{MAX_MODELS} models" unless report["models"].size.between?(1, MAX_MODELS)
564
608
  unless results.is_a?(Array) && results.size.between?(1, MAX_RESULTS)
@@ -605,6 +649,7 @@ module ActionAgent
605
649
  end
606
650
  trace_id!(result_metadata["trace_id"]) if result_metadata["trace_id"]
607
651
  judge_trace_ids!(result_metadata["judge_trace_ids"])
652
+ validate_judge_usage!(result["judge_usage"], "result.judge_usage")
608
653
  end
609
654
  distinct_specs = label_specs.values.uniq
610
655
  raise Invalid, "two model labels name the same provider/model" if distinct_specs.size < label_specs.size
@@ -655,6 +700,40 @@ module ActionAgent
655
700
  string!(judge.dig("suggested_tool", "name"), "result.diagnosis.judge.suggested_tool.name", 200)
656
701
  end
657
702
 
703
+ # The release the report says it evaluated: a digest the dashboard can
704
+ # match to a version cut on deploy, and the deploy and label as text.
705
+ def validate_release!
706
+ release = report["release"]
707
+ return if release.nil?
708
+
709
+ object!(release, "report.release")
710
+ unless release["digest"].nil? || (release["digest"].is_a?(String) && DIGEST_PATTERN.match?(release["digest"]))
711
+ raise Invalid, "report.release.digest must be 1-64 letters, digits or . _ -"
712
+ end
713
+
714
+ string!(release["revision"], "report.release.revision", 200)
715
+ string!(release["label"], "report.release.label", 200)
716
+ end
717
+
718
+ # What the judge spent, as ActiveAgent::Evals::Report writes it per
719
+ # result and for the run.
720
+ def validate_judge_usage!(usage, name)
721
+ return if usage.nil?
722
+
723
+ object!(usage, name)
724
+ %w[calls input_tokens output_tokens].each do |key|
725
+ next if usage[key].nil?
726
+ raise Invalid, "#{name}.#{key} must be a non-negative integer" unless usage[key].is_a?(Integer) && usage[key] >= 0 && usage[key] <= NUMERIC_LIMITS["input_tokens"]
727
+ end
728
+ numeric!(usage["cost"], "#{name}.cost", max: NUMERIC_LIMITS["cost"])
729
+ string!(usage["model"], "#{name}.model", 200)
730
+ string!(usage["source"], "#{name}.source", 20)
731
+ optional_object!(usage["by_kind"], "#{name}.by_kind")
732
+ (usage["by_kind"] || {}).each_value do |count|
733
+ raise Invalid, "#{name}.by_kind counts must be non-negative integers" unless count.is_a?(Integer) && count >= 0
734
+ end
735
+ end
736
+
658
737
  def validate_verdict!
659
738
  verdict = report["verdict"]
660
739
  return if verdict.nil?