actionagent 1.2.2 → 1.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +48 -44
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
  6. data/app/controllers/action_agent/api/agents_controller.rb +76 -48
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
  10. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  11. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  12. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  13. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  14. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  15. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  16. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  17. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  18. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  19. data/app/controllers/action_agent/application_controller.rb +25 -2
  20. data/app/controllers/action_agent/dashboard_controller.rb +2 -1
  21. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  22. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  23. data/app/jobs/action_agent/application_job.rb +7 -3
  24. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  25. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  26. data/app/models/action_agent/agent.rb +14 -5
  27. data/app/models/action_agent/agent_template.rb +22 -7
  28. data/app/models/action_agent/evaluation.rb +64 -4
  29. data/app/models/action_agent/evaluation_run.rb +182 -2
  30. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  31. data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
  32. data/app/models/action_agent/recording_action.rb +11 -7
  33. data/app/models/action_agent/sandbox_session.rb +1 -1
  34. data/app/models/action_agent/session_recording.rb +31 -8
  35. data/app/models/action_agent/telemetry_trace.rb +110 -2
  36. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  37. data/app/models/concerns/action_agent/ownable.rb +15 -2
  38. data/app/queries/action_agent/metrics_report.rb +498 -0
  39. data/app/services/action_agent/agent_toolbox.rb +4 -4
  40. data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
  41. data/app/services/action_agent/mcp_catalog.rb +46 -8
  42. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  43. data/app/services/action_agent/playwright_mcp_client.rb +6 -6
  44. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  45. data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
  46. data/app/services/action_agent/tool_discovery.rb +22 -8
  47. data/config/routes.rb +25 -2
  48. data/lib/action_agent/engine.rb +101 -19
  49. data/lib/action_agent/version.rb +1 -1
  50. data/lib/action_agent.rb +72 -6
  51. data/lib/generators/action_agent/install_generator.rb +20 -7
  52. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  53. data/lib/tasks/action_agent.rake +9 -0
  54. metadata +19 -6
@@ -4,16 +4,16 @@ module ActionAgent
4
4
  class SandboxCleanupJob < ApplicationJob
5
5
  queue_as :sandboxes
6
6
 
7
- # Clean up Cloud Run resources for an expired sandbox
7
+ # Release the infrastructure behind an expired sandbox.
8
8
  def perform(sandbox_session_id)
9
9
  sandbox = SandboxSession.find_by(id: sandbox_session_id)
10
10
  return unless sandbox
11
11
 
12
12
  Rails.logger.info("Cleaning up sandbox: #{sandbox.session_id}")
13
13
 
14
- # Delete Cloud Run Job if exists
14
+ # Terminate the backend resource if one was provisioned
15
15
  if sandbox.cloud_run_job_id.present? && !Rails.env.development?
16
- delete_cloud_run_job(sandbox.cloud_run_job_id)
16
+ terminate_backend_sandbox(sandbox.cloud_run_job_id)
17
17
  end
18
18
 
19
19
  # Optionally delete old sandbox records
@@ -30,13 +30,16 @@ module ActionAgent
30
30
 
31
31
  private
32
32
 
33
- def delete_cloud_run_job(job_id)
34
- require "google/cloud/run/v2"
35
-
36
- client = Google::Cloud::Run::V2::Jobs::Client.new
37
- client.delete_job(name: job_id)
38
- rescue => e
39
- Rails.logger.warn("Failed to delete Cloud Run job #{job_id}: #{e.message}")
33
+ # Through the orchestrator, like provisioning: whichever backend the
34
+ # host registered (Incus, Kubernetes, Cloud Run, or the built-in mock)
35
+ # reclaims its own resource. This used to require google/cloud/run/v2
36
+ # directly, which the engine does not depend on — a LoadError is a
37
+ # ScriptError, not a StandardError, so it escaped the rescue and the job
38
+ # failed on every host but the one that happened to bundle the SDK.
39
+ def terminate_backend_sandbox(sandbox_id)
40
+ SandboxOrchestrator.new.terminate(sandbox_id)
41
+ rescue StandardError => e
42
+ Rails.logger.warn("Failed to terminate sandbox #{sandbox_id}: #{e.message}")
40
43
  end
41
44
  end
42
45
  end
@@ -181,14 +181,21 @@ module ActionAgent
181
181
  agent_versions.count
182
182
  end
183
183
 
184
- # Generate Ruby agent class code
184
+ # Generate Ruby agent class code.
185
+ #
186
+ # The class is named by telemetry_agent_class: it parameterizes a name
187
+ # with spaces ("My Agent" -> MyAgentAgent, not `class My AgentAgent`) and
188
+ # appends the Agent suffix only when it is missing, so an observed agent
189
+ # whose reported class already ends in Agent is not doubled. It is also
190
+ # the key traces are correlated on, so the exported class reports under
191
+ # the same name this record listens for.
185
192
  def to_agent_class_code
186
193
  <<~RUBY
187
- class #{agent_class_name || name.camelize}Agent < ApplicationAgent
194
+ class #{telemetry_agent_class} < ApplicationAgent
188
195
  generate_with :#{provider}, model: "#{model}"#{model_config_code}
189
196
 
190
197
  def perform
191
- prompt#{instructions_code}
198
+ #{instructions_code}
192
199
  end
193
200
  end
194
201
  RUBY
@@ -334,10 +341,12 @@ module ActionAgent
334
341
  ", #{configs}"
335
342
  end
336
343
 
344
+ # Exactly one prompt call: a bare `prompt` without instructions, or a
345
+ # single `prompt instructions:` heredoc with them.
337
346
  def instructions_code
338
- return "" if instructions.blank?
347
+ return "prompt" if instructions.blank?
339
348
 
340
- "\n prompt instructions: <<~INSTRUCTIONS\n #{instructions.gsub("\n", "\n ")}\n INSTRUCTIONS"
349
+ "prompt instructions: <<~INSTRUCTIONS\n #{instructions.gsub("\n", "\n ")}\n INSTRUCTIONS"
341
350
  end
342
351
  end
343
352
  end
@@ -24,10 +24,14 @@ module ActionAgent
24
24
  automation
25
25
  ].freeze
26
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,
27
+ # Build (unsaved) an agent from this template inside +relation+: a host
28
+ # user's agents association, or the engine's owner scope
29
+ # (Api::BaseController#owner_agents). The relation decides the owner —
30
+ # including no owner at all in a single-user install, where the old
31
+ # `user.agents.build` had no user to call it on and raised.
32
+ def build_agent_in(relation, name: nil)
33
+ relation.build(
34
+ name: name.presence || self.name,
31
35
  description: description,
32
36
  provider: provider,
33
37
  model: model,
@@ -40,6 +44,12 @@ module ActionAgent
40
44
  model_config: model_config,
41
45
  status: :draft
42
46
  )
47
+ end
48
+
49
+ # Create an agent from this template for a host user with an `agents`
50
+ # association.
51
+ def create_agent_for(user, name: nil)
52
+ agent = build_agent_in(user.agents, name: name)
43
53
 
44
54
  if agent.save
45
55
  increment!(:usage_count)
@@ -158,12 +168,17 @@ module ActionAgent
158
168
  appearance: { hat: "fedora", hatAccessory: "theaterMasks", heldItem: "browser" },
159
169
  instruction_sets: [],
160
170
  tools: %w[playwright],
161
- mcp_servers: {
162
- playwright: {
171
+ # An array of server entries, which is the shape agents.mcp_servers
172
+ # takes everywhere else (the builder's strong params permit an
173
+ # array). The old top-level Hash was copied onto agents verbatim and
174
+ # crashed ToolDiscovery for the whole workspace.
175
+ mcp_servers: [
176
+ {
177
+ name: "playwright",
163
178
  command: "npx",
164
179
  args: [ "-y", "@anthropic/mcp-server-playwright" ]
165
180
  }
166
- },
181
+ ],
167
182
  model_config: { temperature: 0.2, max_tokens: 4096 },
168
183
  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
184
  icon: "🎭",
@@ -13,6 +13,7 @@ module ActionAgent
13
13
  class Evaluation < ApplicationRecord
14
14
  belongs_to :agent
15
15
  has_many :evaluation_runs, dependent: :destroy
16
+ has_many :scenarios, class_name: "EvaluationScenario", dependent: :destroy
16
17
 
17
18
  # judge_defined: the judge model authors the KPI criteria itself from the
18
19
  # agent's instructions + sample interactions on the first run, then scores
@@ -47,8 +48,66 @@ module ActionAgent
47
48
  Array(config["compare_models"]).map(&:to_s).reject(&:blank?)
48
49
  end
49
50
 
50
- def run!
51
- EvaluationRunnerService.call(self)
51
+ # A scenario evaluation replays its own prompts rather than sampling the
52
+ # agent's recorded generations.
53
+ def scenario_suite?
54
+ scenarios.any?
55
+ end
56
+
57
+ def scenario_groups
58
+ # The index preloads scenarios for a page of evaluations; read the
59
+ # loaded association there rather than querying once per suite.
60
+ return scenarios.filter_map { |scenario| scenario.group.presence }.uniq.sort if scenarios.loaded?
61
+
62
+ scenarios.where.not(group: [ nil, "" ]).distinct.order(:group).pluck(:group)
63
+ end
64
+
65
+ # Runs the evaluation. `selection` narrows a scenario evaluation to some of
66
+ # its scenarios (`scenario_ids`, `keys`, `group`) and/or to specific
67
+ # `models`; it is ignored by a generation-sampling evaluation.
68
+ def run!(run: nil, **selection)
69
+ # A run created ahead of time (run_later!) is the scenario runner's even
70
+ # if the suite has since lost its scenarios: it fails that run with
71
+ # "No scenarios selected" rather than leaving it pending forever.
72
+ if run || scenario_suite?
73
+ ScenarioEvaluationRunner.call(self, selection: selection, run: run)
74
+ else
75
+ EvaluationRunnerService.call(self)
76
+ end
77
+ end
78
+
79
+ # Creates the run now and executes it in the background, so a suite of
80
+ # many scenarios under several models does not have to finish inside one
81
+ # request. Returns the pending EvaluationRun.
82
+ def run_later!(**selection)
83
+ run = evaluation_runs.create!(status: :pending, selection: selection.deep_stringify_keys)
84
+ EvaluationRunJob.perform_later(id, run.id, selection.deep_stringify_keys)
85
+ run
86
+ end
87
+
88
+ # Replaces the suite with the scenarios described by +attributes+ (the
89
+ # ActiveAgent::Evals::ScenarioParser output). Keys already in the suite keep their records, so
90
+ # earlier runs' results still resolve to their scenario, and keep their
91
+ # enabled flag unless the attributes set it (a paste cannot).
92
+ def replace_scenarios!(attributes)
93
+ transaction do
94
+ keep = attributes.map { |attrs| attrs["key"] }
95
+ scenarios.where.not(key: keep).destroy_all
96
+
97
+ attributes.each_with_index do |attrs, index|
98
+ scenario = scenarios.find_or_initialize_by(key: attrs["key"])
99
+ scenario.assign_attributes(
100
+ prompt: attrs["prompt"],
101
+ group: attrs["group"],
102
+ notes: attrs["notes"],
103
+ expectations: attrs["expectations"] || {},
104
+ position: attrs.fetch("position", index),
105
+ enabled: attrs.fetch("enabled") { scenario.new_record? || scenario.enabled }
106
+ )
107
+ scenario.save!
108
+ end
109
+ end
110
+ scenarios.reload
52
111
  end
53
112
 
54
113
  def llm_criteria
@@ -60,8 +119,9 @@ module ActionAgent
60
119
  def validate_criteria
61
120
  if criteria.blank?
62
121
  # 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?
122
+ # on the first run — and a scenario suite is scored by its scenarios'
123
+ # own expectations even with no criteria.
124
+ errors.add(:criteria, "must include at least one criterion") unless judge_defined? || scenarios.any?
65
125
  return
66
126
  end
67
127
 
@@ -2,19 +2,199 @@
2
2
 
3
3
  module ActionAgent
4
4
  # One execution of an Evaluation over a sample of the agent's generations.
5
- # scores: { criterion_key => { "score", "min", "max", "passed", "total" } }
5
+ # scores: { criterion_key => { "score", "min", "max", "passed", "total" } },
6
+ # except on a comparison run, where each criterion is a cohort map of
7
+ # model => stats and "_"-prefixed metadata keys sit alongside the criteria.
8
+ # See #average_score, which is what has to tolerate both shapes.
6
9
  class EvaluationRun < ApplicationRecord
7
10
  belongs_to :evaluation
11
+ has_many :scenario_results, class_name: "EvaluationScenarioResult", dependent: :destroy
8
12
 
9
13
  enum :status, { pending: 0, running: 1, complete: 2, failed: 3 }
10
14
 
11
15
  scope :recent, -> { order(created_at: :desc) }
12
16
 
17
+ # Which scenarios and models a scenario run covered; empty for a
18
+ # generation-sampling run.
19
+ def selection
20
+ value = super
21
+ value.is_a?(Hash) ? value : {}
22
+ end
23
+
24
+ # The candidate models a scenario run compared, in the order they were
25
+ # requested; empty for a generation-sampling run.
26
+ def models
27
+ Array(scores&.dig("_models")&.keys)
28
+ end
29
+
30
+ # The label ActiveAgent::Evals::Report gives a verdict it ranked by pass
31
+ # rate itself, for a comparison no judge was available to rule on. Read
32
+ # from the framework rather than restated: the report reads it back when
33
+ # it names the judge, so the two have to agree on the string.
34
+ PASS_RATE_JUDGE = ActiveAgent::Evals::Report::PASS_RATE_JUDGE
35
+
36
+ # The verdict a comparison run recorded — the judge's pick and rationale
37
+ # when a judge wrote it, the framework's pass-rate ranking otherwise —
38
+ # as `{ "winner", "rationale", "judge" }`; nil for a single-model or
39
+ # generation-sampling run.
40
+ def recorded_verdict
41
+ verdict = scores&.dig("_verdict")
42
+ verdict.to_h.stringify_keys.presence if verdict.is_a?(Hash)
43
+ end
44
+
45
+ # How the report names the judge, the way the suite panel does: the
46
+ # judge the recorded verdict names — unless that is only the pass-rate
47
+ # ranking — else the evaluation's judge model. nil when neither is set,
48
+ # which the report reads as "rules".
49
+ def judge_label
50
+ recorded = recorded_verdict&.dig("judge").to_s
51
+ return recorded if recorded.present? && recorded != PASS_RATE_JUDGE
52
+
53
+ evaluation.judge_model.presence
54
+ end
55
+
56
+ # scores is not uniformly { criterion => stats }: a comparison run also
57
+ # records "_"-prefixed metadata (EvaluationRunnerService writes
58
+ # "_missing_models" as an Array and "_verdict"), and each of its criteria
59
+ # is a cohort map of model => stats rather than a single stats hash.
60
+ # Only real criterion scores are averaged; anything else is ignored
61
+ # rather than raising and taking the whole Evaluations page down.
13
62
  def average_score
14
- values = scores.values.map { |s| s["score"] }.compact
63
+ values = (scores || {}).reject { |key, _| key.to_s.start_with?("_") }.filter_map do |_key, value|
64
+ criterion_score(value) if value.is_a?(Hash)
65
+ end
15
66
  return nil if values.empty?
16
67
 
17
68
  (values.sum.to_f / values.size).round(3)
18
69
  end
70
+
71
+ # Aggregate usage over the run's scenario results, for display after a
72
+ # run: estimated cost, token totals, summed model time, and the run's
73
+ # wall-clock runtime. Returns nil for a generation-sampling run, which
74
+ # replays nothing itself.
75
+ def usage
76
+ totals = scenario_results.pick(
77
+ Arel.sql("COUNT(*)"), Arel.sql("SUM(cost)"), Arel.sql("SUM(input_tokens)"),
78
+ Arel.sql("SUM(output_tokens)"), Arel.sql("SUM(duration_ms)")
79
+ )
80
+ replays = totals&.first.to_i
81
+ return nil if replays.zero?
82
+
83
+ {
84
+ replays: replays,
85
+ cost: totals[1]&.to_f,
86
+ input_tokens: totals[2].to_i,
87
+ output_tokens: totals[3].to_i,
88
+ model_time_ms: totals[4].to_i,
89
+ runtime_ms: completed_at.present? ? ((completed_at - created_at) * 1000).round : nil
90
+ }
91
+ end
92
+
93
+ # Route templates for the report's fix item actions, relative to the
94
+ # dashboard mount: `%{key}` is filled in per MCP server by the report.
95
+ # The JSON API leaves `mount` empty — the React app resolves paths
96
+ # against the mount itself (dashboardPath) — while the standalone HTML
97
+ # report page is served outside the app and needs the absolute path.
98
+ def report_links(mount: "")
99
+ base = mount.to_s.chomp("/")
100
+
101
+ {
102
+ "mcp" => "#{base}/mcp/%{key}",
103
+ "tools" => "#{base}/tools",
104
+ "instructions" => "#{base}/agents/#{evaluation.agent_id}/edit"
105
+ }
106
+ end
107
+
108
+ # What to fix, from the framework's Report: one item per fault plus one
109
+ # per instruction change the judge proposed, each naming the tools
110
+ # involved, the MCP server that serves them and whether this run's
111
+ # agent has it enabled (EvaluationToolResolver), and the dashboard
112
+ # action that addresses it. Empty for a generation-sampling run.
113
+ def fix_items(links: report_links)
114
+ to_report(links: links).fix_items
115
+ end
116
+
117
+ # Rebuilds the framework's Report from this run's persisted results, so
118
+ # the dashboard serves the same self-contained report page a CLI run
119
+ # writes with Report#to_html. The run's recorded verdict and judge go
120
+ # with it: the report is not to re-rank the rebuilt results by pass
121
+ # rate and show a different judge's pick, verdict or `judged by` than
122
+ # the suite panel does. Raises ActiveRecord::RecordNotFound via the
123
+ # caller for a run of a generation-sampling evaluation, which has no
124
+ # scenario results to report on.
125
+ def to_report(links: report_links)
126
+ rows = scenario_results.includes(:scenario).joins(:scenario)
127
+ .order(EvaluationScenario.arel_table[:position], EvaluationScenario.arel_table[:id], :model)
128
+ selected = selected_specs
129
+ specs = {}
130
+ results = rows.map do |row|
131
+ spec = specs[[ row.provider.to_s, row.model ]] ||= selected[[ row.provider.to_s, row.model ]] || ModelSpec.new(
132
+ label: [ row.provider.presence, row.model ].compact.join("/"), provider: row.provider.to_s, model: row.model
133
+ )
134
+ ActiveAgent::Evals::Result.new(
135
+ scenario: ActiveAgent::Evals::Scenario.from_hash(row.scenario.as_json_summary),
136
+ spec: spec,
137
+ replay: ActiveAgent::Evals::Replay.new(
138
+ answer: row.output, tool_calls: Array(row.tool_calls), duration_ms: row.duration_ms,
139
+ input_tokens: row.input_tokens, output_tokens: row.output_tokens,
140
+ cost: row.cost&.to_f, error: row.error_message
141
+ ),
142
+ scores: row.scores.to_h, score: row.score, status: row.status,
143
+ diagnosis: row.diagnosis.presence
144
+ )
145
+ end
146
+
147
+ ActiveAgent::Evals::Report.new(
148
+ results: results,
149
+ models: (selected.values & specs.values) + (specs.values - selected.values),
150
+ metadata: {
151
+ "evaluation" => evaluation.name,
152
+ "agent" => evaluation.agent&.name,
153
+ "run" => id,
154
+ "finished" => completed_at&.iso8601
155
+ }.compact,
156
+ verdict: recorded_verdict,
157
+ judge_label: judge_label,
158
+ tool_resolver: EvaluationToolResolver.new(evaluation.agent),
159
+ agent_name: evaluation.agent&.name,
160
+ links: links
161
+ )
162
+ end
163
+
164
+ private
165
+
166
+ ModelSpec = ActiveAgent::Evals::ModelSpec
167
+ private_constant :ModelSpec
168
+
169
+ # The candidate specs the run was asked to compare, keyed by
170
+ # [provider, model] in the order requested. ScenarioEvaluationRunner
171
+ # persists each ModelSpec#to_h in `selection`, and it is that label —
172
+ # the string the user typed, e.g. "gpt-5-mini" — that keys the run's
173
+ # `_models` and names the verdict's winner, so the rebuilt report has
174
+ # to reuse it rather than relabel every model provider/model.
175
+ def selected_specs
176
+ Array(selection["models"]).filter_map do |entry|
177
+ next unless entry.is_a?(Hash)
178
+
179
+ entry = entry.stringify_keys
180
+ next if entry["model"].blank?
181
+
182
+ ModelSpec.new(label: entry["label"].presence || entry["model"], provider: entry["provider"].to_s, model: entry["model"])
183
+ end.index_by { |spec| [ spec.provider, spec.model ] }
184
+ end
185
+
186
+ # A criterion is either scored directly ({ "score" => 0.8, ... }) or, on a
187
+ # comparison run, a map of model => stats; that cohort's mean is the
188
+ # criterion's headline score. Skipped criteria carry no score at all.
189
+ def criterion_score(value)
190
+ return value["score"] if value["score"].is_a?(Numeric)
191
+
192
+ cohort = value.each_value.filter_map do |stats|
193
+ stats["score"] if stats.is_a?(Hash) && stats["score"].is_a?(Numeric)
194
+ end
195
+ return nil if cohort.empty?
196
+
197
+ cohort.sum.to_f / cohort.size
198
+ end
19
199
  end
20
200
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # One user-authored task an evaluation replays through its agent: the
5
+ # message a user would send, the group of related tasks it belongs to, and
6
+ # what a passing answer is expected to do.
7
+ #
8
+ # `expectations` holds the scenario-level checks ScenarioEvaluationRunner
9
+ # scores in addition to the evaluation's criteria:
10
+ #
11
+ # "tools" — tool names the agent is expected to call (any one of them)
12
+ # "contains" — substrings or patterns the answer must include
13
+ # "not_contains" — substrings or patterns the answer must avoid
14
+ #
15
+ # `key` is stable within the suite ("blame_3"), so results of successive
16
+ # runs line up by scenario even after the suite is re-imported.
17
+ class EvaluationScenario < ApplicationRecord
18
+ belongs_to :evaluation
19
+ has_many :results, class_name: "EvaluationScenarioResult", foreign_key: :evaluation_scenario_id,
20
+ dependent: :destroy, inverse_of: :scenario
21
+
22
+ validates :key, presence: true, uniqueness: { scope: :evaluation_id }
23
+ validates :prompt, presence: true
24
+
25
+ scope :enabled, -> { where(enabled: true) }
26
+ scope :ordered, -> { order(:position, :id) }
27
+ scope :in_group, ->(group) { where(group: group) }
28
+
29
+ def expectations
30
+ value = super
31
+ value.is_a?(Hash) ? value : {}
32
+ end
33
+
34
+ def expected_tools
35
+ Array(expectations["tools"]).map(&:to_s).reject(&:blank?)
36
+ end
37
+
38
+ def expected_patterns
39
+ Array(expectations["contains"]).map(&:to_s).reject(&:blank?)
40
+ end
41
+
42
+ def forbidden_patterns
43
+ Array(expectations["not_contains"]).map(&:to_s).reject(&:blank?)
44
+ end
45
+
46
+ def as_json_summary
47
+ {
48
+ id: id,
49
+ key: key,
50
+ group: group,
51
+ prompt: prompt,
52
+ notes: notes,
53
+ expectations: expectations,
54
+ position: position,
55
+ enabled: enabled
56
+ }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # What one scenario produced under one model in one evaluation run: the
5
+ # AgentRun that replayed it, the answer, the tools it called, its score per
6
+ # criterion, and — when it fell short — the fault and the recommended fix.
7
+ #
8
+ # `fault` is one of FAULTS; `diagnosis` carries the evidence behind it and,
9
+ # when a judge was available, its suggested tool or instruction change.
10
+ class EvaluationScenarioResult < ApplicationRecord
11
+ belongs_to :evaluation_run
12
+ belongs_to :scenario, class_name: "EvaluationScenario", foreign_key: :evaluation_scenario_id, inverse_of: :results
13
+ belongs_to :agent_run, optional: true
14
+
15
+ enum :status, { pending: 0, passed: 1, failed: 2, errored: 3 }
16
+
17
+ # Why a scenario did not pass, from the most mechanical cause to the
18
+ # least; ActiveAgent::Evals::Diagnosis assigns exactly one per failing result.
19
+ FAULTS = ActiveAgent::Evals::Diagnosis::FAULTS
20
+
21
+ validates :model, presence: true
22
+ validates :fault, inclusion: { in: FAULTS }, allow_nil: true
23
+
24
+ scope :for_model, ->(model) { where(model: model) }
25
+ scope :faulted, -> { where.not(fault: nil) }
26
+
27
+ def tool_calls
28
+ value = super
29
+ value.is_a?(Array) ? value : []
30
+ end
31
+
32
+ def tool_names
33
+ tool_calls.filter_map { |call| call.is_a?(Hash) ? (call["name"] || call[:name]) : call }.map(&:to_s)
34
+ end
35
+
36
+ def tool_errors
37
+ tool_calls.select { |call| call.is_a?(Hash) && (call["error"] || call[:error]) }
38
+ end
39
+
40
+ def as_json_summary
41
+ {
42
+ id: id,
43
+ scenario_id: evaluation_scenario_id,
44
+ scenario_key: scenario.key,
45
+ group: scenario.group,
46
+ prompt: scenario.prompt,
47
+ model: model,
48
+ provider: provider,
49
+ status: status,
50
+ score: score,
51
+ scores: scores,
52
+ output: output,
53
+ tool_calls: tool_calls,
54
+ duration_ms: duration_ms,
55
+ input_tokens: input_tokens,
56
+ output_tokens: output_tokens,
57
+ cost: cost&.to_f,
58
+ fault: fault,
59
+ recommendation: recommendation,
60
+ diagnosis: diagnosis,
61
+ error_message: error_message,
62
+ agent_run_id: agent_run_id
63
+ }
64
+ end
65
+ end
66
+ end
@@ -75,14 +75,23 @@ module ActionAgent
75
75
  }
76
76
  end
77
77
 
78
- private
79
-
78
+ # The value with passwords, card numbers and the like masked. Public so
79
+ # every read path — /actions, the show timeline, and the export
80
+ # cassette — serializes through the one redaction rule.
80
81
  def redacted_value
81
82
  return value unless should_redact?
82
83
 
83
84
  "[REDACTED]"
84
85
  end
85
86
 
87
+ # Metadata with the sensitive keys removed. Public for the same reason.
88
+ def safe_metadata
89
+ # Remove any sensitive data from metadata
90
+ metadata.except("password", "credit_card", "cvv", "ssn")
91
+ end
92
+
93
+ private
94
+
86
95
  def should_redact?
87
96
  return false unless value.present?
88
97
 
@@ -102,11 +111,6 @@ module ActionAgent
102
111
  selector_is_sensitive || value_is_sensitive
103
112
  end
104
113
 
105
- def safe_metadata
106
- # Remove any sensitive data from metadata
107
- metadata.except("password", "credit_card", "cvv", "ssn")
108
- end
109
-
110
114
  def extract_url
111
115
  case action_type
112
116
  when "navigate"
@@ -48,7 +48,7 @@ module ActionAgent
48
48
  # Unknown keys are dropped rather than raising — a session outlives a
49
49
  # catalog edit.
50
50
  def mcp_catalog_entries
51
- Array(mcp_servers).filter_map { |key| McpCatalog.find(key) }
51
+ Array(mcp_servers).filter_map { |key| MCPCatalog.find(key) }
52
52
  end
53
53
 
54
54
  # Check if session is still valid
@@ -31,20 +31,30 @@ module ActionAgent
31
31
  name&.start_with?("user_takeover_")
32
32
  end
33
33
 
34
- # Start a new recording session
35
- def self.start!(agent_run: nil, sandbox_session: nil, name: nil)
36
- create!(
34
+ # Start a new recording session.
35
+ #
36
+ # The owner column is written here, at creation: the index and recent
37
+ # endpoints scope through it, and a recording nothing ever stamped was
38
+ # invisible in the list to the very person who made it. An explicit
39
+ # +owner+ wins; otherwise the recording inherits the owner of the sandbox
40
+ # or agent it records. Ownable#owner= is a no-op in a single-user
41
+ # install, where nothing is owned.
42
+ def self.start!(agent_run: nil, sandbox_session: nil, name: nil, owner: nil)
43
+ recording = new(
37
44
  agent_run: agent_run,
38
45
  sandbox_session: sandbox_session,
39
46
  name: name || generate_name(agent_run, sandbox_session),
40
47
  status: :recording,
41
48
  metadata: { started_at: Time.current.iso8601 }
42
49
  )
50
+ recording.owner = owner || inherited_owner(agent_run, sandbox_session)
51
+ recording.save!
52
+ recording
43
53
  end
44
54
 
45
55
  # Start a user takeover session (for lander demo analytics)
46
- def self.start_user_session!(visitor_id: nil, parent_demo_id: nil, page_url: nil)
47
- create!(
56
+ def self.start_user_session!(visitor_id: nil, parent_demo_id: nil, page_url: nil, owner: nil)
57
+ recording = new(
48
58
  name: "user_takeover_#{Time.current.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}",
49
59
  status: :recording,
50
60
  metadata: {
@@ -56,6 +66,16 @@ module ActionAgent
56
66
  user_agent: nil # Will be set from request
57
67
  }
58
68
  )
69
+ recording.owner = owner
70
+ recording.save!
71
+ recording
72
+ end
73
+
74
+ # Whoever owns the sandbox or agent a recording is made against. Both
75
+ # models declare the same owner candidates as this one, so the record
76
+ # they hand back is of the class this install owns things through.
77
+ def self.inherited_owner(agent_run, sandbox_session)
78
+ sandbox_session&.owner || agent_run&.agent&.owner
59
79
  end
60
80
 
61
81
  # Record a browser action
@@ -112,7 +132,10 @@ module ActionAgent
112
132
  )
113
133
  end
114
134
 
115
- # Get timeline data for playback
135
+ # Get timeline data for playback. Values and metadata go through the
136
+ # same redaction the /actions endpoint applies: this is what #show
137
+ # renders, and it used to hand back the cleartext password that
138
+ # /actions had just redacted for the same action.
116
139
  def timeline
117
140
  recording_actions.order(:sequence).map do |action|
118
141
  {
@@ -121,9 +144,9 @@ module ActionAgent
121
144
  sequence: action.sequence,
122
145
  timestamp_ms: action.timestamp_ms,
123
146
  selector: action.selector,
124
- value: action.value,
147
+ value: action.redacted_value,
125
148
  screenshot_key: action.screenshot_key,
126
- metadata: action.metadata
149
+ metadata: action.safe_metadata
127
150
  }
128
151
  end
129
152
  end