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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +51 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
- 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
|