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,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class Agent < ApplicationRecord
5
+ include Ownable
6
+ owned_by :user, :account
7
+
8
+ has_many :agent_versions, dependent: :destroy
9
+ has_many :agent_runs, dependent: :destroy
10
+ has_many :evaluations, dependent: :destroy
11
+ has_many :agent_memories, as: :memorable, dependent: :destroy
12
+
13
+ # Polymorphic rows (agent_memories, agent_contexts) store this string.
14
+ # A host app that grew these tables under its own Agent constant keeps
15
+ # its existing rows readable by setting agent_polymorphic_name.
16
+ def self.polymorphic_name
17
+ ActionAgent.agent_polymorphic_name || super
18
+ end
19
+
20
+ # Validations
21
+ validates :name, presence: true, length: { minimum: 2, maximum: 100 }
22
+ validates :slug, presence: true, format: { with: /\A[a-z0-9\-_]+\z/ }
23
+ # Slugs are unique per owner. Which column that means depends on the
24
+ # configured mode, so it is resolved at validation time rather than
25
+ # baked into a uniqueness scope when the class loads.
26
+ validate :slug_unique_within_owner
27
+ validates :provider, presence: true
28
+ validates :model, presence: true
29
+ validate :validate_action_prompts
30
+
31
+ # Status enum
32
+ # `observed` agents were discovered from reported telemetry rather than
33
+ # authored here. They can't be executed by the platform — we can't push
34
+ # instructions into someone else's app — so they're read-only until forked.
35
+ enum :status, { draft: 0, active: 1, archived: 2, observed: 3 }
36
+
37
+ scope :observed_agents, -> { where(status: :observed) }
38
+ scope :authored, -> { where.not(status: :observed) }
39
+
40
+ # Callbacks
41
+ before_validation :generate_slug, on: :create
42
+ after_create :create_initial_version
43
+ after_update :create_version_on_config_change, if: :configuration_changed?
44
+
45
+ # Scopes
46
+ scope :active_agents, -> { where(status: :active) }
47
+ scope :by_provider, ->(provider) { where(provider: provider) }
48
+ # jsonb containment on PostgreSQL; a substring match on the serialized
49
+ # array everywhere else. The fallback can over-match a tool whose name
50
+ # is a prefix of another, so the JSON quoting is kept in the pattern.
51
+ scope :with_tool, ->(tool) {
52
+ if postgres?
53
+ # Cast both sides: @> is a jsonb operator, and a host app mounting
54
+ # the engine over its own pre-existing tables may have declared the
55
+ # column as json.
56
+ where("tools::jsonb @> ?::jsonb", [ tool ].to_json)
57
+ else
58
+ where("tools LIKE ?", "%\"#{tool}\"%")
59
+ end
60
+ }
61
+
62
+ # Available presets matching AgentAvatar component
63
+ PRESET_TYPES = %w[
64
+ terminal webDeveloper documentAnalysis writing translation
65
+ playwright research imageAnalysis computerUse productDesign
66
+ ].freeze
67
+
68
+ # Available instruction sets
69
+ INSTRUCTION_SETS = %w[
70
+ github ruby rails aws gcp python typescript docker kubernetes
71
+ ].freeze
72
+
73
+ # Available tools/MCPs
74
+ AVAILABLE_TOOLS = %w[
75
+ terminal playwright filesystem code database slack fetch search edit translate memory agents
76
+ ].freeze
77
+
78
+ # Available providers
79
+ PROVIDERS = %w[openai anthropic ollama openrouter].freeze
80
+
81
+ # The ActiveAgent class name this agent's runs are recorded under — the
82
+ # correlation key between platform Agent records and telemetry traces
83
+ # (TelemetryTrace#agent_class) and solid_agent contexts.
84
+ def telemetry_agent_class
85
+ base = agent_class_name.presence || name.parameterize(separator: "_").camelize
86
+ base.end_with?("Agent") ? base : "#{base}Agent"
87
+ end
88
+
89
+ # The agent's long-term memory (solid_agent HasMemory contract) — the
90
+ # summary list its runs read/write via the memory tools.
91
+ def memory
92
+ AgentMemory.for(self)
93
+ end
94
+
95
+ # The default action every agent has; uses the base instructions alone.
96
+ DEFAULT_ACTION = "ask"
97
+
98
+ # All invokable action names: the default plus each named action prompt.
99
+ def available_actions
100
+ [ DEFAULT_ACTION ] + Array(action_prompts).filter_map { |ap| ap["name"].presence }
101
+ end
102
+
103
+ def action_prompt_for(action_name)
104
+ Array(action_prompts).find { |ap| ap["name"] == action_name.to_s }
105
+ end
106
+
107
+ # The system instructions an action executes under: named actions stack
108
+ # their prompt below the agent's base instructions; the default action
109
+ # uses the base instructions alone.
110
+ def composed_instructions_for(action_name)
111
+ action = action_prompt_for(action_name)
112
+ [ instructions, action&.dig("prompt") ].map(&:presence).compact.join("\n\n").presence
113
+ end
114
+
115
+ # Returns the configuration as a hash for versioning
116
+ def configuration_snapshot
117
+ {
118
+ name: name,
119
+ description: description,
120
+ provider: provider,
121
+ model: model,
122
+ instructions: instructions,
123
+ action_prompts: action_prompts,
124
+ preset_type: preset_type,
125
+ appearance: appearance,
126
+ instruction_sets: instruction_sets,
127
+ tools: tools,
128
+ mcp_servers: mcp_servers,
129
+ model_config: model_config,
130
+ response_format: response_format
131
+ }
132
+ end
133
+
134
+ # Restore from a version
135
+ def restore_from_version!(version)
136
+ config = version.configuration_snapshot
137
+ update!(
138
+ instructions: config["instructions"],
139
+ action_prompts: config["action_prompts"] || [],
140
+ preset_type: config["preset_type"],
141
+ appearance: config["appearance"],
142
+ instruction_sets: config["instruction_sets"],
143
+ tools: config["tools"],
144
+ mcp_servers: config["mcp_servers"],
145
+ model_config: config["model_config"],
146
+ response_format: config["response_format"]
147
+ )
148
+ end
149
+
150
+ # Get the latest version
151
+ def latest_version
152
+ agent_versions.order(version_number: :desc).first
153
+ end
154
+
155
+ # Maps each historical instructions digest to the first version that
156
+ # introduced it ("v3"), so run cohorts can label instruction changes with
157
+ # real agent versions instead of raw hashes.
158
+ def instructions_digest_versions
159
+ agent_versions.order(:version_number).each_with_object({}) do |version, map|
160
+ snapshot = version.configuration_snapshot
161
+ base = snapshot["instructions"]
162
+ label = "v#{version.version_number}"
163
+
164
+ if base.present?
165
+ map[Digest::SHA256.hexdigest(base).first(8)] ||= label
166
+ end
167
+
168
+ # Named actions run under composed instructions (base + action
169
+ # prompt), so their runs carry a different digest per action.
170
+ Array(snapshot["action_prompts"]).each do |action|
171
+ composed = [ base, action["prompt"] ].map(&:presence).compact.join("\n\n")
172
+ next if composed.blank?
173
+
174
+ map[Digest::SHA256.hexdigest(composed).first(8)] ||= label
175
+ end
176
+ end
177
+ end
178
+
179
+ # Get version count
180
+ def version_count
181
+ agent_versions.count
182
+ end
183
+
184
+ # Generate Ruby agent class code
185
+ def to_agent_class_code
186
+ <<~RUBY
187
+ class #{agent_class_name || name.camelize}Agent < ApplicationAgent
188
+ generate_with :#{provider}, model: "#{model}"#{model_config_code}
189
+
190
+ def perform
191
+ prompt#{instructions_code}
192
+ end
193
+ end
194
+ RUBY
195
+ end
196
+
197
+ # Execute a run with this agent
198
+ def execute(input_prompt, action: nil, **params)
199
+ run = agent_runs.create!(
200
+ input_prompt: input_prompt,
201
+ action_name: normalized_action(action),
202
+ input_params: params,
203
+ status: :pending,
204
+ trace_id: SecureRandom.uuid
205
+ )
206
+
207
+ # Queue the execution job
208
+ AgentExecutionJob.perform_later(run.id)
209
+
210
+ run
211
+ end
212
+
213
+ # Quick test execution (synchronous)
214
+ def test_execute(input_prompt, action: nil, **params)
215
+ run = agent_runs.create!(
216
+ input_prompt: input_prompt,
217
+ action_name: normalized_action(action),
218
+ input_params: params,
219
+ status: :running,
220
+ trace_id: SecureRandom.uuid,
221
+ started_at: Time.current
222
+ )
223
+
224
+ begin
225
+ # Build and execute the agent
226
+ result = AgentExecutionService.call(self, run)
227
+
228
+ run.update!(
229
+ output: result[:output],
230
+ output_metadata: result[:metadata],
231
+ status: :complete,
232
+ completed_at: Time.current,
233
+ duration_ms: ((Time.current - run.started_at) * 1000).to_i,
234
+ input_tokens: result.dig(:usage, :input_tokens),
235
+ output_tokens: result.dig(:usage, :output_tokens),
236
+ total_tokens: result.dig(:usage, :total_tokens)
237
+ )
238
+ rescue => e
239
+ run.update!(
240
+ status: :failed,
241
+ completed_at: Time.current,
242
+ error_message: e.message,
243
+ error_backtrace: e.backtrace&.first(10)&.join("\n")
244
+ )
245
+ end
246
+
247
+ run
248
+ end
249
+
250
+ private
251
+
252
+ def slug_unique_within_owner
253
+ return if slug.blank?
254
+
255
+ siblings = self.class.for_owner(owner)
256
+ siblings = siblings.where.not(id: id) if persisted?
257
+ errors.add(:slug, "has already been taken") if siblings.exists?(slug: slug)
258
+ end
259
+
260
+ def generate_slug
261
+ return if slug.present?
262
+
263
+ base_slug = name.to_s.parameterize
264
+ self.slug = base_slug
265
+
266
+ # Checked globally rather than per owner: a host app may have a global
267
+ # unique index on slug (ours does), and suffixing costs one query.
268
+ counter = 1
269
+ while self.class.exists?(slug: slug)
270
+ self.slug = "#{base_slug}-#{counter}"
271
+ counter += 1
272
+ end
273
+ end
274
+
275
+ def create_initial_version
276
+ agent_versions.create!(
277
+ version_number: 1,
278
+ change_summary: "Initial creation",
279
+ configuration_snapshot: configuration_snapshot
280
+ )
281
+ end
282
+
283
+ VERSIONED_FIELDS = %w[
284
+ instructions action_prompts preset_type appearance instruction_sets
285
+ tools mcp_servers model_config response_format
286
+ ].freeze
287
+
288
+ def configuration_changed?
289
+ saved_changes.keys.any? { |key| VERSIONED_FIELDS.include?(key) }
290
+ end
291
+
292
+ def create_version_on_config_change
293
+ next_version = (latest_version&.version_number || 0) + 1
294
+ changed_fields = saved_changes.keys.select { |key| VERSIONED_FIELDS.include?(key) }
295
+
296
+ agent_versions.create!(
297
+ version_number: next_version,
298
+ change_summary: "Updated: #{changed_fields.join(', ')}",
299
+ configuration_snapshot: configuration_snapshot
300
+ )
301
+ end
302
+
303
+ # Unknown action names fall back to the default rather than failing the
304
+ # run — an action can be renamed between enqueue and execution.
305
+ def normalized_action(action)
306
+ action = action.to_s.presence
307
+ action && available_actions.include?(action) ? action : nil
308
+ end
309
+
310
+ def validate_action_prompts
311
+ return if action_prompts.blank?
312
+
313
+ unless action_prompts.is_a?(Array) && action_prompts.all? { |ap| ap.is_a?(Hash) }
314
+ errors.add(:action_prompts, "must be a list of action definitions")
315
+ return
316
+ end
317
+
318
+ names = action_prompts.map { |ap| ap["name"].to_s }
319
+ names.each do |action_name|
320
+ unless action_name.match?(/\A[a-z][a-z0-9_]*\z/)
321
+ errors.add(:action_prompts, "action name '#{action_name}' must be snake_case")
322
+ end
323
+ if action_name == DEFAULT_ACTION
324
+ errors.add(:action_prompts, "'#{DEFAULT_ACTION}' is the built-in default action")
325
+ end
326
+ end
327
+ errors.add(:action_prompts, "action names must be unique") if names.uniq.size != names.size
328
+ end
329
+
330
+ def model_config_code
331
+ return "" if model_config.blank?
332
+
333
+ configs = model_config.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
334
+ ", #{configs}"
335
+ end
336
+
337
+ def instructions_code
338
+ return "" if instructions.blank?
339
+
340
+ "\n prompt instructions: <<~INSTRUCTIONS\n #{instructions.gsub("\n", "\n ")}\n INSTRUCTIONS"
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Conversation context persisted by SolidAgent::HasContext — one row per
5
+ # (contextable, agent, action). On this platform the contextable is the
6
+ # dashboard Agent record, so a context is that agent's interaction stream.
7
+ #
8
+ # Based on the solid_agent install generator's AgentContext, extended with
9
+ # record_generation_with_provenance! (the concern's duck-typed extension
10
+ # point) so each generation carries the telemetry trace_id and provenance.
11
+ class AgentContext < ApplicationRecord
12
+ belongs_to :contextable, polymorphic: true, optional: true
13
+ has_many :messages, class_name: "AgentMessage", dependent: :destroy
14
+ has_many :generations, class_name: "AgentGeneration", dependent: :destroy
15
+
16
+ validates :agent_name, presence: true
17
+ validates :action_name, presence: true
18
+
19
+ scope :recent, -> { order(updated_at: :desc) }
20
+ scope :for_agent, ->(name) { where(agent_name: name) }
21
+ scope :for_action, ->(name) { where(action_name: name) }
22
+ scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
23
+ scope :for_agents, ->(agents) { where(contextable_type: Agent.polymorphic_name, contextable_id: agents.select(:id)) }
24
+
25
+ # Records a generation response and updates token counts.
26
+ #
27
+ # Defensive against response objects that don't expose provider/duration
28
+ # (activeagent 1.0.3 responses don't) — solid_agent's stock template would
29
+ # raise (and silently drop the generation) on those.
30
+ def record_generation!(response, extra_attributes = {})
31
+ usage = response.respond_to?(:usage) ? response.usage : nil
32
+
33
+ generation = generations.create!({
34
+ content: response.message&.content,
35
+ model: value_if_responds(response, :model),
36
+ provider: value_if_responds(response, :provider),
37
+ finish_reason: value_if_responds(response, :finish_reason),
38
+ input_tokens: usage&.input_tokens || 0,
39
+ output_tokens: usage&.output_tokens || 0,
40
+ cached_tokens: value_if_responds(usage, :cached_tokens) || 0,
41
+ reasoning_tokens: value_if_responds(usage, :reasoning_tokens) || 0,
42
+ tool_calls: extract_tool_calls(response),
43
+ raw_response: value_if_responds(response, :raw_response),
44
+ duration_seconds: extract_duration_seconds(response, usage)
45
+ }.merge(extra_attributes))
46
+
47
+ increment!(:total_input_tokens, generation.input_tokens)
48
+ increment!(:total_output_tokens, generation.output_tokens)
49
+
50
+ add_assistant_message(response.message&.content, metadata: { "tool_calls" => generation.tool_calls })
51
+
52
+ generation
53
+ end
54
+
55
+ # Called by SolidAgent::HasContext when defined: persists the generation
56
+ # together with its provenance hash and the telemetry trace_id, so
57
+ # conversation records join to active_agent_telemetry_traces.
58
+ def record_generation_with_provenance!(response, provenance)
59
+ provenance = (provenance || {}).deep_stringify_keys
60
+
61
+ record_generation!(
62
+ response,
63
+ trace_id: provenance["trace_id"],
64
+ provenance: provenance
65
+ )
66
+ end
67
+
68
+ def add_user_message(content, **attributes)
69
+ messages.create!(role: "user", content: content, **attributes)
70
+ end
71
+
72
+ def add_assistant_message(content, **attributes)
73
+ messages.create!(role: "assistant", content: content, **attributes)
74
+ end
75
+
76
+ def add_system_message(content)
77
+ messages.create!(role: "system", content: content)
78
+ end
79
+
80
+ def add_tool_message(tool_call_id:, tool_name:, result:, arguments: nil, duration_ms: nil)
81
+ messages.create!(
82
+ role: "tool",
83
+ tool_call_id: tool_call_id,
84
+ tool_name: tool_name,
85
+ tool_result: result,
86
+ tool_arguments: arguments.presence || {},
87
+ metadata: duration_ms ? { "duration_ms" => duration_ms } : {},
88
+ content: result.is_a?(String) ? result : result.to_json
89
+ )
90
+ end
91
+
92
+ def total_tokens
93
+ total_input_tokens + total_output_tokens
94
+ end
95
+
96
+ def input_params
97
+ options&.dig("input_params") || {}
98
+ end
99
+
100
+ private
101
+
102
+ def value_if_responds(response, method)
103
+ response.respond_to?(method) ? response.public_send(method) : nil
104
+ end
105
+
106
+ def extract_duration_seconds(response, usage)
107
+ return response.duration if response.respond_to?(:duration) && response.duration
108
+
109
+ duration_ms = usage&.respond_to?(:duration_ms) ? usage.duration_ms : nil
110
+ duration_ms ? duration_ms / 1000.0 : nil
111
+ end
112
+
113
+ def extract_tool_calls(response)
114
+ message = response.message
115
+ return [] unless message.respond_to?(:tool_calls)
116
+
117
+ tool_calls = message.tool_calls
118
+ return [] if tool_calls.blank?
119
+
120
+ tool_calls.map do |tc|
121
+ {
122
+ id: value_if_responds(tc, :id),
123
+ name: value_if_responds(tc, :name),
124
+ arguments: value_if_responds(tc, :arguments)
125
+ }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # One LLM generation call within an agent conversation context. From the
5
+ # solid_agent install generator, extended with trace_id (joins to
6
+ # active_agent_telemetry_traces) and the SolidAgent provenance hash.
7
+ class AgentGeneration < ApplicationRecord
8
+ belongs_to :agent_context
9
+
10
+ scope :recent, -> { order(created_at: :desc) }
11
+ scope :by_model, ->(model) { where(model: model) }
12
+ scope :with_tool_calls, -> { where(json_array_not_empty_sql(:tool_calls)) }
13
+ scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
14
+
15
+ def total_tokens
16
+ input_tokens + output_tokens
17
+ end
18
+
19
+ def has_tool_calls?
20
+ tool_calls.present? && tool_calls.any?
21
+ end
22
+
23
+ def completed?
24
+ finish_reason == "stop"
25
+ end
26
+
27
+ def truncated?
28
+ finish_reason == "length"
29
+ end
30
+
31
+ # Provider prompt-cache hit on this generation?
32
+ def cache_hit?
33
+ cached_tokens.to_i.positive?
34
+ end
35
+
36
+ # Extended thinking captured?
37
+ def thinking?
38
+ reasoning_tokens.to_i.positive?
39
+ end
40
+
41
+ # The telemetry trace this generation belongs to, when recorded.
42
+ def telemetry_trace
43
+ return nil if trace_id.blank?
44
+
45
+ ActionAgent.trace_model.find_by(trace_id: trace_id)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Agent-curated long-term memory for a subject record (solid_agent
5
+ # HasMemory contract). On this platform the memorable is usually the
6
+ # dashboard Agent record; agents write/read it through the save_memory /
7
+ # recall_memory tools during generation runs.
8
+ #
9
+ # Memory is scoped to (memorable, scope) — not to an agent class — so any
10
+ # agent operating on the same subject shares it, making it a handoff
11
+ # channel between agents. Entry source_agent records who wrote each note.
12
+ class AgentMemory < ApplicationRecord
13
+ DEFAULT_SCOPE = "default"
14
+
15
+ belongs_to :memorable, polymorphic: true, optional: true
16
+ has_many :entries, class_name: "AgentMemoryEntry", dependent: :destroy
17
+
18
+ validates :scope, presence: true
19
+
20
+ def self.for(memorable, scope: DEFAULT_SCOPE)
21
+ find_or_create_by!(memorable: memorable, scope: scope.to_s)
22
+ end
23
+
24
+ def remember(content, source_agent: nil, category: nil)
25
+ entries.create!(content: content, source_agent: source_agent, category: category)
26
+ end
27
+
28
+ def recall(limit: 20, category: nil)
29
+ scope = entries.order(created_at: :desc)
30
+ scope = scope.where(category: category) if category.present?
31
+ scope.limit(limit || 20).to_a
32
+ end
33
+
34
+ def summary_list
35
+ entries.order(:created_at).pluck(:content)
36
+ end
37
+
38
+ # Formatted block suitable for injecting into another agent's
39
+ # instructions when handing a subject off.
40
+ def to_prompt
41
+ notes = entries.order(:created_at).map do |entry|
42
+ source = entry.source_agent.present? ? " (#{entry.source_agent})" : ""
43
+ "- #{entry.content}#{source}"
44
+ end
45
+ return "" if notes.empty?
46
+
47
+ "Memory notes for this subject:\n#{notes.join("\n")}"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # One agent-authored summary note in an AgentMemory.
5
+ class AgentMemoryEntry < ApplicationRecord
6
+ belongs_to :agent_memory
7
+
8
+ validates :content, presence: true
9
+
10
+ scope :chronological, -> { order(:created_at) }
11
+ scope :by_category, ->(category) { where(category: category) }
12
+ scope :from_agent, ->(agent_name) { where(source_agent: agent_name) }
13
+ end
14
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A single message in an agent conversation context (user, assistant,
5
+ # system, or tool). From the solid_agent install generator; the provenance
6
+ # and content_checksum columns are populated automatically by
7
+ # SolidAgent::HasContext.
8
+ class AgentMessage < ApplicationRecord
9
+ belongs_to :agent_context
10
+
11
+ validates :role, presence: true, inclusion: { in: %w[user assistant system tool] }
12
+
13
+ scope :by_role, ->(role) { where(role: role) }
14
+ scope :user_messages, -> { by_role("user") }
15
+ scope :assistant_messages, -> { by_role("assistant") }
16
+ scope :chronological, -> { order(created_at: :asc) }
17
+
18
+ # Converts the message to a hash format for ActiveAgent prompts
19
+ def to_message_hash
20
+ hash = { role: role, content: content }
21
+ hash[:tool_calls] = tool_calls_data if role == "assistant" && tool_calls_data.present?
22
+
23
+ if role == "tool"
24
+ hash[:tool_call_id] = tool_call_id
25
+ hash[:name] = tool_name
26
+ end
27
+
28
+ hash
29
+ end
30
+
31
+ def tool_calls_data
32
+ metadata&.dig("tool_calls") || []
33
+ end
34
+
35
+ def tool_call?
36
+ role == "assistant" && tool_calls_data.present?
37
+ end
38
+
39
+ def tool_result?
40
+ role == "tool"
41
+ end
42
+ end
43
+ end