actionagent 1.7.2 → 1.8.1
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 +59 -54
- data/app/controllers/action_agent/api/agents_controller.rb +19 -3
- data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
- data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
- data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
- data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
- data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
- data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
- data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
- data/app/jobs/action_agent/code_session_job.rb +171 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
- data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
- data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
- data/app/models/action_agent/agent.rb +49 -6
- data/app/models/action_agent/agent_run.rb +20 -1
- data/app/models/action_agent/code_session.rb +145 -0
- data/app/models/action_agent/evaluation.rb +64 -0
- data/app/models/action_agent/evaluation_run.rb +166 -54
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/github_connection.rb +75 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/provider_key.rb +148 -10
- data/app/models/action_agent/sandbox_session.rb +194 -17
- data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
- data/app/services/action_agent/agent_execution_service.rb +4 -2
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_tool_roster.rb +20 -9
- data/app/services/action_agent/claude_code_auth.rb +86 -0
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +25 -5
- data/app/services/action_agent/evaluation_standing.rb +145 -0
- data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
- data/app/services/action_agent/github_client.rb +111 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
- data/app/services/action_agent/local_sandbox_databases.rb +257 -0
- data/app/services/action_agent/mcp_client.rb +5 -1
- data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
- data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
- data/app/services/action_agent/ollama_host_probe.rb +75 -0
- data/app/services/action_agent/payload_bounds.rb +36 -0
- data/app/services/action_agent/sandbox_manifest.rb +67 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
- data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
- data/app/services/action_agent/secret_scrubber.rb +37 -0
- data/app/services/action_agent/tool_discovery.rb +19 -5
- data/config/routes.rb +23 -4
- data/lib/action_agent/engine.rb +1 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +153 -3
- data/lib/generators/action_agent/install_generator.rb +35 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
- data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
- data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
- data/lib/tasks/claude_code.rake +16 -0
- data/lib/tasks/sandbox.rake +26 -0
- metadata +28 -2
|
@@ -4,6 +4,7 @@ module ActionAgent
|
|
|
4
4
|
module Api
|
|
5
5
|
class AgentsController < BaseController
|
|
6
6
|
include AgentSerialization
|
|
7
|
+
include RunSandbox
|
|
7
8
|
|
|
8
9
|
# Ranking for the agent cards. Every dimension except "recent" reads the
|
|
9
10
|
# scorecard, which is computed in Ruby over both execution sources, so
|
|
@@ -25,7 +26,7 @@ module ActionAgent
|
|
|
25
26
|
# same reason as the rest and one more: a keyword splat wins over the
|
|
26
27
|
# arguments before it, so a client sending params[params][actor] would
|
|
27
28
|
# otherwise name the caller its own run is authorized as.
|
|
28
|
-
RESERVED_EXECUTION_KEYS = [ :attachments, :action, :actor, :current_user ].freeze
|
|
29
|
+
RESERVED_EXECUTION_KEYS = [ :attachments, :action, :actor, :current_user, :runtime_sandbox ].freeze
|
|
29
30
|
|
|
30
31
|
before_action :set_agent, only: [
|
|
31
32
|
:show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics,
|
|
@@ -169,13 +170,16 @@ module ActionAgent
|
|
|
169
170
|
#
|
|
170
171
|
# JSON as before, or multipart from the runner's composer: the new
|
|
171
172
|
# user message, its files (attachments[]) and the conversation to
|
|
172
|
-
# continue (params[context_id], or a top-level context_id).
|
|
173
|
+
# continue (params[context_id], or a top-level context_id). A
|
|
174
|
+
# `sandbox_id` runs it against that checkout sandbox's app runtime
|
|
175
|
+
# too, without editing the agent (see RunSandbox); so does /test.
|
|
173
176
|
def execute
|
|
174
177
|
run = @agent.execute(
|
|
175
178
|
execution_prompt,
|
|
176
179
|
action: params[:action_name],
|
|
177
180
|
attachments: uploaded_attachments,
|
|
178
181
|
actor: agent_actor,
|
|
182
|
+
runtime_sandbox: run_sandbox_for(@agent, params[:sandbox_id])&.runtime_server_key,
|
|
179
183
|
**execution_params
|
|
180
184
|
)
|
|
181
185
|
record_execution_usage
|
|
@@ -190,6 +194,7 @@ module ActionAgent
|
|
|
190
194
|
action: params[:action_name],
|
|
191
195
|
attachments: uploaded_attachments,
|
|
192
196
|
actor: agent_actor,
|
|
197
|
+
runtime_sandbox: run_sandbox_for(@agent, params[:sandbox_id])&.runtime_server_key,
|
|
193
198
|
**execution_params
|
|
194
199
|
)
|
|
195
200
|
record_execution_usage
|
|
@@ -268,11 +273,22 @@ module ActionAgent
|
|
|
268
273
|
# What the Tools tab edits: the MCP services this agent can be given
|
|
269
274
|
# and the tools it can be offered, each with the calls, errors and
|
|
270
275
|
# latency recorded for it in the window.
|
|
276
|
+
#
|
|
277
|
+
# The checkout runtimes offered are the agent owner's — the only ones
|
|
278
|
+
# MCPToolDispatcher reaches for this agent — and of those, only the
|
|
279
|
+
# ones the caller may see as well. Intersected by id rather than with
|
|
280
|
+
# merge: both relations constrain the same owner column, and merge
|
|
281
|
+
# lets the second condition replace the first instead of adding to it.
|
|
271
282
|
def tool_roster
|
|
283
|
+
runtimes = SandboxSession.runtime_server_listings(
|
|
284
|
+
SandboxSession.for_owner(@agent.owner).where(id: owned(SandboxSession).select(:id))
|
|
285
|
+
)
|
|
286
|
+
|
|
272
287
|
render json: AgentToolRoster.new(
|
|
273
288
|
agent: @agent,
|
|
274
289
|
traces: owned_traces,
|
|
275
|
-
hours: params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
|
|
290
|
+
hours: params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i,
|
|
291
|
+
runtimes: runtimes
|
|
276
292
|
).as_json
|
|
277
293
|
end
|
|
278
294
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Headless Claude Code sessions inside one of the caller's checkout
|
|
6
|
+
# sandboxes (Settings -> Integrations): start one with a prompt, poll its
|
|
7
|
+
# transcript, cancel it.
|
|
8
|
+
#
|
|
9
|
+
# A session runs on the owner's Anthropic API key (or, with
|
|
10
|
+
# ActionAgent.claude_code_auth = :local_login, this machine's own Claude
|
|
11
|
+
# Code login; see ClaudeCodeAuth) and edits the checkout, so starting one
|
|
12
|
+
# is execution — gated, and counted against the host app's quota, like
|
|
13
|
+
# running an agent.
|
|
14
|
+
class CodeSessionsController < BaseController
|
|
15
|
+
before_action :require_owner!
|
|
16
|
+
before_action :require_execution_enabled!, only: [ :create ]
|
|
17
|
+
before_action :set_sandbox
|
|
18
|
+
before_action :set_code_session, only: [ :show, :cancel ]
|
|
19
|
+
|
|
20
|
+
# A model name as Claude Code's --model takes it ("sonnet",
|
|
21
|
+
# "claude-sonnet-4-5", "claude-sonnet-4-5[1m]"). It becomes an argument
|
|
22
|
+
# to the CLI, so nothing that could read as another option gets there.
|
|
23
|
+
MODEL_NAME = /\A[A-Za-z0-9][A-Za-z0-9._:\[\]-]{0,99}\z/
|
|
24
|
+
|
|
25
|
+
# GET /api/sandboxes/:sandbox_id/code_sessions
|
|
26
|
+
def index
|
|
27
|
+
render json: { code_sessions: @sandbox.code_sessions.recent.limit(20).map(&:summary) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# POST /api/sandboxes/:sandbox_id/code_sessions
|
|
31
|
+
def create
|
|
32
|
+
runner = params[:runner].nil? ? "claude_code" : params[:runner]
|
|
33
|
+
unless CodeSession::RUNNERS.include?(runner)
|
|
34
|
+
return render json: { error: "runner must be claude_code or codex" }, status: :unprocessable_entity
|
|
35
|
+
end
|
|
36
|
+
if (refusal = refusal_for(@sandbox, runner: runner))
|
|
37
|
+
return render json: { error: refusal }, status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if (current = busy_session)
|
|
41
|
+
return render json: busy_body(current), status: :conflict
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
code_session = CodeSession.new(
|
|
45
|
+
sandbox_session: @sandbox,
|
|
46
|
+
prompt: string_param(:prompt),
|
|
47
|
+
model: string_param(:model).presence,
|
|
48
|
+
runner: runner,
|
|
49
|
+
# Owned like the sandbox it runs in.
|
|
50
|
+
user_id: @sandbox.try(:user_id),
|
|
51
|
+
account_id: @sandbox.try(:account_id)
|
|
52
|
+
)
|
|
53
|
+
if (error = invalid_request(code_session))
|
|
54
|
+
return render json: { error: error }, status: :unprocessable_entity
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
enforce_execution_quota!
|
|
58
|
+
return if performed?
|
|
59
|
+
|
|
60
|
+
# Checked again under the sandbox's row lock, so two requests racing
|
|
61
|
+
# past the check above cannot both start a session in one checkout.
|
|
62
|
+
current = nil
|
|
63
|
+
@sandbox.with_lock do
|
|
64
|
+
current = busy_session
|
|
65
|
+
code_session.save! unless current
|
|
66
|
+
end
|
|
67
|
+
return render json: busy_body(current), status: :conflict if current
|
|
68
|
+
|
|
69
|
+
record_execution_usage
|
|
70
|
+
CodeSessionJob.perform_later(code_session.id)
|
|
71
|
+
|
|
72
|
+
render json: { code_session: code_session.summary }, status: :created
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GET /api/sandboxes/:sandbox_id/code_sessions/:id?after=N
|
|
76
|
+
# The transcript from event N onward, for incremental polling.
|
|
77
|
+
def show
|
|
78
|
+
render json: { code_session: @code_session.details(after: integer_param(:after, default: 0)) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# POST /api/sandboxes/:sandbox_id/code_sessions/:id/cancel
|
|
82
|
+
def cancel
|
|
83
|
+
was_running = false
|
|
84
|
+
@code_session.with_lock do
|
|
85
|
+
next unless @code_session.queued? || @code_session.running?
|
|
86
|
+
|
|
87
|
+
was_running = @code_session.running?
|
|
88
|
+
# A queued session is settled at once: nothing will ever run it. A
|
|
89
|
+
# running one is settled by CodeSessionJob once its Claude Code has
|
|
90
|
+
# stopped and its diff was taken (see CodeSession#diff_pending?).
|
|
91
|
+
@code_session.update!(status: :cancelled, finished_at: was_running ? nil : Time.current)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A queued session never started: CodeSessionJob skips it. A running
|
|
95
|
+
# one is stopped by its backend; CodeSessionJob then finds it
|
|
96
|
+
# cancelled and leaves it so.
|
|
97
|
+
stop(@code_session) if was_running
|
|
98
|
+
|
|
99
|
+
render json: { code_session: @code_session.summary }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# A checkout runs on its account's GitHub token and Claude Code
|
|
105
|
+
# credential, so in a multi-tenant install it must belong to the
|
|
106
|
+
# caller's current account too — not only to the caller, who may have
|
|
107
|
+
# left that account or switched to another.
|
|
108
|
+
def set_sandbox
|
|
109
|
+
scope = owned(SandboxSession)
|
|
110
|
+
scope = scope.where(account_id: current_account.id) if current_account
|
|
111
|
+
@sandbox = scope.find_by!(session_id: params[:sandbox_id])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def set_code_session
|
|
115
|
+
@code_session = @sandbox.code_sessions.find(params[:id])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Why +sandbox+ cannot take a Claude Code session now, or nil.
|
|
119
|
+
def refusal_for(sandbox, runner: "claude_code")
|
|
120
|
+
label = runner == "codex" ? "Codex" : "Claude Code"
|
|
121
|
+
return "#{label} sessions run only in a checkout (app_runtime) sandbox" unless sandbox.app_runtime?
|
|
122
|
+
return "The sandbox has expired; start a new one" if sandbox.ready? && !sandbox.active?
|
|
123
|
+
return "The sandbox is #{sandbox.status}; wait until it is ready" unless sandbox.ready?
|
|
124
|
+
|
|
125
|
+
orchestrator = SandboxOrchestrator.new
|
|
126
|
+
unless orchestrator.supports_code_runner?(runner)
|
|
127
|
+
return "The #{orchestrator.backend_name} sandbox backend cannot run #{label} sessions"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if runner == "codex"
|
|
131
|
+
return "Codex is not connected: connect an OpenAI API key in Settings -> Integrations first" if sandbox.runtime_environment(runner: runner).blank?
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
ClaudeCodeAuth.backend_refusal(orchestrator) || ClaudeCodeAuth.credential_refusal(sandbox)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The session of this sandbox that is queued or running, if any: one
|
|
138
|
+
# checkout, one Claude Code at a time.
|
|
139
|
+
def busy_session
|
|
140
|
+
@sandbox.code_sessions.where(status: [ :queued, :running ]).first
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def busy_body(current)
|
|
144
|
+
label = current.runner == "codex" ? "Codex" : "Claude Code"
|
|
145
|
+
{ error: "A #{label} session is already #{current.status} in this sandbox", code_session: current.summary }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def invalid_request(code_session)
|
|
149
|
+
label = code_session.runner == "codex" ? "Codex" : "Claude Code"
|
|
150
|
+
return "model is not a #{label} model name" if code_session.model && !code_session.model.match?(MODEL_NAME)
|
|
151
|
+
|
|
152
|
+
code_session.errors.full_messages.to_sentence unless code_session.valid?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A prompt or model sent as an array or object is not one.
|
|
156
|
+
def string_param(name)
|
|
157
|
+
value = params[name]
|
|
158
|
+
value.is_a?(String) ? value : nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def stop(code_session)
|
|
162
|
+
SandboxOrchestrator.new.cancel_code_session(@sandbox, code_session)
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
Rails.logger.warn("Failed to stop Claude Code session #{code_session.id}: #{e.message}")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -10,6 +10,8 @@ module ActionAgent
|
|
|
10
10
|
# than sampling recorded generations, and can be narrowed to a group, to
|
|
11
11
|
# specific scenarios, or to specific models.
|
|
12
12
|
class EvaluationsController < BaseController
|
|
13
|
+
include EvaluationRunStarting
|
|
14
|
+
|
|
13
15
|
rescue_from ActiveAgent::Evals::ScenarioParser::ParseError do |error|
|
|
14
16
|
render json: { errors: [ error.message ] }, status: :unprocessable_entity
|
|
15
17
|
end
|
|
@@ -39,6 +41,11 @@ module ActionAgent
|
|
|
39
41
|
# 50 most recent. The scope is already restricted to the current user's
|
|
40
42
|
# agents, so an id outside it simply returns nothing.
|
|
41
43
|
#
|
|
44
|
+
# Archived evaluations are left out the same way, before the limit,
|
|
45
|
+
# unless `archived=1` asks for them; `archived_count` says how many
|
|
46
|
+
# the page left out. Each evaluation carries its standing against the
|
|
47
|
+
# agent's current version and its headline run (EvaluationSerializer).
|
|
48
|
+
#
|
|
42
49
|
# Three fields feed the dashboard's model pickers. They describe the
|
|
43
50
|
# credentials of #picker_credentials_owner:
|
|
44
51
|
# - judge_provider: the provider a judge model runs on, null
|
|
@@ -52,16 +59,36 @@ module ActionAgent
|
|
|
52
59
|
def index
|
|
53
60
|
scope = evaluations_scope
|
|
54
61
|
scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
|
|
55
|
-
|
|
62
|
+
archived_count = scope.archived.count
|
|
63
|
+
scope = scope.unarchived unless include_archived?
|
|
64
|
+
evaluations = scope.includes(:agent, :scenarios, evaluation_runs: :agent_version).recent.limit(50).to_a
|
|
65
|
+
preload_standing_and_costs(evaluations)
|
|
56
66
|
owner = picker_credentials_owner
|
|
57
67
|
|
|
58
68
|
render json: {
|
|
59
69
|
evaluations: evaluations.map { |evaluation| serialize(evaluation) },
|
|
70
|
+
archived_count: archived_count,
|
|
60
71
|
**judge_provider_fields(owner),
|
|
61
72
|
model_providers: AgentExecutionService.available_providers(owner)
|
|
62
73
|
}
|
|
63
74
|
end
|
|
64
75
|
|
|
76
|
+
# PATCH /api/evaluations/:id
|
|
77
|
+
# `evaluation: { archived: true | false }` archives the evaluation —
|
|
78
|
+
# it keeps its runs but leaves the index, the pooled pass rate and the
|
|
79
|
+
# agent's scorecard — or brings it back. A new run brings it back too.
|
|
80
|
+
def update
|
|
81
|
+
evaluation = evaluations_scope.find(params[:id])
|
|
82
|
+
archived = params.require(:evaluation)[:archived]
|
|
83
|
+
if archived.nil?
|
|
84
|
+
return render json: { errors: [ "evaluation.archived must be true or false" ] }, status: :unprocessable_entity
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
ActiveModel::Type::Boolean.new.cast(archived) ? evaluation.archive! : evaluation.unarchive!
|
|
88
|
+
|
|
89
|
+
render json: { evaluation: serialize(evaluation.reload) }
|
|
90
|
+
end
|
|
91
|
+
|
|
65
92
|
# Runs listed per evaluation on GET /api/evaluations/:id. The rest of
|
|
66
93
|
# the history stays reachable by run id; `run_count` says how long it is.
|
|
67
94
|
RUN_HISTORY_LIMIT = 20
|
|
@@ -70,7 +97,8 @@ module ActionAgent
|
|
|
70
97
|
def show
|
|
71
98
|
evaluation = evaluations_scope.find(params[:id])
|
|
72
99
|
run_count = evaluation.evaluation_runs.count
|
|
73
|
-
runs = evaluation.evaluation_runs.recent.limit(RUN_HISTORY_LIMIT).to_a
|
|
100
|
+
runs = evaluation.evaluation_runs.includes(:agent_version).recent.limit(RUN_HISTORY_LIMIT).to_a
|
|
101
|
+
EvaluationRunCost.preload(runs).each { |id, breakdown| runs.find { |run| run.id == id }&.cost_breakdown = breakdown }
|
|
74
102
|
|
|
75
103
|
render json: {
|
|
76
104
|
evaluation: serialize(evaluation).merge(
|
|
@@ -107,7 +135,7 @@ module ActionAgent
|
|
|
107
135
|
end
|
|
108
136
|
|
|
109
137
|
if evaluation.save
|
|
110
|
-
|
|
138
|
+
start_evaluation_run(evaluation, selection_params) if run_requested?
|
|
111
139
|
render json: { evaluation: serialize(evaluation.reload) }, status: :created
|
|
112
140
|
else
|
|
113
141
|
render json: { errors: evaluation.errors.full_messages }, status: :unprocessable_entity
|
|
@@ -116,10 +144,17 @@ module ActionAgent
|
|
|
116
144
|
|
|
117
145
|
# POST /api/evaluations/:id/run
|
|
118
146
|
# A scenario suite accepts a selection: scenario_ids[], keys[], group,
|
|
119
|
-
# models[] (or a comma-separated `models` string)
|
|
147
|
+
# models[] (or a comma-separated `models` string), and `sandbox_id`: a
|
|
148
|
+
# checkout sandbox of the caller's whose app runtime every replay of
|
|
149
|
+
# this run reaches as well, without the agent being edited (see
|
|
150
|
+
# RunSandbox). The run records which one it used.
|
|
120
151
|
def run
|
|
121
152
|
evaluation = current_evaluation
|
|
122
|
-
|
|
153
|
+
selection = selection_params
|
|
154
|
+
if (sandbox = evaluation_run_sandbox(evaluation, requested_sandbox_id))
|
|
155
|
+
selection[:sandbox_id] = sandbox.session_id
|
|
156
|
+
end
|
|
157
|
+
run = start_evaluation_run(evaluation, selection)
|
|
123
158
|
evaluation.reload
|
|
124
159
|
|
|
125
160
|
render json: {
|
|
@@ -134,16 +169,21 @@ module ActionAgent
|
|
|
134
169
|
# fix items — the faults grouped with the tools, MCP server and
|
|
135
170
|
# dashboard action that address each. Fix item paths are relative to
|
|
136
171
|
# the mount: the React app resolves them itself (dashboardPath).
|
|
172
|
+
# `costs` is what each scenario cost across the models, judge apart,
|
|
173
|
+
# and the run's total (EvaluationRunCost#costs); every result carries
|
|
174
|
+
# its effective cost and how it was priced.
|
|
137
175
|
def show_run
|
|
138
176
|
evaluation = evaluations_scope.find(params[:id])
|
|
139
177
|
run = evaluation.evaluation_runs.find(params[:run_id])
|
|
140
178
|
results = run.scenario_results.includes(:scenario).joins(:scenario)
|
|
141
179
|
.order(EvaluationScenario.arel_table[:position], EvaluationScenario.arel_table[:id], :model)
|
|
180
|
+
breakdown = run.cost_breakdown
|
|
142
181
|
|
|
143
182
|
render json: {
|
|
144
183
|
evaluation: serialize(evaluation),
|
|
145
184
|
run: serialize_run(run, number: run_number(evaluation, run))
|
|
146
|
-
.merge(results: results.map(
|
|
185
|
+
.merge(results: results.map { |result| result.as_json_summary(costs: breakdown.result(result)) },
|
|
186
|
+
fix_items: safe_fix_items(run), costs: breakdown.costs)
|
|
147
187
|
}
|
|
148
188
|
end
|
|
149
189
|
|
|
@@ -216,28 +256,19 @@ module ActionAgent
|
|
|
216
256
|
|
|
217
257
|
private
|
|
218
258
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
rescue StandardError => e
|
|
233
|
-
Rails.logger.warn(
|
|
234
|
-
"[ActionAgent] evaluation #{evaluation.id} run failed: #{e.class}: #{e.message}"
|
|
235
|
-
)
|
|
236
|
-
# The service records the failure before re-raising; a failure that
|
|
237
|
-
# predates the run record (creating it, say) is recorded here so the
|
|
238
|
-
# response always carries one.
|
|
239
|
-
evaluation.evaluation_runs.recent.first ||
|
|
240
|
-
evaluation.evaluation_runs.create!(status: :failed, error_message: e.message, completed_at: Time.current)
|
|
259
|
+
def include_archived?
|
|
260
|
+
ActiveModel::Type::Boolean.new.cast(params[:archived]) == true
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# One query per table for the page's standings and for the costs of
|
|
264
|
+
# the runs it serializes in full (each evaluation's latest and headline
|
|
265
|
+
# run), rather than one per evaluation.
|
|
266
|
+
def preload_standing_and_costs(evaluations)
|
|
267
|
+
EvaluationStanding.preload(evaluations)
|
|
268
|
+
runs = evaluations.flat_map do |evaluation|
|
|
269
|
+
[ EvaluationSerializer.recent_runs(evaluation, 1).first, evaluation.standing_info.headline_run ]
|
|
270
|
+
end.compact.uniq(&:id)
|
|
271
|
+
EvaluationRunCost.preload(runs).each { |id, breakdown| runs.find { |run| run.id == id }&.cost_breakdown = breakdown }
|
|
241
272
|
end
|
|
242
273
|
|
|
243
274
|
# Returns whose credentials the index's model picker fields describe.
|
|
@@ -292,10 +323,7 @@ module ActionAgent
|
|
|
292
323
|
def require_executable_scenario_agent!
|
|
293
324
|
agent = action_name == "create" ? requested_agent : current_evaluation.agent
|
|
294
325
|
return unless agent.observed?
|
|
295
|
-
if action_name == "run"
|
|
296
|
-
adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(current_evaluation)
|
|
297
|
-
return if adapter.respond_to?(:call)
|
|
298
|
-
end
|
|
326
|
+
return if action_name == "run" && !unexecutable_scenario_run?(current_evaluation)
|
|
299
327
|
|
|
300
328
|
render json: {
|
|
301
329
|
error: "Observed agents are read-only — duplicate this agent to create an executable copy"
|
|
@@ -310,19 +338,18 @@ module ActionAgent
|
|
|
310
338
|
params.require(:scenario).permit(:prompt, :group, :notes, :enabled, :key, expectations: {})
|
|
311
339
|
end
|
|
312
340
|
|
|
313
|
-
#
|
|
314
|
-
#
|
|
341
|
+
# The sandbox id a run was asked to use, from the selection or the top
|
|
342
|
+
# level of the request.
|
|
343
|
+
def requested_sandbox_id
|
|
344
|
+
selection_source[:sandbox_id].presence || params[:sandbox_id].presence
|
|
345
|
+
end
|
|
346
|
+
|
|
315
347
|
def selection_params
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
models = models.to_s.split(",") unless models.is_a?(Array)
|
|
348
|
+
evaluation_run_selection(selection_source)
|
|
349
|
+
end
|
|
319
350
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
keys: Array(source[:keys]).map(&:to_s).reject(&:blank?),
|
|
323
|
-
group: source[:group].to_s.presence,
|
|
324
|
-
models: models.map(&:to_s).map(&:strip).reject(&:blank?)
|
|
325
|
-
}.compact_blank
|
|
351
|
+
def selection_source
|
|
352
|
+
params[:evaluation].is_a?(ActionController::Parameters) && params[:evaluation].key?(:selection) ? params[:evaluation][:selection] : params
|
|
326
353
|
end
|
|
327
354
|
|
|
328
355
|
# Scenarios from text, a YAML/JSON suite, or a list of objects. Production
|
|
@@ -373,97 +400,13 @@ module ActionAgent
|
|
|
373
400
|
models.map(&:to_s).map(&:strip).reject(&:blank?)
|
|
374
401
|
end
|
|
375
402
|
|
|
376
|
-
def serialize(evaluation)
|
|
377
|
-
# size reads the preloaded association on index and COUNTs elsewhere.
|
|
378
|
-
run_count = evaluation.evaluation_runs.size
|
|
379
|
-
latest, previous = recent_runs(evaluation, 2)
|
|
380
|
-
|
|
381
|
-
{
|
|
382
|
-
id: evaluation.id,
|
|
383
|
-
name: evaluation.name,
|
|
384
|
-
agent: { id: evaluation.agent.id, name: evaluation.agent.name, slug: evaluation.agent.slug },
|
|
385
|
-
judge_kind: evaluation.judge_kind,
|
|
386
|
-
judge_model: evaluation.judge_model,
|
|
387
|
-
criteria: evaluation.criteria,
|
|
388
|
-
compare_models: evaluation.compare_models,
|
|
389
|
-
config: evaluation.config,
|
|
390
|
-
sample_size: evaluation.sample_size,
|
|
391
|
-
scenario_suite: evaluation.scenario_suite?,
|
|
392
|
-
scenario_count: evaluation.scenarios.size,
|
|
393
|
-
scenario_groups: evaluation.scenario_suite? ? evaluation.scenario_groups : [],
|
|
394
|
-
created_at: evaluation.created_at.iso8601,
|
|
395
|
-
run_count: run_count,
|
|
396
|
-
latest_run: latest ? serialize_run(latest, number: run_count) : nil,
|
|
397
|
-
# Just enough of the run before it for the list to show movement
|
|
398
|
-
# ("+3 passed vs #2") without a request per evaluation.
|
|
399
|
-
previous_run: previous ? serialize_run_summary(previous, number: run_count - 1) : nil
|
|
400
|
-
}
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
# Newest first. Sorts the preloaded association when index loaded it
|
|
404
|
-
# rather than issuing one ORDER BY query per evaluation.
|
|
405
|
-
def recent_runs(evaluation, limit)
|
|
406
|
-
runs = evaluation.evaluation_runs
|
|
407
|
-
if runs.loaded?
|
|
408
|
-
runs.sort_by { |run| [ run.created_at, run.id ] }.reverse.first(limit)
|
|
409
|
-
else
|
|
410
|
-
runs.recent.limit(limit).to_a
|
|
411
|
-
end
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
# A run's position in its evaluation's history, oldest = 1.
|
|
415
|
-
def run_number(evaluation, run)
|
|
416
|
-
evaluation.evaluation_runs.where("created_at < ? OR (created_at = ? AND id <= ?)", run.created_at, run.created_at, run.id).count
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
# `number` is the run's position in its evaluation's history, oldest =
|
|
420
|
-
# 1, so the dashboard can say "Run #3" and "vs #2".
|
|
421
|
-
def serialize_run(run, number: nil)
|
|
422
|
-
serialize_run_summary(run, number: number).merge(
|
|
423
|
-
scores: run.scores,
|
|
424
|
-
selection: run.selection,
|
|
425
|
-
models: run.models,
|
|
426
|
-
usage: run.usage,
|
|
427
|
-
error_message: run.error_message
|
|
428
|
-
)
|
|
429
|
-
end
|
|
403
|
+
def serialize(evaluation) = EvaluationSerializer.evaluation(evaluation)
|
|
430
404
|
|
|
431
|
-
def
|
|
432
|
-
{
|
|
433
|
-
id: run.id,
|
|
434
|
-
number: number,
|
|
435
|
-
status: run.status,
|
|
436
|
-
average_score: safe_average_score(run),
|
|
437
|
-
samples_evaluated: run.samples_evaluated,
|
|
438
|
-
samples_passed: run.samples_passed,
|
|
439
|
-
completed_at: run.completed_at&.iso8601,
|
|
440
|
-
created_at: run.created_at.iso8601
|
|
441
|
-
}
|
|
442
|
-
end
|
|
405
|
+
def serialize_run(run, number: nil) = EvaluationSerializer.run(run, number: number)
|
|
443
406
|
|
|
444
|
-
|
|
445
|
-
# which older runs recorded in earlier shapes; a run they cannot be
|
|
446
|
-
# built for still serves its results rather than 500-ing the panel.
|
|
447
|
-
def safe_fix_items(run)
|
|
448
|
-
run.fix_items
|
|
449
|
-
rescue StandardError => e
|
|
450
|
-
Rails.logger.warn(
|
|
451
|
-
"[ActionAgent] evaluation run #{run.id} fix_items failed: #{e.class}: #{e.message}"
|
|
452
|
-
)
|
|
453
|
-
[]
|
|
454
|
-
end
|
|
407
|
+
def run_number(evaluation, run) = EvaluationSerializer.run_number(evaluation, run)
|
|
455
408
|
|
|
456
|
-
|
|
457
|
-
# unaverageable scores payload used to 500 the entire Evaluations page
|
|
458
|
-
# instead of degrading that one run's headline number.
|
|
459
|
-
def safe_average_score(run)
|
|
460
|
-
run.average_score
|
|
461
|
-
rescue StandardError => e
|
|
462
|
-
Rails.logger.warn(
|
|
463
|
-
"[ActionAgent] evaluation run #{run.id} average_score failed: #{e.class}: #{e.message}"
|
|
464
|
-
)
|
|
465
|
-
nil
|
|
466
|
-
end
|
|
409
|
+
def safe_fix_items(run) = EvaluationSerializer.fix_items(run)
|
|
467
410
|
end
|
|
468
411
|
end
|
|
469
412
|
end
|