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,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Registers an Agent for each distinct agent action we observe in telemetry.
|
|
5
|
+
#
|
|
6
|
+
# Agents authored in the dashboard have records; agents running inside a
|
|
7
|
+
# customer's own app only ever reported traces, so the Agents list read 0 while
|
|
8
|
+
# Traces and Interactions were full of their runs. Ingest calls this to give
|
|
9
|
+
# every observed agent an identity that evaluations, versions, and per-agent
|
|
10
|
+
# metrics can hang off.
|
|
11
|
+
#
|
|
12
|
+
# Identity is (account, service_name, agent_class, agent_action) — one agent
|
|
13
|
+
# per action, not per class. Clara.respond (admin assistant, every MCP tool,
|
|
14
|
+
# ~$0.03/run) and Clara.title (no tools, temperature 0.2, ~$0.0004/run) are
|
|
15
|
+
# different agents that share a class name because one app method spawns both.
|
|
16
|
+
# Collapsing them would blend a $0.03 agent with a $0.0004 one into a single
|
|
17
|
+
# meaningless cost-per-run.
|
|
18
|
+
#
|
|
19
|
+
# Never raises: a registration failure must not fail a customer's telemetry.
|
|
20
|
+
class AgentRegistrar
|
|
21
|
+
# Guards against a misconfigured reporter with a dynamic agent class
|
|
22
|
+
# creating unbounded records.
|
|
23
|
+
MAX_OBSERVED_PER_OWNER = 200
|
|
24
|
+
|
|
25
|
+
def self.call(trace)
|
|
26
|
+
new(trace).call
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Rails.logger.warn("[AgentRegistrar] #{e.class}: #{e.message}")
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(trace)
|
|
33
|
+
@trace = trace
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
return if @trace.agent_id.present?
|
|
38
|
+
return if agent_class.blank?
|
|
39
|
+
|
|
40
|
+
owner = owner_for_trace
|
|
41
|
+
return if owner.nil? && ActionAgent.multi_tenant?
|
|
42
|
+
|
|
43
|
+
agent = find_or_create_agent(owner)
|
|
44
|
+
return if agent.nil?
|
|
45
|
+
|
|
46
|
+
@trace.update_columns(agent_id: agent.id)
|
|
47
|
+
touch_observation(agent)
|
|
48
|
+
agent
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
attr_reader :trace
|
|
54
|
+
|
|
55
|
+
def agent_class
|
|
56
|
+
@trace.agent_class.presence
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def action_name
|
|
60
|
+
@trace.agent_action.presence
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Name carries the action so the two Claras are distinguishable anywhere a
|
|
64
|
+
# bare agent name is shown.
|
|
65
|
+
def display_name
|
|
66
|
+
action_name ? "#{agent_class}.#{action_name}" : agent_class
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Who the agents observed on this trace belong to. Multi-tenant installs
|
|
70
|
+
# resolve the trace's tenant; a single-tenant dashboard owns everything
|
|
71
|
+
# itself, so there is nothing to attribute to.
|
|
72
|
+
def owner_for_trace
|
|
73
|
+
resolver = ActionAgent.trace_owner_resolver
|
|
74
|
+
return resolver.call(@trace) if resolver
|
|
75
|
+
|
|
76
|
+
ActionAgent.multi_tenant? ? @trace.try(:account) : nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_or_create_agent(owner)
|
|
80
|
+
agents = Agent.for_owner(owner)
|
|
81
|
+
existing = agents.find_by(
|
|
82
|
+
service_name: @trace.service_name,
|
|
83
|
+
agent_class_name: agent_class,
|
|
84
|
+
action_name: action_name
|
|
85
|
+
)
|
|
86
|
+
return existing if existing
|
|
87
|
+
|
|
88
|
+
return if agents.observed_agents.count >= MAX_OBSERVED_PER_OWNER
|
|
89
|
+
|
|
90
|
+
create_observed_agent(owner)
|
|
91
|
+
rescue ActiveRecord::RecordNotUnique
|
|
92
|
+
# Expected when concurrent ingest registers the same agent twice: the other
|
|
93
|
+
# writer won, so adopt its record. If no such record exists the collision
|
|
94
|
+
# was something else (a slug clash, say) and must not be swallowed.
|
|
95
|
+
agents.find_by(
|
|
96
|
+
service_name: @trace.service_name,
|
|
97
|
+
agent_class_name: agent_class,
|
|
98
|
+
action_name: action_name
|
|
99
|
+
) || raise
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def create_observed_agent(owner)
|
|
103
|
+
agent = Agent.new(
|
|
104
|
+
name: display_name,
|
|
105
|
+
slug: observed_slug(owner),
|
|
106
|
+
status: :observed,
|
|
107
|
+
service_name: @trace.service_name,
|
|
108
|
+
agent_class_name: agent_class,
|
|
109
|
+
action_name: action_name,
|
|
110
|
+
source: @trace.sdk_info&.dig("name"),
|
|
111
|
+
provider: llm_attribute("llm.provider") || "openai",
|
|
112
|
+
model: llm_attribute("llm.model") || "unknown",
|
|
113
|
+
instructions: llm_attribute("llm.instructions").to_s,
|
|
114
|
+
tools: observed_tools,
|
|
115
|
+
first_observed_at: @trace.timestamp,
|
|
116
|
+
last_observed_at: @trace.timestamp
|
|
117
|
+
)
|
|
118
|
+
agent.owner = owner
|
|
119
|
+
agent.save!
|
|
120
|
+
agent
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Slugs are unique per owner, but a host app may have grown a global
|
|
124
|
+
# unique index instead, and two tenants observing the same agent in the
|
|
125
|
+
# same service would then collide — the second silently failing to
|
|
126
|
+
# register. Check globally and suffix until free; it costs one query.
|
|
127
|
+
def observed_slug(_owner)
|
|
128
|
+
base = [ @trace.service_name, agent_class, action_name ].compact.join("-").parameterize
|
|
129
|
+
return base unless Agent.exists?(slug: base)
|
|
130
|
+
|
|
131
|
+
5.times do
|
|
132
|
+
candidate = "#{base}-#{SecureRandom.hex(3)}"
|
|
133
|
+
return candidate unless Agent.exists?(slug: candidate)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
"#{base}-#{SecureRandom.uuid}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Config we can only learn by watching: the model actually used, the
|
|
140
|
+
# instructions actually sent (when content capture is on), and the tools
|
|
141
|
+
# actually called — which is how Clara.respond acquires a tool list while
|
|
142
|
+
# Clara.title correctly stays empty.
|
|
143
|
+
def llm_attribute(key)
|
|
144
|
+
spans.filter_map { |span| span.dig("attributes", key).presence }.first
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The roster the agent was OFFERED (ActiveAgent puts it on the prompt span
|
|
148
|
+
# as prompt.input.tools), falling back to the tools actually CALLED for
|
|
149
|
+
# SDKs without roster capture. A tool the agent never happens to call is
|
|
150
|
+
# still part of its configuration.
|
|
151
|
+
def observed_tools
|
|
152
|
+
offered = begin
|
|
153
|
+
JSON.parse(llm_attribute("prompt.input.tools").to_s)
|
|
154
|
+
rescue JSON::ParserError
|
|
155
|
+
[]
|
|
156
|
+
end
|
|
157
|
+
names = Array(offered).filter_map { |tool| tool.is_a?(Hash) ? tool["name"].presence : nil }
|
|
158
|
+
names.presence || spans.filter_map { |span| span.dig("attributes", "tool.name").presence }.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def spans
|
|
162
|
+
@spans ||= Array(@trace.spans)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Config is learned progressively. An agent first seen before content
|
|
166
|
+
# capture was enabled registers with no instructions; the trace that finally
|
|
167
|
+
# carries them should fill that in rather than leave the record permanently
|
|
168
|
+
# blank. Only fills gaps — an operator's edits are never overwritten.
|
|
169
|
+
def touch_observation(agent)
|
|
170
|
+
updates = {}
|
|
171
|
+
|
|
172
|
+
if agent.last_observed_at.blank? || agent.last_observed_at < @trace.timestamp
|
|
173
|
+
updates[:last_observed_at] = @trace.timestamp
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if agent.instructions.blank? && (instructions = llm_attribute("llm.instructions")).present?
|
|
177
|
+
updates[:instructions] = instructions
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Union, not gap-fill: later traces legitimately grow the roster (new
|
|
181
|
+
# tools ship). Names are only ever added, so operator edits survive.
|
|
182
|
+
if observed_tools.any?
|
|
183
|
+
merged = Array(agent.tools) | observed_tools
|
|
184
|
+
updates[:tools] = merged if merged != Array(agent.tools)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if agent.model.blank? || agent.model == "unknown"
|
|
188
|
+
model = llm_attribute("llm.model")
|
|
189
|
+
updates[:model] = model if model.present?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
return if updates.empty?
|
|
193
|
+
|
|
194
|
+
agent.update_columns(updates.merge(updated_at: Time.current))
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Per-agent scorecard stats for the dashboard's agent cards, computed with
|
|
5
|
+
# grouped queries (no per-agent N+1) over two sources:
|
|
6
|
+
#
|
|
7
|
+
# * agent_runs — executions the platform itself ran, and
|
|
8
|
+
# * active_agent_telemetry_traces — executions reported by an SDK in the
|
|
9
|
+
# customer's own app, attributed to an Agent by AgentRegistrar.
|
|
10
|
+
#
|
|
11
|
+
# Agents discovered by observation (status: :observed) only ever have the
|
|
12
|
+
# second kind, so a runs-only scorecard reported 0 for every tile while the
|
|
13
|
+
# Traces view showed real traffic.
|
|
14
|
+
#
|
|
15
|
+
# A platform run writes BOTH an AgentRun and a trace sharing a trace_id, so
|
|
16
|
+
# traces are counted only when no AgentRun claims the same trace_id —
|
|
17
|
+
# otherwise every platform execution would count twice.
|
|
18
|
+
class AgentScorecard
|
|
19
|
+
WINDOW = 30.days
|
|
20
|
+
|
|
21
|
+
# @param agents [Enumerable<Agent>]
|
|
22
|
+
# @return [Hash{Integer => Hash}] agent_id => stats
|
|
23
|
+
def self.for_agents(agents)
|
|
24
|
+
ids = agents.map(&:id)
|
|
25
|
+
return {} if ids.empty?
|
|
26
|
+
|
|
27
|
+
window_start = WINDOW.ago
|
|
28
|
+
windowed = AgentRun.where(agent_id: ids, created_at: window_start..)
|
|
29
|
+
|
|
30
|
+
run_counts = windowed.group(:agent_id).count
|
|
31
|
+
completed_counts = windowed.where(status: :complete).group(:agent_id).count
|
|
32
|
+
avg_durations = windowed.where.not(duration_ms: nil).group(:agent_id).average(:duration_ms)
|
|
33
|
+
token_sums = windowed.group(:agent_id).sum("COALESCE(total_tokens, 0)")
|
|
34
|
+
last_runs = AgentRun.where(agent_id: ids).group(:agent_id).maximum(:created_at)
|
|
35
|
+
eval_runs = latest_evaluation_runs(ids)
|
|
36
|
+
|
|
37
|
+
trace_stats = telemetry_stats(ids, window_start)
|
|
38
|
+
trace_last = unclaimed_traces(ids, nil).group(:agent_id).maximum(:timestamp)
|
|
39
|
+
costs = estimated_costs(ids, windowed, window_start)
|
|
40
|
+
|
|
41
|
+
ids.index_with do |id|
|
|
42
|
+
runs = run_counts[id].to_i
|
|
43
|
+
stats = trace_stats[id] || {}
|
|
44
|
+
traced = stats[:count].to_i
|
|
45
|
+
total = runs + traced
|
|
46
|
+
succeeded = completed_counts[id].to_i + stats[:ok].to_i
|
|
47
|
+
eval_run = eval_runs[id]
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
window_days: (WINDOW / 1.day).to_i,
|
|
51
|
+
runs: total,
|
|
52
|
+
# Which sources contributed, so the UI can say where numbers came from.
|
|
53
|
+
run_sources: run_sources(runs, traced),
|
|
54
|
+
success_rate: total.positive? ? (succeeded * 100.0 / total).round(1) : nil,
|
|
55
|
+
avg_duration_ms: blended_duration(avg_durations[id], runs, stats[:avg_duration], traced),
|
|
56
|
+
tokens: token_sums[id].to_i + stats[:tokens].to_i,
|
|
57
|
+
cost: costs[id],
|
|
58
|
+
eval_score: eval_run&.average_score,
|
|
59
|
+
eval_samples_passed: eval_run&.samples_passed,
|
|
60
|
+
eval_samples_evaluated: eval_run&.samples_evaluated,
|
|
61
|
+
last_run_at: [ last_runs[id], trace_last[id] ].compact.max&.iso8601
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Count, success, latency and tokens for SDK-reported executions, in one
|
|
67
|
+
# grouped query. The success count uses SUM(CASE ...) rather than the
|
|
68
|
+
# tidier COUNT(*) FILTER so the same statement runs on SQLite and MySQL.
|
|
69
|
+
def self.telemetry_stats(agent_ids, window_start)
|
|
70
|
+
traces = ActionAgent.trace_model.table_name
|
|
71
|
+
|
|
72
|
+
rows = unclaimed_traces(agent_ids, window_start)
|
|
73
|
+
.group("#{traces}.agent_id")
|
|
74
|
+
.pluck(
|
|
75
|
+
Arel.sql("#{traces}.agent_id"),
|
|
76
|
+
Arel.sql("COUNT(*)"),
|
|
77
|
+
Arel.sql("SUM(CASE WHEN #{traces}.status <> 'ERROR' THEN 1 ELSE 0 END)"),
|
|
78
|
+
Arel.sql("AVG(#{traces}.total_duration_ms)"),
|
|
79
|
+
Arel.sql(
|
|
80
|
+
"SUM(COALESCE(total_input_tokens, 0) + COALESCE(total_output_tokens, 0) + " \
|
|
81
|
+
"COALESCE(total_thinking_tokens, 0))"
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
rows.to_h do |agent_id, count, ok, avg_duration, tokens|
|
|
86
|
+
[ agent_id, { count: count, ok: ok.to_i, avg_duration: avg_duration, tokens: tokens } ]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
private_class_method :telemetry_stats
|
|
90
|
+
|
|
91
|
+
# Estimated USD spend per agent over the window, across both sources.
|
|
92
|
+
# Rates are per model (ModelPricing), so the token columns are plucked
|
|
93
|
+
# alongside the model rather than summed in SQL — two queries, no payload
|
|
94
|
+
# loading: the trace's model lives in the spans jsonb and is extracted
|
|
95
|
+
# there, the run's in output_metadata.
|
|
96
|
+
def self.estimated_costs(agent_ids, windowed_runs, window_start)
|
|
97
|
+
costs = Hash.new(0.0)
|
|
98
|
+
priced = Set.new
|
|
99
|
+
|
|
100
|
+
run_models(windowed_runs).each do |agent_id, model, input, output|
|
|
101
|
+
cost = ModelPricing.estimate(model: model, input_tokens: input, output_tokens: output)
|
|
102
|
+
next unless cost
|
|
103
|
+
|
|
104
|
+
costs[agent_id] += cost
|
|
105
|
+
priced << agent_id
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
trace_models(unclaimed_traces(agent_ids, window_start)).each do |agent_id, model, input, output|
|
|
109
|
+
cost = ModelPricing.estimate(model: model, input_tokens: input, output_tokens: output)
|
|
110
|
+
next unless cost
|
|
111
|
+
|
|
112
|
+
costs[agent_id] += cost
|
|
113
|
+
priced << agent_id
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# nil, not 0.0, for agents with nothing to price — the card shows "—"
|
|
117
|
+
# rather than claiming a real $0.00 spend.
|
|
118
|
+
agent_ids.index_with { |id| priced.include?(id) ? costs[id].round(4) : nil }
|
|
119
|
+
end
|
|
120
|
+
private_class_method :estimated_costs
|
|
121
|
+
|
|
122
|
+
# [agent_id, model, input_tokens, output_tokens] per run. The model
|
|
123
|
+
# lives in the output_metadata JSON, which only PostgreSQL can reach
|
|
124
|
+
# from SQL; elsewhere the column is read back in Ruby.
|
|
125
|
+
def self.run_models(runs)
|
|
126
|
+
if AgentRun.postgres?
|
|
127
|
+
runs.pluck(:agent_id, Arel.sql("output_metadata ->> 'model'"), :input_tokens, :output_tokens)
|
|
128
|
+
else
|
|
129
|
+
runs.pluck(:agent_id, :output_metadata, :input_tokens, :output_tokens).map do |agent_id, metadata, input, output|
|
|
130
|
+
[ agent_id, metadata.is_a?(Hash) ? metadata["model"] : nil, input, output ]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
private_class_method :run_models
|
|
135
|
+
|
|
136
|
+
# The same, for reported traces: the model is on the first llm span.
|
|
137
|
+
def self.trace_models(traces)
|
|
138
|
+
table = ActionAgent.trace_model.table_name
|
|
139
|
+
|
|
140
|
+
ActionAgent.trace_model.pluck_with_llm_model(
|
|
141
|
+
traces, Arel.sql("#{table}.agent_id"), :total_input_tokens, :total_output_tokens
|
|
142
|
+
).map { |model, agent_id, input, output| [ agent_id, model, input, output ] }
|
|
143
|
+
end
|
|
144
|
+
private_class_method :trace_models
|
|
145
|
+
|
|
146
|
+
# Traces attributed to these agents that no AgentRun already accounts for.
|
|
147
|
+
# window_start nil scans all time (used for "last activity"). Shared with
|
|
148
|
+
# AgentExecutions so the cards, the list and the counts never disagree.
|
|
149
|
+
def self.unclaimed_traces(agent_ids, window_start)
|
|
150
|
+
AgentExecutions.unclaimed_traces(agent_ids, since: window_start)
|
|
151
|
+
end
|
|
152
|
+
private_class_method :unclaimed_traces
|
|
153
|
+
|
|
154
|
+
def self.run_sources(runs, traced)
|
|
155
|
+
sources = []
|
|
156
|
+
sources << "platform" if runs.positive?
|
|
157
|
+
sources << "telemetry" if traced.positive?
|
|
158
|
+
sources
|
|
159
|
+
end
|
|
160
|
+
private_class_method :run_sources
|
|
161
|
+
|
|
162
|
+
# Duration averages weight by how many executions each source contributed,
|
|
163
|
+
# so a blend of platform runs and traces isn't skewed by the smaller set.
|
|
164
|
+
def self.blended_duration(run_avg, runs, trace_avg, traced)
|
|
165
|
+
weighted = (run_avg.to_f * runs) + (trace_avg.to_f * traced)
|
|
166
|
+
counted = (run_avg ? runs : 0) + (trace_avg ? traced : 0)
|
|
167
|
+
return nil if counted.zero?
|
|
168
|
+
|
|
169
|
+
(weighted / counted).round
|
|
170
|
+
end
|
|
171
|
+
private_class_method :blended_duration
|
|
172
|
+
|
|
173
|
+
# Latest complete evaluation run per agent. DISTINCT ON would do this in
|
|
174
|
+
# one pass on PostgreSQL, but it has no portable equivalent, so the
|
|
175
|
+
# highest id per agent (runs are only ever appended) is selected first
|
|
176
|
+
# and those rows fetched by id.
|
|
177
|
+
def self.latest_evaluation_runs(agent_ids)
|
|
178
|
+
evaluations = Evaluation.table_name
|
|
179
|
+
runs = EvaluationRun.table_name
|
|
180
|
+
|
|
181
|
+
newest = EvaluationRun.complete.joins(:evaluation)
|
|
182
|
+
.where(evaluations => { agent_id: agent_ids })
|
|
183
|
+
.group("#{evaluations}.agent_id")
|
|
184
|
+
.pluck(Arel.sql("#{evaluations}.agent_id"), Arel.sql("MAX(#{runs}.id)"))
|
|
185
|
+
|
|
186
|
+
by_run_id = newest.to_h { |agent_id, run_id| [ run_id, agent_id ] }
|
|
187
|
+
EvaluationRun.where(id: by_run_id.keys).index_by { |run| by_run_id[run.id] }
|
|
188
|
+
end
|
|
189
|
+
private_class_method :latest_evaluation_runs
|
|
190
|
+
end
|
|
191
|
+
end
|