actionagent 1.9.6 → 1.9.8
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/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +75 -62
- data/app/controllers/action_agent/api/agents_controller.rb +15 -6
- data/app/controllers/action_agent/api/base_controller.rb +3 -2
- data/app/controllers/action_agent/api/mcp_server_configs_controller.rb +114 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +7 -4
- data/app/controllers/action_agent/api/metrics_controller.rb +5 -4
- data/app/controllers/action_agent/api/provider_keys_controller.rb +1 -1
- data/app/controllers/action_agent/api/provider_models_controller.rb +17 -5
- data/app/controllers/action_agent/traces_controller.rb +3 -2
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +4 -2
- data/app/jobs/action_agent/agent_execution_job.rb +26 -6
- data/app/jobs/action_agent/evaluation_judge_job.rb +3 -2
- data/app/models/action_agent/agent_run.rb +25 -6
- data/app/models/action_agent/catalog_scenario.rb +8 -2
- data/app/models/action_agent/evaluation_run.rb +54 -3
- data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
- data/app/models/action_agent/mcp_server_config.rb +273 -0
- data/app/models/action_agent/model_pricing.rb +135 -24
- data/app/models/action_agent/scenario_product.rb +24 -0
- data/app/models/action_agent/scenario_set.rb +9 -0
- data/app/models/action_agent/telemetry_trace.rb +35 -14
- data/app/queries/action_agent/agent_executions.rb +4 -1
- data/app/queries/action_agent/metrics_report.rb +1 -1
- data/app/serializers/action_agent/evaluation_serializer.rb +6 -2
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +4 -2
- data/app/services/action_agent/agent_scorecard.rb +16 -12
- data/app/services/action_agent/agent_tool_roster.rb +5 -2
- data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
- data/app/services/action_agent/evaluation_report_import.rb +63 -4
- data/app/services/action_agent/evaluation_run_cost.rb +6 -5
- data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
- data/app/services/action_agent/mcp_client.rb +36 -4
- data/app/services/action_agent/mcp_host_guard.rb +129 -0
- data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
- data/app/services/action_agent/ollama_host_probe.rb +36 -4
- data/app/services/action_agent/scenario_catalog_import.rb +7 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +135 -18
- data/app/services/action_agent/tool_discovery.rb +5 -1
- data/config/routes.rb +6 -0
- data/lib/action_agent/engine.rb +10 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +17 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
- data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
- data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
- data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
- data/lib/generators/action_agent/templates/migrations/034_add_catalog_product_criteria.rb.erb +24 -0
- data/lib/generators/action_agent/templates/migrations/035_add_catalog_pass_bars.rb.erb +28 -0
- metadata +10 -2
|
@@ -279,10 +279,10 @@ module ActionAgent
|
|
|
279
279
|
# ones the caller may see as well. Intersected by id rather than with
|
|
280
280
|
# merge: both relations constrain the same owner column, and merge
|
|
281
281
|
# lets the second condition replace the first instead of adding to it.
|
|
282
|
+
# The servers the owner added on the MCP Services page are offered the
|
|
283
|
+
# same way.
|
|
282
284
|
def tool_roster
|
|
283
|
-
runtimes =
|
|
284
|
-
SandboxSession.for_owner(@agent.owner).where(id: owned(SandboxSession).select(:id))
|
|
285
|
-
)
|
|
285
|
+
runtimes = owner_server_listings(@agent.owner)
|
|
286
286
|
|
|
287
287
|
render json: AgentToolRoster.new(
|
|
288
288
|
agent: @agent,
|
|
@@ -303,9 +303,7 @@ module ActionAgent
|
|
|
303
303
|
# an agent's history: which services its agents already enable.
|
|
304
304
|
def new_tool_roster
|
|
305
305
|
agent = owner_agents.build
|
|
306
|
-
runtimes =
|
|
307
|
-
SandboxSession.for_owner(agent.owner).where(id: owned(SandboxSession).select(:id))
|
|
308
|
-
)
|
|
306
|
+
runtimes = owner_server_listings(agent.owner)
|
|
309
307
|
|
|
310
308
|
render json: AgentToolRoster.new(
|
|
311
309
|
agent: agent,
|
|
@@ -444,6 +442,17 @@ module ActionAgent
|
|
|
444
442
|
|
|
445
443
|
private
|
|
446
444
|
|
|
445
|
+
# The checkout runtimes and added MCP servers an agent of +owner+ can be
|
|
446
|
+
# given, as MCP server listings: +owner+'s, and of those only the ones
|
|
447
|
+
# the caller may see as well.
|
|
448
|
+
def owner_server_listings(owner)
|
|
449
|
+
SandboxSession.runtime_server_listings(
|
|
450
|
+
SandboxSession.for_owner(owner).where(id: owned(SandboxSession).select(:id))
|
|
451
|
+
) + MCPServerConfig.listings(
|
|
452
|
+
MCPServerConfig.for_owner(owner).where(id: owned(MCPServerConfig).select(:id))
|
|
453
|
+
)
|
|
454
|
+
end
|
|
455
|
+
|
|
447
456
|
# Observed agents were discovered from reported telemetry; the platform
|
|
448
457
|
# has no configuration to run them with (a placeholder model, no
|
|
449
458
|
# instructions), so executing one only manufactured a failed run that
|
|
@@ -114,8 +114,8 @@ module ActionAgent
|
|
|
114
114
|
|
|
115
115
|
# Returns the credentials the caller owns, for masking with
|
|
116
116
|
# SecretScrubber: provider keys, GitHub tokens, checkout sandboxes'
|
|
117
|
-
# runtime tokens, dashboard API keys (newest first)
|
|
118
|
-
# telemetry key. Raises what a lookup raises, such as a credential that
|
|
117
|
+
# runtime tokens, dashboard API keys (newest first), the header values
|
|
118
|
+
# of the owner's own MCP servers and the owner's telemetry key. Raises what a lookup raises, such as a credential that
|
|
119
119
|
# cannot be decrypted, so each caller decides whether to go on without
|
|
120
120
|
# the list.
|
|
121
121
|
# @return [Array<String>]
|
|
@@ -125,6 +125,7 @@ module ActionAgent
|
|
|
125
125
|
*owned(GithubConnection).limit(OWNER_CREDENTIAL_LIMIT).pluck(:access_token),
|
|
126
126
|
*owned(SandboxSession).where.not(runtime_mcp_token: nil).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:runtime_mcp_token),
|
|
127
127
|
*owned(ApiKey).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:token),
|
|
128
|
+
*owned(MCPServerConfig).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).flat_map(&:secret_values),
|
|
128
129
|
current_owner.try(:telemetry_api_key)
|
|
129
130
|
].compact
|
|
130
131
|
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The caller's own remote MCP servers (MCPServerConfig), managed from the
|
|
6
|
+
# MCP Services page and enabled on agents as "custom:<id>".
|
|
7
|
+
#
|
|
8
|
+
# Every query goes through `owned`, so a caller reads, changes, tests and
|
|
9
|
+
# deletes only their owner's configurations; another owner's id is a 404.
|
|
10
|
+
# Header values are write-only: responses carry each header's name and a
|
|
11
|
+
# masked value. On update, a header sent with a blank value keeps the
|
|
12
|
+
# value stored under that name, and a header left out is removed; leaving
|
|
13
|
+
# +headers+ out of the request keeps them all.
|
|
14
|
+
#
|
|
15
|
+
# Writes and tests ask permission_checker for :manage_credentials, since a
|
|
16
|
+
# configuration holds credentials and decides what the owner's agents
|
|
17
|
+
# call. Reading the list asks nothing.
|
|
18
|
+
class MCPServerConfigsController < BaseController
|
|
19
|
+
before_action :require_owner!
|
|
20
|
+
before_action :set_config, only: %i[update destroy test]
|
|
21
|
+
|
|
22
|
+
# GET /api/mcp_server_configs
|
|
23
|
+
def index
|
|
24
|
+
configs = owned(MCPServerConfig).order(:name, :id)
|
|
25
|
+
|
|
26
|
+
render json: {
|
|
27
|
+
mcp_server_configs: configs.map(&:as_api_json),
|
|
28
|
+
allow_private_hosts: MCPHostGuard.allow_private?,
|
|
29
|
+
can_manage: can_manage?
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# POST /api/mcp_server_configs
|
|
34
|
+
def create
|
|
35
|
+
config = owned(MCPServerConfig).new
|
|
36
|
+
assign_attributes(config)
|
|
37
|
+
return unless authorize_action!(:manage_credentials, config)
|
|
38
|
+
|
|
39
|
+
config.save!
|
|
40
|
+
render json: { mcp_server_config: config.as_api_json }, status: :created
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# PATCH /api/mcp_server_configs/:id
|
|
44
|
+
def update
|
|
45
|
+
return unless authorize_action!(:manage_credentials, @config)
|
|
46
|
+
|
|
47
|
+
assign_attributes(@config)
|
|
48
|
+
# A new URL has not been tested; what an earlier test found about the
|
|
49
|
+
# old one no longer describes it.
|
|
50
|
+
reset_test_result(@config) if @config.url_changed?
|
|
51
|
+
@config.save!
|
|
52
|
+
render json: { mcp_server_config: @config.as_api_json }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# DELETE /api/mcp_server_configs/:id
|
|
56
|
+
#
|
|
57
|
+
# Agents that enable the configuration keep naming its key, which then
|
|
58
|
+
# resolves to nothing: the Tools tab lists it as unknown so it can be
|
|
59
|
+
# switched off.
|
|
60
|
+
def destroy
|
|
61
|
+
return unless authorize_action!(:manage_credentials, @config)
|
|
62
|
+
|
|
63
|
+
@config.destroy!
|
|
64
|
+
head :no_content
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# POST /api/mcp_server_configs/:id/test
|
|
68
|
+
#
|
|
69
|
+
# Runs tools/list against the saved configuration and records the
|
|
70
|
+
# outcome, the discovered tool names included. Answers 200 either way:
|
|
71
|
+
# a server that does not answer is a result to show, not a failed
|
|
72
|
+
# request.
|
|
73
|
+
def test
|
|
74
|
+
return unless authorize_action!(:manage_credentials, @config)
|
|
75
|
+
|
|
76
|
+
result = @config.test_connection!
|
|
77
|
+
render json: result.merge(mcp_server_config: @config.as_api_json)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def set_config
|
|
83
|
+
@config = owned(MCPServerConfig).find(params[:id])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def can_manage?
|
|
87
|
+
ActionAgent.permitted?(current_user, :manage_credentials, owned(MCPServerConfig).new)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assign_attributes(config)
|
|
91
|
+
attributes = params.require(:mcp_server_config).permit(:name, :description, :url, :enabled)
|
|
92
|
+
config.assign_attributes(attributes)
|
|
93
|
+
config.assign_headers(header_params) if params[:mcp_server_config].key?(:headers)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The submitted headers as { name, value } pairs. Accepts a list, or a
|
|
97
|
+
# Hash of name => value.
|
|
98
|
+
def header_params
|
|
99
|
+
submitted = params[:mcp_server_config][:headers]
|
|
100
|
+
return [] if submitted.blank?
|
|
101
|
+
|
|
102
|
+
if submitted.is_a?(Array)
|
|
103
|
+
submitted.map { |header| header.respond_to?(:permit) ? header.permit(:name, :value).to_h : {} }
|
|
104
|
+
else
|
|
105
|
+
submitted.permit!.to_h.map { |name, value| { "name" => name, "value" => value } }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reset_test_result(config)
|
|
110
|
+
config.assign_attributes(last_tested_at: nil, last_test_status: nil, last_test_error: nil, tool_names: [])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -10,7 +10,8 @@ module ActionAgent
|
|
|
10
10
|
# its configuration, and the default catalog (MCPCatalog). An install
|
|
11
11
|
# therefore sees both what it is already using and what it could turn on.
|
|
12
12
|
# The caller's live checkout sandbox runtimes are listed with the catalog,
|
|
13
|
-
# so the one started from Settings -> Integrations shows up here too
|
|
13
|
+
# so the one started from Settings -> Integrations shows up here too, and
|
|
14
|
+
# so are the servers the caller added (MCPServerConfigsController).
|
|
14
15
|
class MCPServersController < BaseController
|
|
15
16
|
before_action :require_owner!
|
|
16
17
|
# Launching provisions a sandbox and runs a server in it, so it answers
|
|
@@ -110,14 +111,16 @@ module ActionAgent
|
|
|
110
111
|
|
|
111
112
|
private
|
|
112
113
|
|
|
113
|
-
# Runtimes are the caller's own: a listing carries no
|
|
114
|
-
# repositories another owner has running
|
|
114
|
+
# Runtimes and added servers are the caller's own: a listing carries no
|
|
115
|
+
# token or header, but which repositories another owner has running and
|
|
116
|
+
# which servers they call are still theirs to know.
|
|
115
117
|
def discovery
|
|
116
118
|
ToolDiscovery.new(
|
|
117
119
|
traces: owned_traces,
|
|
118
120
|
agents: owner_agents,
|
|
119
121
|
hours: window_hours,
|
|
120
|
-
runtimes: SandboxSession.runtime_server_listings(owned(SandboxSession))
|
|
122
|
+
runtimes: SandboxSession.runtime_server_listings(owned(SandboxSession)) +
|
|
123
|
+
MCPServerConfig.listings(owned(MCPServerConfig))
|
|
121
124
|
)
|
|
122
125
|
end
|
|
123
126
|
|
|
@@ -181,17 +181,18 @@ module ActionAgent
|
|
|
181
181
|
end
|
|
182
182
|
|
|
183
183
|
# Estimated spend for the window, total and per agent class. The model
|
|
184
|
-
#
|
|
184
|
+
# and the provider it ran on live inside the spans JSON (first llm
|
|
185
|
+
# span); the rate is the model's under that provider.
|
|
185
186
|
def cost_statistics(scope)
|
|
186
|
-
rows = ActionAgent.trace_model.
|
|
187
|
+
rows = ActionAgent.trace_model.pluck_with_llm_model_and_provider(
|
|
187
188
|
scope, :agent_class, :total_input_tokens, :total_output_tokens
|
|
188
189
|
)
|
|
189
190
|
|
|
190
191
|
by_agent = Hash.new(0.0)
|
|
191
192
|
total = 0.0
|
|
192
193
|
|
|
193
|
-
rows.each do |model, agent_class, input_tokens, output_tokens|
|
|
194
|
-
cost = ModelPricing.estimate(model: model, input_tokens: input_tokens, output_tokens: output_tokens)
|
|
194
|
+
rows.each do |model, provider, agent_class, input_tokens, output_tokens|
|
|
195
|
+
cost = ModelPricing.estimate(model: model, provider: provider, input_tokens: input_tokens, output_tokens: output_tokens)
|
|
195
196
|
next unless cost
|
|
196
197
|
|
|
197
198
|
total += cost
|
|
@@ -94,7 +94,7 @@ module ActionAgent
|
|
|
94
94
|
|
|
95
95
|
host = params[:credential].presence || stored&.credential || platform_host(provider)
|
|
96
96
|
if host.blank?
|
|
97
|
-
return render json: { ok: false, host: nil, models: [], latency_ms: nil, error: "No host configured" }
|
|
97
|
+
return render json: { ok: false, host: nil, models: [], latency_ms: nil, error: "No host configured", cloud: nil, rates: {} }
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
render json: OllamaHostProbe.call(host: host, api_key: probe_api_key(stored, host)).to_h
|
|
@@ -9,10 +9,12 @@ module ActionAgent
|
|
|
9
9
|
# side, so the UI can't drift stale). Ollama is queried live from the
|
|
10
10
|
# caller's effective host (see ProviderCredentials: their personal key,
|
|
11
11
|
# the host resolver's, the account's, falling back to the platform
|
|
12
|
-
# config) so locally pulled models appear
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# config) so locally pulled models appear — or, when the host is Ollama
|
|
13
|
+
# Cloud, the cloud's models, which the response marks (`cloud`) and
|
|
14
|
+
# prices (`rates`, per model a price list names); OpenRouter is queried
|
|
15
|
+
# from its public catalog, and Anthropic from its Models API with the
|
|
16
|
+
# caller's effective key. Live lookups fall back to the curated list on
|
|
17
|
+
# any failure, and when the caller's credentials cannot be resolved.
|
|
16
18
|
#
|
|
17
19
|
# When the host app loads RubyLLM, the chat models its registry lists for
|
|
18
20
|
# the provider that take and return text follow: RubyLLM's bundled
|
|
@@ -51,7 +53,9 @@ module ActionAgent
|
|
|
51
53
|
source = "curated"
|
|
52
54
|
end
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
payload = { provider: provider, models: (models + registry_models(provider)).uniq, source: source }
|
|
57
|
+
payload.merge!(ollama_detail) if provider == "ollama"
|
|
58
|
+
render json: payload
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
private
|
|
@@ -101,9 +105,17 @@ module ActionAgent
|
|
|
101
105
|
return nil
|
|
102
106
|
end
|
|
103
107
|
|
|
108
|
+
@ollama_probe = result
|
|
104
109
|
[ result.models, "live" ] if result.models.any?
|
|
105
110
|
end
|
|
106
111
|
|
|
112
|
+
# What the live lookup learned about the caller's Ollama host: whether
|
|
113
|
+
# it is Ollama Cloud (nil when no host answered), and the rate of
|
|
114
|
+
# every listed model a price list names, in $ per million tokens.
|
|
115
|
+
def ollama_detail
|
|
116
|
+
{ cloud: @ollama_probe&.cloud, rates: @ollama_probe&.rates || {} }
|
|
117
|
+
end
|
|
118
|
+
|
|
107
119
|
# Queries the Anthropic Models API with the caller's effective key
|
|
108
120
|
# (newest first, as returned by the API) so new model releases appear
|
|
109
121
|
# without a deploy. The platform's own key is never used here.
|
|
@@ -4,7 +4,7 @@ module ActionAgent
|
|
|
4
4
|
# Controller for viewing telemetry traces.
|
|
5
5
|
#
|
|
6
6
|
# Provides:
|
|
7
|
-
# -
|
|
7
|
+
# - Bounded list of recent traces with filtering (no pagination)
|
|
8
8
|
# - Detail view with span timeline
|
|
9
9
|
# - Metrics overview
|
|
10
10
|
# - Live updates via Turbo Streams
|
|
@@ -76,7 +76,8 @@ module ActionAgent
|
|
|
76
76
|
)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
limit = Integer(params[:limit], 10, exception: false) || 50
|
|
80
|
+
traces.limit(limit.clamp(1, 1000))
|
|
80
81
|
end
|
|
81
82
|
|
|
82
83
|
def calculate_metrics
|
|
@@ -842,7 +842,7 @@ module ActionAgent
|
|
|
842
842
|
change = comparison_change(before, after)
|
|
843
843
|
next if change == "unchanged_pass"
|
|
844
844
|
|
|
845
|
-
{ scenario_key: key[0], model: key[1], change: change, before: before&.status, after: after&.status,
|
|
845
|
+
{ scenario_key: key[0], model: key[1], trial: key[2], change: change, before: before&.status, after: after&.status,
|
|
846
846
|
fault: after&.fault, result_id: after&.id }
|
|
847
847
|
end
|
|
848
848
|
|
|
@@ -1012,8 +1012,10 @@ module ActionAgent
|
|
|
1012
1012
|
end
|
|
1013
1013
|
|
|
1014
1014
|
# The run's results keyed by [scenario key, model].
|
|
1015
|
+
# Results by scenario key, model and trial: a run that replayed each
|
|
1016
|
+
# scenario several times compares trial by trial.
|
|
1015
1017
|
def comparison_rows(run)
|
|
1016
|
-
run.scenario_results.includes(:scenario).index_by { |result| [ result.evaluated_scenario["key"], result.model ] }
|
|
1018
|
+
run.scenario_results.includes(:scenario).index_by { |result| [ result.evaluated_scenario["key"], result.model, result.trial ] }
|
|
1017
1019
|
end
|
|
1018
1020
|
|
|
1019
1021
|
# One of: removed, added_pass, added_fail, unchanged_pass, regressed,
|
|
@@ -6,13 +6,12 @@ module ActionAgent
|
|
|
6
6
|
|
|
7
7
|
def perform(run_id)
|
|
8
8
|
run = AgentRun.find(run_id)
|
|
9
|
-
#
|
|
10
|
-
# executed again,
|
|
11
|
-
# AgentResumeJob continues
|
|
12
|
-
# generation.
|
|
13
|
-
return
|
|
9
|
+
# Only a pending run is executed. A finished run (complete, failed or
|
|
10
|
+
# cancelled) is never executed again, a run waiting for input only
|
|
11
|
+
# AgentResumeJob continues, and a running run already has a worker. A
|
|
12
|
+
# retry of a failed run would re-run the generation.
|
|
13
|
+
return unless claim_pending(run)
|
|
14
14
|
|
|
15
|
-
run.update!(status: :running, started_at: Time.current)
|
|
16
15
|
run.add_log("Starting execution", level: :info)
|
|
17
16
|
|
|
18
17
|
begin
|
|
@@ -38,6 +37,27 @@ module ActionAgent
|
|
|
38
37
|
|
|
39
38
|
private
|
|
40
39
|
|
|
40
|
+
# Moves the run from pending to running in one conditional update, as
|
|
41
|
+
# CodeSessionJob#start claims a session, and returns whether this job is
|
|
42
|
+
# the one that did. The write checks the status itself, so of two
|
|
43
|
+
# workers that loaded the run as pending only the first to write
|
|
44
|
+
# executes it, and the other starts no generation, on every database. A
|
|
45
|
+
# read under the row lock followed by a write would be refused on SQLite
|
|
46
|
+
# with Rails 7.2, whose deferred transactions cannot write once another
|
|
47
|
+
# writer has committed. Nothing is held over the generation, so a cancel
|
|
48
|
+
# never waits for it. The update skips callbacks, so the status change is
|
|
49
|
+
# broadcast here.
|
|
50
|
+
def claim_pending(run)
|
|
51
|
+
now = Time.current
|
|
52
|
+
claimed = AgentRun.where(id: run.id, status: AgentRun.statuses[:pending])
|
|
53
|
+
.update_all(status: AgentRun.statuses[:running], started_at: now, updated_at: now)
|
|
54
|
+
return false if claimed.zero?
|
|
55
|
+
|
|
56
|
+
run.reload
|
|
57
|
+
run.broadcast_update
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
41
61
|
def execute_agent(agent_record, run)
|
|
42
62
|
AgentExecutionService.call(agent_record, run)
|
|
43
63
|
end
|
|
@@ -17,11 +17,12 @@ module ActionAgent
|
|
|
17
17
|
return finish_without_result(run_id, selection) unless row
|
|
18
18
|
|
|
19
19
|
run = row.evaluation_run
|
|
20
|
+
runner = ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run)
|
|
20
21
|
begin
|
|
21
|
-
|
|
22
|
+
runner.judge_result!(row)
|
|
22
23
|
rescue StandardError => e
|
|
23
24
|
Rails.logger.warn("[ActionAgent] evaluation run #{run.id}: scoring result #{result_id} failed: #{e.class}: #{e.message}")
|
|
24
|
-
|
|
25
|
+
runner.fail_judging!(row.reload, e)
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -313,18 +313,37 @@ module ActionAgent
|
|
|
313
313
|
LiveUpdates.broadcast("agent_runs_#{agent_id}", type: "update", id: id, status: status)
|
|
314
314
|
end
|
|
315
315
|
|
|
316
|
+
# Statuses a cancel moves to `cancelled`. A finished run keeps its end.
|
|
317
|
+
CANCELLABLE_STATUSES = %i[pending running awaiting_input].freeze
|
|
318
|
+
|
|
316
319
|
# Cancels a run that is in progress or waiting for input, and every
|
|
317
|
-
# request for input it still has pending.
|
|
320
|
+
# request for input it still has pending. Returns whether this call
|
|
321
|
+
# cancelled it.
|
|
322
|
+
#
|
|
323
|
+
# The status is checked by the write itself, as AgentExecutionJob claims
|
|
324
|
+
# a pending run, because this copy of the run may be older than the
|
|
325
|
+
# database's: the request's, or a caller's that has held it since it
|
|
326
|
+
# executed. A run that finished in the meantime is left as it finished,
|
|
327
|
+
# and this copy is reloaded to show how. The update skips callbacks, so
|
|
328
|
+
# the cancel is broadcast here, once.
|
|
318
329
|
#
|
|
319
330
|
# @param reason [String] recorded as the run's error message
|
|
331
|
+
# @return [Boolean]
|
|
320
332
|
def cancel!(reason = "Cancelled by user")
|
|
321
|
-
return unless in_progress? || awaiting_input?
|
|
333
|
+
return false unless in_progress? || awaiting_input?
|
|
322
334
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
335
|
+
now = Time.current
|
|
336
|
+
cancelled = transaction do
|
|
337
|
+
changed = self.class.where(id: id, status: self.class.statuses.values_at(*CANCELLABLE_STATUSES))
|
|
338
|
+
.update_all(status: self.class.statuses[:cancelled], completed_at: now, error_message: reason, updated_at: now)
|
|
339
|
+
next false if changed.zero?
|
|
340
|
+
|
|
341
|
+
input_requests.pending.update_all(status: InputRequest.statuses[:cancelled], updated_at: now)
|
|
342
|
+
true
|
|
326
343
|
end
|
|
327
|
-
|
|
344
|
+
reload
|
|
345
|
+
broadcast_update if cancelled
|
|
346
|
+
cancelled
|
|
328
347
|
end
|
|
329
348
|
|
|
330
349
|
# Expires the run's overdue requests for input, which fails the run (see
|
|
@@ -27,9 +27,15 @@ module ActionAgent
|
|
|
27
27
|
value.is_a?(Hash) ? value : {}
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def metadata
|
|
31
|
+
value = self[:metadata]
|
|
32
|
+
value.is_a?(Hash) ? value : {}
|
|
33
|
+
end
|
|
34
|
+
|
|
30
35
|
# @return [ActiveAgent::Evals::Scenario]
|
|
31
36
|
def to_scenario
|
|
32
|
-
ActiveAgent::Evals::Scenario.from_hash(to_document.merge("position" => position), group: set.key, group_name: set.name
|
|
37
|
+
ActiveAgent::Evals::Scenario.from_hash(to_document.merge("position" => position), group: set.key, group_name: set.name,
|
|
38
|
+
metadata: metadata)
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def to_document
|
|
@@ -41,7 +47,7 @@ module ActionAgent
|
|
|
41
47
|
"tags" => tags.presence,
|
|
42
48
|
"params" => params.presence,
|
|
43
49
|
"production_only" => (true if production_only?)
|
|
44
|
-
}.compact
|
|
50
|
+
}.compact.merge(metadata.deep_stringify_keys.except(*ActiveAgent::Evals::Catalog::SCENARIO_KEYS))
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
# The attributes Evaluation#replace_scenarios! takes.
|
|
@@ -87,6 +87,48 @@ module ActionAgent
|
|
|
87
87
|
comparison if comparison.is_a?(Hash) && comparison.present?
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# The bars this run was gated by (ActiveAgent::Evals::PassThresholds),
|
|
91
|
+
# as the run recorded them when it started or was imported, so a later
|
|
92
|
+
# change to the catalog does not regrade it; nil for a run whose suite
|
|
93
|
+
# declared none, one recorded before runs kept them, and a framework
|
|
94
|
+
# without per-group bars.
|
|
95
|
+
def pass_thresholds
|
|
96
|
+
self.class.pass_thresholds_from(scores&.dig("_pass_thresholds"))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A recorded `pass_thresholds` mapping (Report#to_h's: "threshold",
|
|
100
|
+
# "min_pass_rate", "groups") as PassThresholds, or nil for anything
|
|
101
|
+
# that is not one.
|
|
102
|
+
def self.pass_thresholds_from(value)
|
|
103
|
+
return nil unless value.is_a?(Hash) && defined?(ActiveAgent::Evals::PassThresholds)
|
|
104
|
+
|
|
105
|
+
value = value.stringify_keys
|
|
106
|
+
groups = value["groups"].nil? ? {} : value["groups"]
|
|
107
|
+
return nil unless groups.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
ActiveAgent::Evals::PassThresholds.new(suite: value.slice(*ActiveAgent::Evals::PassThresholds::SUITE_KEYS), groups: groups)
|
|
110
|
+
rescue ArgumentError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The gates the run's results met or missed (Report#gates, each
|
|
115
|
+
# `{ "group", "name", "label", "passed", "total", "pass_rate",
|
|
116
|
+
# "min_pass_rate", "gated", "met" }`), as recorded when it completed;
|
|
117
|
+
# nil for a run without bars.
|
|
118
|
+
def gates
|
|
119
|
+
value = scores&.dig("_gates")
|
|
120
|
+
value.select { |gate| gate.is_a?(Hash) } if value.is_a?(Array)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Whether every gate was met, as Report#passed? answers: false when no
|
|
124
|
+
# gate was reached; nil for a run without bars.
|
|
125
|
+
def gates_met
|
|
126
|
+
rows = gates
|
|
127
|
+
return nil if rows.nil?
|
|
128
|
+
|
|
129
|
+
rows.any? && rows.all? { |gate| gate["met"] == true }
|
|
130
|
+
end
|
|
131
|
+
|
|
90
132
|
# How the report names the judge, the way the suite panel does: the
|
|
91
133
|
# judge the recorded verdict names — unless that is only the pass-rate
|
|
92
134
|
# ranking — else the evaluation's judge model. nil when neither is set,
|
|
@@ -255,17 +297,19 @@ module ActionAgent
|
|
|
255
297
|
# for one the engine judged.
|
|
256
298
|
#
|
|
257
299
|
# The engine runs against every activeagent since 1.4, so the report
|
|
258
|
-
# kwargs that arrived later are passed only when Report.new takes them
|
|
300
|
+
# kwargs that arrived later are passed only when Report.new takes them,
|
|
301
|
+
# and a result's trial only when Result carries one.
|
|
259
302
|
def to_report(links: report_links, estimate: true)
|
|
260
303
|
# A replay not yet scored (a fanned-out run in progress) is not a
|
|
261
304
|
# result the report can grade, so it is left out rather than read as
|
|
262
305
|
# a failure.
|
|
263
306
|
rows = scenario_results.where.not(status: :pending).includes(:scenario).sort_by do |row|
|
|
264
|
-
[ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model ]
|
|
307
|
+
[ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model, row.trial, row.id ]
|
|
265
308
|
end
|
|
266
309
|
breakdown = cost_breakdown if estimate
|
|
267
310
|
selected = selected_specs
|
|
268
311
|
specs = {}
|
|
312
|
+
trials = self.class.result_accepts?(:trial)
|
|
269
313
|
results = rows.map do |row|
|
|
270
314
|
spec = specs[[ row.provider.to_s, row.model ]] ||= selected[[ row.provider.to_s, row.model ]] || ModelSpec.new(
|
|
271
315
|
label: [ row.provider.presence, row.model ].compact.join("/"), provider: row.provider.to_s, model: row.model
|
|
@@ -280,7 +324,8 @@ module ActionAgent
|
|
|
280
324
|
metadata: replay_metadata_for(row, breakdown)
|
|
281
325
|
),
|
|
282
326
|
scores: row.scores.to_h, score: row.score, status: row.status,
|
|
283
|
-
diagnosis: row.evaluation_diagnosis.presence
|
|
327
|
+
diagnosis: row.evaluation_diagnosis.presence,
|
|
328
|
+
**(trials ? { trial: row.trial } : {})
|
|
284
329
|
)
|
|
285
330
|
end
|
|
286
331
|
|
|
@@ -304,6 +349,7 @@ module ActionAgent
|
|
|
304
349
|
kwargs[:judge_usage] = judge_usage_run if judge_usage_run && self.class.report_accepts?(:judge_usage)
|
|
305
350
|
kwargs[:release] = release_summary if release_summary && self.class.report_accepts?(:release)
|
|
306
351
|
kwargs[:comparison] = recorded_comparison if recorded_comparison && self.class.report_accepts?(:comparison)
|
|
352
|
+
kwargs[:pass_thresholds] = pass_thresholds if pass_thresholds && self.class.report_accepts?(:pass_thresholds)
|
|
307
353
|
self.class.report_class.new(**kwargs)
|
|
308
354
|
end
|
|
309
355
|
|
|
@@ -317,6 +363,11 @@ module ActionAgent
|
|
|
317
363
|
report_class.instance_method(:initialize).parameters.any? { |type, name| name == keyword && %i[key keyreq].include?(type) }
|
|
318
364
|
end
|
|
319
365
|
|
|
366
|
+
# Whether the installed framework's Result carries +member+.
|
|
367
|
+
def self.result_accepts?(member)
|
|
368
|
+
ActiveAgent::Evals::Result.members.include?(member)
|
|
369
|
+
end
|
|
370
|
+
|
|
320
371
|
# The release this run scored, as the report names it — `{ "digest",
|
|
321
372
|
# "revision", "label" }` — or nil for a run pinned to a dashboard edit
|
|
322
373
|
# or to no version.
|
|
@@ -45,10 +45,19 @@ module ActionAgent
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# The diagnosis as the dashboard shows it: without the storage keys —
|
|
48
|
-
# the replay metadata, the scenario snapshot
|
|
49
|
-
# publishing application reported (served as `judge_usage` instead)
|
|
48
|
+
# the replay metadata, the scenario snapshot, the judge usage the
|
|
49
|
+
# publishing application reported (served as `judge_usage` instead) and
|
|
50
|
+
# the trial number.
|
|
50
51
|
def evaluation_diagnosis
|
|
51
|
-
(diagnosis || {}).except("_replay_metadata", "_scenario_snapshot", "_judge_usage")
|
|
52
|
+
(diagnosis || {}).except("_replay_metadata", "_scenario_snapshot", "_judge_usage", "_trial")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Which replay of the scenario under the model this is, from 1: a
|
|
56
|
+
# published run keeps every replay of a scenario the application ran
|
|
57
|
+
# more than once (EvaluationReportImport). A result with no record is
|
|
58
|
+
# the first.
|
|
59
|
+
def trial
|
|
60
|
+
[ diagnosis&.dig("_trial").to_i, 1 ].max
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
# A catalog can be refreshed without changing what an earlier run asked
|