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
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A catalog of evaluation scenarios kept in the dashboard: the products an
5
+ # owner evaluates, each with named sets of scenarios, as
6
+ # ActiveAgent::Evals::Catalog reads and writes them.
7
+ #
8
+ # The catalog is imported from a YAML document (pasted, uploaded, read from
9
+ # a connected repository at a ref, or built through the API), keeps the
10
+ # canonical document and its digest, and writes the document to Active
11
+ # Storage when the host has it (see ActionAgent.active_storage), so the
12
+ # scenarios live in the database, in a file a person can version, and in
13
+ # the host's storage, never only in code. A set runs by becoming an
14
+ # evaluation of the agent under test (ScenarioSet#materialize!), so every
15
+ # run's results, traces and recordings are the evaluation's.
16
+ class ScenarioCatalog < ApplicationRecord
17
+ include Ownable
18
+ owned_by :account, :user
19
+
20
+ SOURCE_KINDS = %w[upload repository api].freeze
21
+ KEY_FORMAT = /\A[A-Za-z0-9][A-Za-z0-9_.\/-]*\z/
22
+ # Bounds an import: the document's bytes and the scenarios it holds.
23
+ MAX_BYTES = 2.megabytes
24
+ MAX_SCENARIOS = 2000
25
+
26
+ has_many :products, -> { order(:position, :id) }, class_name: "ActionAgent::ScenarioProduct",
27
+ foreign_key: :scenario_catalog_id, inverse_of: :catalog, dependent: :destroy
28
+
29
+ # The document as last imported or exported, per ActionAgent.active_storage.
30
+ has_one_attached :document_file, **ActionAgent.attachment_options if ActionAgent.active_storage_macros?
31
+
32
+ validates :key, presence: true, format: { with: KEY_FORMAT }, length: { maximum: 120 },
33
+ uniqueness: { scope: [ :account_id, :user_id ] }
34
+ validates :name, presence: true, length: { maximum: 200 }
35
+ validates :source_kind, inclusion: { in: SOURCE_KINDS }
36
+
37
+ scope :ordered, -> { order(:name, :id) }
38
+
39
+ # Whether the document can be written to Active Storage here.
40
+ def self.attachments_available?
41
+ ActionAgent.active_storage_available? && method_defined?(:document_file)
42
+ end
43
+
44
+ def metadata
45
+ value = self[:metadata]
46
+ value.is_a?(Hash) ? value : {}
47
+ end
48
+
49
+ def sets
50
+ ScenarioSet.where(scenario_product_id: products.select(:id))
51
+ end
52
+
53
+ def scenarios
54
+ CatalogScenario.where(scenario_set_id: sets.select(:id))
55
+ end
56
+
57
+ def scenario_count
58
+ scenarios.count
59
+ end
60
+
61
+ # The catalog as the framework reads it, built from the records.
62
+ # @return [ActiveAgent::Evals::Catalog]
63
+ def to_catalog
64
+ ActiveAgent::Evals::Catalog.new([ to_document ], name: key)
65
+ end
66
+
67
+ # The canonical YAML of the records: the whole catalog, or only the
68
+ # products, sets or scenarios named by id (with the products and sets
69
+ # that hold them), as a document of this catalog's key that a merge
70
+ # import (ScenarioCatalogImport mode "merge") reads back into it.
71
+ def export_yaml(product_ids: nil, set_ids: nil, scenario_ids: nil)
72
+ selection = { product_ids: ids(product_ids), set_ids: ids(set_ids), scenario_ids: ids(scenario_ids) }
73
+ return to_catalog.to_yaml if selection.values.all?(&:nil?)
74
+
75
+ ActiveAgent::Evals::Catalog.new([ to_document(**selection) ], name: key).to_yaml
76
+ end
77
+
78
+ # A key for a new product, set or scenario: +text+ as a key, made unique
79
+ # among +taken+ with a numeric suffix.
80
+ def self.key_for(text, taken)
81
+ base = text.to_s.parameterize(separator: "_").first(60).presence || "item"
82
+ base = "k_#{base}" unless base.match?(KEY_FORMAT)
83
+ candidate = base
84
+ suffix = 1
85
+ candidate = "#{base}_#{suffix += 1}" while taken.include?(candidate)
86
+ candidate
87
+ end
88
+
89
+ # Rewrites the stored document and digest from the records, after a
90
+ # change made through the API rather than an import.
91
+ def refresh_document!
92
+ catalog = to_catalog
93
+ update!(document: catalog.to_yaml, digest: catalog.digest)
94
+ end
95
+
96
+ # Writes the document to Active Storage when the host has it, unless
97
+ # the attached file already carries this digest.
98
+ # @return [Boolean] whether a file is attached now
99
+ def sync_to_storage!
100
+ return false unless self.class.attachments_available?
101
+ return true if document_file.attached? && synced_digest == digest
102
+
103
+ document_file.attach(io: StringIO.new(document.to_s), filename: "#{key.tr('/', '-')}.yml", content_type: "application/x-yaml")
104
+ update!(synced_at: Time.current, synced_digest: digest)
105
+ true
106
+ end
107
+
108
+ # Replaces the records with the attached document, for a database
109
+ # restored behind its storage.
110
+ # @return [ScenarioCatalog]
111
+ def restore_from_storage!
112
+ raise ActiveRecord::RecordNotFound, "no document is attached to catalog #{key}" unless self.class.attachments_available? && document_file.attached?
113
+
114
+ ScenarioCatalogImport.new(owner: owner, document: document_file.download, catalog: self, source_kind: source_kind, source_path: source_path).call
115
+ end
116
+
117
+ def synced?
118
+ synced_digest.present? && synced_digest == digest
119
+ end
120
+
121
+ def summary
122
+ {
123
+ id: id,
124
+ key: key,
125
+ name: name,
126
+ description: description,
127
+ source_kind: source_kind,
128
+ source_path: source_path,
129
+ digest: digest,
130
+ synced: synced?,
131
+ synced_at: synced_at,
132
+ storage_available: self.class.attachments_available?,
133
+ product_count: products.size,
134
+ scenario_count: scenario_count,
135
+ updated_at: updated_at
136
+ }
137
+ end
138
+
139
+ private
140
+
141
+ # The catalog's document, narrowed to the products, sets and scenarios
142
+ # named by id when any are (see #export_yaml). A product or set is kept
143
+ # whole when it is named, and otherwise only for what it holds that is.
144
+ def to_document(product_ids: nil, set_ids: nil, scenario_ids: nil)
145
+ narrowed = [ product_ids, set_ids, scenario_ids ].any?
146
+ documents = products.includes(sets: :scenarios).filter_map do |product|
147
+ next product.to_document unless narrowed
148
+ next product.to_document if product_ids&.include?(product.id)
149
+
150
+ sets = product.sets.filter_map do |set|
151
+ next set.to_document if set_ids&.include?(set.id)
152
+
153
+ chosen = set.scenarios.select { |scenario| scenario_ids&.include?(scenario.id) }
154
+ set.to_document.merge("scenarios" => chosen.map(&:to_document)) if chosen.any?
155
+ end
156
+ product.to_document.merge("sets" => sets) if sets.any?
157
+ end
158
+ {
159
+ "catalog" => key,
160
+ "name" => name,
161
+ "description" => description,
162
+ "products" => documents
163
+ }.merge(metadata).compact
164
+ end
165
+
166
+ def ids(value)
167
+ return nil if value.blank?
168
+
169
+ Array(value).flat_map { |entry| entry.to_s.split(",") }.filter_map { |entry| Integer(entry.strip, exception: false) }
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A product in a scenario catalog: what its sets of scenarios run against.
5
+ # That is a dashboard agent, a project (whose booted app the replays reach,
6
+ # with its sandbox's browser), or, until one is chosen, the `agent:` name
7
+ # the document gave.
8
+ class ScenarioProduct < ApplicationRecord
9
+ belongs_to :catalog, class_name: "ActionAgent::ScenarioCatalog", foreign_key: :scenario_catalog_id, inverse_of: :products
10
+ belongs_to :agent, class_name: "ActionAgent::Agent", optional: true
11
+ belongs_to :project, class_name: "ActionAgent::Project", optional: true
12
+ has_many :sets, -> { order(:position, :id) }, class_name: "ActionAgent::ScenarioSet",
13
+ foreign_key: :scenario_product_id, inverse_of: :product, dependent: :destroy
14
+
15
+ validates :key, presence: true, format: { with: ScenarioCatalog::KEY_FORMAT }, length: { maximum: 120 },
16
+ uniqueness: { scope: :scenario_catalog_id }
17
+ validates :name, presence: true, length: { maximum: 200 }
18
+
19
+ def metadata
20
+ value = self[:metadata]
21
+ value.is_a?(Hash) ? value : {}
22
+ end
23
+
24
+ # The agent a set of this product runs against when the caller names
25
+ # none: the product's agent, else the project's agent under test, else
26
+ # the owner's agent named by the document.
27
+ # @param candidates [ActiveRecord::Relation, nil] the agents the caller may run
28
+ # @return [Agent, nil]
29
+ def target_agent(candidates = nil)
30
+ agent || project&.target_agent || (agent_name.present? && candidates ? candidates.find_by(name: agent_name) : nil)
31
+ end
32
+
33
+ def to_document
34
+ {
35
+ "key" => key,
36
+ "name" => name,
37
+ "description" => description,
38
+ "agent" => agent_name.presence || agent&.name,
39
+ "sets" => sets.map(&:to_document)
40
+ }.merge(metadata).compact
41
+ end
42
+
43
+ def summary
44
+ {
45
+ id: id,
46
+ key: key,
47
+ name: name,
48
+ description: description,
49
+ agent_name: agent_name,
50
+ agent: agent && { id: agent.id, name: agent.name },
51
+ project: project && { id: project.id, name: project.name, repository: project.repository, ref: project.checkout_ref },
52
+ set_count: sets.size,
53
+ scenario_count: sets.sum { |set| set.scenarios.size }
54
+ }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A named set of scenarios in a catalog product: a smoke set, the UAT of
5
+ # one pull request, a regression list. A set runs by becoming an
6
+ # evaluation of the agent under test (#materialize!), named
7
+ # "<catalog>/<product>/<set>", whose scenarios are replaced from the set
8
+ # each time, so the evaluation's runs, results, traces and recordings are
9
+ # the set's history, and the catalog document can change without changing
10
+ # what an earlier run asked (EvaluationScenarioResult keeps a snapshot).
11
+ class ScenarioSet < ApplicationRecord
12
+ # Raised by #materialize! and #run! when no agent is known to run against.
13
+ class NoAgent < StandardError; end
14
+
15
+ belongs_to :product, class_name: "ActionAgent::ScenarioProduct", foreign_key: :scenario_product_id, inverse_of: :sets
16
+ belongs_to :evaluation, class_name: "ActionAgent::Evaluation", optional: true
17
+ has_many :scenarios, -> { order(:position, :id) }, class_name: "ActionAgent::CatalogScenario",
18
+ foreign_key: :scenario_set_id, inverse_of: :set, dependent: :destroy
19
+
20
+ validates :key, presence: true, format: { with: ScenarioCatalog::KEY_FORMAT }, length: { maximum: 120 },
21
+ uniqueness: { scope: :scenario_product_id }
22
+ validates :name, presence: true, length: { maximum: 200 }
23
+
24
+ delegate :catalog, to: :product
25
+
26
+ def judge
27
+ value = self[:judge]
28
+ value.is_a?(Hash) ? value : {}
29
+ end
30
+
31
+ def criteria
32
+ Array(self[:criteria]).select { |criterion| criterion.is_a?(Hash) }
33
+ end
34
+
35
+ def metadata
36
+ value = self[:metadata]
37
+ value.is_a?(Hash) ? value : {}
38
+ end
39
+
40
+ # The evaluation's name: the catalog, product and set keys.
41
+ def evaluation_name
42
+ "#{catalog.key}/#{product.key}/#{key}".first(EvaluationReportImport::MAX_EVALUATION_NAME)
43
+ end
44
+
45
+ # The reference an evaluation and its runs keep to this set.
46
+ def catalog_reference
47
+ {
48
+ "catalog_id" => catalog.id,
49
+ "catalog_key" => catalog.key,
50
+ "product_key" => product.key,
51
+ "set_key" => key,
52
+ "set_id" => id,
53
+ "digest" => catalog.digest
54
+ }
55
+ end
56
+
57
+ # Creates or refreshes the evaluation of +agent+ this set runs as, with
58
+ # the set's judge and criteria, and its scenarios replaced from the
59
+ # set's (a scenario the set dropped is disabled, not destroyed, so runs
60
+ # that scored it still resolve their results). Remembers the evaluation.
61
+ #
62
+ # @param agent [Agent] the agent under test; the product's by default
63
+ # @return [Evaluation]
64
+ def materialize!(agent: nil)
65
+ agent ||= product.target_agent
66
+ raise NoAgent, "Choose the agent to run #{evaluation_name} against: the product names none" if agent.nil?
67
+
68
+ attributes = scenarios.each_with_index.map { |scenario, index| scenario.evaluation_attributes(index) }
69
+ transaction do
70
+ target = evaluation if evaluation&.agent_id == agent.id
71
+ target ||= agent.evaluations.find_or_initialize_by(name: evaluation_name)
72
+ target.judge_kind = judge["kind"].to_s.presence || target.judge_kind.presence || "rules"
73
+ target.judge_model = judge["model"].to_s.presence if judge["model"].present?
74
+ target.criteria = criteria if criteria.present?
75
+ target.config = target.config.merge("catalog" => catalog_reference)
76
+ # An evaluation with neither criteria nor scenarios is invalid, so a
77
+ # new one gets its scenarios before the save.
78
+ attributes.each { |attrs| target.scenarios.build(attrs.except("enabled")) } if target.new_record?
79
+ target.save!
80
+ target.replace_scenarios!(attributes, on_removed: :disable) unless target.previously_new_record?
81
+ update!(evaluation: target) if evaluation_id != target.id
82
+ target
83
+ end
84
+ end
85
+
86
+ # Materializes and runs the set. Against a project, the run waits for the
87
+ # project's sandbox and reaches it with its browser, as the project's own
88
+ # evaluation does (ProjectEvaluationJob); otherwise the evaluation runs
89
+ # in the background with the given selection (models, keys).
90
+ #
91
+ # @return [EvaluationRun]
92
+ def run!(agent: nil, project: nil, mount_url: nil, **selection)
93
+ agent ||= project&.target_agent || product.target_agent
94
+ if project && agent.nil?
95
+ raise NoAgent, "Choose the agent to evaluate in project #{project.name} first"
96
+ end
97
+
98
+ target = materialize!(agent: agent)
99
+ reference = { "catalog" => catalog_reference }
100
+ if project
101
+ sandbox = project.current_sandbox_session
102
+ raise NoAgent, "Boot project #{project.name} before running #{evaluation_name} against it" if sandbox.nil?
103
+
104
+ run = target.evaluation_runs.create!(status: :pending,
105
+ selection: reference.merge("project_id" => project.id, "sandbox_id" => sandbox.session_id).merge(selection.deep_stringify_keys))
106
+ ProjectEvaluationJob.perform_later(project.id, run.id, mount_url)
107
+ run
108
+ else
109
+ target.run_later!(**selection.deep_stringify_keys.merge(reference).deep_symbolize_keys)
110
+ end
111
+ end
112
+
113
+ def to_document
114
+ {
115
+ "key" => key,
116
+ "name" => name,
117
+ "description" => description,
118
+ "judge" => judge.presence,
119
+ "criteria" => criteria.presence,
120
+ "scenarios" => scenarios.map(&:to_document)
121
+ }.merge(metadata).compact
122
+ end
123
+
124
+ def summary
125
+ {
126
+ id: id,
127
+ key: key,
128
+ name: name,
129
+ description: description,
130
+ judge: judge,
131
+ criteria_count: criteria.size,
132
+ scenario_count: scenarios.size,
133
+ evaluation: evaluation && { id: evaluation.id, name: evaluation.name, agent_id: evaluation.agent_id },
134
+ latest_run: evaluation&.latest_run&.then { |run| { id: run.id, status: run.status, created_at: run.created_at } }
135
+ }
136
+ end
137
+ end
138
+ end
@@ -7,13 +7,36 @@ module ActionAgent
7
7
 
8
8
  belongs_to :agent_run, optional: true
9
9
  belongs_to :sandbox_session, optional: true
10
+ # The conversation a recording belongs to. A recording of a conversation
11
+ # outlives any one of its runs.
12
+ belongs_to :agent_context, optional: true
10
13
 
11
14
  has_many :recording_actions, dependent: :destroy
12
15
  has_many :recording_snapshots, dependent: :destroy
16
+ # Deleted rather than destroyed: destroying an event looks up its
17
+ # attachment, and a host that loads Active Storage without migrating it
18
+ # has no table to look in. Attached payloads are purged first.
19
+ has_many :recording_events, dependent: :delete_all
20
+
21
+ before_destroy :purge_attached_event_payloads, prepend: true
13
22
 
14
23
  enum :status, { recording: 0, completed: 1, failed: 2 }
15
24
 
25
+ # Where a recording came from: an agent's browser, or a person using the
26
+ # dashboard. Recordings made before this was recorded have none.
27
+ SOURCES = %w[agent dashboard].freeze
28
+
29
+ # Browser state that must never leave the server in a read response: the
30
+ # handoff state a recording carries is a copy of the visitor's cookies
31
+ # and web storage. Only the handoff endpoint returns it, to the owner,
32
+ # when they continue the session.
33
+ SENSITIVE_STATE_KEYS = %w[cookies session_storage local_storage].freeze
34
+
35
+ INGEST_TOKEN_PREFIX = "aarec_"
36
+ INGEST_TOKEN_TTL = 2.hours
37
+
16
38
  validates :status, presence: true
39
+ validates :source, inclusion: { in: SOURCES }, allow_nil: true
17
40
  validate :must_have_parent, unless: -> { demo_recording? || user_session? }
18
41
 
19
42
  scope :recent, -> { order(created_at: :desc) }
@@ -36,18 +59,20 @@ module ActionAgent
36
59
  # The owner column is written here, at creation: the index and recent
37
60
  # endpoints scope through it, and a recording nothing ever stamped was
38
61
  # invisible in the list to the very person who made it. An explicit
39
- # +owner+ wins; otherwise the recording inherits the owner of the sandbox
40
- # or agent it records. Ownable#owner= is a no-op in a single-user
41
- # install, where nothing is owned.
42
- def self.start!(agent_run: nil, sandbox_session: nil, name: nil, owner: nil)
62
+ # +owner+ wins; otherwise the recording inherits the owner of the sandbox,
63
+ # agent or conversation it records. Ownable#owner= is a no-op in a
64
+ # single-user install, where nothing is owned.
65
+ def self.start!(agent_run: nil, sandbox_session: nil, agent_context: nil, source: nil, name: nil, owner: nil)
43
66
  recording = new(
44
67
  agent_run: agent_run,
45
68
  sandbox_session: sandbox_session,
46
- name: name || generate_name(agent_run, sandbox_session),
69
+ agent_context: agent_context,
70
+ source: source,
71
+ name: name || generate_name(agent_run, sandbox_session, agent_context),
47
72
  status: :recording,
48
73
  metadata: { started_at: Time.current.iso8601 }
49
74
  )
50
- recording.owner = owner || inherited_owner(agent_run, sandbox_session)
75
+ recording.owner = owner || inherited_owner(agent_run, sandbox_session, agent_context)
51
76
  recording.save!
52
77
  recording
53
78
  end
@@ -71,11 +96,73 @@ module ActionAgent
71
96
  recording
72
97
  end
73
98
 
74
- # Whoever owns the sandbox or agent a recording is made against. Both
75
- # models declare the same owner candidates as this one, so the record
76
- # they hand back is of the class this install owns things through.
77
- def self.inherited_owner(agent_run, sandbox_session)
78
- sandbox_session&.owner || agent_run&.agent&.owner
99
+ # Whoever owns the sandbox, agent or conversation's agent a recording is
100
+ # made against. Both models declare the same owner candidates as this
101
+ # one, so the record they hand back is of the class this install owns
102
+ # things through.
103
+ def self.inherited_owner(agent_run, sandbox_session, agent_context = nil)
104
+ conversation_agent = agent_context&.contextable
105
+ sandbox_session&.owner || agent_run&.agent&.owner || (conversation_agent.owner if conversation_agent.is_a?(Agent))
106
+ end
107
+
108
+ # Returns +value+ without SENSITIVE_STATE_KEYS in any Hash it holds, at
109
+ # any depth.
110
+ def self.without_sensitive_state(value)
111
+ case value
112
+ when Hash
113
+ value.each_with_object({}) do |(key, item), clean|
114
+ next if SENSITIVE_STATE_KEYS.include?(key.to_s)
115
+
116
+ clean[key] = without_sensitive_state(item)
117
+ end
118
+ when Array then value.map { |item| without_sensitive_state(item) }
119
+ else value
120
+ end
121
+ end
122
+
123
+ # The digest an ingest token is stored and looked up as.
124
+ def self.ingest_token_digest(token)
125
+ Digest::SHA256.hexdigest(token.to_s)
126
+ end
127
+
128
+ # Issues the token a browser posts this recording's events with, and
129
+ # returns it. Only its digest is stored, and issuing another replaces it.
130
+ # The token expires after +expires_in+, or when the recording's sandbox
131
+ # does if that is sooner. See #ingest_token_valid? for when it is
132
+ # accepted.
133
+ # @return [String]
134
+ def issue_ingest_token!(expires_in: INGEST_TOKEN_TTL)
135
+ token = "#{INGEST_TOKEN_PREFIX}#{SecureRandom.base58(32)}"
136
+ expires_at = [ Time.current + expires_in, sandbox_session&.expires_at ].compact.min
137
+ update!(ingest_token_digest: self.class.ingest_token_digest(token), ingest_token_expires_at: expires_at)
138
+ token
139
+ end
140
+
141
+ # Whether +token+ may post events to this recording now: it is the token
142
+ # last issued for this recording, it has not expired, the recording is
143
+ # still recording, and the recording's sandbox, if it has one, is active.
144
+ def ingest_token_valid?(token)
145
+ return false if token.blank? || ingest_token_digest.blank?
146
+ return false unless recording? && ingest_token_expires_at&.future?
147
+ return false if sandbox_session_id && !sandbox_live?
148
+
149
+ ActiveSupport::SecurityUtils.secure_compare(ingest_token_digest, self.class.ingest_token_digest(token))
150
+ end
151
+
152
+ # Stores one event the server observed, such as an agent's browser
153
+ # action, and counts it. Unlike a browser's batches, server events are
154
+ # not capped.
155
+ # @param data [Hash] the event
156
+ # @param started_at [Time] when it began
157
+ # @param finished_at [Time] when it ended
158
+ # @return [RecordingEvent]
159
+ def record_server_event!(kind:, data:, started_at:, finished_at: started_at)
160
+ event = recording_events.new(kind: kind, clock_offset_ms: 0)
161
+ event.events = [ { "at" => RecordingEvent.milliseconds(started_at), "data" => data } ]
162
+ event.occurred_to = [ finished_at, event.occurred_from ].max
163
+ event.save!
164
+ self.class.update_counters(id, event_count: 1, event_bytes: event.byte_size)
165
+ event
79
166
  end
80
167
 
81
168
  # Record a browser action
@@ -136,10 +223,14 @@ module ActionAgent
136
223
  # same redaction the /actions endpoint applies: this is what #show
137
224
  # renders, and it used to hand back the cleartext password that
138
225
  # /actions had just redacted for the same action.
226
+ #
227
+ # Each entry names its action `action_type`, as /actions entries do.
228
+ # `type` carries the same value for readers of the older key.
139
229
  def timeline
140
230
  recording_actions.order(:sequence).map do |action|
141
231
  {
142
232
  id: action.id,
233
+ action_type: action.action_type,
143
234
  type: action.action_type,
144
235
  sequence: action.sequence,
145
236
  timestamp_ms: action.timestamp_ms,
@@ -153,17 +244,33 @@ module ActionAgent
153
244
 
154
245
  private
155
246
 
247
+ def purge_attached_event_payloads
248
+ return unless RecordingEvent.attachments_available?
249
+
250
+ ActiveStorage::Attachment
251
+ .where(record_type: RecordingEvent.polymorphic_name, name: "payload_file", record_id: recording_events.select(:id))
252
+ .includes(:blob)
253
+ .find_each(&:purge_later)
254
+ end
255
+
256
+ def sandbox_live?
257
+ live = SandboxSession.active.where(id: sandbox_session_id)
258
+ live.where(expires_at: nil).or(live.where(expires_at: Time.current..)).exists?
259
+ end
260
+
156
261
  def must_have_parent
157
- return if agent_run.present? || sandbox_session.present?
262
+ return if agent_run.present? || sandbox_session.present? || agent_context.present?
158
263
 
159
- errors.add(:base, "must belong to an agent_run or sandbox_session")
264
+ errors.add(:base, "must belong to an agent_run, sandbox_session or agent_context")
160
265
  end
161
266
 
162
- def self.generate_name(agent_run, sandbox_session)
267
+ def self.generate_name(agent_run, sandbox_session, agent_context = nil)
163
268
  prefix = if agent_run&.agent
164
269
  agent_run.agent.name.parameterize
165
270
  elsif sandbox_session&.agent_template
166
271
  sandbox_session.agent_template.name.parameterize
272
+ elsif agent_context&.agent_name.present?
273
+ agent_context.agent_name.parameterize
167
274
  else
168
275
  "session"
169
276
  end
@@ -47,7 +47,28 @@ module ActionAgent
47
47
  # The release of the agent this trace came from (see #attach_agent_version!).
48
48
  belongs_to :agent_version, class_name: "ActionAgent::AgentVersion", optional: true
49
49
 
50
- scope :for_account, ->(account) { where(account: account) if ActionAgent.multi_tenant? }
50
+ # The tenant's traces in multi-tenant mode; every trace otherwise. The
51
+ # account is matched by class, as Ownable.for_owner matches an owner: a
52
+ # user maps to its tenant through ActionAgent.tenant_for, and nil or any
53
+ # other class scopes to nothing.
54
+ scope :for_account, lambda { |account|
55
+ next unless ActionAgent.multi_tenant?
56
+
57
+ account = tenant_account(account)
58
+ account ? where(account_id: account.id) : none
59
+ }
60
+
61
+ # +account+ as an instance of the configured account class, or nil.
62
+ def self.tenant_account(account)
63
+ return nil if account.nil?
64
+
65
+ account_class = ActionAgent.account_class.presence&.safe_constantize
66
+ return nil if account_class.nil?
67
+ return account if account.is_a?(account_class)
68
+
69
+ tenant = ActionAgent.tenant_for(account)
70
+ tenant if tenant.is_a?(account_class)
71
+ end
51
72
 
52
73
  # Creates a TelemetryTrace from an ingested trace payload.
53
74
  #
@@ -192,24 +213,7 @@ module ActionAgent
192
213
  root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}
193
214
 
194
215
  total_duration = root_span["duration_ms"]
195
-
196
- # Instrumentation mirrors LLM token usage onto the root span for
197
- # display, so summing every span double-counts. When child spans
198
- # carry token data, they are the source of truth; the root span only
199
- # counts for single-span traces.
200
- counted_spans = spans.reject { |s| s["parent_span_id"].nil? }
201
- counted_spans = spans if counted_spans.none? { |s| span_token_sum(s).positive? }
202
-
203
- total_input = 0
204
- total_output = 0
205
- total_thinking = 0
206
-
207
- counted_spans.each do |span|
208
- tokens = span["tokens"] || {}
209
- total_input += (tokens["input"] || 0)
210
- total_output += (tokens["output"] || 0)
211
- total_thinking += (tokens["thinking"] || 0)
212
- end
216
+ total_input, total_output, total_thinking = token_totals(spans)
213
217
 
214
218
  # Extract agent info from root span attributes
215
219
  attributes = root_span["attributes"] || {}
@@ -262,6 +266,57 @@ module ActionAgent
262
266
  end
263
267
 
264
268
 
269
+ # The input, output and thinking tokens +spans+ add up to.
270
+ #
271
+ # Instrumentation mirrors LLM token usage onto the root span for display,
272
+ # so summing every span double-counts. When child spans carry token data,
273
+ # they are the source of truth; the root span only counts for
274
+ # single-span traces.
275
+ #
276
+ # @return [Array(Integer, Integer, Integer)]
277
+ # @api private
278
+ def self.token_totals(spans)
279
+ counted = spans.reject { |s| s["parent_span_id"].nil? }
280
+ counted = spans if counted.none? { |s| span_token_sum(s).positive? }
281
+
282
+ counted.each_with_object([ 0, 0, 0 ]) do |span, totals|
283
+ tokens = span["tokens"] || {}
284
+ totals[0] += (tokens["input"] || 0)
285
+ totals[1] += (tokens["output"] || 0)
286
+ totals[2] += (tokens["thinking"] || 0)
287
+ end
288
+ end
289
+
290
+ # Adds a later execution segment of the same run to this trace: a run that
291
+ # paused for input records its resumed segment under the same trace id.
292
+ # The segment's root span becomes a child of this trace's root, and the
293
+ # totals, status and error cover both.
294
+ #
295
+ # @param segment [Array<Hash>] the segment's flattened spans
296
+ # @return [self]
297
+ def append_segment!(segment)
298
+ parent_id = root_span&.dig("span_id")
299
+ segment = Array(segment).map do |span|
300
+ span = span.to_h.stringify_keys
301
+ span["parent_span_id"].nil? && parent_id ? span.merge("parent_span_id" => parent_id) : span
302
+ end
303
+ combined = Array(spans) + segment
304
+ input, output, thinking = self.class.token_totals(combined)
305
+ segment_root = segment.find { |span| span["parent_span_id"] == parent_id }
306
+ error_span = segment.find { |span| span["status"] == STATUS_ERROR }
307
+
308
+ update!(
309
+ spans: combined,
310
+ total_input_tokens: input,
311
+ total_output_tokens: output,
312
+ total_thinking_tokens: thinking,
313
+ total_duration_ms: total_duration_ms.to_f + segment_root&.dig("duration_ms").to_f,
314
+ status: error_span ? STATUS_ERROR : status,
315
+ error_message: error_message.presence || error_span&.dig("attributes", "error.message")
316
+ )
317
+ self
318
+ end
319
+
265
320
  # Sums a span's token counts (used to decide which spans carry the
266
321
  # authoritative token data during ingestion).
267
322
  #