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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class AgentRun < ApplicationRecord
5
+ belongs_to :agent
6
+
7
+ # Status enum
8
+ enum :status, { pending: 0, running: 1, complete: 2, failed: 3, cancelled: 4 }
9
+
10
+ # Validations
11
+ validates :trace_id, presence: true
12
+
13
+ # Scopes
14
+ scope :recent, -> { order(created_at: :desc) }
15
+ scope :successful, -> { where(status: :complete) }
16
+ scope :failed_runs, -> { where(status: :failed) }
17
+ scope :today, -> { where("created_at >= ?", Time.current.beginning_of_day) }
18
+
19
+ # Callbacks
20
+ before_validation :set_trace_id, on: :create
21
+ after_update_commit :broadcast_update, if: :saved_change_to_status?
22
+
23
+ # Add a log entry
24
+ def add_log(message, level: :info)
25
+ new_logs = logs || []
26
+ new_logs << {
27
+ timestamp: Time.current.iso8601,
28
+ level: level.to_s,
29
+ message: message
30
+ }
31
+ update!(logs: new_logs)
32
+ end
33
+
34
+ # Appends a progress event to logs mid-run so pollers can stream what the
35
+ # agent is doing (pending llm/tool/agent calls). Events pair up by eid:
36
+ # a "started" event is pending until a "done"/"error" with the same eid
37
+ # lands. update_column: no validations/callbacks, safe from the run's own
38
+ # execution thread; reads current DB state so add_log interleaves safely.
39
+ def append_event(eid:, kind:, label:, status: "done", detail: nil, duration_ms: nil)
40
+ event = {
41
+ "at" => Time.current.iso8601(3),
42
+ "eid" => eid,
43
+ "kind" => kind.to_s,
44
+ "label" => label.to_s,
45
+ "status" => status.to_s
46
+ }
47
+ event["detail"] = detail.to_s.byteslice(0, 1200).to_s.scrub if detail
48
+ event["duration_ms"] = duration_ms if duration_ms
49
+ current = self.class.where(id: id).pick(:logs) || []
50
+ update_column(:logs, current + [ event ])
51
+ event
52
+ end
53
+
54
+ # Stable short fingerprint of the instructions this run executed under —
55
+ # the grouping key (with model) for configuration cohorts when comparing
56
+ # instruction/model changes.
57
+ def instructions_digest
58
+ instructions = output_metadata&.dig("instructions")
59
+ return nil if instructions.blank?
60
+
61
+ Digest::SHA256.hexdigest(instructions).first(8)
62
+ end
63
+
64
+ # Deterministic memorable name for the digest ("calm-heron") — reads far
65
+ # better than hex when comparing cohorts, and is stable across runs and
66
+ # deployments because it's derived from the digest alone.
67
+ CODENAME_ADJECTIVES = %w[
68
+ calm brisk quiet bold amber coral dusky fresh golden keen
69
+ lively mellow nimble pale rustic silver tidal vivid wry zesty
70
+ arid breezy crisp dapper eager foggy hazy icy jolly lunar
71
+ misty polar
72
+ ].freeze
73
+ CODENAME_NOUNS = %w[
74
+ heron otter falcon cedar willow harbor mesa ridge grove delta
75
+ prairie summit canyon reef atoll fjord tundra oasis lagoon dune
76
+ glacier meadow bluff cove marsh basin knoll strait quarry vale
77
+ hollow crag
78
+ ].freeze
79
+
80
+ def instructions_codename
81
+ digest = instructions_digest
82
+ return nil unless digest
83
+
84
+ value = digest.to_i(16)
85
+ "#{CODENAME_ADJECTIVES[value % 32]}-#{CODENAME_NOUNS[(value / 32) % 32]}"
86
+ end
87
+
88
+ # Calculate duration if not set
89
+ def calculated_duration_ms
90
+ return duration_ms if duration_ms.present?
91
+ return nil unless started_at && completed_at
92
+
93
+ ((completed_at - started_at) * 1000).to_i
94
+ end
95
+
96
+ # Check if run is still in progress
97
+ def in_progress?
98
+ pending? || running?
99
+ end
100
+
101
+ # Check if run is finished
102
+ def finished?
103
+ complete? || failed? || cancelled?
104
+ end
105
+
106
+ # Get a summary for display
107
+ def summary
108
+ {
109
+ id: id,
110
+ status: status,
111
+ input_preview: input_prompt&.truncate(100),
112
+ output_preview: output&.truncate(200),
113
+ duration_ms: calculated_duration_ms,
114
+ tokens: total_tokens,
115
+ provider: output_metadata&.dig("provider"),
116
+ model: output_metadata&.dig("model"),
117
+ action_name: action_name || output_metadata&.dig("action") || "ask",
118
+ instructions_digest: instructions_digest,
119
+ instructions_codename: instructions_codename,
120
+ instructions_preview: output_metadata&.dig("instructions")&.truncate(120),
121
+ created_at: created_at,
122
+ error: error_message
123
+ }
124
+ end
125
+
126
+ # Stream output updates via ActionCable
127
+ def broadcast_update
128
+ payload = { type: "update", run: summary }
129
+ ActionCable.server.broadcast("agent_run_#{id}", payload)
130
+ ActionCable.server.broadcast("agent_runs_#{agent_id}", payload)
131
+ end
132
+
133
+ # Cancel a running execution
134
+ def cancel!
135
+ return unless in_progress?
136
+
137
+ update!(
138
+ status: :cancelled,
139
+ completed_at: Time.current,
140
+ error_message: "Cancelled by user"
141
+ )
142
+ broadcast_update
143
+ end
144
+
145
+ private
146
+
147
+ def set_trace_id
148
+ self.trace_id ||= SecureRandom.uuid
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class AgentTemplate < ApplicationRecord
5
+ # Validations
6
+ validates :name, presence: true
7
+ validates :slug, presence: true, uniqueness: true
8
+ validates :category, presence: true
9
+
10
+ # Scopes
11
+ scope :featured, -> { where(featured: true) }
12
+ scope :by_category, ->(cat) { where(category: cat) }
13
+ scope :popular, -> { order(usage_count: :desc) }
14
+ scope :public_templates, -> { where(public: true) }
15
+ scope :free_tier, -> { where(free_tier: true) }
16
+
17
+ # Categories
18
+ CATEGORIES = %w[
19
+ productivity
20
+ development
21
+ research
22
+ creative
23
+ data
24
+ automation
25
+ ].freeze
26
+
27
+ # Create an agent from this template for a user
28
+ def create_agent_for(user, name: nil)
29
+ agent = user.agents.build(
30
+ name: name || self.name,
31
+ description: description,
32
+ provider: provider,
33
+ model: model,
34
+ instructions: instructions,
35
+ preset_type: preset_type,
36
+ appearance: appearance,
37
+ instruction_sets: instruction_sets,
38
+ tools: tools,
39
+ mcp_servers: mcp_servers,
40
+ model_config: model_config,
41
+ status: :draft
42
+ )
43
+
44
+ if agent.save
45
+ increment!(:usage_count)
46
+ end
47
+
48
+ agent
49
+ end
50
+
51
+ # Seed default templates
52
+ def self.seed_defaults!
53
+ templates = [
54
+ {
55
+ name: "Code Assistant",
56
+ slug: "code-assistant",
57
+ description: "A helpful coding assistant that can explain code, suggest improvements, and help debug issues.",
58
+ category: "development",
59
+ provider: "openai",
60
+ model: "gpt-4o",
61
+ preset_type: "terminal",
62
+ appearance: { hat: "fedora", heldItem: "terminal" },
63
+ instruction_sets: %w[github ruby rails typescript],
64
+ tools: %w[terminal code filesystem],
65
+ model_config: { temperature: 0.3 },
66
+ instructions: "You are a senior software engineer with expertise in multiple programming languages. Help users with:\n- Code explanations and reviews\n- Debugging issues\n- Suggesting best practices\n- Writing tests\n\nAlways explain your reasoning and provide examples when helpful.",
67
+ icon: "💻",
68
+ featured: true
69
+ },
70
+ {
71
+ name: "Research Assistant",
72
+ slug: "research-assistant",
73
+ description: "Helps research topics, summarize information, and organize findings.",
74
+ category: "research",
75
+ provider: "anthropic",
76
+ model: "claude-sonnet-5",
77
+ preset_type: "research",
78
+ appearance: { hat: "safari", heldItem: "magnifyingGlass" },
79
+ instruction_sets: %w[github python],
80
+ tools: %w[fetch search memory],
81
+ model_config: { temperature: 0.5 },
82
+ instructions: "You are a thorough research assistant. Help users by:\n- Searching for relevant information\n- Summarizing complex topics\n- Organizing findings into clear reports\n- Identifying key insights and patterns\n\nAlways cite sources when available and distinguish between facts and opinions.",
83
+ icon: "🔍",
84
+ featured: true
85
+ },
86
+ {
87
+ name: "Writing Assistant",
88
+ slug: "writing-assistant",
89
+ description: "Helps with writing, editing, and improving text content.",
90
+ category: "creative",
91
+ provider: "openai",
92
+ model: "gpt-4o",
93
+ preset_type: "writing",
94
+ appearance: { hat: "fedora", hatAccessory: "feather", heldItem: "scroll" },
95
+ instruction_sets: [],
96
+ tools: %w[edit translate],
97
+ model_config: { temperature: 0.7 },
98
+ instructions: "You are a skilled writer and editor. Help users with:\n- Writing and editing content\n- Improving clarity and flow\n- Adjusting tone for different audiences\n- Grammar and style corrections\n\nMaintain the author's voice while suggesting improvements.",
99
+ icon: "✍️",
100
+ featured: true
101
+ },
102
+ {
103
+ name: "Browser Automation",
104
+ slug: "browser-automation",
105
+ description: "Automates web browsing tasks like form filling, data extraction, and testing.",
106
+ category: "automation",
107
+ provider: "anthropic",
108
+ model: "claude-sonnet-5",
109
+ preset_type: "playwright",
110
+ appearance: { hat: "fedora", hatAccessory: "theaterMasks", heldItem: "browser" },
111
+ instruction_sets: %w[typescript],
112
+ tools: %w[playwright filesystem],
113
+ model_config: { temperature: 0.2 },
114
+ instructions: "You are a browser automation specialist. Help users by:\n- Navigating web pages\n- Filling out forms\n- Extracting data from websites\n- Testing web applications\n\nAlways wait for page loads and handle errors gracefully.",
115
+ icon: "🎭",
116
+ featured: false
117
+ },
118
+ {
119
+ name: "Data Analyst",
120
+ slug: "data-analyst",
121
+ description: "Analyzes data, creates visualizations, and provides insights.",
122
+ category: "data",
123
+ provider: "openai",
124
+ model: "gpt-4o",
125
+ preset_type: "documentAnalysis",
126
+ appearance: { hat: "fedora", heldItem: "document" },
127
+ instruction_sets: %w[python],
128
+ tools: %w[code database filesystem],
129
+ model_config: { temperature: 0.3 },
130
+ instructions: "You are a data analyst. Help users by:\n- Analyzing datasets\n- Creating visualizations\n- Finding patterns and insights\n- Generating reports\n\nExplain your methodology and provide clear interpretations of results.",
131
+ icon: "📊",
132
+ featured: true
133
+ },
134
+ {
135
+ name: "DevOps Assistant",
136
+ slug: "devops-assistant",
137
+ description: "Helps with infrastructure, deployments, and system administration.",
138
+ category: "development",
139
+ provider: "openai",
140
+ model: "gpt-4o",
141
+ preset_type: "terminal",
142
+ appearance: { hat: "fedora", heldItem: "terminal" },
143
+ instruction_sets: %w[docker kubernetes aws gcp],
144
+ tools: %w[terminal filesystem code],
145
+ model_config: { temperature: 0.2 },
146
+ instructions: "You are a DevOps engineer. Help users with:\n- Infrastructure setup and management\n- CI/CD pipeline configuration\n- Container orchestration\n- Cloud resource management\n\nAlways prioritize security and follow best practices.",
147
+ icon: "🚀",
148
+ featured: false
149
+ },
150
+ {
151
+ name: "PlaywrightMCP Demo",
152
+ slug: "playwright-mcp-demo",
153
+ description: "Free browser automation demo using Playwright MCP. Navigate sites, take screenshots, and extract content.",
154
+ category: "automation",
155
+ provider: "anthropic",
156
+ model: "claude-sonnet-5",
157
+ preset_type: "playwright",
158
+ appearance: { hat: "fedora", hatAccessory: "theaterMasks", heldItem: "browser" },
159
+ instruction_sets: [],
160
+ tools: %w[playwright],
161
+ mcp_servers: {
162
+ playwright: {
163
+ command: "npx",
164
+ args: [ "-y", "@anthropic/mcp-server-playwright" ]
165
+ }
166
+ },
167
+ model_config: { temperature: 0.2, max_tokens: 4096 },
168
+ instructions: "You are a browser automation assistant using Playwright MCP.\n\nAvailable actions:\n- browser_navigate: Go to a URL\n- browser_snapshot: Get the accessibility tree\n- browser_click: Click on an element\n- browser_type: Type text into an input\n- browser_take_screenshot: Capture the page\n- browser_wait_for: Wait for text or element\n\nGuidelines:\n1. Always take a snapshot first to understand the page\n2. Use element refs from snapshots for interactions\n3. Wait for page loads before taking actions\n4. Handle errors gracefully\n5. Limit yourself to 10 steps maximum\n\nAlways describe what you see and what actions you're taking.",
169
+ icon: "🎭",
170
+ featured: true,
171
+ free_tier: true
172
+ }
173
+ ]
174
+
175
+ templates.each do |template_attrs|
176
+ AgentTemplate.find_or_create_by!(slug: template_attrs[:slug]) do |t|
177
+ t.assign_attributes(template_attrs)
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class AgentVersion < ApplicationRecord
5
+ belongs_to :agent
6
+
7
+ validates :version_number, presence: true, uniqueness: { scope: :agent_id }
8
+ validates :configuration_snapshot, presence: true
9
+
10
+ # Scopes
11
+ scope :recent, -> { order(version_number: :desc) }
12
+ scope :by_version, ->(num) { where(version_number: num) }
13
+
14
+ # Compare two versions
15
+ def diff(other_version)
16
+ return {} unless other_version
17
+
18
+ changes = {}
19
+ configuration_snapshot.each do |key, value|
20
+ other_value = other_version.configuration_snapshot[key]
21
+ if value != other_value
22
+ changes[key] = { from: other_value, to: value }
23
+ end
24
+ end
25
+ changes
26
+ end
27
+
28
+ # Get previous version
29
+ def previous
30
+ agent.agent_versions.where("version_number < ?", version_number).order(version_number: :desc).first
31
+ end
32
+
33
+ # Get next version
34
+ def next_version
35
+ agent.agent_versions.where("version_number > ?", version_number).order(version_number: :asc).first
36
+ end
37
+
38
+ # Check if this is the latest version
39
+ def latest?
40
+ agent.latest_version&.id == id
41
+ end
42
+
43
+ # Check if this is the initial version
44
+ def initial?
45
+ version_number == 1
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # API key generated from Settings -> API Keys. Authenticates requests to
5
+ # the telemetry ingest endpoint as a Bearer token.
6
+ #
7
+ # The token is generated server-side (never accepted from user input) and
8
+ # encrypted at rest with Active Record Encryption, so the host app needs
9
+ # `rails db:encryption:init` before creating keys. Deterministic
10
+ # encryption keeps find_by(token:) lookups working against the ciphertext.
11
+ # Set ActionAgent.encrypt_credentials = false to store tokens
12
+ # in plain text instead; that is a downgrade, not a default.
13
+ class ApiKey < ApplicationRecord
14
+ TOKEN_PREFIX = "aa_"
15
+
16
+ include Ownable
17
+ owned_by :account, :user
18
+
19
+ encrypts :token, deterministic: true if ActionAgent.encrypt_credentials
20
+
21
+ validates :name, presence: true, length: { maximum: 100 }
22
+ validates :token, presence: true, uniqueness: true
23
+
24
+ before_validation :generate_token, on: :create
25
+
26
+ # Finds the key for a presented bearer token. Returns nil for blank or
27
+ # unknown tokens.
28
+ def self.authenticate(token)
29
+ return nil if token.blank?
30
+
31
+ find_by(token: token)
32
+ end
33
+
34
+ def touch_last_used!
35
+ # Throttled to avoid a write per ingest request.
36
+ update_column(:last_used_at, Time.current) if last_used_at.nil? || last_used_at < 1.minute.ago
37
+ end
38
+
39
+ # Safe to display in the dashboard key list: "aa_3xam…k9Q2"
40
+ def masked_token
41
+ "#{token_prefix}…#{token.last(4)}"
42
+ end
43
+
44
+ private
45
+
46
+ def generate_token
47
+ return if token.present?
48
+
49
+ self.token = "#{TOKEN_PREFIX}#{SecureRandom.base58(32)}"
50
+ self.token_prefix = token.first(TOKEN_PREFIX.length + 4)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Base class for all Dashboard engine models.
5
+ #
6
+ # Table names come from ActionAgent.table_name_prefix through
7
+ # Rails' standard namespaced-model resolution, so the engine's own
8
+ # migrations (active_agent_agents, active_agent_agent_runs, ...) and a
9
+ # host app that already owns the tables unprefixed are both supported
10
+ # without touching the models.
11
+ #
12
+ # Ownership is configurable: multi-tenant installs scope records to an
13
+ # Account, single-tenant installs to a User, and a single-user install
14
+ # scopes to nothing at all.
15
+ class ApplicationRecord < ::ActiveRecord::Base
16
+ include AdapterAware
17
+
18
+ self.abstract_class = true
19
+
20
+ # Models that are not themselves owned still answer the ownership
21
+ # questions, so callers can scope any dashboard relation uniformly.
22
+ class << self
23
+ def owner_association = nil
24
+ def for_owner(_owner) = all
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # An evaluation definition for an agent: a named set of criteria scored
5
+ # against the agent's recorded behavior — its recent generations
6
+ # (solid_agent's agent_generations dataset) and its telemetry traces.
7
+ #
8
+ # Criteria are stored as an array of { "key", "type", "config" } hashes.
9
+ # Rule-based criterion types score each sampled generation
10
+ # deterministically; telemetry criterion types score aggregates over the
11
+ # agent's traces (error rate, latency); the llm_judge type asks a judge
12
+ # model to score each sample and requires a configured provider.
13
+ class Evaluation < ApplicationRecord
14
+ belongs_to :agent
15
+ has_many :evaluation_runs, dependent: :destroy
16
+
17
+ # judge_defined: the judge model authors the KPI criteria itself from the
18
+ # agent's instructions + sample interactions on the first run, then scores
19
+ # against them (criteria stay persisted/editable so scores are comparable
20
+ # across runs and models).
21
+ JUDGE_KINDS = %w[rules llm judge_defined].freeze
22
+
23
+ RULE_CRITERION_TYPES = %w[
24
+ response_present min_length max_latency_ms token_budget contains not_contains
25
+ ].freeze
26
+ # Scored from the agent's telemetry traces (aggregate, not per-sample).
27
+ TELEMETRY_CRITERION_TYPES = %w[trace_error_rate trace_latency].freeze
28
+ CRITERION_TYPES = (RULE_CRITERION_TYPES + TELEMETRY_CRITERION_TYPES + %w[llm_judge]).freeze
29
+
30
+ validates :name, presence: true, uniqueness: { scope: :agent_id }
31
+ validates :judge_kind, inclusion: { in: JUDGE_KINDS }
32
+ validates :sample_size, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
33
+ validate :validate_criteria
34
+
35
+ scope :recent, -> { order(updated_at: :desc) }
36
+
37
+ def latest_run
38
+ evaluation_runs.order(created_at: :desc).first
39
+ end
40
+
41
+ def judge_defined?
42
+ judge_kind == "judge_defined"
43
+ end
44
+
45
+ # Candidate models for per-cohort comparison scoring (config, optional).
46
+ def compare_models
47
+ Array(config["compare_models"]).map(&:to_s).reject(&:blank?)
48
+ end
49
+
50
+ def run!
51
+ EvaluationRunnerService.call(self)
52
+ end
53
+
54
+ def llm_criteria
55
+ criteria.select { |c| c["type"] == "llm_judge" }
56
+ end
57
+
58
+ private
59
+
60
+ def validate_criteria
61
+ if criteria.blank?
62
+ # judge_defined evaluations start empty — the judge authors the KPIs
63
+ # on the first run.
64
+ errors.add(:criteria, "must include at least one criterion") unless judge_defined?
65
+ return
66
+ end
67
+
68
+ criteria.each do |criterion|
69
+ unless criterion.is_a?(Hash) && criterion["key"].present?
70
+ errors.add(:criteria, "entries must have a key")
71
+ next
72
+ end
73
+
74
+ unless CRITERION_TYPES.include?(criterion["type"])
75
+ errors.add(:criteria, "unknown criterion type #{criterion['type']}")
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # One execution of an Evaluation over a sample of the agent's generations.
5
+ # scores: { criterion_key => { "score", "min", "max", "passed", "total" } }
6
+ class EvaluationRun < ApplicationRecord
7
+ belongs_to :evaluation
8
+
9
+ enum :status, { pending: 0, running: 1, complete: 2, failed: 3 }
10
+
11
+ scope :recent, -> { order(created_at: :desc) }
12
+
13
+ def average_score
14
+ values = scores.values.map { |s| s["score"] }.compact
15
+ return nil if values.empty?
16
+
17
+ (values.sum.to_f / values.size).round(3)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Estimates LLM spend from token counts. The activeagent gem's telemetry
5
+ # records tokens only; the platform layers pricing on top for the cost
6
+ # figures shown in Traces and Metrics.
7
+ #
8
+ # Rates come from RubyLLM's model registry (USD per million tokens,
9
+ # maintained upstream per model) when the model is known there; the static
10
+ # pattern table below is the fallback for aliases/self-hosted models, and
11
+ # a conservative blended rate covers everything else so totals stay
12
+ # meaningful. Costs are always presented as estimates.
13
+ class ModelPricing
14
+ PRICES = [
15
+ # [pattern, input $/1M, output $/1M]
16
+ [ /gpt-4o-mini/i, 0.15, 0.60 ],
17
+ [ /gpt-4o/i, 2.50, 10.00 ],
18
+ [ /gpt-4\.1-nano/i, 0.10, 0.40 ],
19
+ [ /gpt-4\.1-mini/i, 0.40, 1.60 ],
20
+ [ /gpt-4\.1/i, 2.00, 8.00 ],
21
+ [ /o3-mini|o4-mini/i, 1.10, 4.40 ],
22
+ [ /claude.*(fable|mythos)/i, 10.00, 50.00 ],
23
+ [ /claude.*haiku-?4/i, 1.00, 5.00 ],
24
+ [ /claude.*(haiku)/i, 0.80, 4.00 ],
25
+ [ /claude.*(sonnet)/i, 3.00, 15.00 ],
26
+ [ /claude.*opus-(5|4-[5-9])/i, 5.00, 25.00 ],
27
+ [ /claude.*(opus)/i, 15.00, 75.00 ],
28
+ [ /gemini.*flash/i, 0.10, 0.40 ],
29
+ [ /gemini.*pro/i, 1.25, 10.00 ],
30
+ [ /llama|mistral|mixtral|qwen|deepseek/i, 0.20, 0.60 ],
31
+ # Zero-prices "mock-*" traces recorded before the mock fallback was
32
+ # removed, so legacy rows never register as real spend.
33
+ [ /mock/i, 0.0, 0.0 ]
34
+ ].freeze
35
+
36
+ # Fallback blended rate for unknown models ($/1M input, $/1M output)
37
+ DEFAULT_RATE = [ 1.00, 4.00 ].freeze
38
+
39
+ # @return [Float, nil] estimated USD cost, nil when there is nothing to price
40
+ def self.estimate(model:, input_tokens:, output_tokens:)
41
+ input = input_tokens.to_i
42
+ output = output_tokens.to_i
43
+ return nil if input.zero? && output.zero?
44
+
45
+ input_rate, output_rate = rate_for(model)
46
+ ((input * input_rate) + (output * output_rate)) / 1_000_000.0
47
+ end
48
+
49
+ def self.rate_for(model)
50
+ return DEFAULT_RATE if model.blank?
51
+
52
+ registry_rate(model) || static_rate(model)
53
+ end
54
+
55
+ # Exact per-model rates from RubyLLM's registry. Lookups are memoized —
56
+ # the registry scan is not free and trace serialization calls this per
57
+ # row.
58
+ def self.registry_rate(model)
59
+ @registry_rates ||= {}
60
+ return @registry_rates[model] if @registry_rates.key?(model)
61
+
62
+ @registry_rates[model] = begin
63
+ info = RubyLLM.models.find(model.to_s)
64
+ tokens = info&.pricing&.text_tokens
65
+ if tokens&.input && tokens&.output
66
+ [ tokens.input, tokens.output ]
67
+ end
68
+ rescue StandardError
69
+ nil
70
+ end
71
+ end
72
+
73
+ def self.static_rate(model)
74
+ PRICES.each do |pattern, input_rate, output_rate|
75
+ return [ input_rate, output_rate ] if model.to_s.match?(pattern)
76
+ end
77
+ DEFAULT_RATE
78
+ end
79
+ end
80
+ end