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