actionagent 1.8.1 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
- data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/interaction_feedback.rb +24 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +155 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -2
|
@@ -0,0 +1,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
|
|
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
|
-
|
|
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
|
|
75
|
-
# models declare the same owner candidates as this
|
|
76
|
-
# they hand back is of the class this install owns
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
#
|