actionagent 1.2.2 → 1.5.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 +69 -43
- data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
- data/app/controllers/action_agent/api/agents_controller.rb +191 -56
- 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/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- 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 +3 -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 +74 -23
- data/app/models/action_agent/agent_run.rb +99 -0
- 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 +190 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +86 -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 +126 -3
- 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/serializers/action_agent/agent_message_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +294 -16
- data/app/services/action_agent/agent_registrar.rb +7 -6
- data/app/services/action_agent/agent_toolbox.rb +49 -7
- data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
- data/app/services/action_agent/evaluation_evidence.rb +234 -0
- data/app/services/action_agent/evaluation_runner_service.rb +13 -3
- data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_client.rb +167 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +36 -3
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +106 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +104 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +22 -5
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module ActionAgent
|
|
4
4
|
class Agent < ApplicationRecord
|
|
5
|
+
class ObservedAgentError < StandardError; end
|
|
6
|
+
|
|
5
7
|
include Ownable
|
|
6
8
|
owned_by :user, :account
|
|
7
9
|
|
|
@@ -72,7 +74,7 @@ module ActionAgent
|
|
|
72
74
|
|
|
73
75
|
# Available tools/MCPs
|
|
74
76
|
AVAILABLE_TOOLS = %w[
|
|
75
|
-
terminal playwright filesystem code database slack fetch search edit translate memory agents
|
|
77
|
+
terminal playwright filesystem code database slack fetch search edit translate memory agents ui
|
|
76
78
|
].freeze
|
|
77
79
|
|
|
78
80
|
# Available providers
|
|
@@ -181,28 +183,34 @@ module ActionAgent
|
|
|
181
183
|
agent_versions.count
|
|
182
184
|
end
|
|
183
185
|
|
|
184
|
-
# Generate Ruby agent class code
|
|
186
|
+
# Generate Ruby agent class code.
|
|
187
|
+
#
|
|
188
|
+
# The class is named by telemetry_agent_class: it parameterizes a name
|
|
189
|
+
# with spaces ("My Agent" -> MyAgentAgent, not `class My AgentAgent`) and
|
|
190
|
+
# appends the Agent suffix only when it is missing, so an observed agent
|
|
191
|
+
# whose reported class already ends in Agent is not doubled. It is also
|
|
192
|
+
# the key traces are correlated on, so the exported class reports under
|
|
193
|
+
# the same name this record listens for.
|
|
185
194
|
def to_agent_class_code
|
|
186
195
|
<<~RUBY
|
|
187
|
-
class #{
|
|
196
|
+
class #{telemetry_agent_class} < ApplicationAgent
|
|
188
197
|
generate_with :#{provider}, model: "#{model}"#{model_config_code}
|
|
189
198
|
|
|
190
199
|
def perform
|
|
191
|
-
|
|
200
|
+
#{instructions_code}
|
|
192
201
|
end
|
|
193
202
|
end
|
|
194
203
|
RUBY
|
|
195
204
|
end
|
|
196
205
|
|
|
197
|
-
# Execute a run with this agent
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
)
|
|
206
|
+
# Execute a run with this agent. Files in +attachments+ (uploaded files,
|
|
207
|
+
# {io:, filename:, content_type:} hashes or blobs) are stored on the run
|
|
208
|
+
# before the job is enqueued, so a worker on another machine finds them
|
|
209
|
+
# attached. +params+ (provider/model overrides, the context_id of a
|
|
210
|
+
# conversation to continue) are kept on the run as input_params.
|
|
211
|
+
def execute(input_prompt, action: nil, attachments: [], **params)
|
|
212
|
+
ensure_executable!
|
|
213
|
+
run = create_run(input_prompt, action: action, attachments: attachments, params: params, status: :pending)
|
|
206
214
|
|
|
207
215
|
# Queue the execution job
|
|
208
216
|
AgentExecutionJob.perform_later(run.id)
|
|
@@ -211,14 +219,11 @@ module ActionAgent
|
|
|
211
219
|
end
|
|
212
220
|
|
|
213
221
|
# Quick test execution (synchronous)
|
|
214
|
-
def test_execute(input_prompt, action: nil, **params)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
status: :running,
|
|
220
|
-
trace_id: SecureRandom.uuid,
|
|
221
|
-
started_at: Time.current
|
|
222
|
+
def test_execute(input_prompt, action: nil, attachments: [], **params)
|
|
223
|
+
ensure_executable!
|
|
224
|
+
run = create_run(
|
|
225
|
+
input_prompt, action: action, attachments: attachments, params: params,
|
|
226
|
+
status: :running, started_at: Time.current
|
|
222
227
|
)
|
|
223
228
|
|
|
224
229
|
begin
|
|
@@ -247,8 +252,52 @@ module ActionAgent
|
|
|
247
252
|
run
|
|
248
253
|
end
|
|
249
254
|
|
|
255
|
+
# An observed agent was reconstructed from telemetry, so it is read-only:
|
|
256
|
+
# editing it would rewrite a record of what ran. Executing it is a
|
|
257
|
+
# different question — it carries the instructions, model and MCP servers a
|
|
258
|
+
# run needs, and evaluating the agent that actually served production is
|
|
259
|
+
# the case operators ask for. So a run is allowed once the agent names a
|
|
260
|
+
# server the dashboard can reach, and refused when it would have nothing to
|
|
261
|
+
# call.
|
|
262
|
+
def ensure_executable!
|
|
263
|
+
return unless observed?
|
|
264
|
+
return if MCPToolDispatcher.new(self).any_reachable_server?
|
|
265
|
+
|
|
266
|
+
raise ObservedAgentError,
|
|
267
|
+
"This agent was observed from telemetry and is read-only: it names no reachable MCP server — duplicate it to create an executable copy"
|
|
268
|
+
end
|
|
269
|
+
|
|
250
270
|
private
|
|
251
271
|
|
|
272
|
+
# Refuses files before creating anything: a run that exists but lost
|
|
273
|
+
# its attachments would execute against the wrong prompt.
|
|
274
|
+
def create_run(input_prompt, action:, attachments:, params:, **attributes)
|
|
275
|
+
files = Array.wrap(attachments).compact
|
|
276
|
+
raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?
|
|
277
|
+
|
|
278
|
+
run = agent_runs.create!(
|
|
279
|
+
input_prompt: input_prompt,
|
|
280
|
+
action_name: normalized_action(action),
|
|
281
|
+
input_params: params,
|
|
282
|
+
trace_id: SecureRandom.uuid,
|
|
283
|
+
**attributes
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if files.any?
|
|
287
|
+
begin
|
|
288
|
+
run.attachments.attach(*files)
|
|
289
|
+
rescue StandardError
|
|
290
|
+
# An attach that raises (an unwritable service, a value that is not
|
|
291
|
+
# a file) happens after the row exists, and the caller never reaches
|
|
292
|
+
# the enqueue: without this the run would sit pending forever.
|
|
293
|
+
run.destroy
|
|
294
|
+
raise
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
run
|
|
299
|
+
end
|
|
300
|
+
|
|
252
301
|
def slug_unique_within_owner
|
|
253
302
|
return if slug.blank?
|
|
254
303
|
|
|
@@ -334,10 +383,12 @@ module ActionAgent
|
|
|
334
383
|
", #{configs}"
|
|
335
384
|
end
|
|
336
385
|
|
|
386
|
+
# Exactly one prompt call: a bare `prompt` without instructions, or a
|
|
387
|
+
# single `prompt instructions:` heredoc with them.
|
|
337
388
|
def instructions_code
|
|
338
|
-
return "" if instructions.blank?
|
|
389
|
+
return "prompt" if instructions.blank?
|
|
339
390
|
|
|
340
|
-
"
|
|
391
|
+
"prompt instructions: <<~INSTRUCTIONS\n #{instructions.gsub("\n", "\n ")}\n INSTRUCTIONS"
|
|
341
392
|
end
|
|
342
393
|
end
|
|
343
394
|
end
|
|
@@ -4,6 +4,49 @@ module ActionAgent
|
|
|
4
4
|
class AgentRun < ApplicationRecord
|
|
5
5
|
belongs_to :agent
|
|
6
6
|
|
|
7
|
+
# Raised when a caller hands a run files to attach in a host app that
|
|
8
|
+
# has nowhere to keep them.
|
|
9
|
+
class AttachmentsUnavailable < StandardError
|
|
10
|
+
def initialize(message = "Attachments need Active Storage in the host app (run `rails active_storage:install`)")
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Files uploaded with the run. The execution service delivers them to
|
|
16
|
+
# the model (images and PDFs as data URIs, text inlined) and the
|
|
17
|
+
# persisted user message keeps a manifest of them.
|
|
18
|
+
#
|
|
19
|
+
# Guarded like RecordingSnapshot: a host app created with
|
|
20
|
+
# --skip-active-storage has no has_many_attached to call.
|
|
21
|
+
has_many_attached :attachments if defined?(ActiveStorage)
|
|
22
|
+
|
|
23
|
+
# Whether runs can carry files in this host app: Active Storage loaded,
|
|
24
|
+
# the macro applied, and its tables migrated. Never raises — a host
|
|
25
|
+
# that skipped `rails active_storage:install` still runs agents, it
|
|
26
|
+
# just can't attach files to them.
|
|
27
|
+
def self.attachments_available?
|
|
28
|
+
defined?(ActiveStorage) && method_defined?(:attachments) && ActiveStorage::Blob.table_exists?
|
|
29
|
+
rescue StandardError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# How an attachment reaches the model, by MIME type with a filename
|
|
34
|
+
# fallback for the text formats browsers upload as octet-stream:
|
|
35
|
+
# images and documents ride along as data URIs, text is inlined into
|
|
36
|
+
# the prompt, anything else is only described.
|
|
37
|
+
TEXT_CONTENT_TYPES = %w[application/json application/xml application/x-yaml application/csv].freeze
|
|
38
|
+
TEXT_EXTENSIONS = %w[.csv .md .txt .json .yml .yaml].freeze
|
|
39
|
+
|
|
40
|
+
def self.attachment_kind(content_type, filename = nil)
|
|
41
|
+
type = content_type.to_s.downcase
|
|
42
|
+
return "image" if type.start_with?("image/")
|
|
43
|
+
return "document" if type == "application/pdf"
|
|
44
|
+
return "text" if type.start_with?("text/") || TEXT_CONTENT_TYPES.include?(type)
|
|
45
|
+
return "text" if TEXT_EXTENSIONS.include?(File.extname(filename.to_s).downcase)
|
|
46
|
+
|
|
47
|
+
"file"
|
|
48
|
+
end
|
|
49
|
+
|
|
7
50
|
# Status enum
|
|
8
51
|
enum :status, { pending: 0, running: 1, complete: 2, failed: 3, cancelled: 4 }
|
|
9
52
|
|
|
@@ -103,6 +146,37 @@ module ActionAgent
|
|
|
103
146
|
complete? || failed? || cancelled?
|
|
104
147
|
end
|
|
105
148
|
|
|
149
|
+
# The conversation this run belongs to: the one it actually wrote to
|
|
150
|
+
# once it has executed (output_metadata), else the one the caller asked
|
|
151
|
+
# to continue. A pinned id the run declined — another agent's context,
|
|
152
|
+
# or another action's — must not be the id the API reports, or the
|
|
153
|
+
# runner would open a conversation the turn is not in.
|
|
154
|
+
def context_id
|
|
155
|
+
output_metadata&.dig("context_id") || input_params&.dig("context_id")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The run's files as the runner and the persisted user message show
|
|
159
|
+
# them: one hash per attachment, with the kind the execution service
|
|
160
|
+
# sorted it into and a blob URL for thumbnails (nil when the host app
|
|
161
|
+
# didn't draw Active Storage's routes). Empty without Active Storage.
|
|
162
|
+
def attachment_manifest
|
|
163
|
+
return [] unless self.class.attachments_available?
|
|
164
|
+
|
|
165
|
+
attachment_records.map do |attachment|
|
|
166
|
+
blob = attachment.blob
|
|
167
|
+
{
|
|
168
|
+
"id" => attachment.id,
|
|
169
|
+
"blob_id" => blob.id,
|
|
170
|
+
"signed_id" => blob.signed_id,
|
|
171
|
+
"filename" => blob.filename.to_s,
|
|
172
|
+
"content_type" => blob.content_type,
|
|
173
|
+
"byte_size" => blob.byte_size,
|
|
174
|
+
"kind" => self.class.attachment_kind(blob.content_type, blob.filename.to_s),
|
|
175
|
+
"url" => blob_path(blob)
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
106
180
|
# Get a summary for display
|
|
107
181
|
def summary
|
|
108
182
|
{
|
|
@@ -118,6 +192,8 @@ module ActionAgent
|
|
|
118
192
|
instructions_digest: instructions_digest,
|
|
119
193
|
instructions_codename: instructions_codename,
|
|
120
194
|
instructions_preview: output_metadata&.dig("instructions")&.truncate(120),
|
|
195
|
+
attachments: attachment_manifest,
|
|
196
|
+
context_id: context_id,
|
|
121
197
|
created_at: created_at,
|
|
122
198
|
error: error_message
|
|
123
199
|
}
|
|
@@ -147,5 +223,28 @@ module ActionAgent
|
|
|
147
223
|
def set_trace_id
|
|
148
224
|
self.trace_id ||= SecureRandom.uuid
|
|
149
225
|
end
|
|
226
|
+
|
|
227
|
+
# Reads the association as loaded when a list preloaded it
|
|
228
|
+
# (with_attachments below), so serializing a page of runs costs two
|
|
229
|
+
# queries rather than two per run; a single run fetches its own.
|
|
230
|
+
def attachment_records
|
|
231
|
+
if attachments_attachments.loaded?
|
|
232
|
+
attachments_attachments.sort_by(&:id)
|
|
233
|
+
else
|
|
234
|
+
attachments_attachments.includes(:blob).order(:id).to_a
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Preloads attachments and blobs for a list of runs — a no-op scope in
|
|
239
|
+
# a host without Active Storage, so callers need no guard of their own.
|
|
240
|
+
def self.with_attachments
|
|
241
|
+
attachments_available? ? with_attached_attachments : all
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def blob_path(blob)
|
|
245
|
+
Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
|
|
246
|
+
rescue StandardError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
150
249
|
end
|
|
151
250
|
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,207 @@
|
|
|
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
|
+
def report_metadata
|
|
31
|
+
value = scores&.dig("_metadata")
|
|
32
|
+
value.is_a?(Hash) ? value : {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The label ActiveAgent::Evals::Report gives a verdict it ranked by pass
|
|
36
|
+
# rate itself, for a comparison no judge was available to rule on. Read
|
|
37
|
+
# from the framework rather than restated: the report reads it back when
|
|
38
|
+
# it names the judge, so the two have to agree on the string.
|
|
39
|
+
PASS_RATE_JUDGE = ActiveAgent::Evals::Report::PASS_RATE_JUDGE
|
|
40
|
+
|
|
41
|
+
# The verdict a comparison run recorded — the judge's pick and rationale
|
|
42
|
+
# when a judge wrote it, the framework's pass-rate ranking otherwise —
|
|
43
|
+
# as `{ "winner", "rationale", "judge" }`; nil for a single-model or
|
|
44
|
+
# generation-sampling run.
|
|
45
|
+
def recorded_verdict
|
|
46
|
+
verdict = scores&.dig("_verdict")
|
|
47
|
+
verdict.to_h.stringify_keys.presence if verdict.is_a?(Hash)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# How the report names the judge, the way the suite panel does: the
|
|
51
|
+
# judge the recorded verdict names — unless that is only the pass-rate
|
|
52
|
+
# ranking — else the evaluation's judge model. nil when neither is set,
|
|
53
|
+
# which the report reads as "rules".
|
|
54
|
+
def judge_label
|
|
55
|
+
return scores["_judge_label"] if scores&.key?("_judge_label")
|
|
56
|
+
|
|
57
|
+
recorded = recorded_verdict&.dig("judge").to_s
|
|
58
|
+
return recorded if recorded.present? && recorded != PASS_RATE_JUDGE
|
|
59
|
+
|
|
60
|
+
evaluation.judge_model.presence
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# scores is not uniformly { criterion => stats }: a comparison run also
|
|
64
|
+
# records "_"-prefixed metadata (EvaluationRunnerService writes
|
|
65
|
+
# "_missing_models" as an Array and "_verdict"), and each of its criteria
|
|
66
|
+
# is a cohort map of model => stats rather than a single stats hash.
|
|
67
|
+
# Only real criterion scores are averaged; anything else is ignored
|
|
68
|
+
# rather than raising and taking the whole Evaluations page down.
|
|
13
69
|
def average_score
|
|
14
|
-
values = scores.
|
|
70
|
+
values = (scores || {}).reject { |key, _| key.to_s.start_with?("_") }.filter_map do |_key, value|
|
|
71
|
+
criterion_score(value) if value.is_a?(Hash)
|
|
72
|
+
end
|
|
15
73
|
return nil if values.empty?
|
|
16
74
|
|
|
17
75
|
(values.sum.to_f / values.size).round(3)
|
|
18
76
|
end
|
|
77
|
+
|
|
78
|
+
# Aggregate usage over the run's scenario results, for display after a
|
|
79
|
+
# run: estimated cost, token totals, summed model time, and the run's
|
|
80
|
+
# wall-clock runtime. Returns nil for a generation-sampling run, which
|
|
81
|
+
# replays nothing itself.
|
|
82
|
+
def usage
|
|
83
|
+
totals = scenario_results.pick(
|
|
84
|
+
Arel.sql("COUNT(*)"), Arel.sql("SUM(cost)"), Arel.sql("SUM(input_tokens)"),
|
|
85
|
+
Arel.sql("SUM(output_tokens)"), Arel.sql("SUM(duration_ms)")
|
|
86
|
+
)
|
|
87
|
+
replays = totals&.first.to_i
|
|
88
|
+
return nil if replays.zero?
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
replays: replays,
|
|
92
|
+
cost: totals[1]&.to_f,
|
|
93
|
+
input_tokens: totals[2].to_i,
|
|
94
|
+
output_tokens: totals[3].to_i,
|
|
95
|
+
model_time_ms: totals[4].to_i,
|
|
96
|
+
runtime_ms: completed_at.present? ? ((completed_at - created_at) * 1000).round : nil
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Route templates for the report's fix item actions, relative to the
|
|
101
|
+
# dashboard mount: `%{key}` is filled in per MCP server by the report.
|
|
102
|
+
# The JSON API leaves `mount` empty — the React app resolves paths
|
|
103
|
+
# against the mount itself (dashboardPath) — while the standalone HTML
|
|
104
|
+
# report page is served outside the app and needs the absolute path.
|
|
105
|
+
def report_links(mount: "")
|
|
106
|
+
base = mount.to_s.chomp("/")
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"mcp" => "#{base}/mcp/%{key}",
|
|
110
|
+
"tools" => "#{base}/tools",
|
|
111
|
+
"instructions" => "#{base}/agents/#{evaluation.agent_id}/edit"
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# What to fix, from the framework's Report: one item per fault plus one
|
|
116
|
+
# per instruction change the judge proposed, each naming the tools
|
|
117
|
+
# involved, the MCP server that serves them and whether this run's
|
|
118
|
+
# agent has it enabled (EvaluationToolResolver), and the dashboard
|
|
119
|
+
# action that addresses it. Empty for a generation-sampling run.
|
|
120
|
+
def fix_items(links: report_links)
|
|
121
|
+
to_report(links: links).fix_items
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Rebuilds the framework's Report from this run's persisted results, so
|
|
125
|
+
# the dashboard serves the same self-contained report page a CLI run
|
|
126
|
+
# writes with Report#to_html. The run's recorded verdict and judge go
|
|
127
|
+
# with it: the report is not to re-rank the rebuilt results by pass
|
|
128
|
+
# rate and show a different judge's pick, verdict or `judged by` than
|
|
129
|
+
# the suite panel does. Raises ActiveRecord::RecordNotFound via the
|
|
130
|
+
# caller for a run of a generation-sampling evaluation, which has no
|
|
131
|
+
# scenario results to report on.
|
|
132
|
+
def to_report(links: report_links)
|
|
133
|
+
rows = scenario_results.includes(:scenario).sort_by do |row|
|
|
134
|
+
[ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model ]
|
|
135
|
+
end
|
|
136
|
+
selected = selected_specs
|
|
137
|
+
specs = {}
|
|
138
|
+
results = rows.map do |row|
|
|
139
|
+
spec = specs[[ row.provider.to_s, row.model ]] ||= selected[[ row.provider.to_s, row.model ]] || ModelSpec.new(
|
|
140
|
+
label: [ row.provider.presence, row.model ].compact.join("/"), provider: row.provider.to_s, model: row.model
|
|
141
|
+
)
|
|
142
|
+
ActiveAgent::Evals::Result.new(
|
|
143
|
+
scenario: ActiveAgent::Evals::Scenario.from_hash(row.evaluated_scenario),
|
|
144
|
+
spec: spec,
|
|
145
|
+
replay: ActiveAgent::Evals::Replay.new(
|
|
146
|
+
answer: row.output, tool_calls: Array(row.tool_calls), duration_ms: row.duration_ms,
|
|
147
|
+
input_tokens: row.input_tokens, output_tokens: row.output_tokens,
|
|
148
|
+
cost: row.cost&.to_f, error: row.error_message, metadata: row.replay_metadata
|
|
149
|
+
),
|
|
150
|
+
scores: row.scores.to_h, score: row.score, status: row.status,
|
|
151
|
+
diagnosis: row.evaluation_diagnosis.presence
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
ActiveAgent::Evals::Report.new(
|
|
156
|
+
results: results,
|
|
157
|
+
models: (selected.values & specs.values) + (specs.values - selected.values),
|
|
158
|
+
metadata: {
|
|
159
|
+
"evaluation" => evaluation.name,
|
|
160
|
+
"agent" => evaluation.agent&.name,
|
|
161
|
+
"run" => id,
|
|
162
|
+
"finished" => completed_at&.iso8601
|
|
163
|
+
}.compact.merge(report_metadata),
|
|
164
|
+
verdict: recorded_verdict,
|
|
165
|
+
judge_label: judge_label,
|
|
166
|
+
tool_resolver: EvaluationToolResolver.new(evaluation.agent),
|
|
167
|
+
agent_name: evaluation.agent&.name,
|
|
168
|
+
links: links
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
ModelSpec = ActiveAgent::Evals::ModelSpec
|
|
175
|
+
private_constant :ModelSpec
|
|
176
|
+
|
|
177
|
+
# The candidate specs the run was asked to compare, keyed by
|
|
178
|
+
# [provider, model] in the order requested. ScenarioEvaluationRunner
|
|
179
|
+
# persists each ModelSpec#to_h in `selection`, and it is that label —
|
|
180
|
+
# the string the user typed, e.g. "gpt-5-mini" — that keys the run's
|
|
181
|
+
# `_models` and names the verdict's winner, so the rebuilt report has
|
|
182
|
+
# to reuse it rather than relabel every model provider/model.
|
|
183
|
+
def selected_specs
|
|
184
|
+
Array(selection["models"]).filter_map do |entry|
|
|
185
|
+
next unless entry.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
entry = entry.stringify_keys
|
|
188
|
+
next if entry["model"].blank?
|
|
189
|
+
|
|
190
|
+
ModelSpec.new(label: entry["label"].presence || entry["model"], provider: entry["provider"].to_s, model: entry["model"])
|
|
191
|
+
end.index_by { |spec| [ spec.provider, spec.model ] }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A criterion is either scored directly ({ "score" => 0.8, ... }) or, on a
|
|
195
|
+
# comparison run, a map of model => stats; that cohort's mean is the
|
|
196
|
+
# criterion's headline score. Skipped criteria carry no score at all.
|
|
197
|
+
def criterion_score(value)
|
|
198
|
+
return value["score"] if value["score"].is_a?(Numeric)
|
|
199
|
+
|
|
200
|
+
cohort = value.each_value.filter_map do |stats|
|
|
201
|
+
stats["score"] if stats.is_a?(Hash) && stats["score"].is_a?(Numeric)
|
|
202
|
+
end
|
|
203
|
+
return nil if cohort.empty?
|
|
204
|
+
|
|
205
|
+
cohort.sum.to_f / cohort.size
|
|
206
|
+
end
|
|
19
207
|
end
|
|
20
208
|
end
|