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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Reconstructs a conversation stream from a telemetry trace, in the shape the
5
+ # dashboard's Interactions view already renders for platform-executed agents.
6
+ #
7
+ # Interactions normally reads solid_agent persistence (AgentContext /
8
+ # AgentMessage / AgentGeneration), which only exists for agents the platform
9
+ # ran itself. Agents running inside a customer's own app — a RubyLLM chat
10
+ # reporting through the telemetry endpoint, say — never write those tables, so
11
+ # the view was blind to them.
12
+ #
13
+ # The spans carry the same story when the reporter opts into content capture:
14
+ # the llm span holds `llm.prompt` / `llm.instructions` / `llm.completion`, and
15
+ # each tool span holds `tool.arguments` / `tool.result`. Ordering them by start
16
+ # time yields prompt → tool call → tool result → response.
17
+ class TraceInteractionSerializer
18
+ def self.summary(trace)
19
+ new(trace).summary
20
+ end
21
+
22
+ def self.detail(trace)
23
+ new(trace).detail
24
+ end
25
+
26
+ def initialize(trace)
27
+ @trace = trace
28
+ @spans = Array(trace.spans)
29
+ end
30
+
31
+ def summary
32
+ {
33
+ id: "trace-#{@trace.id}",
34
+ source: "telemetry",
35
+ agent_name: @trace.agent_class,
36
+ action_name: @trace.agent_action,
37
+ display_name: @trace.display_name,
38
+ agent: nil,
39
+ service_name: @trace.service_name,
40
+ environment: @trace.environment,
41
+ model: @trace.model,
42
+ tokens: {
43
+ input: @trace.total_input_tokens,
44
+ output: @trace.total_output_tokens,
45
+ total: @trace.total_input_tokens.to_i + @trace.total_output_tokens.to_i
46
+ },
47
+ message_count: messages.size,
48
+ preview: preview,
49
+ # Tool activity is known even when content capture is off, so a run
50
+ # reported without prompts still shows what it did.
51
+ tool_count: tool_spans.size,
52
+ duration_ms: @trace.total_duration_ms&.to_f,
53
+ generation_count: llm_spans.size,
54
+ created_at: @trace.timestamp.iso8601,
55
+ last_activity_at: @trace.timestamp.iso8601
56
+ }
57
+ end
58
+
59
+ def detail
60
+ summary.merge(
61
+ instructions: llm_attribute("llm.instructions") || any_attribute("prompt.input.instructions"),
62
+ messages: messages,
63
+ generations: generations
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ # What the run was asked and what it answered — the opening prompt and the
70
+ # final assistant turn, since the middle of a stream is tool traffic.
71
+ def preview
72
+ said = ->(role, list) { list.find { |m| m[:role] == role && m[:content].present? }&.[](:content) }
73
+
74
+ {
75
+ input: InteractionPreview.line(said.call("user", messages)),
76
+ # A tool-calling assistant turn carries no prose, so the last one that
77
+ # does is the answer.
78
+ output: InteractionPreview.line(said.call("assistant", messages.reverse))
79
+ }
80
+ end
81
+
82
+ # The generation span. Older traces wrapped a separate `llm` span inside a
83
+ # root; newer ones merge them, since the provider loop *is* the interaction.
84
+ # Accept both, and never count the same generation twice.
85
+ def llm_spans
86
+ @llm_spans ||= begin
87
+ nested = @spans.select { |span| span["type"] == "llm" }
88
+ nested.presence || @spans.select { |span| span["parent_span_id"].nil? && span.dig("attributes", "llm.model") }
89
+ end
90
+ end
91
+
92
+ def tool_spans
93
+ @tool_spans ||= @spans.select { |span| span["type"] == "tool" }
94
+ .sort_by { |span| span["start_time"].to_s }
95
+ end
96
+
97
+ def llm_attribute(key)
98
+ llm_spans.filter_map { |span| span.dig("attributes", key).presence }.first
99
+ end
100
+
101
+ # ActiveAgent's instrumentation puts prompt contents on the prompt span
102
+ # (RubyLLM puts everything on the llm span) — accept either home.
103
+ def any_attribute(key)
104
+ @spans.filter_map { |span| span.dig("attributes", key).presence }.first
105
+ end
106
+
107
+ # ActiveAgent reports the outbound conversation as a JSON array of
108
+ # {role, content} on the prompt span rather than a single llm.prompt.
109
+ def outbound_messages
110
+ raw = any_attribute("prompt.input.messages")
111
+ return [] if raw.blank?
112
+
113
+ entries = raw.is_a?(String) ? JSON.parse(raw) : raw
114
+ entries.is_a?(Array) ? entries.select { |m| m.is_a?(Hash) && m["content"].present? } : []
115
+ rescue JSON::ParserError, TypeError
116
+ []
117
+ end
118
+
119
+ # prompt → (tool call → tool result)* → completion. Tool pairs are ordered by
120
+ # span start time, so the stream reads the way the run actually happened.
121
+ def messages
122
+ @messages ||= begin
123
+ stream = []
124
+ index = 0
125
+ prompt_span = @spans.find { |span| span["type"] == "prompt" }
126
+
127
+ if (prompt = llm_attribute("llm.prompt"))
128
+ stream << message(index += 1, role: "user", content: prompt, at: @trace.timestamp, timing: timing_for(prompt_span))
129
+ else
130
+ outbound_messages.each do |entry|
131
+ stream << message(index += 1, role: entry["role"].presence || "user", content: entry["content"], at: @trace.timestamp, timing: timing_for(prompt_span))
132
+ end
133
+ end
134
+
135
+ tool_spans.each do |span|
136
+ name = span.dig("attributes", "tool.name")
137
+ call_id = span.dig("attributes", "tool.call_id")
138
+ started = parse_time(span["start_time"]) || @trace.timestamp
139
+ finished = parse_time(span["end_time"]) || started
140
+
141
+ stream << message(
142
+ index += 1, role: "assistant", content: nil, at: started,
143
+ tool_name: name, tool_call_id: call_id,
144
+ tool_calls: parsed(span.dig("attributes", "tool.arguments") || span.dig("attributes", "tool.input.args")),
145
+ timing: timing_for(span)
146
+ )
147
+ stream << message(
148
+ index += 1, role: "tool",
149
+ content: span.dig("attributes", "tool.result") || span.dig("attributes", "tool.output.result"),
150
+ at: finished,
151
+ tool_name: name, tool_call_id: call_id,
152
+ timing: timing_for(span)
153
+ )
154
+ end
155
+
156
+ if (completion = llm_attribute("llm.completion") || llm_attribute("llm.output.message"))
157
+ stream << message(index += 1, role: "assistant", content: completion, at: end_time, timing: timing_for(llm_spans.first))
158
+ end
159
+
160
+ stream
161
+ end
162
+ end
163
+
164
+ # Where a message's span sits on the trace's wall clock — lets the
165
+ # conversation view double as a waterfall (span-message pill bars).
166
+ def trace_start
167
+ @trace_start ||= @spans.filter_map { |span| parse_time(span["start_time"]) }.min
168
+ end
169
+
170
+ def timing_for(span)
171
+ return {} unless span
172
+
173
+ start = parse_time(span["start_time"])
174
+ offset = (start && trace_start) ? ((start - trace_start) * 1000.0).round(2) : 0
175
+ {
176
+ span_start_ms: [ offset, 0 ].max,
177
+ span_duration_ms: span["duration_ms"]&.to_f&.round(2) || 0,
178
+ trace_duration_ms: @trace.total_duration_ms&.to_f&.round(2)
179
+ }
180
+ end
181
+
182
+ def message(index, role:, content:, at:, tool_name: nil, tool_call_id: nil, tool_calls: nil, timing: nil)
183
+ {
184
+ id: "#{@trace.id}-#{index}",
185
+ role: role,
186
+ content: content,
187
+ tool_name: tool_name,
188
+ tool_call_id: tool_call_id,
189
+ tool_calls: tool_calls,
190
+ content_checksum: nil,
191
+ created_at: at.iso8601(3),
192
+ **(timing || {})
193
+ }
194
+ end
195
+
196
+ def generations
197
+ llm_spans.map.with_index(1) do |span, index|
198
+ attributes = span["attributes"] || {}
199
+ tokens = span["tokens"] || {}
200
+
201
+ {
202
+ id: "#{@trace.id}-llm-#{index}",
203
+ model: attributes["llm.model"],
204
+ provider: attributes["llm.provider"],
205
+ finish_reason: span["status"] == "ERROR" ? "error" : attributes["llm.finish_reason"],
206
+ tokens: {
207
+ input: tokens["input"].to_i,
208
+ output: tokens["output"].to_i,
209
+ total: tokens.values_at("input", "output", "thinking").compact.sum(&:to_i),
210
+ cached: 0,
211
+ thinking: tokens["thinking"].to_i
212
+ },
213
+ cache_hit: false,
214
+ thinking: tokens["thinking"].to_i.positive?,
215
+ duration_seconds: span["duration_ms"] ? (span["duration_ms"].to_f / 1000).round(3) : nil,
216
+ trace_id: @trace.trace_id,
217
+ created_at: (parse_time(span["start_time"]) || @trace.timestamp).iso8601(3)
218
+ }
219
+ end
220
+ end
221
+
222
+ # Tool arguments arrive JSON-encoded; hand the view an object when possible
223
+ # so it renders structurally rather than as an escaped string.
224
+ def parsed(value)
225
+ return nil if value.blank?
226
+ # A reporter can send an attribute already decoded — JSON.parse raises
227
+ # TypeError rather than ParserError on a Hash or Array, and one such
228
+ # span would otherwise take down the whole Interactions list.
229
+ return value unless value.is_a?(String)
230
+
231
+ JSON.parse(value)
232
+ rescue JSON::ParserError, TypeError
233
+ value
234
+ end
235
+
236
+ def end_time
237
+ parse_time(llm_spans.first&.dig("end_time")) || @trace.timestamp
238
+ end
239
+
240
+ def parse_time(value)
241
+ Time.zone.parse(value.to_s)
242
+ rescue ArgumentError, TypeError
243
+ nil
244
+ end
245
+ end
246
+ end