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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # The dashboard's aggregate queries are written portably, but a few of
5
+ # them have a much faster PostgreSQL form (jsonb traversal, containment).
6
+ # This is how those queries choose which one to run.
7
+ module AdapterAware
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # True when the backing store is PostgreSQL.
12
+ def postgres?
13
+ connection.adapter_name.to_s.downcase.include?("postgres")
14
+ end
15
+
16
+ # SQL grouping +column+ into hourly buckets. PostgreSQL has
17
+ # date_trunc; SQLite and MySQL get the equivalent format string.
18
+ # Pair with hour_bucket_epoch, which normalises what each adapter
19
+ # returns into epoch seconds so callers compare buckets the same way.
20
+ def hour_bucket_sql(column = :timestamp)
21
+ case connection.adapter_name.to_s.downcase
22
+ when /postgres/ then "date_trunc('hour', #{column})"
23
+ when /mysql/ then "DATE_FORMAT(#{column}, '%Y-%m-%d %H:00:00')"
24
+ else "strftime('%Y-%m-%d %H:00:00', #{column})"
25
+ end
26
+ end
27
+
28
+ # SQL matching rows whose JSON array +column+ actually has entries.
29
+ #
30
+ # `where.not(column: [])` cannot express this and is not a slower way
31
+ # of doing it — Active Record reads the empty array as an empty IN
32
+ # list, so the condition compiles to `1=1` and matches every row.
33
+ # +column+ is always a literal from this codebase, never user input.
34
+ def json_array_not_empty_sql(column)
35
+ case connection.adapter_name.to_s.downcase
36
+ when /postgres/ then "COALESCE(jsonb_array_length(#{column}::jsonb), 0) > 0"
37
+ when /mysql/ then "COALESCE(JSON_LENGTH(#{column}), 0) > 0"
38
+ else "COALESCE(json_array_length(#{column}), 0) > 0"
39
+ end
40
+ end
41
+
42
+ # PostgreSQL hands back a Time; the others a UTC string.
43
+ def hour_bucket_epoch(value)
44
+ return value.to_i if value.is_a?(Time) || value.is_a?(DateTime)
45
+
46
+ Time.find_zone("UTC").parse(value.to_s).to_i
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Attaches a dashboard record to whatever the host app calls an owner.
5
+ #
6
+ # Each model names the associations that could own it, most preferred
7
+ # first, and the first one the host app has actually configured wins:
8
+ #
9
+ # class Agent < ApplicationRecord
10
+ # include Ownable
11
+ # owned_by :user, :account
12
+ # end
13
+ #
14
+ # So an app with users scopes agents per user, an app with only accounts
15
+ # scopes them per account, and a single-user self-hosted install declares
16
+ # neither association and owns everything implicitly. That ordering is
17
+ # per-model on purpose: an app can reasonably keep agents per user while
18
+ # keeping API keys per account, and the platform does exactly that.
19
+ #
20
+ # Both foreign keys exist in the engine's schema either way, so moving a
21
+ # deployment between shapes is a configuration change, not a migration.
22
+ module Ownable
23
+ extend ActiveSupport::Concern
24
+
25
+ CLASS_FOR = { account: :account_class, user: :user_class }.freeze
26
+
27
+ class_methods do
28
+ # Declares the candidate owners for this model, most preferred first.
29
+ def owned_by(*candidates)
30
+ @owner_candidates = candidates.map(&:to_sym)
31
+
32
+ @owner_candidates.each do |candidate|
33
+ class_name = ActionAgent.public_send(CLASS_FOR.fetch(candidate))
34
+ next if class_name.blank?
35
+
36
+ belongs_to candidate, class_name: class_name, optional: true
37
+ end
38
+ end
39
+
40
+ def owner_candidates
41
+ @owner_candidates || []
42
+ end
43
+
44
+ # The association this install owns the model through, or nil when the
45
+ # host app configured no owner model at all.
46
+ def owner_association
47
+ owner_candidates.find { |c| ActionAgent.public_send(CLASS_FOR.fetch(c)).present? }
48
+ end
49
+
50
+ # Scopes to records owned by +owner+.
51
+ #
52
+ # A nil owner is read two different ways, and the difference is the
53
+ # whole point:
54
+ #
55
+ # * No owner model configured at all — the single-user self-hosted
56
+ # install. Nothing is owned, so everything is visible.
57
+ # * An owner model IS configured but did not resolve — a signed-out
58
+ # request, or a resolver that returned nil. Returning `all` here
59
+ # would hand one tenant every other tenant's records, so it
60
+ # returns nothing instead.
61
+ def for_owner(owner)
62
+ return all if owner_association.nil?
63
+ return none if owner.nil?
64
+
65
+ case owner_association
66
+ when :account then where(account_id: owner.id)
67
+ when :user then where(user_id: owner.id)
68
+ else all
69
+ end
70
+ end
71
+ end
72
+
73
+ # The record's owner under the current configuration, or nil.
74
+ def owner
75
+ association = self.class.owner_association
76
+ association && public_send(association)
77
+ end
78
+
79
+ # Assigns +owner+ to whichever association this install uses. A no-op
80
+ # when the host app configured no owner model.
81
+ def owner=(record)
82
+ association = self.class.owner_association
83
+ public_send(:"#{association}=", record) if association
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Concern for adding session recording to models that track browser interactions
5
+ #
6
+ # Include this in models like AgentRun or SandboxSession to enable
7
+ # automatic session recording for playback and handoff.
8
+ #
9
+ # Usage:
10
+ # class AgentRun < ApplicationRecord
11
+ # include SessionRecordable
12
+ # end
13
+ #
14
+ # # Start recording
15
+ # run.start_session_recording!
16
+ #
17
+ # # Record an action
18
+ # run.record_browser_action!(type: :click, selector: "#submit", screenshot: png_data)
19
+ #
20
+ # # Complete recording
21
+ # run.complete_session_recording!
22
+ #
23
+ module SessionRecordable
24
+ extend ActiveSupport::Concern
25
+
26
+ included do
27
+ has_one :session_recording, dependent: :destroy
28
+ end
29
+
30
+ # Start a new session recording
31
+ def start_session_recording!(name: nil)
32
+ return session_recording if session_recording&.recording?
33
+
34
+ create_session_recording!(
35
+ name: name || default_recording_name,
36
+ status: :recording,
37
+ metadata: { started_at: Time.current.iso8601 }
38
+ )
39
+ end
40
+
41
+ # Record a browser action
42
+ def record_browser_action!(type:, selector: nil, value: nil, screenshot: nil, dom_snapshot: nil, metadata: {})
43
+ return unless session_recording&.recording?
44
+
45
+ session_recording.record_action!(
46
+ action_type: type.to_s,
47
+ selector: selector,
48
+ value: value,
49
+ screenshot: screenshot,
50
+ dom_snapshot: dom_snapshot,
51
+ metadata: metadata
52
+ )
53
+ end
54
+
55
+ # Complete the recording
56
+ def complete_session_recording!
57
+ session_recording&.complete!
58
+ end
59
+
60
+ # Fail the recording
61
+ def fail_session_recording!(error_message = nil)
62
+ session_recording&.fail!(error_message)
63
+ end
64
+
65
+ # Check if recording is active
66
+ def recording_active?
67
+ session_recording&.recording?
68
+ end
69
+
70
+ # Get the session recording service
71
+ def recording_service
72
+ return nil unless session_recording
73
+
74
+ SessionRecordingService.new(session_recording)
75
+ end
76
+
77
+ private
78
+
79
+ def default_recording_name
80
+ prefix = if respond_to?(:name) && name.present?
81
+ name.parameterize
82
+ elsif respond_to?(:agent) && agent&.name
83
+ agent.name.parameterize
84
+ else
85
+ "session"
86
+ end
87
+
88
+ "#{prefix}_#{Time.current.strftime('%Y%m%d_%H%M%S')}"
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # One agent execution, whoever ran it.
5
+ #
6
+ # The platform records executions it initiates as AgentRun rows; agents
7
+ # running inside a customer's own app only ever produce a TelemetryTrace.
8
+ # They are the same grain — one attempt at one agent action — so the
9
+ # dashboard treats them as one list with a `source` discriminator rather
10
+ # than as two competing tabs:
11
+ #
12
+ # dashboard — an AgentRun (we executed it; has lifecycle, logs, cancel)
13
+ # reported — a trace no AgentRun claims (their app executed it)
14
+ #
15
+ # A platform execution usually writes BOTH an AgentRun and a trace sharing a
16
+ # trace_id, so traces are counted only when unclaimed. The reverse is not
17
+ # symmetric: a run that fails before its root span exists has no trace at
18
+ # all, which is why runs — not traces — are the authoritative source when
19
+ # both exist.
20
+ #
21
+ # Distinct from AgentContext, which is a *stream*: one durable conversation
22
+ # per agent action that many executions append to. A stream is a parent of
23
+ # executions, not an alternative to them.
24
+ class AgentExecutions
25
+ SOURCES = %w[dashboard reported].freeze
26
+
27
+ # What to rank a list of executions by. "Popular" is deliberately absent:
28
+ # it's an aggregate property of an agent or a model, not of one attempt —
29
+ # the agent list and the metrics table sort by it, this list can't.
30
+ SORTS = {
31
+ "recent" => "Most recent",
32
+ "longest" => "Longest running",
33
+ "cost" => "Highest cost",
34
+ "tokens" => "Most tokens"
35
+ }.freeze
36
+ DEFAULT_SORT = "recent"
37
+
38
+ Row = Struct.new(
39
+ :id, :source, :status, :occurred_at, :duration_ms, :tokens,
40
+ :input_tokens, :output_tokens, :cost,
41
+ :trace_id, :agent_id, :action_name, :provider, :model,
42
+ :input_preview, :output_preview, :error_message,
43
+ keyword_init: true
44
+ ) do
45
+ def as_json(*)
46
+ to_h.transform_values { |value| value.is_a?(Time) ? value.iso8601(3) : value }
47
+ end
48
+
49
+ # Runs are addressable by record id; reported executions only by trace.
50
+ def to_param
51
+ source == "dashboard" ? id.to_s : "trace-#{id}"
52
+ end
53
+ end
54
+
55
+ # @param agents [ActiveRecord::Relation, Array<Agent>] scope to these agents
56
+ # @param owner [Object, nil] tenant whose reported traces are in scope;
57
+ # nil in a single-tenant install, where every trace is in scope
58
+ # @param window_minutes [Integer, nil] nil means all time
59
+ # @param source [String, nil] "dashboard" | "reported" | nil for both
60
+ # @param status [String, nil] AgentRun status; also filters reported by OK/ERROR
61
+ # @param sort [String, nil] one of SORTS; unknown values fall back to recent
62
+ def initialize(agents:, owner: nil, window_minutes: nil, source: nil, status: nil, sort: nil)
63
+ @agent_ids = Array(agents.respond_to?(:pluck) ? agents.pluck(:id) : agents.map(&:id))
64
+ @owner = owner
65
+ @window_minutes = window_minutes
66
+ @source = source.presence
67
+ @status = status.presence
68
+ @sort = SORTS.key?(sort.to_s) ? sort.to_s : DEFAULT_SORT
69
+ end
70
+
71
+ # Ranked by the chosen dimension, descending. The two sources can only be
72
+ # ordered together in Ruby (no SQL union), which is also why this always
73
+ # ranks the whole window rather than the page.
74
+ def page(page: 1, per_page: 20)
75
+ all = sorted_rows
76
+ offset = (page.to_i - 1) * per_page.to_i
77
+ { rows: all[offset, per_page.to_i] || [], total: all.size }
78
+ end
79
+
80
+ def sorted_rows
81
+ # Ties (and rows missing the sorted value entirely — an execution still
82
+ # running has no duration) fall back to newest first, so the list never
83
+ # reorders arbitrarily between requests.
84
+ rows.sort_by { |row| [ -sort_value(row), -(row.occurred_at || Time.at(0)).to_f ] }
85
+ end
86
+
87
+ def rows
88
+ @rows ||= (include_dashboard? ? run_rows : []) + (include_reported? ? trace_rows : [])
89
+ end
90
+
91
+ # Traces attributed to these agents that no AgentRun already accounts for.
92
+ # Shared with AgentScorecard so every surface counts the same executions.
93
+ def self.unclaimed_traces(agent_ids, since: nil, owner: nil)
94
+ traces = ActionAgent.trace_model
95
+ runs_table = AgentRun.table_name
96
+ traces_table = traces.table_name
97
+
98
+ scope = traces.where(agent_id: agent_ids)
99
+ scope = scope.for_account(owner) if owner
100
+ scope = scope.where(timestamp: since..) if since
101
+ scope
102
+ .joins(<<~SQL.squish)
103
+ LEFT JOIN #{runs_table}
104
+ ON #{runs_table}.trace_id = #{traces_table}.trace_id
105
+ AND #{runs_table}.agent_id = #{traces_table}.agent_id
106
+ SQL
107
+ .where(runs_table => { id: nil })
108
+ end
109
+
110
+ private
111
+
112
+ def sort_value(row)
113
+ case @sort
114
+ when "longest" then row.duration_ms.to_f
115
+ when "cost" then row.cost.to_f
116
+ when "tokens" then row.tokens.to_i
117
+ else (row.occurred_at || Time.at(0)).to_f
118
+ end
119
+ end
120
+
121
+ def include_dashboard? = @source.nil? || @source == "dashboard"
122
+ # A single-tenant dashboard reads every trace it holds. A multi-tenant
123
+ # one has no business reading any without a tenant to scope them to.
124
+ def include_reported?
125
+ return false if ActionAgent.multi_tenant? && @owner.nil?
126
+
127
+ @source.nil? || @source == "reported"
128
+ end
129
+
130
+ def since = @window_minutes ? @window_minutes.to_i.minutes.ago : nil
131
+
132
+ def run_rows
133
+ scope = AgentRun.where(agent_id: @agent_ids)
134
+ scope = scope.where(created_at: since..) if since
135
+ scope = scope.where(status: @status) if @status
136
+
137
+ scope.map do |run|
138
+ model = run.output_metadata&.dig("model")
139
+ Row.new(
140
+ id: run.id,
141
+ source: "dashboard",
142
+ status: run.status,
143
+ occurred_at: run.created_at,
144
+ duration_ms: run.calculated_duration_ms,
145
+ tokens: run.total_tokens.to_i,
146
+ input_tokens: run.input_tokens.to_i,
147
+ output_tokens: run.output_tokens.to_i,
148
+ cost: ModelPricing.estimate(
149
+ model: model,
150
+ input_tokens: run.input_tokens,
151
+ output_tokens: run.output_tokens
152
+ ),
153
+ trace_id: run.trace_id,
154
+ agent_id: run.agent_id,
155
+ action_name: run.action_name,
156
+ provider: run.output_metadata&.dig("provider"),
157
+ model: model,
158
+ input_preview: run.input_prompt.to_s.truncate(160),
159
+ output_preview: run.output.to_s.truncate(160).presence,
160
+ error_message: run.error_message
161
+ )
162
+ end
163
+ end
164
+
165
+ def trace_rows
166
+ scope = self.class.unclaimed_traces(@agent_ids, since: since, owner: @owner)
167
+ # A run status filter has no exact analogue on a trace; map the two that
168
+ # do rather than silently ignoring the filter.
169
+ scope = scope.where(status: "ERROR") if @status == "failed"
170
+ scope = scope.where.not(status: "ERROR") if @status == "complete"
171
+ return [] if @status.present? && !%w[failed complete].include?(@status)
172
+
173
+ scope.map do |trace|
174
+ model = trace.model
175
+ Row.new(
176
+ id: trace.id,
177
+ source: "reported",
178
+ status: trace.status == "ERROR" ? "failed" : "complete",
179
+ occurred_at: trace.timestamp,
180
+ duration_ms: trace.total_duration_ms&.round,
181
+ tokens: trace.total_tokens.to_i,
182
+ input_tokens: trace.total_input_tokens.to_i,
183
+ output_tokens: trace.total_output_tokens.to_i,
184
+ cost: ModelPricing.estimate(
185
+ model: model,
186
+ input_tokens: trace.total_input_tokens,
187
+ output_tokens: trace.total_output_tokens
188
+ ),
189
+ trace_id: trace.trace_id,
190
+ agent_id: trace.agent_id,
191
+ action_name: trace.agent_action,
192
+ provider: trace.provider,
193
+ model: model,
194
+ input_preview: nil,
195
+ output_preview: nil,
196
+ error_message: trace.error_message
197
+ )
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Serializes AgentMessage records for the shared InteractionStream UI
5
+ # (Interactions view and the agent Conversation History run detail).
6
+ class AgentMessageSerializer
7
+ def self.call(message)
8
+ {
9
+ id: message.id,
10
+ role: message.role,
11
+ content: message.content,
12
+ tool_name: message.tool_name,
13
+ tool_call_id: message.tool_call_id,
14
+ tool_calls: message.tool_calls_data,
15
+ tool_arguments: message.tool_arguments.presence,
16
+ tool_result: message.tool_result,
17
+ duration_ms: message.metadata&.dig("duration_ms"),
18
+ content_checksum: message.content_checksum,
19
+ created_at: message.created_at.iso8601(3)
20
+ }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # The two lines a collapsed interaction row shows — what the stream was
5
+ # asked, and what it finally answered. Both sources of an interaction produce
6
+ # them: solid_agent contexts (Api::InteractionsController) and reported
7
+ # traces (TraceInteractionSerializer), so a list mixing the two reads the
8
+ # same way either side of the row came from.
9
+ module InteractionPreview
10
+ # One line each. The list is fifty streams deep, and the row clips to a
11
+ # single line anyway — sending the whole conversation to be truncated in
12
+ # the browser would pay for text nobody reads.
13
+ LIMIT = 300
14
+
15
+ def self.line(value)
16
+ text = value.to_s.squish
17
+ return nil if text.blank?
18
+
19
+ text.truncate(LIMIT)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Serializes TelemetryTrace records (gem span payloads) into the view-model
5
+ # consumed by the dashboard's TracesView: relative span offsets for the
6
+ # waterfall timeline, nesting depth, and token totals.
7
+ class TelemetryTraceSerializer
8
+ # Span types understood by the dashboard timeline (superset of the gem's
9
+ # ActiveAgent::Telemetry::Span::TYPES).
10
+ KNOWN_SPAN_TYPES = %w[root prompt generate llm tool thinking embedding response error].freeze
11
+
12
+ def self.summary(trace)
13
+ new(trace).summary
14
+ end
15
+
16
+ def self.detail(trace)
17
+ new(trace).detail
18
+ end
19
+
20
+ def initialize(trace)
21
+ @trace = trace
22
+ end
23
+
24
+ def summary
25
+ {
26
+ id: @trace.id,
27
+ trace_id: @trace.trace_id,
28
+ short_id: @trace.trace_id&.first(8),
29
+ agent: @trace.agent_class,
30
+ action: @trace.agent_action,
31
+ display_name: @trace.display_name,
32
+ service_name: @trace.service_name,
33
+ environment: @trace.environment,
34
+ provider: @trace.provider,
35
+ model: @trace.model,
36
+ status: @trace.status,
37
+ error: @trace.error_message,
38
+ duration_ms: @trace.total_duration_ms&.to_f&.round(2),
39
+ timestamp: @trace.timestamp&.iso8601(3),
40
+ timestamp_ms: @trace.timestamp&.to_f&.*(1000)&.round,
41
+ tokens: {
42
+ input: @trace.total_input_tokens || 0,
43
+ output: @trace.total_output_tokens || 0,
44
+ thinking: @trace.total_thinking_tokens || 0,
45
+ total: @trace.total_tokens
46
+ },
47
+ estimated_cost: ModelPricing.estimate(
48
+ model: @trace.model,
49
+ input_tokens: @trace.total_input_tokens,
50
+ output_tokens: @trace.total_output_tokens
51
+ ),
52
+ spans: serialized_spans
53
+ }
54
+ end
55
+
56
+ def detail
57
+ summary.merge(
58
+ resource_attributes: @trace.resource_attributes,
59
+ sdk_info: @trace.sdk_info
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ # Maps raw gem spans (start_time/end_time ISO strings, parent_span_id
66
+ # links) to waterfall rows with millisecond offsets relative to the root
67
+ # span and a nesting depth.
68
+ def serialized_spans
69
+ spans = Array(@trace.spans)
70
+ return [] if spans.empty?
71
+
72
+ by_id = spans.index_by { |s| s["span_id"] }
73
+ root = spans.find { |s| s["parent_span_id"].nil? } || spans.first
74
+ root_start = parse_time(root["start_time"])
75
+
76
+ spans.map do |span|
77
+ start = parse_time(span["start_time"])
78
+ offset = (root_start && start) ? ((start - root_start) * 1000).round(2) : 0
79
+
80
+ {
81
+ span_id: span["span_id"],
82
+ name: span["name"],
83
+ type: normalized_type(span),
84
+ start: [ offset, 0 ].max,
85
+ duration: span["duration_ms"]&.to_f&.round(2) || 0,
86
+ nested: depth_of(span, by_id),
87
+ status: span["status"],
88
+ error: span["status"] == "ERROR",
89
+ tokens: span["tokens"],
90
+ attributes: span["attributes"]
91
+ }
92
+ end
93
+ end
94
+
95
+ def normalized_type(span)
96
+ type = span["type"].to_s
97
+ KNOWN_SPAN_TYPES.include?(type) ? type : "root"
98
+ end
99
+
100
+ def depth_of(span, by_id)
101
+ depth = 0
102
+ current = span
103
+ while (parent_id = current["parent_span_id"])
104
+ parent = by_id[parent_id]
105
+ break unless parent
106
+
107
+ depth += 1
108
+ current = parent
109
+ break if depth > 10
110
+ end
111
+ depth
112
+ end
113
+
114
+ def parse_time(value)
115
+ return nil if value.blank?
116
+
117
+ Time.zone.parse(value.to_s)
118
+ rescue ArgumentError
119
+ nil
120
+ end
121
+ end
122
+ end