actionagent 0.0.0 → 1.2.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +51 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
  95. metadata +209 -12
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class SandboxSession < ApplicationRecord
5
+ include Ownable
6
+ owned_by :user, :account
7
+
8
+ belongs_to :agent_template, optional: true
9
+
10
+ # Session statuses
11
+ enum :status, {
12
+ pending: 0,
13
+ provisioning: 1,
14
+ ready: 2,
15
+ running: 3,
16
+ completed: 4,
17
+ expired: 5,
18
+ failed: 6
19
+ }
20
+
21
+ # Sandbox types
22
+ SANDBOX_TYPES = %w[playwright_mcp terminal research].freeze
23
+
24
+ # Free tier limits
25
+ FREE_TIER_LIMITS = {
26
+ max_runs: 10,
27
+ timeout_seconds: 300,
28
+ max_tokens: 50_000,
29
+ session_duration_minutes: 15
30
+ }.freeze
31
+
32
+ # Validations
33
+ validates :session_id, presence: true, uniqueness: true
34
+ validates :sandbox_type, inclusion: { in: SANDBOX_TYPES }
35
+
36
+ # Callbacks
37
+ before_validation :generate_session_id, on: :create
38
+ before_create :set_expiration
39
+
40
+ # Scopes
41
+ scope :active, -> { where(status: [ :pending, :provisioning, :ready, :running ]) }
42
+ scope :expired_sessions, -> { where("expires_at < ?", Time.current) }
43
+ scope :by_type, ->(type) { where(sandbox_type: type) }
44
+ scope :anonymous, -> { where(user_id: nil) }
45
+ scope :recent, -> { order(created_at: :desc) }
46
+
47
+ # Catalog entries for the MCP servers this session was started with.
48
+ # Unknown keys are dropped rather than raising — a session outlives a
49
+ # catalog edit.
50
+ def mcp_catalog_entries
51
+ Array(mcp_servers).filter_map { |key| McpCatalog.find(key) }
52
+ end
53
+
54
+ # Check if session is still valid
55
+ def active?
56
+ !expired? && !failed? && !completed? && expires_at > Time.current
57
+ end
58
+
59
+ # Check if can run more tasks
60
+ def can_run?
61
+ active? && runs_count < max_runs
62
+ end
63
+
64
+ # Record a new run (thread-safe for parallel execution)
65
+ def record_run!(task:, result:, duration_ms:, tokens:, screenshots: [], provider: nil)
66
+ run = {
67
+ id: SecureRandom.uuid,
68
+ task: task,
69
+ result: result,
70
+ duration_ms: duration_ms,
71
+ tokens: tokens,
72
+ screenshots: screenshots,
73
+ provider: provider,
74
+ status: "completed",
75
+ created_at: Time.current.iso8601
76
+ }
77
+
78
+ # Use pessimistic locking to prevent race conditions when multiple providers run in parallel
79
+ with_lock do
80
+ reload # Reload to get the latest state
81
+ self.runs = runs + [ run ]
82
+ self.runs_count = runs.size
83
+ self.total_tokens += tokens
84
+ self.total_duration_ms += duration_ms
85
+ self.last_activity_at = Time.current
86
+ save!
87
+ end
88
+
89
+ run
90
+ end
91
+
92
+ # Provision the Cloud Run sandbox
93
+ def provision!
94
+ return if provisioning? || ready?
95
+
96
+ update!(status: :provisioning)
97
+
98
+ # In development, run synchronously for immediate feedback
99
+ if Rails.env.development? || Rails.env.test?
100
+ SandboxProvisionJob.perform_now(id)
101
+ else
102
+ SandboxProvisionJob.perform_later(id)
103
+ end
104
+ end
105
+
106
+ # Mark as ready with Cloud Run URL
107
+ def mark_ready!(cloud_run_url:, cloud_run_job_id: nil)
108
+ update!(
109
+ status: :ready,
110
+ cloud_run_url: cloud_run_url,
111
+ cloud_run_job_id: cloud_run_job_id
112
+ )
113
+ end
114
+
115
+ # Expire the session
116
+ def expire!
117
+ update!(status: :expired)
118
+ # Cleanup Cloud Run resources
119
+ SandboxCleanupJob.perform_later(id) if cloud_run_job_id.present?
120
+ end
121
+
122
+ # Summary for API responses
123
+ def summary
124
+ {
125
+ id: id,
126
+ session_id: session_id,
127
+ sandbox_type: sandbox_type,
128
+ status: status,
129
+ runs_count: runs_count,
130
+ max_runs: max_runs,
131
+ total_tokens: total_tokens,
132
+ expires_at: expires_at&.iso8601,
133
+ created_at: created_at.iso8601,
134
+ cloud_run_url: cloud_run_url,
135
+ mcp_servers: Array(mcp_servers)
136
+ }
137
+ end
138
+
139
+ # Detailed info including runs
140
+ def details
141
+ summary.merge(
142
+ runs: runs,
143
+ total_duration_ms: total_duration_ms,
144
+ last_activity_at: last_activity_at&.iso8601
145
+ )
146
+ end
147
+
148
+ private
149
+
150
+ def generate_session_id
151
+ self.session_id ||= SecureRandom.uuid
152
+ end
153
+
154
+ def set_expiration
155
+ self.expires_at ||= FREE_TIER_LIMITS[:session_duration_minutes].minutes.from_now
156
+ self.max_runs ||= FREE_TIER_LIMITS[:max_runs]
157
+ self.timeout_seconds ||= FREE_TIER_LIMITS[:timeout_seconds]
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class SessionRecording < ApplicationRecord
5
+ include Ownable
6
+ owned_by :user, :account
7
+
8
+ belongs_to :agent_run, optional: true
9
+ belongs_to :sandbox_session, optional: true
10
+
11
+ has_many :recording_actions, dependent: :destroy
12
+ has_many :recording_snapshots, dependent: :destroy
13
+
14
+ enum :status, { recording: 0, completed: 1, failed: 2 }
15
+
16
+ validates :status, presence: true
17
+ validate :must_have_parent, unless: -> { demo_recording? || user_session? }
18
+
19
+ scope :recent, -> { order(created_at: :desc) }
20
+ scope :for_agent, ->(agent_id) { joins(:agent_run).where(agent_runs: { agent_id: agent_id }) }
21
+ scope :demo, -> { where(name: "lander_demo") }
22
+ scope :user_sessions, -> { where("name LIKE ?", "user_takeover_%") }
23
+
24
+ # Check if this is a demo recording (doesn't require parent)
25
+ def demo_recording?
26
+ name&.start_with?("lander_") || name == "demo"
27
+ end
28
+
29
+ # Check if this is a user takeover session (doesn't require parent)
30
+ def user_session?
31
+ name&.start_with?("user_takeover_")
32
+ end
33
+
34
+ # Start a new recording session
35
+ def self.start!(agent_run: nil, sandbox_session: nil, name: nil)
36
+ create!(
37
+ agent_run: agent_run,
38
+ sandbox_session: sandbox_session,
39
+ name: name || generate_name(agent_run, sandbox_session),
40
+ status: :recording,
41
+ metadata: { started_at: Time.current.iso8601 }
42
+ )
43
+ end
44
+
45
+ # Start a user takeover session (for lander demo analytics)
46
+ def self.start_user_session!(visitor_id: nil, parent_demo_id: nil, page_url: nil)
47
+ create!(
48
+ name: "user_takeover_#{Time.current.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}",
49
+ status: :recording,
50
+ metadata: {
51
+ started_at: Time.current.iso8601,
52
+ session_type: "user_takeover",
53
+ visitor_id: visitor_id,
54
+ parent_demo_id: parent_demo_id,
55
+ page_url: page_url,
56
+ user_agent: nil # Will be set from request
57
+ }
58
+ )
59
+ end
60
+
61
+ # Record a browser action
62
+ def record_action!(action_type:, selector: nil, value: nil, screenshot: nil, dom_snapshot: nil, metadata: {})
63
+ raise "Recording already completed" unless recording?
64
+
65
+ action = recording_actions.create!(
66
+ action_type: action_type,
67
+ sequence: next_sequence,
68
+ timestamp_ms: elapsed_ms,
69
+ selector: selector,
70
+ value: value,
71
+ metadata: metadata
72
+ )
73
+
74
+ # Handle screenshot attachment if provided
75
+ if screenshot.present?
76
+ snapshot = store_snapshot(screenshot, :screenshot, action)
77
+ action.update!(screenshot_key: snapshot.storage_key)
78
+ end
79
+
80
+ # Handle DOM snapshot if provided
81
+ if dom_snapshot.present?
82
+ snapshot = store_snapshot(dom_snapshot, :dom, action)
83
+ action.update!(dom_snapshot_key: snapshot.storage_key)
84
+ end
85
+
86
+ increment!(:action_count)
87
+ action
88
+ end
89
+
90
+ # Complete the recording
91
+ def complete!
92
+ return unless recording?
93
+
94
+ update!(
95
+ status: :completed,
96
+ duration_ms: elapsed_ms,
97
+ metadata: metadata.merge(completed_at: Time.current.iso8601)
98
+ )
99
+ end
100
+
101
+ # Mark recording as failed
102
+ def fail!(error_message = nil)
103
+ return unless recording?
104
+
105
+ update!(
106
+ status: :failed,
107
+ duration_ms: elapsed_ms,
108
+ metadata: metadata.merge(
109
+ failed_at: Time.current.iso8601,
110
+ error: error_message
111
+ )
112
+ )
113
+ end
114
+
115
+ # Get timeline data for playback
116
+ def timeline
117
+ recording_actions.order(:sequence).map do |action|
118
+ {
119
+ id: action.id,
120
+ type: action.action_type,
121
+ sequence: action.sequence,
122
+ timestamp_ms: action.timestamp_ms,
123
+ selector: action.selector,
124
+ value: action.value,
125
+ screenshot_key: action.screenshot_key,
126
+ metadata: action.metadata
127
+ }
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def must_have_parent
134
+ return if agent_run.present? || sandbox_session.present?
135
+
136
+ errors.add(:base, "must belong to an agent_run or sandbox_session")
137
+ end
138
+
139
+ def self.generate_name(agent_run, sandbox_session)
140
+ prefix = if agent_run&.agent
141
+ agent_run.agent.name.parameterize
142
+ elsif sandbox_session&.agent_template
143
+ sandbox_session.agent_template.name.parameterize
144
+ else
145
+ "session"
146
+ end
147
+
148
+ "#{prefix}_#{Time.current.strftime('%Y%m%d_%H%M%S')}"
149
+ end
150
+
151
+ def next_sequence
152
+ (recording_actions.maximum(:sequence) || 0) + 1
153
+ end
154
+
155
+ def elapsed_ms
156
+ ((Time.current - created_at) * 1000).to_i
157
+ end
158
+
159
+ def store_snapshot(data, snapshot_type, action = nil)
160
+ storage_key = generate_storage_key(snapshot_type, action&.sequence)
161
+
162
+ # For now, store metadata - actual file upload handled by service
163
+ recording_snapshots.create!(
164
+ recording_action: action,
165
+ storage_key: storage_key,
166
+ snapshot_type: snapshot_type,
167
+ file_size_bytes: data.bytesize
168
+ )
169
+ end
170
+
171
+ def generate_storage_key(snapshot_type, sequence = nil)
172
+ parts = [ "recordings", id, snapshot_type.to_s ]
173
+ parts << sequence.to_s if sequence
174
+ parts << SecureRandom.hex(8)
175
+ parts.join("/")
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Stores telemetry traces from ActiveAgent clients.
5
+ #
6
+ # Each trace represents a complete generation lifecycle, including prompt
7
+ # preparation, LLM calls, tool invocations, and error handling.
8
+ #
9
+ # This model supports two modes:
10
+ # - Local mode: No account association (single-tenant, self-hosted)
11
+ # - Multi-tenant mode: With account association (for activeagents.ai platform)
12
+ #
13
+ # @example Creating a trace from ingested data (local mode)
14
+ # ActionAgent::TelemetryTrace.create_from_payload(trace_payload, sdk_info)
15
+ #
16
+ # @example Creating a trace with account (multi-tenant mode)
17
+ # ActionAgent::TelemetryTrace.create_from_payload(trace_payload, sdk_info, account: account)
18
+ #
19
+ class TelemetryTrace < ::ActiveRecord::Base
20
+ include ActionAgent::AdapterAware
21
+
22
+ self.table_name = "active_agent_telemetry_traces"
23
+
24
+ # Optional account association for multi-tenant mode
25
+ # The host app can add: belongs_to :account if needed
26
+ if ActionAgent.multi_tenant?
27
+ belongs_to :account, class_name: ActionAgent.account_class
28
+ end
29
+
30
+ # Status values for traces
31
+ STATUS_OK = "OK"
32
+ STATUS_ERROR = "ERROR"
33
+ STATUS_UNSET = "UNSET"
34
+
35
+ validates :trace_id, presence: true
36
+
37
+ # Scopes
38
+ scope :recent, -> { order(timestamp: :desc) }
39
+ scope :with_errors, -> { where(status: STATUS_ERROR) }
40
+ scope :for_service, ->(name) { where(service_name: name) }
41
+ scope :for_environment, ->(env) { where(environment: env) }
42
+ scope :for_agent, ->(agent_class) { where(agent_class: agent_class) }
43
+ scope :for_date_range, ->(start_date, end_date) { where(timestamp: start_date..end_date) }
44
+ # The dashboard agent this trace was attributed to on ingest, if any.
45
+ belongs_to :agent, class_name: "ActionAgent::Agent", optional: true
46
+
47
+ scope :for_account, ->(account) { where(account: account) if ActionAgent.multi_tenant? }
48
+
49
+ # Creates a TelemetryTrace from an ingested trace payload.
50
+ #
51
+ # Extracts relevant data from the trace payload and stores it in a
52
+ # normalized format for querying and analysis.
53
+ #
54
+ # @param trace [Hash] The trace payload from ActiveAgent::Telemetry
55
+ # @param sdk_info [Hash] SDK metadata
56
+ # @param account [Object, nil] Optional account for multi-tenant mode
57
+ # @return [TelemetryTrace] The created trace
58
+ # Plucks [llm_model, *columns] per trace, where llm_model comes from the
59
+ # first llm span. PostgreSQL digs into the spans jsonb in SQL so span
60
+ # payloads never reach Ruby; other adapters read the column back and dig
61
+ # in Ruby, which costs more but keeps the dashboard adapter-agnostic.
62
+ def self.pluck_with_llm_model(scope, *columns)
63
+ if postgres?
64
+ scope.pluck(
65
+ Arel.sql(
66
+ # spans is cast rather than assumed to be jsonb: the column is
67
+ # json on every install created before the migration template
68
+ # started picking jsonb per adapter, and jsonb_array_elements
69
+ # rejects a json argument outright.
70
+ "(SELECT s.value -> 'attributes' ->> 'llm.model' " \
71
+ "FROM jsonb_array_elements(spans::jsonb) AS s " \
72
+ "WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
73
+ ),
74
+ *columns
75
+ )
76
+ else
77
+ scope.pluck(:spans, *columns).map do |spans, *rest|
78
+ llm = Array(spans).find { |span| span.is_a?(Hash) && span["type"].to_s == "llm" }
79
+ [ llm&.dig("attributes", "llm.model"), *rest ]
80
+ end
81
+ end
82
+ end
83
+
84
+ def self.create_from_payload(trace, sdk_info = {}, account: nil)
85
+ spans = trace["spans"] || []
86
+ root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}
87
+
88
+ total_duration = root_span["duration_ms"]
89
+
90
+ # Instrumentation mirrors LLM token usage onto the root span for
91
+ # display, so summing every span double-counts. When child spans
92
+ # carry token data, they are the source of truth; the root span only
93
+ # counts for single-span traces.
94
+ counted_spans = spans.reject { |s| s["parent_span_id"].nil? }
95
+ counted_spans = spans if counted_spans.none? { |s| span_token_sum(s).positive? }
96
+
97
+ total_input = 0
98
+ total_output = 0
99
+ total_thinking = 0
100
+
101
+ counted_spans.each do |span|
102
+ tokens = span["tokens"] || {}
103
+ total_input += (tokens["input"] || 0)
104
+ total_output += (tokens["output"] || 0)
105
+ total_thinking += (tokens["thinking"] || 0)
106
+ end
107
+
108
+ # Extract agent info from root span attributes
109
+ attributes = root_span["attributes"] || {}
110
+ agent_class = attributes["agent.class"]
111
+ agent_action = attributes["agent.action"]
112
+
113
+ # Find any error message
114
+ error_span = spans.find { |s| s["status"] == STATUS_ERROR }
115
+ error_message = error_span&.dig("attributes", "error.message")
116
+
117
+ attrs = {
118
+ trace_id: trace["trace_id"],
119
+ service_name: trace["service_name"],
120
+ environment: trace["environment"],
121
+ timestamp: Time.parse(trace["timestamp"]),
122
+ spans: spans,
123
+ resource_attributes: trace["resource_attributes"],
124
+ sdk_info: sdk_info,
125
+ total_duration_ms: total_duration,
126
+ total_input_tokens: total_input,
127
+ total_output_tokens: total_output,
128
+ total_thinking_tokens: total_thinking,
129
+ status: root_span["status"] || STATUS_UNSET,
130
+ agent_class: agent_class,
131
+ agent_action: agent_action,
132
+ error_message: error_message
133
+ }
134
+
135
+ # Add account if in multi-tenant mode
136
+ attrs[:account] = account if ActionAgent.multi_tenant? && account
137
+
138
+ create!(attrs)
139
+ end
140
+
141
+ # Sums a span's token counts (used to decide which spans carry the
142
+ # authoritative token data during ingestion).
143
+ #
144
+ # @api private
145
+ def self.span_token_sum(span)
146
+ tokens = span["tokens"] || {}
147
+ tokens.fetch("input", 0).to_i + tokens.fetch("output", 0).to_i + tokens.fetch("thinking", 0).to_i
148
+ end
149
+
150
+ # Returns the root span of this trace.
151
+ #
152
+ # @return [Hash, nil] The root span or nil
153
+ def root_span
154
+ spans&.find { |s| s["parent_span_id"].nil? }
155
+ end
156
+
157
+ # Returns all LLM spans in this trace.
158
+ #
159
+ # @return [Array<Hash>] LLM spans
160
+ def llm_spans
161
+ spans&.select { |s| s["type"] == "llm" } || []
162
+ end
163
+
164
+ # Returns all tool call spans in this trace.
165
+ #
166
+ # @return [Array<Hash>] Tool spans
167
+ def tool_spans
168
+ spans&.select { |s| s["type"] == "tool" } || []
169
+ end
170
+
171
+ # Returns each tool call in this trace, normalized for display.
172
+ #
173
+ # Tool spans are tagged with their origin at instrumentation time
174
+ # (ActiveAgent::Telemetry::ToolOrigin), but traces ingested before that
175
+ # shipped — or sent by another SDK — only carry +tool.name+. Those are
176
+ # classified on read from the same naming convention, so a dashboard
177
+ # sees consistent attribution across old and new traces.
178
+ #
179
+ # @return [Array<Hash>] one entry per tool span with :name, :base_name,
180
+ # :origin, :mcp_server, :duration_ms, :status, :error, :arguments and
181
+ # :result
182
+ def tool_usage
183
+ tool_spans.map do |span|
184
+ attributes = span["attributes"] || {}
185
+ name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
186
+ classification = classify_tool(name, attributes)
187
+
188
+ {
189
+ name: name,
190
+ base_name: attributes["tool.base_name"] || classification[:tool],
191
+ origin: attributes["tool.origin"] || classification[:origin],
192
+ mcp_server: attributes["tool.mcp_server"] || classification[:server],
193
+ duration_ms: span["duration_ms"],
194
+ status: span["status"],
195
+ error: attributes["error.message"],
196
+ arguments: attributes["tool.input.args"],
197
+ result: attributes["tool.output.result"]
198
+ }
199
+ end
200
+ end
201
+
202
+ # Returns the tools this trace's generation request OFFERED the
203
+ # provider, whether or not the model went on to call any of them.
204
+ #
205
+ # Instrumentation records the roster on the prompt span as
206
+ # +prompt.input.tools+ (name, description, parameter keys), which is
207
+ # the agent's declared tool surface for that generation. Reading it
208
+ # here is what lets a dashboard show a tool that exists but has never
209
+ # been invoked — a state that tool spans alone can't express.
210
+ #
211
+ # @return [Array<Hash>] entries with :name, :description, :parameters,
212
+ # :origin and :mcp_server
213
+ def declared_tools
214
+ Array(tool_roster).filter_map do |tool|
215
+ next unless tool.is_a?(Hash)
216
+
217
+ name = (tool["name"] || tool[:name]).to_s
218
+ next if name.empty?
219
+
220
+ classification = ActiveAgent::Telemetry::ToolOrigin.classify(name)
221
+ {
222
+ name: name,
223
+ description: (tool["description"] || tool[:description]).presence,
224
+ parameters: normalize_parameters(tool["parameters"] || tool[:parameters]),
225
+ origin: classification[:origin],
226
+ mcp_server: classification[:server]
227
+ }
228
+ end
229
+ end
230
+
231
+ # Returns the distinct MCP servers this trace touched — both the ones
232
+ # it called and the ones it was merely offered.
233
+ #
234
+ # @return [Array<String>] server names, in first-seen order
235
+ def mcp_servers
236
+ (tool_usage.filter_map { |tool| tool[:mcp_server] } +
237
+ declared_tools.filter_map { |tool| tool[:mcp_server] }).uniq
238
+ end
239
+
240
+ # Returns total token count.
241
+ #
242
+ # @return [Integer] Total tokens used
243
+ def total_tokens
244
+ (total_input_tokens || 0) + (total_output_tokens || 0) + (total_thinking_tokens || 0)
245
+ end
246
+
247
+ # Returns whether this trace had an error.
248
+ #
249
+ # @return [Boolean]
250
+ def error?
251
+ status == STATUS_ERROR
252
+ end
253
+
254
+ # Returns display name for the trace.
255
+ #
256
+ # @return [String] Display name (e.g., "WeatherAgent.forecast")
257
+ def display_name
258
+ if agent_class && agent_action
259
+ "#{agent_class}.#{agent_action}"
260
+ elsif agent_class
261
+ agent_class
262
+ else
263
+ trace_id&.first(8)
264
+ end
265
+ end
266
+
267
+ # Returns formatted duration.
268
+ #
269
+ # @return [String] Duration in ms or s
270
+ def formatted_duration
271
+ return "—" unless total_duration_ms
272
+
273
+ if total_duration_ms >= 1000
274
+ "#{(total_duration_ms / 1000.0).round(2)}s"
275
+ else
276
+ "#{total_duration_ms.round(0)}ms"
277
+ end
278
+ end
279
+
280
+ # Returns formatted token count.
281
+ #
282
+ # @return [String] Token count with K suffix for large numbers
283
+ def formatted_tokens
284
+ count = total_tokens
285
+ return "0" if count.zero?
286
+
287
+ if count >= 1000
288
+ "#{(count / 1000.0).round(1)}K"
289
+ else
290
+ count.to_s
291
+ end
292
+ end
293
+
294
+ # Returns the provider used (from LLM spans).
295
+ #
296
+ # @return [String, nil] Provider name
297
+ def provider
298
+ llm_span = llm_spans.first
299
+ return nil unless llm_span
300
+
301
+ llm_span.dig("attributes", "llm.provider")
302
+ end
303
+
304
+ # Returns the model used (from LLM spans).
305
+ #
306
+ # @return [String, nil] Model name
307
+ def model
308
+ llm_span = llm_spans.first
309
+ return nil unless llm_span
310
+
311
+ llm_span.dig("attributes", "llm.model")
312
+ end
313
+
314
+ private
315
+
316
+ # The offered roster, as stored. Instrumentation writes JSON onto the
317
+ # prompt span; +llm.tools+ is accepted as an alias because some SDK
318
+ # versions put the roster on the llm span instead, and a payload that
319
+ # arrived already decoded is passed straight through.
320
+ def tool_roster
321
+ raw = spans.to_a.filter_map do |span|
322
+ attributes = span["attributes"] || {}
323
+ attributes["prompt.input.tools"].presence || attributes["llm.tools"].presence
324
+ end.first
325
+ return nil if raw.blank?
326
+ return raw unless raw.is_a?(String)
327
+
328
+ JSON.parse(raw)
329
+ rescue JSON::ParserError
330
+ nil
331
+ end
332
+
333
+ # The roster records parameters as a name list, but a raw JSON Schema
334
+ # arrives instead when an SDK forwards tool definitions verbatim.
335
+ def normalize_parameters(parameters)
336
+ return [] if parameters.blank?
337
+ return parameters.map(&:to_s) if parameters.is_a?(Array)
338
+
339
+ if parameters.is_a?(Hash)
340
+ properties = parameters["properties"] || parameters[:properties]
341
+ return properties.keys.map(&:to_s) if properties.is_a?(Hash)
342
+ end
343
+
344
+ []
345
+ end
346
+
347
+ # Recovers a tool's origin for traces that predate origin tagging.
348
+ # Prefers an explicit server attribute when the SDK sent one, then
349
+ # falls back to the shared name-convention classifier.
350
+ def classify_tool(name, attributes)
351
+ explicit = attributes["mcp.server"] || attributes["tool.server"]
352
+ return { origin: "mcp", server: explicit, tool: name } if explicit.present?
353
+
354
+ ActiveAgent::Telemetry::ToolOrigin.classify(name)
355
+ end
356
+ end
357
+ end