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,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