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
|
@@ -23,6 +23,12 @@ module ActionAgent
|
|
|
23
23
|
# is reached the same way (SandboxSession.browser_server_entry), and only
|
|
24
24
|
# this way. Only those keys are taken; a catalog server the agent does not
|
|
25
25
|
# enable stays out of reach.
|
|
26
|
+
#
|
|
27
|
+
# A server a dashboard user added on the MCP Services page is enabled as
|
|
28
|
+
# "custom:<id>" and resolves through MCPServerConfig.server_entry, among the
|
|
29
|
+
# agent's owner's configurations only and only while it is enabled. Its
|
|
30
|
+
# client refuses a host that resolves to a non-public address (see
|
|
31
|
+
# MCPHostGuard) unless the host app allows them.
|
|
26
32
|
class MCPToolDispatcher
|
|
27
33
|
HTTP_TRANSPORTS = %w[http streamable_http sse].freeze
|
|
28
34
|
class SandboxUnavailable < MCPClient::Error; end
|
|
@@ -43,6 +49,7 @@ module ActionAgent
|
|
|
43
49
|
def self.unreachable_reason(entry)
|
|
44
50
|
return "Unknown to the catalog" if entry.nil?
|
|
45
51
|
return nil if SandboxSession.runtime_server_key?(entry[:key].to_s)
|
|
52
|
+
return "Switched off on the MCP Services page" if entry[:custom] && entry[:enabled] == false
|
|
46
53
|
|
|
47
54
|
transport = entry[:transport].to_s
|
|
48
55
|
unless transport.in?(HTTP_TRANSPORTS)
|
|
@@ -284,14 +291,16 @@ module ActionAgent
|
|
|
284
291
|
end
|
|
285
292
|
|
|
286
293
|
# A catalog server, the app runtime of a checkout sandbox the agent's
|
|
287
|
-
# owner started (keys "sandbox:<session_id>"),
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
294
|
+
# owner started (keys "sandbox:<session_id>"), a server the agent's owner
|
|
295
|
+
# added ("custom:<id>"), or, for a key this run was given, that sandbox's
|
|
296
|
+
# browser ("browser:<session_id>"). The sandbox and custom lookups are
|
|
297
|
+
# scoped to the agent's owner, so naming another tenant's session or
|
|
298
|
+
# configuration resolves to nothing.
|
|
291
299
|
def catalog_entry(key)
|
|
292
300
|
if SandboxSession.browser_server_key?(key)
|
|
293
301
|
return extra_server_keys.include?(key) ? SandboxSession.browser_server_entry(key, owner: agent.try(:owner)) : nil
|
|
294
302
|
end
|
|
303
|
+
return MCPServerConfig.server_entry(key, owner: agent.try(:owner)) if MCPServerConfig.key?(key)
|
|
295
304
|
return MCPCatalog.connection(key) unless SandboxSession.runtime_server_key?(key)
|
|
296
305
|
|
|
297
306
|
SandboxSession.runtime_server_entry(key, owner: agent.try(:owner))
|
|
@@ -303,7 +312,8 @@ module ActionAgent
|
|
|
303
312
|
@clients[entry[:key]] ||= MCPClient.new(
|
|
304
313
|
url: absolute_url(entry[:url]),
|
|
305
314
|
label: entry[:name] || entry[:key],
|
|
306
|
-
headers: entry[:headers] || {}
|
|
315
|
+
headers: entry[:headers] || {},
|
|
316
|
+
public_only: entry[:public_only] ? true : false
|
|
307
317
|
)
|
|
308
318
|
end
|
|
309
319
|
|
|
@@ -10,12 +10,19 @@ module ActionAgent
|
|
|
10
10
|
# The host is the OpenAI-compatible base URL (".../v1"); the probe calls
|
|
11
11
|
# GET {host}/models. An optional API key is sent as a Bearer token for
|
|
12
12
|
# remote servers behind an authenticating proxy or Ollama Cloud.
|
|
13
|
+
#
|
|
14
|
+
# The result says whether the host is Ollama Cloud (`cloud`) and, for
|
|
15
|
+
# each listed model a price list names, its rate (`rates`): the cloud's
|
|
16
|
+
# models are metered per token, and a local server's copy of a cloud
|
|
17
|
+
# model is priced at the same list rate (ModelPricing). A model priced
|
|
18
|
+
# only by a guess has no entry.
|
|
13
19
|
class OllamaHostProbe
|
|
14
20
|
TIMEOUT_SECONDS = 4
|
|
21
|
+
CLOUD_HOST = "ollama.com"
|
|
15
22
|
|
|
16
|
-
Result = Struct.new(:ok, :host, :models, :latency_ms, :error, keyword_init: true) do
|
|
23
|
+
Result = Struct.new(:ok, :host, :models, :latency_ms, :error, :cloud, :rates, keyword_init: true) do
|
|
17
24
|
def to_h
|
|
18
|
-
{ ok: ok, host: host, models: models, latency_ms: latency_ms, error: error }
|
|
25
|
+
{ ok: ok, host: host, models: models, latency_ms: latency_ms, error: error, cloud: cloud, rates: rates }
|
|
19
26
|
end
|
|
20
27
|
end
|
|
21
28
|
|
|
@@ -23,11 +30,24 @@ module ActionAgent
|
|
|
23
30
|
new(host: host, api_key: api_key).call
|
|
24
31
|
end
|
|
25
32
|
|
|
33
|
+
# Whether +host+ addresses Ollama Cloud (the framework's
|
|
34
|
+
# ActiveAgent::Providers::Ollama::Cloud.cloud_host?, kept in step).
|
|
35
|
+
def self.cloud_host?(host)
|
|
36
|
+
name = URI.parse(host.to_s.strip).host.to_s.downcase
|
|
37
|
+
name == CLOUD_HOST || name.end_with?(".#{CLOUD_HOST}")
|
|
38
|
+
rescue URI::InvalidURIError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
26
42
|
def initialize(host:, api_key: nil)
|
|
27
43
|
@host = ProviderKey.normalize_host(host)
|
|
28
44
|
@api_key = api_key.presence
|
|
29
45
|
end
|
|
30
46
|
|
|
47
|
+
def cloud?
|
|
48
|
+
self.class.cloud_host?(@host)
|
|
49
|
+
end
|
|
50
|
+
|
|
31
51
|
def call
|
|
32
52
|
return failure("No host configured") if @host.blank?
|
|
33
53
|
|
|
@@ -49,7 +69,7 @@ module ActionAgent
|
|
|
49
69
|
|
|
50
70
|
data = JSON.parse(response.body)
|
|
51
71
|
models = Array(data["data"]).filter_map { |model| model["id"] }.sort
|
|
52
|
-
Result.new(ok: true, host: @host, models: models, latency_ms: latency_ms, error: nil)
|
|
72
|
+
Result.new(ok: true, host: @host, models: models, latency_ms: latency_ms, error: nil, cloud: cloud?, rates: rates_for(models))
|
|
53
73
|
rescue JSON::ParserError
|
|
54
74
|
failure("#{@host} did not return JSON — is this the OpenAI-compatible /v1 endpoint?")
|
|
55
75
|
rescue URI::InvalidURIError, ArgumentError
|
|
@@ -68,8 +88,20 @@ module ActionAgent
|
|
|
68
88
|
@api_key ? { "Authorization" => "Bearer #{@api_key}" } : {}
|
|
69
89
|
end
|
|
70
90
|
|
|
91
|
+
# `{ "gpt-oss:120b" => { input: 0.15, output: 0.6, source: "catalog" } }`
|
|
92
|
+
# for every listed model a price list names, in $ per million tokens.
|
|
93
|
+
def rates_for(models)
|
|
94
|
+
provider = cloud? ? ModelPricing::OLLAMA_CLOUD_PROVIDER : "ollama"
|
|
95
|
+
models.each_with_object({}) do |id, rates|
|
|
96
|
+
detail = ModelPricing.rate_detail(id, provider: provider)
|
|
97
|
+
next if ModelPricing::FALLBACK_SOURCES.include?(detail[:source])
|
|
98
|
+
|
|
99
|
+
rates[id] = detail.slice(:input, :output, :source)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
71
103
|
def failure(message, latency_ms = nil)
|
|
72
|
-
Result.new(ok: false, host: @host, models: [], latency_ms: latency_ms, error: message)
|
|
104
|
+
Result.new(ok: false, host: @host, models: [], latency_ms: latency_ms, error: message, cloud: cloud?, rates: {})
|
|
73
105
|
end
|
|
74
106
|
end
|
|
75
107
|
end
|
|
@@ -181,6 +181,10 @@ module ActionAgent
|
|
|
181
181
|
metadata: entry.metadata, agent: resolve_agent(entry) || (merge? ? product.agent : nil),
|
|
182
182
|
project: resolve_project(entry) || (merge? ? product.project : nil)
|
|
183
183
|
)
|
|
184
|
+
# Until add_catalog_product_criteria runs, imports keep working without the lists.
|
|
185
|
+
product.criteria = entry.criteria if product.has_attribute?(:criteria)
|
|
186
|
+
# Until add_catalog_pass_bars runs, imports keep working without the bars.
|
|
187
|
+
product.pass = entry.pass if product.has_attribute?(:pass)
|
|
184
188
|
product.position = position_for(product, entry.position, offset)
|
|
185
189
|
product.save!
|
|
186
190
|
replace_sets(product, entry)
|
|
@@ -197,6 +201,7 @@ module ActionAgent
|
|
|
197
201
|
name: set_entry.name, description: set_entry.description, judge: set_entry.judge,
|
|
198
202
|
criteria: set_entry.criteria, metadata: set_entry.metadata
|
|
199
203
|
)
|
|
204
|
+
set.pass = set_entry.pass if set.has_attribute?(:pass)
|
|
200
205
|
set.position = position_for(set, set_entry.position, offset)
|
|
201
206
|
set.save!
|
|
202
207
|
replace_scenarios(set, set_entry)
|
|
@@ -213,6 +218,8 @@ module ActionAgent
|
|
|
213
218
|
prompt: scenario.prompt, notes: scenario.notes, expectations: scenario.expectations,
|
|
214
219
|
tags: scenario.tags, params: scenario.params, production_only: scenario.production_only?
|
|
215
220
|
)
|
|
221
|
+
# Until add_catalog_scenario_metadata runs, imports keep working without extensions.
|
|
222
|
+
record.metadata = scenario.metadata if record.has_attribute?(:metadata)
|
|
216
223
|
record.position = position_for(record, scenario.position, offset)
|
|
217
224
|
record.save!
|
|
218
225
|
end
|
|
@@ -22,6 +22,11 @@ module ActionAgent
|
|
|
22
22
|
# decisive (ActiveAgent::Evals::Comparison)
|
|
23
23
|
# "_selection" — the scenarios and models this run covered, and
|
|
24
24
|
# the checkout sandbox it replayed against, if any
|
|
25
|
+
# "_pass_thresholds" — for an evaluation of a catalog set that declares
|
|
26
|
+
# `pass` bars: the bars, recorded as the run starts,
|
|
27
|
+
# so every replay and the completion gate by the same
|
|
28
|
+
# ones however the catalog changes meanwhile
|
|
29
|
+
# "_gates" — with them: each group's gate per model, met or not
|
|
25
30
|
#
|
|
26
31
|
# A selection's `sandbox_id` names a checkout sandbox whose app runtime
|
|
27
32
|
# every replay reaches beside the agent's own MCP servers, as if the agent
|
|
@@ -41,6 +46,7 @@ module ActionAgent
|
|
|
41
46
|
# any replay, and one started here is stopped once the run ends.
|
|
42
47
|
class ScenarioEvaluationRunner < EvaluationRunnerService
|
|
43
48
|
Evals = ActiveAgent::Evals
|
|
49
|
+
JUDGE_LEASE_DURATION = 30.minutes
|
|
44
50
|
|
|
45
51
|
# The providers a candidate model name resolves against. `mock` is the
|
|
46
52
|
# framework's test double, accepted so the test suite can compare cohorts
|
|
@@ -80,6 +86,7 @@ module ActionAgent
|
|
|
80
86
|
specs = model_specs
|
|
81
87
|
run = @run || @evaluation.evaluation_runs.create!(status: :pending)
|
|
82
88
|
run.update!(status: :running, selection: selection_summary(scenarios, specs))
|
|
89
|
+
record_pass_thresholds!(run)
|
|
83
90
|
ensure_sandbox_live!
|
|
84
91
|
|
|
85
92
|
if scenarios.empty?
|
|
@@ -124,7 +131,7 @@ module ActionAgent
|
|
|
124
131
|
adapter.call(evaluation: @evaluation, owner: owner, scenarios: tasks, models: specs, on_result: metered)
|
|
125
132
|
else
|
|
126
133
|
ensure_judge_defined_kpis! if @evaluation.judge_defined?
|
|
127
|
-
default_report(tasks, specs, on_result)
|
|
134
|
+
default_report(tasks, specs, on_result, run)
|
|
128
135
|
end
|
|
129
136
|
raise ArgumentError, "scenario evaluation adapter must return an ActiveAgent::Evals::Report" unless report.is_a?(Evals::Report)
|
|
130
137
|
reported = report.results.map { |result| [ result.scenario.key, result.label ] }
|
|
@@ -187,17 +194,20 @@ module ActionAgent
|
|
|
187
194
|
# A job delivered again for a result that is already scored, or that a
|
|
188
195
|
# concurrent delivery has claimed, scores nothing. It still checks
|
|
189
196
|
# whether the run is complete: the earlier delivery may have stored the
|
|
190
|
-
# last result and stopped before completing the run.
|
|
197
|
+
# last result and stopped before completing the run. A result another
|
|
198
|
+
# delivery holds a live lease on is tried again when the lease expires
|
|
199
|
+
# (see #wait_for_claim!).
|
|
191
200
|
def judge_result!(row)
|
|
192
201
|
run = row.evaluation_run
|
|
193
202
|
return unless run.running?
|
|
194
|
-
return finish_fan_out!(run) unless row.pending?
|
|
203
|
+
return finish_fan_out!(run) unless row.pending?
|
|
204
|
+
return wait_for_claim!(row, run) unless claim_result!(row)
|
|
195
205
|
|
|
196
206
|
spec = fan_out_specs(run).fetch([ row.provider.to_s, row.model ]) do
|
|
197
207
|
Evals::ModelSpec.new(label: row.model, provider: row.provider.to_s, model: row.model)
|
|
198
208
|
end
|
|
199
209
|
result = fan_out_scorer(run).evaluate(Evals::Scenario.from_hash(row.evaluated_scenario), spec, replay_from(row))
|
|
200
|
-
|
|
210
|
+
store_claimed_result!(row,
|
|
201
211
|
status: result.status,
|
|
202
212
|
score: result.score,
|
|
203
213
|
scores: result.scores,
|
|
@@ -227,7 +237,15 @@ module ActionAgent
|
|
|
227
237
|
row = stored_result(run, scenario, spec)
|
|
228
238
|
task = Evals::Scenario.from_hash(scenario.as_json_summary)
|
|
229
239
|
row ||= store_replay(run, scenario, task, spec, Evals::Replay.failed(error))
|
|
230
|
-
|
|
240
|
+
attributes = { status: :errored, error_message: row.error_message.presence || error_text(error) }
|
|
241
|
+
if scoring_leases?
|
|
242
|
+
# Another replay delivery may fail while this result is being
|
|
243
|
+
# scored. Only an unclaimed replay belongs to this failure path.
|
|
244
|
+
EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: nil)
|
|
245
|
+
.update_all(attributes.merge(updated_at: Time.current))
|
|
246
|
+
elsif row.pending?
|
|
247
|
+
row.update!(attributes)
|
|
248
|
+
end
|
|
231
249
|
end
|
|
232
250
|
end
|
|
233
251
|
finish_fan_out!(run)
|
|
@@ -237,7 +255,7 @@ module ActionAgent
|
|
|
237
255
|
# still completes.
|
|
238
256
|
def fail_judging!(row, error)
|
|
239
257
|
run = row.evaluation_run
|
|
240
|
-
|
|
258
|
+
store_claimed_result!(row, status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending?
|
|
241
259
|
finish_fan_out!(run)
|
|
242
260
|
end
|
|
243
261
|
|
|
@@ -331,15 +349,59 @@ module ActionAgent
|
|
|
331
349
|
@evaluation.scenarios.where(id: ids).count * specs.size
|
|
332
350
|
end
|
|
333
351
|
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
#
|
|
352
|
+
# A timestamp comparison alone only rejects a stale reader. A duplicate
|
|
353
|
+
# delivered after that write reads the new timestamp and could score too.
|
|
354
|
+
# The lease excludes fresh readers as well, without holding a database
|
|
355
|
+
# lock across a provider call. A redelivery can reclaim an expired lease.
|
|
356
|
+
#
|
|
357
|
+
# Without the lease columns (migration 030 not yet run) the claim is the
|
|
358
|
+
# timestamp comparison alone, as before the lease.
|
|
341
359
|
def claim_result!(row)
|
|
342
|
-
|
|
360
|
+
now = Time.current
|
|
361
|
+
claim = EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at)
|
|
362
|
+
return claim.update_all(updated_at: now) == 1 unless scoring_leases?
|
|
363
|
+
|
|
364
|
+
token = SecureRandom.uuid
|
|
365
|
+
claimed = claim.where("scoring_token IS NULL OR scoring_expires_at <= ?", now)
|
|
366
|
+
.update_all(scoring_token: token, scoring_expires_at: now + JUDGE_LEASE_DURATION, updated_at: now)
|
|
367
|
+
@scoring_token = token if claimed == 1
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# A claim lost to another delivery leaves the result pending, and that
|
|
371
|
+
# delivery may be a worker that stopped: the queue's redelivery of its
|
|
372
|
+
# job lands here, inside the lease the stopped worker still holds. So the
|
|
373
|
+
# job is enqueued again for when that lease expires. If the holder stores
|
|
374
|
+
# the result first, the later delivery finds it scored and only checks
|
|
375
|
+
# whether the run is complete. A result left pending with no live lease
|
|
376
|
+
# (the claim raced another write to the row) is tried again at once.
|
|
377
|
+
# Without the lease columns a lost claim is a concurrent delivery that is
|
|
378
|
+
# scoring the result now, as before.
|
|
379
|
+
def wait_for_claim!(row, run)
|
|
380
|
+
current = EvaluationScenarioResult.find_by(id: row.id) if scoring_leases?
|
|
381
|
+
if current&.pending?
|
|
382
|
+
retry_at = [ current.scoring_expires_at, Time.current ].compact.max
|
|
383
|
+
EvaluationJudgeJob.set(wait_until: retry_at).perform_later(row.id, @selection.to_h, run.id)
|
|
384
|
+
end
|
|
385
|
+
finish_fan_out!(run)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Fence late successes and failures: after a lease is reclaimed, the old
|
|
389
|
+
# worker cannot overwrite its successor. Only the current owner releases
|
|
390
|
+
# the lease. A failure before claiming may mark an unclaimed row errored.
|
|
391
|
+
def store_claimed_result!(row, **attributes)
|
|
392
|
+
return row.update!(attributes) unless scoring_leases?
|
|
393
|
+
|
|
394
|
+
row.assign_attributes(attributes)
|
|
395
|
+
row.validate!
|
|
396
|
+
EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: @scoring_token)
|
|
397
|
+
.update_all(attributes.merge(scoring_token: nil, scoring_expires_at: nil, updated_at: Time.current))
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Whether migration 030's lease columns are installed. A host that
|
|
401
|
+
# upgraded the gem without running the generator keeps scoring with the
|
|
402
|
+
# timestamp claim instead of failing every judge job on a missing column.
|
|
403
|
+
def scoring_leases?
|
|
404
|
+
(%w[scoring_token scoring_expires_at] - EvaluationScenarioResult.column_names).empty?
|
|
343
405
|
end
|
|
344
406
|
|
|
345
407
|
# What Evals::Runner does with a replay that raises: an errored replay,
|
|
@@ -397,7 +459,7 @@ module ActionAgent
|
|
|
397
459
|
available_tools: fan_out_state(run).fetch("tools") { tool_roster },
|
|
398
460
|
instructions: @evaluation.agent.instructions,
|
|
399
461
|
agent_name: @evaluation.agent.name,
|
|
400
|
-
|
|
462
|
+
**gating(run.pass_thresholds, Evals::Runner),
|
|
401
463
|
replay: ->(*) { raise ArgumentError, "a fanned-out result is scored from its stored replay" },
|
|
402
464
|
refine_gate: -> { reserve_refinement!(run) }
|
|
403
465
|
)
|
|
@@ -457,7 +519,7 @@ module ActionAgent
|
|
|
457
519
|
)
|
|
458
520
|
end
|
|
459
521
|
report = Evals::Report.new(results: results, models: specs.values, judge: evals_judge,
|
|
460
|
-
instructions: @evaluation.agent.instructions,
|
|
522
|
+
instructions: @evaluation.agent.instructions, **gating(run.pass_thresholds, Evals::Report))
|
|
461
523
|
state = fan_out_state(run)
|
|
462
524
|
|
|
463
525
|
# The other jobs' judge calls go into this runner's meter before the
|
|
@@ -503,7 +565,7 @@ module ActionAgent
|
|
|
503
565
|
error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
|
|
504
566
|
end
|
|
505
567
|
|
|
506
|
-
def default_report(tasks, specs, on_result)
|
|
568
|
+
def default_report(tasks, specs, on_result, run)
|
|
507
569
|
Evals::Runner.new(
|
|
508
570
|
scenarios: tasks,
|
|
509
571
|
models: specs,
|
|
@@ -512,7 +574,7 @@ module ActionAgent
|
|
|
512
574
|
available_tools: tool_roster,
|
|
513
575
|
instructions: @evaluation.agent.instructions,
|
|
514
576
|
agent_name: @evaluation.agent.name,
|
|
515
|
-
|
|
577
|
+
**gating(run.pass_thresholds, Evals::Runner),
|
|
516
578
|
replay: ->(scenario, spec) { replay(scenario, spec) },
|
|
517
579
|
on_result: on_result
|
|
518
580
|
).call
|
|
@@ -771,6 +833,7 @@ module ActionAgent
|
|
|
771
833
|
comparison = report.comparison if report.respond_to?(:comparison)
|
|
772
834
|
scores["_comparison"] = comparison if comparison
|
|
773
835
|
scores["_selection"] = run.selection
|
|
836
|
+
record_gates(scores, report, run.pass_thresholds)
|
|
774
837
|
scores["_metadata"] = report.metadata
|
|
775
838
|
scores["_judge_label"] = report.judge_label || report.judge&.label
|
|
776
839
|
# The judge's own spend, apart from the replays' (which the results
|
|
@@ -783,6 +846,60 @@ module ActionAgent
|
|
|
783
846
|
scores
|
|
784
847
|
end
|
|
785
848
|
|
|
849
|
+
# The bars a host adapter's report did not carry are the run's own, so
|
|
850
|
+
# the run is gated however its replays ran.
|
|
851
|
+
def record_gates(scores, report, recorded)
|
|
852
|
+
thresholds = (report.pass_thresholds if report.respond_to?(:pass_thresholds)) || recorded
|
|
853
|
+
return if thresholds.nil?
|
|
854
|
+
|
|
855
|
+
scores["_pass_thresholds"] = thresholds.to_h
|
|
856
|
+
scores["_gates"] = report.gates(thresholds).map(&:to_h)
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# --- pass bars ----------------------------------------------------------
|
|
860
|
+
|
|
861
|
+
# The score keyword, and the bars when the run has them and the
|
|
862
|
+
# installed framework's +klass+ takes them: the bars' threshold is the
|
|
863
|
+
# catalog's or PASS_THRESHOLD, the score the dashboard always used.
|
|
864
|
+
def gating(thresholds, klass)
|
|
865
|
+
return { threshold: PASS_THRESHOLD } if thresholds.nil? || !accepts_keyword?(klass, :pass_thresholds)
|
|
866
|
+
|
|
867
|
+
{ threshold: thresholds.threshold, pass_thresholds: thresholds }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def accepts_keyword?(klass, keyword)
|
|
871
|
+
klass.instance_method(:initialize).parameters.any? { |type, name| name == keyword && %i[key keyreq].include?(type) }
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# Records the bars of the catalog set this evaluation runs as, once,
|
|
875
|
+
# as the run starts: a fanned-out replay reads them from the run.
|
|
876
|
+
def record_pass_thresholds!(run)
|
|
877
|
+
thresholds = catalog_pass_thresholds
|
|
878
|
+
return if thresholds.nil?
|
|
879
|
+
|
|
880
|
+
run.update!(scores: (run.scores.is_a?(Hash) ? run.scores : {}).merge("_pass_thresholds" => thresholds.to_h))
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# The product's bars as the suite's and the set's as its group's, as
|
|
884
|
+
# Catalog#suite_for gives them, for an evaluation a catalog set
|
|
885
|
+
# materialized (ScenarioSet#catalog_reference) when the set, under the
|
|
886
|
+
# owner's catalogs, still has the keys the reference names; nil when
|
|
887
|
+
# it declares none, and on a framework without per-group bars.
|
|
888
|
+
def catalog_pass_thresholds
|
|
889
|
+
reference = @evaluation.config.to_h["catalog"]
|
|
890
|
+
return nil unless reference.is_a?(Hash) && defined?(Evals::PassThresholds)
|
|
891
|
+
|
|
892
|
+
set = ScenarioSet.joins(:product).includes(:product)
|
|
893
|
+
.where(ScenarioProduct.table_name => { scenario_catalog_id: ScenarioCatalog.for_owner(owner).select(:id) })
|
|
894
|
+
.find_by(id: reference["set_id"], key: reference["set_key"].to_s)
|
|
895
|
+
return nil if set.nil? || set.product.key != reference["product_key"].to_s
|
|
896
|
+
return nil if set.product.pass.empty? && set.pass.empty?
|
|
897
|
+
|
|
898
|
+
Evals::PassThresholds.new(suite: set.product.pass, groups: { set.key => set.pass })
|
|
899
|
+
rescue ArgumentError
|
|
900
|
+
nil
|
|
901
|
+
end
|
|
902
|
+
|
|
786
903
|
# Records who judged once the calls are in. A judge with no model pinned
|
|
787
904
|
# started out labelled by its provider; it is renamed after the model
|
|
788
905
|
# that answered, here and on the verdict it wrote.
|
|
@@ -37,7 +37,9 @@ module ActionAgent
|
|
|
37
37
|
# rules (single-user, per-user, or multi-tenant) decide what is visible.
|
|
38
38
|
# The same goes for checkout sandbox runtimes: the caller hands in the live
|
|
39
39
|
# ones it may see (SandboxSession.runtime_server_listings), and they are
|
|
40
|
-
# listed beside the catalog under their "sandbox:<session_id>" keys.
|
|
40
|
+
# listed beside the catalog under their "sandbox:<session_id>" keys. The
|
|
41
|
+
# servers the owner added (MCPServerConfig.listings) are handed in the same
|
|
42
|
+
# way, under their "custom:<id>" keys.
|
|
41
43
|
class ToolDiscovery
|
|
42
44
|
DEFAULT_WINDOW_HOURS = 24 * 7
|
|
43
45
|
MAX_WINDOW_HOURS = 24 * 90
|
|
@@ -543,6 +545,8 @@ module ActionAgent
|
|
|
543
545
|
# from traffic is real but undocumented here, and the view says so.
|
|
544
546
|
known: !catalog.nil?,
|
|
545
547
|
runtime: runtime,
|
|
548
|
+
# A server the owner added on the MCP Services page ("custom:<id>").
|
|
549
|
+
custom: MCPServerConfig.key?(key),
|
|
546
550
|
status: server_status(calls, configured, catalog),
|
|
547
551
|
calls: calls,
|
|
548
552
|
errors: bucket ? bucket[:errors] : 0,
|
data/config/routes.rb
CHANGED
|
@@ -193,6 +193,12 @@ ActionAgent::Engine.routes.draw do
|
|
|
193
193
|
end
|
|
194
194
|
end
|
|
195
195
|
|
|
196
|
+
# The caller's own remote MCP servers, enabled on agents as
|
|
197
|
+
# "custom:<id>". Header values are write-only; test runs tools/list.
|
|
198
|
+
resources :mcp_server_configs, only: [ :index, :create, :update, :destroy ] do
|
|
199
|
+
post :test, on: :member
|
|
200
|
+
end
|
|
201
|
+
|
|
196
202
|
resources :instance_tiers, only: [ :index, :show ] do
|
|
197
203
|
collection do
|
|
198
204
|
get :recommend
|
data/lib/action_agent/engine.rb
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# The class body below reads Zeitwerk's inflector. Rails requires Zeitwerk
|
|
4
|
+
# only when an application sets up its autoloaders, which is after
|
|
5
|
+
# Bundler.require has loaded this file; a host that reaches it earlier does so
|
|
6
|
+
# by chance (Action Cable's engine loads it as a side effect). So the constant
|
|
7
|
+
# has to come from here. Zeitwerk is a dependency of Active Support, so it is
|
|
8
|
+
# always installed.
|
|
9
|
+
require "zeitwerk"
|
|
3
10
|
require_relative "assistant_request_filter"
|
|
4
11
|
|
|
5
12
|
module ActionAgent
|
|
@@ -25,7 +32,10 @@ module ActionAgent
|
|
|
25
32
|
"mcp_tool_dispatcher" => "MCPToolDispatcher",
|
|
26
33
|
"mcp_controller" => "MCPController",
|
|
27
34
|
"mcp_dashboard_tools" => "MCPDashboardTools",
|
|
35
|
+
"mcp_host_guard" => "MCPHostGuard",
|
|
28
36
|
"mcp_recording_middleware" => "MCPRecordingMiddleware",
|
|
37
|
+
"mcp_server_config" => "MCPServerConfig",
|
|
38
|
+
"mcp_server_configs_controller" => "MCPServerConfigsController",
|
|
29
39
|
"mcp_servers_controller" => "MCPServersController",
|
|
30
40
|
"playwright_mcp_client" => "PlaywrightMCPClient"
|
|
31
41
|
}.freeze
|
data/lib/action_agent/version.rb
CHANGED
data/lib/action_agent.rb
CHANGED
|
@@ -887,6 +887,22 @@ module ActionAgent
|
|
|
887
887
|
# @return [Array<Hash>]
|
|
888
888
|
attr_accessor :mcp_catalog
|
|
889
889
|
|
|
890
|
+
# Whether a dashboard user's own MCP server (MCPServerConfig, added on the
|
|
891
|
+
# MCP Services page) may point at a loopback, private, link-local or
|
|
892
|
+
# otherwise non-public address. Off by default: those servers are reached
|
|
893
|
+
# from the dashboard's own network, so a URL such as
|
|
894
|
+
# http://169.254.169.254/ or http://10.0.0.5/admin would let any user make
|
|
895
|
+
# the dashboard call an address only it can reach. MCPHostGuard refuses
|
|
896
|
+
# such a URL when it is saved, and refuses a host name that resolves to
|
|
897
|
+
# one when it is called. Turn it on for local development, where the
|
|
898
|
+
# server under test runs on localhost, or for a single-user install whose
|
|
899
|
+
# user already has that network access.
|
|
900
|
+
#
|
|
901
|
+
# Host registrations (+mcp_catalog+) and checkout sandbox runtimes are the
|
|
902
|
+
# host app's own configuration and are not affected.
|
|
903
|
+
# @return [Boolean]
|
|
904
|
+
attr_accessor :allow_private_mcp_hosts
|
|
905
|
+
|
|
890
906
|
# Host-declared ActiveAgent::SchemaTools subclasses whose generated tools
|
|
891
907
|
# are offered alongside AgentToolbox's built-ins: each generated tool is
|
|
892
908
|
# individually selectable in the agent editor, dispatched by name at
|
|
@@ -1358,6 +1374,7 @@ module ActionAgent
|
|
|
1358
1374
|
@user_menu_links = nil
|
|
1359
1375
|
@sign_in_path = nil
|
|
1360
1376
|
@mcp_catalog = []
|
|
1377
|
+
@allow_private_mcp_hosts = false
|
|
1361
1378
|
@agent_actor_resolver = nil
|
|
1362
1379
|
@schema_tools = nil
|
|
1363
1380
|
@schema_tools_path = "app/agent_tools"
|
|
@@ -317,6 +317,17 @@ ActionAgent.configure do |config|
|
|
|
317
317
|
# Expired sandboxes are stopped by `bin/rails action_agent:sandbox:reap`.
|
|
318
318
|
# Schedule it every few minutes.
|
|
319
319
|
|
|
320
|
+
# ==========================================================================
|
|
321
|
+
# MCP servers
|
|
322
|
+
# ==========================================================================
|
|
323
|
+
#
|
|
324
|
+
# Users add their own remote MCP servers on the MCP Services page. Their
|
|
325
|
+
# URLs may not reach a loopback, private or link-local address unless this
|
|
326
|
+
# is on: those servers are called from the dashboard's own network. Turn it
|
|
327
|
+
# on in development to test a server running on localhost.
|
|
328
|
+
#
|
|
329
|
+
# config.allow_private_mcp_hosts = Rails.env.development?
|
|
330
|
+
|
|
320
331
|
# ==========================================================================
|
|
321
332
|
# UI
|
|
322
333
|
# ==========================================================================
|
data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddEvaluationScoringLeases < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_token, :string
|
|
6
|
+
add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_expires_at, :datetime
|
|
7
|
+
end
|
|
8
|
+
end
|
data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddCatalogScenarioMetadata < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def up
|
|
5
|
+
return unless table_exists?(scenarios_table)
|
|
6
|
+
return if column_exists?(scenarios_table, :metadata)
|
|
7
|
+
|
|
8
|
+
type = connection.adapter_name.to_s.downcase.include?("postgres") ? :jsonb : :json
|
|
9
|
+
add_column scenarios_table, :metadata, type
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def down
|
|
13
|
+
return unless table_exists?(scenarios_table)
|
|
14
|
+
return unless column_exists?(scenarios_table, :metadata)
|
|
15
|
+
|
|
16
|
+
remove_column scenarios_table, :metadata
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def scenarios_table
|
|
22
|
+
"#{ActionAgent.table_name_prefix}catalog_scenarios"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# MCP servers a dashboard user adds on the MCP Services page
|
|
4
|
+
# (ActionAgent::MCPServerConfig): a remote server's URL, its request headers
|
|
5
|
+
# (encrypted at rest, in secret_headers), and what the last connection test
|
|
6
|
+
# found. Each row belongs to an account or a user, as provider keys do.
|
|
7
|
+
#
|
|
8
|
+
# Table names follow ActionAgent.table_name_prefix, and JSON columns are
|
|
9
|
+
# jsonb on PostgreSQL and json elsewhere, the same way as
|
|
10
|
+
# create_active_agent_dashboard_tables.
|
|
11
|
+
class CreateActiveAgentMCPServerConfigs < ActiveRecord::Migration<%= migration_version %>
|
|
12
|
+
def change
|
|
13
|
+
prefix = ActionAgent.table_name_prefix
|
|
14
|
+
|
|
15
|
+
create_table "#{prefix}mcp_server_configs" do |t|
|
|
16
|
+
t.string :name, null: false
|
|
17
|
+
t.text :description
|
|
18
|
+
t.string :url, null: false, limit: 2048
|
|
19
|
+
# A JSON object of header name => value, encrypted.
|
|
20
|
+
t.text :secret_headers
|
|
21
|
+
t.boolean :enabled, null: false, default: true
|
|
22
|
+
t.datetime :last_tested_at
|
|
23
|
+
# "ok" or "failed", and the error of a failed test.
|
|
24
|
+
t.string :last_test_status
|
|
25
|
+
t.text :last_test_error
|
|
26
|
+
# The tool names the last successful test listed.
|
|
27
|
+
t.column :tool_names, json_type, **json_default([])
|
|
28
|
+
t.bigint :account_id
|
|
29
|
+
t.bigint :user_id
|
|
30
|
+
t.timestamps
|
|
31
|
+
t.index :account_id, name: "index_#{prefix}mcp_server_configs_on_account"
|
|
32
|
+
t.index :user_id, name: "index_#{prefix}mcp_server_configs_on_user"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def json_type
|
|
39
|
+
@json_type ||= postgres? ? :jsonb : :json
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# MySQL rejects a default on a JSON column outright, so the column is
|
|
43
|
+
# created without one there.
|
|
44
|
+
def json_default(value, null: nil)
|
|
45
|
+
return {} unless postgres?
|
|
46
|
+
|
|
47
|
+
null.nil? ? { default: value } : { default: value, null: null }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def postgres?
|
|
51
|
+
connection.adapter_name.to_s.downcase.include?("postgres")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddCatalogProductCriteria < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def up
|
|
5
|
+
return unless table_exists?(products_table)
|
|
6
|
+
return if column_exists?(products_table, :criteria)
|
|
7
|
+
|
|
8
|
+
type = connection.adapter_name.to_s.downcase.include?("postgres") ? :jsonb : :json
|
|
9
|
+
add_column products_table, :criteria, type
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def down
|
|
13
|
+
return unless table_exists?(products_table)
|
|
14
|
+
return unless column_exists?(products_table, :criteria)
|
|
15
|
+
|
|
16
|
+
remove_column products_table, :criteria
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def products_table
|
|
22
|
+
"#{ActionAgent.table_name_prefix}scenario_products"
|
|
23
|
+
end
|
|
24
|
+
end
|