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.
- checksums.yaml +4 -4
- data/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +48 -44
- data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
- data/app/controllers/action_agent/api/agents_controller.rb +76 -48
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +2 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +14 -5
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +182 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +110 -2
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/services/action_agent/agent_toolbox.rb +4 -4
- data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/playwright_mcp_client.rb +6 -6
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +25 -2
- data/lib/action_agent/engine.rb +101 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +72 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +19 -6
|
@@ -4,16 +4,16 @@ module ActionAgent
|
|
|
4
4
|
class SandboxCleanupJob < ApplicationJob
|
|
5
5
|
queue_as :sandboxes
|
|
6
6
|
|
|
7
|
-
#
|
|
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
|
-
#
|
|
14
|
+
# Terminate the backend resource if one was provisioned
|
|
15
15
|
if sandbox.cloud_run_job_id.present? && !Rails.env.development?
|
|
16
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 #{
|
|
194
|
+
class #{telemetry_agent_class} < ApplicationAgent
|
|
188
195
|
generate_with :#{provider}, model: "#{model}"#{model_config_code}
|
|
189
196
|
|
|
190
197
|
def perform
|
|
191
|
-
|
|
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
|
-
"
|
|
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
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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.
|
|
147
|
+
value: action.redacted_value,
|
|
125
148
|
screenshot_key: action.screenshot_key,
|
|
126
|
-
metadata: action.
|
|
149
|
+
metadata: action.safe_metadata
|
|
127
150
|
}
|
|
128
151
|
end
|
|
129
152
|
end
|