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
|
@@ -81,7 +81,9 @@ module ActionAgent
|
|
|
81
81
|
# checkout sandbox the run replayed against, if any: its session id and
|
|
82
82
|
# checkout, never its token. `agent_version` is the version of the
|
|
83
83
|
# agent the run scored, and `version_state` whether that is the agent's
|
|
84
|
-
# current version ("current", "earlier" or "unrecorded").
|
|
84
|
+
# current version ("current", "earlier" or "unrecorded"). `gates` are the
|
|
85
|
+
# per-group pass gates the run was held to (EvaluationRun#gates) and
|
|
86
|
+
# `gates_met` whether it met every one; both nil for a run without bars.
|
|
85
87
|
def run_summary(run, number: nil)
|
|
86
88
|
{
|
|
87
89
|
id: run.id,
|
|
@@ -95,7 +97,9 @@ module ActionAgent
|
|
|
95
97
|
created_at: run.created_at.iso8601,
|
|
96
98
|
sandbox: run.sandbox,
|
|
97
99
|
agent_version: run.agent_version_summary,
|
|
98
|
-
version_state: run.evaluation.standing_info.version_state(run)
|
|
100
|
+
version_state: run.evaluation.standing_info.version_state(run),
|
|
101
|
+
gates: run.gates,
|
|
102
|
+
gates_met: run.gates_met
|
|
99
103
|
}
|
|
100
104
|
end
|
|
101
105
|
|
|
@@ -695,14 +695,16 @@ module ActionAgent
|
|
|
695
695
|
end
|
|
696
696
|
|
|
697
697
|
# The credentials the run's owner holds, scrubbed from the browser
|
|
698
|
-
# actions the run records: provider keys, the GitHub token,
|
|
699
|
-
#
|
|
698
|
+
# actions the run records: provider keys, the GitHub token, the header
|
|
699
|
+
# values of the owner's own MCP servers, and the runtime and browser
|
|
700
|
+
# tokens of the sandbox the run reaches.
|
|
700
701
|
def recording_secrets
|
|
701
702
|
sandbox_id = @run.try(:sandbox_id)
|
|
702
703
|
sandbox = sandbox_id && SandboxSession.for_owner(owner).find_by(session_id: sandbox_id)
|
|
703
704
|
[
|
|
704
705
|
*ProviderKey.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).pluck(:credential, :api_key).flatten,
|
|
705
706
|
*GithubConnection.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).pluck(:access_token),
|
|
707
|
+
*MCPServerConfig.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).flat_map(&:secret_values),
|
|
706
708
|
sandbox&.runtime_mcp_token,
|
|
707
709
|
sandbox&.browser_token,
|
|
708
710
|
*owner_provider_options(requested_provider).values_at(:access_token, :api_key)
|
|
@@ -101,16 +101,16 @@ module ActionAgent
|
|
|
101
101
|
costs = Hash.new(0.0)
|
|
102
102
|
priced = Set.new
|
|
103
103
|
|
|
104
|
-
run_models(windowed_runs).each do |agent_id, model, input, output|
|
|
105
|
-
cost = ModelPricing.estimate(model: model, input_tokens: input, output_tokens: output)
|
|
104
|
+
run_models(windowed_runs).each do |agent_id, model, provider, input, output|
|
|
105
|
+
cost = ModelPricing.estimate(model: model, provider: provider, input_tokens: input, output_tokens: output)
|
|
106
106
|
next unless cost
|
|
107
107
|
|
|
108
108
|
costs[agent_id] += cost
|
|
109
109
|
priced << agent_id
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
trace_models(unclaimed_traces(agent_ids, window_start)).each do |agent_id, model, input, output|
|
|
113
|
-
cost = ModelPricing.estimate(model: model, input_tokens: input, output_tokens: output)
|
|
112
|
+
trace_models(unclaimed_traces(agent_ids, window_start)).each do |agent_id, model, provider, input, output|
|
|
113
|
+
cost = ModelPricing.estimate(model: model, provider: provider, input_tokens: input, output_tokens: output)
|
|
114
114
|
next unless cost
|
|
115
115
|
|
|
116
116
|
costs[agent_id] += cost
|
|
@@ -123,27 +123,31 @@ module ActionAgent
|
|
|
123
123
|
end
|
|
124
124
|
private_class_method :estimated_costs
|
|
125
125
|
|
|
126
|
-
# [agent_id, model, input_tokens, output_tokens] per run. The
|
|
127
|
-
#
|
|
128
|
-
# from SQL; elsewhere the column is read back in
|
|
126
|
+
# [agent_id, model, provider, input_tokens, output_tokens] per run. The
|
|
127
|
+
# model and the provider live in the output_metadata JSON, which only
|
|
128
|
+
# PostgreSQL can reach from SQL; elsewhere the column is read back in
|
|
129
|
+
# Ruby.
|
|
129
130
|
def self.run_models(runs)
|
|
130
131
|
if AgentRun.postgres?
|
|
131
|
-
runs.pluck(:agent_id, Arel.sql("output_metadata ->> 'model'"),
|
|
132
|
+
runs.pluck(:agent_id, Arel.sql("output_metadata ->> 'model'"), Arel.sql("output_metadata ->> 'provider'"),
|
|
133
|
+
:input_tokens, :output_tokens)
|
|
132
134
|
else
|
|
133
135
|
runs.pluck(:agent_id, :output_metadata, :input_tokens, :output_tokens).map do |agent_id, metadata, input, output|
|
|
134
|
-
|
|
136
|
+
metadata = {} unless metadata.is_a?(Hash)
|
|
137
|
+
[ agent_id, metadata["model"], metadata["provider"], input, output ]
|
|
135
138
|
end
|
|
136
139
|
end
|
|
137
140
|
end
|
|
138
141
|
private_class_method :run_models
|
|
139
142
|
|
|
140
|
-
# The same, for reported traces: the model
|
|
143
|
+
# The same, for reported traces: the model and provider are on the
|
|
144
|
+
# first llm span.
|
|
141
145
|
def self.trace_models(traces)
|
|
142
146
|
table = ActionAgent.trace_model.table_name
|
|
143
147
|
|
|
144
|
-
ActionAgent.trace_model.
|
|
148
|
+
ActionAgent.trace_model.pluck_with_llm_model_and_provider(
|
|
145
149
|
traces, Arel.sql("#{table}.agent_id"), :total_input_tokens, :total_output_tokens
|
|
146
|
-
).map { |model, agent_id, input, output| [ agent_id, model, input, output ] }
|
|
150
|
+
).map { |model, provider, agent_id, input, output| [ agent_id, model, provider, input, output ] }
|
|
147
151
|
end
|
|
148
152
|
private_class_method :trace_models
|
|
149
153
|
|
|
@@ -26,8 +26,10 @@ module ActionAgent
|
|
|
26
26
|
# AgentToolbox turns into function schemas at generation time.
|
|
27
27
|
# * **MCP** — never stored on the roster. Computed from the services the
|
|
28
28
|
# agent enables, which is where they are edited. The services are the
|
|
29
|
-
# catalog plus the
|
|
30
|
-
#
|
|
29
|
+
# catalog plus the listings the caller hands in (+runtimes:+): the live
|
|
30
|
+
# checkout sandbox runtimes, which an agent enables under their
|
|
31
|
+
# "sandbox:<id>" keys, and the servers the owner added on the MCP Services
|
|
32
|
+
# page, enabled under their "custom:<id>" keys.
|
|
31
33
|
#
|
|
32
34
|
# Enablement reads the agent's own configuration: +tools+ for the dashboard
|
|
33
35
|
# capabilities and the schema tools, +mcp_servers+ for services and their
|
|
@@ -135,6 +137,7 @@ module ActionAgent
|
|
|
135
137
|
first_party: catalog ? catalog[:first_party] : false,
|
|
136
138
|
known: !catalog.nil?,
|
|
137
139
|
runtime: SandboxSession.runtime_server_key?(key),
|
|
140
|
+
custom: MCPServerConfig.key?(key),
|
|
138
141
|
transport: transport_label(catalog),
|
|
139
142
|
status: service_status(calls, enabled, catalog),
|
|
140
143
|
enabled: enabled,
|
|
@@ -21,7 +21,7 @@ module ActionAgent
|
|
|
21
21
|
after = indexed(@session.verification_run)
|
|
22
22
|
(before.keys | after.keys).map do |key|
|
|
23
23
|
first, last = before[key], after[key]
|
|
24
|
-
{ scenario_key: key[0], model: key[1], change: self.class.change(first, last),
|
|
24
|
+
{ scenario_key: key[0], model: key[1], trial: key[2], change: self.class.change(first, last),
|
|
25
25
|
before: first&.status, after: last&.status, before_score: first&.score&.to_f, after_score: last&.score&.to_f,
|
|
26
26
|
score_change: first&.score && last&.score ? (last.score - first.score).to_f.round(3) : nil,
|
|
27
27
|
before_fault: first&.fault, fault: last&.fault }
|
|
@@ -45,7 +45,8 @@ module ActionAgent
|
|
|
45
45
|
"[Verification run](#{mount}/evaluations/#{base.evaluation_id}/runs/#{@session.verification_run_id})", "",
|
|
46
46
|
"| Scenario / model | Before | After | Score change | Fault |", "| --- | --- | --- | --- | --- |" ]
|
|
47
47
|
rows.each do |row|
|
|
48
|
-
|
|
48
|
+
trial = " (trial #{row[:trial]})" if row[:trial] > 1
|
|
49
|
+
cells = [ "#{row[:scenario_key]} / #{row[:model]}#{trial}", row[:before], row[:after] || "pending", row[:score_change], row[:fault] || "—" ]
|
|
49
50
|
lines << "| #{cells.map { |cell| cell.to_s.gsub('|', '\\|').gsub(/[\r\n]/, ' ') }.join(' | ')} |"
|
|
50
51
|
end
|
|
51
52
|
lines += [ "", "## Session summary", @session.result.to_s, "", "Code session ##{@session.id}. Review the diff and full-suite regressions before merging." ]
|
|
@@ -54,6 +55,9 @@ module ActionAgent
|
|
|
54
55
|
|
|
55
56
|
private
|
|
56
57
|
|
|
58
|
+
# Results by scenario key, model and trial, so a run that replayed each
|
|
59
|
+
# scenario several times compares trial by trial rather than keeping
|
|
60
|
+
# whichever trial was read last.
|
|
57
61
|
def indexed(run)
|
|
58
62
|
return {} unless run
|
|
59
63
|
item = @session.fix_item || {}
|
|
@@ -62,7 +66,7 @@ module ActionAgent
|
|
|
62
66
|
model = [ result.provider, result.model ].compact_blank.join("/")
|
|
63
67
|
next unless Array(item["scenario_keys"]).include?(key) &&
|
|
64
68
|
(Array(item["models"]).include?(model) || Array(item["models"]).include?(result.model))
|
|
65
|
-
index[[ key, model ]] = result
|
|
69
|
+
index[[ key, model, result.trial ]] = result
|
|
66
70
|
end
|
|
67
71
|
end
|
|
68
72
|
end
|
|
@@ -18,7 +18,7 @@ module ActionAgent
|
|
|
18
18
|
# scope (SCOPE_KEYS in its metadata), named for both
|
|
19
19
|
# scenarios — one per reported scenario key, updated to the reported prompt
|
|
20
20
|
# run — one EvaluationRun per tenant and run_id, with one
|
|
21
|
-
# EvaluationScenarioResult per scenario and
|
|
21
|
+
# EvaluationScenarioResult per scenario, model and trial
|
|
22
22
|
#
|
|
23
23
|
# An envelope's `status` is "complete" when absent. A "running" envelope
|
|
24
24
|
# stores the results so far as a running run, and each later delivery under
|
|
@@ -71,6 +71,9 @@ module ActionAgent
|
|
|
71
71
|
|
|
72
72
|
MAX_BYTES = 2.megabytes
|
|
73
73
|
MAX_RESULTS = 1000
|
|
74
|
+
# The highest trial number the collector stores for a result (its
|
|
75
|
+
# `trial`, ActiveAgent::Evals::Result#trial).
|
|
76
|
+
MAX_TRIALS = 1000
|
|
74
77
|
MAX_MODELS = 50
|
|
75
78
|
MAX_TEXT = MAX_BYTES
|
|
76
79
|
# Scenarios one evaluation may hold, and evaluations one agent may hold,
|
|
@@ -92,6 +95,10 @@ module ActionAgent
|
|
|
92
95
|
JUDGE_USAGE_KEYS = %w[calls input_tokens output_tokens cost model by_kind source].freeze
|
|
93
96
|
JUDGE_USAGE_SOURCES = %w[reported traces meter].freeze
|
|
94
97
|
MAX_JUDGE_KINDS = 10
|
|
98
|
+
# Groups a report's `pass_thresholds` may name; a group holds at least
|
|
99
|
+
# one result, so no report needs more.
|
|
100
|
+
MAX_PASS_GROUPS = MAX_RESULTS
|
|
101
|
+
PASS_THRESHOLD_KEYS = %w[threshold min_pass_rate groups].freeze
|
|
95
102
|
SCOPE_PATTERN = %r{\A[\w .:/@-]{1,100}\z}
|
|
96
103
|
STATUSES = %w[passed failed errored].freeze
|
|
97
104
|
# The envelope statuses a delivery may name.
|
|
@@ -613,6 +620,7 @@ module ActionAgent
|
|
|
613
620
|
recommendation: truncated(result["recommendation"], TEXT_BYTES),
|
|
614
621
|
diagnosis: (result["diagnosis"] || {}).merge(
|
|
615
622
|
"_replay_metadata" => result["metadata"] || {},
|
|
623
|
+
"_trial" => trial_of(result),
|
|
616
624
|
"_scenario_snapshot" => {
|
|
617
625
|
"key" => result["scenario_key"], "group" => result["group"], "prompt" => result["prompt"],
|
|
618
626
|
"position" => scenario.position, "expectations" => {}
|
|
@@ -622,6 +630,12 @@ module ActionAgent
|
|
|
622
630
|
)
|
|
623
631
|
end
|
|
624
632
|
|
|
633
|
+
# Which replay of its scenario under its model a result is: 1 for a
|
|
634
|
+
# result that names none (an absent or null `trial`).
|
|
635
|
+
def trial_of(result)
|
|
636
|
+
result["trial"].nil? ? 1 : result["trial"]
|
|
637
|
+
end
|
|
638
|
+
|
|
625
639
|
# A judge usage as the engine stores it: the keys the dashboard reads,
|
|
626
640
|
# each cut to its type, or nil for none.
|
|
627
641
|
def bounded_judge_usage(usage)
|
|
@@ -664,10 +678,21 @@ module ActionAgent
|
|
|
664
678
|
"_verdict" => report["verdict"],
|
|
665
679
|
"_selection" => selection,
|
|
666
680
|
"_metadata" => metadata,
|
|
667
|
-
"_judge_usage_run" => bounded_judge_usage(report.dig("judge_usage", "run"))
|
|
681
|
+
"_judge_usage_run" => bounded_judge_usage(report.dig("judge_usage", "run")),
|
|
682
|
+
"_pass_thresholds" => recorded_pass_thresholds
|
|
668
683
|
}.compact.merge("_judge_label" => judge_label)
|
|
669
684
|
end
|
|
670
685
|
|
|
686
|
+
# The bars the report was gated by, as PassThresholds normalizes them
|
|
687
|
+
# (validate_pass_thresholds! has refused anything else), so the rebuilt
|
|
688
|
+
# report gates the stored results the way the application's did. nil
|
|
689
|
+
# for a report whose suite declared none.
|
|
690
|
+
def recorded_pass_thresholds
|
|
691
|
+
return nil if report["pass_thresholds"].nil?
|
|
692
|
+
|
|
693
|
+
EvaluationRun.pass_thresholds_from(report["pass_thresholds"])&.to_h
|
|
694
|
+
end
|
|
695
|
+
|
|
671
696
|
# The run's scores in the shape the Evaluations view renders
|
|
672
697
|
# (ScenarioEvaluationRunner#scores_for), summarized from the stored
|
|
673
698
|
# results as the application reported them — no cost is estimated here,
|
|
@@ -679,10 +704,14 @@ module ActionAgent
|
|
|
679
704
|
def summarized_scores(run)
|
|
680
705
|
rebuilt = run.to_report(estimate: false)
|
|
681
706
|
comparison = rebuilt.comparison if rebuilt.respond_to?(:comparison)
|
|
707
|
+
# The gates are worked out from the stored results as well: the
|
|
708
|
+
# report's own "gates" are not taken as the verdict.
|
|
709
|
+
gates = rebuilt.gates.map(&:to_h) if rebuilt.respond_to?(:pass_thresholds) && rebuilt.pass_thresholds
|
|
682
710
|
rebuilt.criterion_scores.merge(
|
|
683
711
|
"_models" => rebuilt.summary_by_model,
|
|
684
712
|
"_recommendations" => rebuilt.recommendations,
|
|
685
|
-
"_comparison" => comparison
|
|
713
|
+
"_comparison" => comparison,
|
|
714
|
+
"_gates" => gates
|
|
686
715
|
).compact.merge(recorded_scores)
|
|
687
716
|
end
|
|
688
717
|
|
|
@@ -715,6 +744,7 @@ module ActionAgent
|
|
|
715
744
|
judge_trace_ids!(metadata["judge_trace_ids"])
|
|
716
745
|
string!(report["judge"], "report.judge", 200)
|
|
717
746
|
validate_release!
|
|
747
|
+
validate_pass_thresholds!
|
|
718
748
|
validate_judge_usage!(report.dig("judge_usage", "run"), "report.judge_usage.run") if report["judge_usage"].is_a?(Hash)
|
|
719
749
|
optional_object!(report["judge_usage"], "report.judge_usage")
|
|
720
750
|
object!(report["models"], "report.models")
|
|
@@ -735,7 +765,9 @@ module ActionAgent
|
|
|
735
765
|
object!(result, "result #{index}")
|
|
736
766
|
%w[scenario_key label provider model].each { |key| string!(result[key], "result.#{key}", 200, required: true) }
|
|
737
767
|
raise Invalid, "result label #{result['label']} is missing from report.models" unless report["models"].key?(result["label"])
|
|
738
|
-
|
|
768
|
+
trial = trial_of(result)
|
|
769
|
+
raise Invalid, "result.trial must be an integer from 1 to #{MAX_TRIALS}" unless trial.is_a?(Integer) && trial.between?(1, MAX_TRIALS)
|
|
770
|
+
raise Invalid, "duplicate scenario/model result" unless pairs.add?([ result["scenario_key"], result["label"], trial ])
|
|
739
771
|
|
|
740
772
|
spec = result.values_at("provider", "model")
|
|
741
773
|
raise Invalid, "result label #{result['label']} names more than one provider/model" if label_specs.fetch(result["label"], spec) != spec
|
|
@@ -844,6 +876,33 @@ module ActionAgent
|
|
|
844
876
|
string!(release["label"], "report.release.label", 200)
|
|
845
877
|
end
|
|
846
878
|
|
|
879
|
+
# The bars the suite declared, as Report#to_h writes them: "threshold"
|
|
880
|
+
# and "min_pass_rate" from 0 to 1, and "groups", each group's
|
|
881
|
+
# "min_pass_rate" and "gated", by group key. Checked by PassThresholds
|
|
882
|
+
# itself, so the import refuses what the framework would.
|
|
883
|
+
def validate_pass_thresholds!
|
|
884
|
+
bars = report["pass_thresholds"]
|
|
885
|
+
return if bars.nil?
|
|
886
|
+
|
|
887
|
+
object!(bars, "report.pass_thresholds")
|
|
888
|
+
unknown = bars.keys - PASS_THRESHOLD_KEYS
|
|
889
|
+
raise Invalid, "report.pass_thresholds has unknown keys #{unknown.join(', ')}" if unknown.any?
|
|
890
|
+
|
|
891
|
+
groups = bars["groups"]
|
|
892
|
+
optional_object!(groups, "report.pass_thresholds.groups")
|
|
893
|
+
if groups && groups.size > MAX_PASS_GROUPS
|
|
894
|
+
raise Invalid, "report.pass_thresholds.groups must name at most #{MAX_PASS_GROUPS} groups"
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
(groups || {}).each_key { |key| string!(key, "report.pass_thresholds.groups key", 200) }
|
|
898
|
+
# A framework without per-group bars stores none, as before.
|
|
899
|
+
return unless defined?(ActiveAgent::Evals::PassThresholds)
|
|
900
|
+
|
|
901
|
+
ActiveAgent::Evals::PassThresholds.new(suite: bars.slice(*ActiveAgent::Evals::PassThresholds::SUITE_KEYS), groups: groups || {})
|
|
902
|
+
rescue ArgumentError => e
|
|
903
|
+
raise Invalid, "report.pass_thresholds: #{e.message}"
|
|
904
|
+
end
|
|
905
|
+
|
|
847
906
|
# What the judge spent, as ActiveAgent::Evals::Report writes it per
|
|
848
907
|
# result and for the run.
|
|
849
908
|
def validate_judge_usage!(usage, name)
|
|
@@ -542,14 +542,14 @@ module ActionAgent
|
|
|
542
542
|
return {} if scope.nil?
|
|
543
543
|
|
|
544
544
|
table = ActionAgent.trace_model.table_name
|
|
545
|
-
rows = ActionAgent.trace_model.
|
|
545
|
+
rows = ActionAgent.trace_model.pluck_with_llm_model_and_provider(
|
|
546
546
|
scope.where(trace_id: ids),
|
|
547
547
|
Arel.sql("#{table}.trace_id"), Arel.sql("#{table}.total_input_tokens"), Arel.sql("#{table}.total_output_tokens"),
|
|
548
548
|
Arel.sql("#{table}.agent_action")
|
|
549
549
|
)
|
|
550
|
-
rows.to_h do |model, trace_id, input, output, action|
|
|
550
|
+
rows.to_h do |model, provider, trace_id, input, output, action|
|
|
551
551
|
[ trace_id.to_s, Trace.new(trace_id: trace_id.to_s, input_tokens: input.to_i, output_tokens: output.to_i,
|
|
552
|
-
model: model, provider: provider_of(model), action: action) ]
|
|
552
|
+
model: model, provider: provider.presence || provider_of(model), action: action) ]
|
|
553
553
|
end
|
|
554
554
|
rescue StandardError => e
|
|
555
555
|
Rails.logger.warn("[ActionAgent] evaluation run #{@run.id} trace pricing failed: #{e.class}: #{e.message}")
|
|
@@ -565,8 +565,9 @@ module ActionAgent
|
|
|
565
565
|
|
|
566
566
|
private
|
|
567
567
|
|
|
568
|
-
#
|
|
569
|
-
#
|
|
568
|
+
# For a trace whose llm span recorded no provider: a gateway's model
|
|
569
|
+
# names carry the vendor; the model's own name does not say which
|
|
570
|
+
# provider served it, which ModelPricing works around.
|
|
570
571
|
def provider_of(model)
|
|
571
572
|
head, rest = model.to_s.split("/", 2)
|
|
572
573
|
rest.present? && ModelPricing::GATEWAY_PREFIXES.include?(head.downcase) ? head.downcase : nil
|
|
@@ -11,15 +11,18 @@ module ActionAgent
|
|
|
11
11
|
# explicit +mcp__server__tool+ namespace wins because the name said so,
|
|
12
12
|
# then MCPCatalog's hints for the bare names of well-known servers, then
|
|
13
13
|
# the servers the agent itself declares in +mcp_servers+ when one of them
|
|
14
|
-
# lists the tool
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# lists the tool, then the servers the agent's owner added on the MCP
|
|
15
|
+
# Services page (MCPServerConfig, "custom:<id>"), by the tools their last
|
|
16
|
+
# connection test listed. Nothing here reads telemetry: the resolver runs
|
|
17
|
+
# inside the request that serializes a run, and every lookup is a constant,
|
|
18
|
+
# a single agent attribute, or one query for the owner's configurations.
|
|
17
19
|
#
|
|
18
20
|
# The status is what the fix item's action turns on:
|
|
19
21
|
#
|
|
20
22
|
# "enabled" — the agent's mcp_servers configuration names the server
|
|
21
|
-
# "available" — the catalog (built-in or ActionAgent.mcp_catalog)
|
|
22
|
-
# the server and the
|
|
23
|
+
# "available" — the catalog (built-in or ActionAgent.mcp_catalog) or the
|
|
24
|
+
# owner's enabled configurations know the server, and the
|
|
25
|
+
# agent has not enabled it
|
|
23
26
|
# nil — the namespace named a server nothing here knows; the
|
|
24
27
|
# report renders that as "unknown"
|
|
25
28
|
#
|
|
@@ -60,14 +63,15 @@ module ActionAgent
|
|
|
60
63
|
|
|
61
64
|
ActiveAgent::Telemetry::ToolOrigin.server_for(name).presence ||
|
|
62
65
|
MCPCatalog.server_for_tool(name).presence ||
|
|
63
|
-
configured_tools[name]
|
|
66
|
+
configured_tools[name] ||
|
|
67
|
+
custom_tools[name]
|
|
64
68
|
end
|
|
65
69
|
|
|
66
70
|
# @param key [String] a server key
|
|
67
71
|
# @return [String, nil] ENABLED, AVAILABLE, or nil when unknown
|
|
68
72
|
def status_for(key)
|
|
69
73
|
return ENABLED if configured_keys.include?(normalize(key))
|
|
70
|
-
return AVAILABLE if MCPCatalog.find(key)
|
|
74
|
+
return AVAILABLE if MCPCatalog.find(key) || custom_config(key)
|
|
71
75
|
|
|
72
76
|
nil
|
|
73
77
|
end
|
|
@@ -105,6 +109,7 @@ module ActionAgent
|
|
|
105
109
|
# (which is what MCPCatalog.display_name falls back to as well).
|
|
106
110
|
def display_name_for(key)
|
|
107
111
|
return MCPCatalog.display_name(key) if MCPCatalog.find(key)
|
|
112
|
+
return custom_config(key).name if custom_config(key)
|
|
108
113
|
|
|
109
114
|
configured_names[normalize(key)] || MCPCatalog.display_name(key)
|
|
110
115
|
end
|
|
@@ -145,6 +150,29 @@ module ActionAgent
|
|
|
145
150
|
end
|
|
146
151
|
end
|
|
147
152
|
|
|
153
|
+
# The agent's owner's enabled MCPServerConfig rows, read once. None
|
|
154
|
+
# without an agent: there is no owner to scope them to.
|
|
155
|
+
def custom_configs
|
|
156
|
+
@custom_configs ||= agent ? MCPServerConfig.for_owner(agent.try(:owner)).enabled.order(:id).to_a : []
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def custom_config(key)
|
|
160
|
+
return nil unless MCPServerConfig.key?(key)
|
|
161
|
+
|
|
162
|
+
custom_configs.find { |config| config.server_key == key.to_s.strip }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# bare tool name => "custom:<id>", from the tools each of the owner's
|
|
166
|
+
# configurations listed when last tested. A configuration the agent
|
|
167
|
+
# enables claims a name before one it does not.
|
|
168
|
+
def custom_tools
|
|
169
|
+
@custom_tools ||= custom_configs
|
|
170
|
+
.sort_by { |config| [ configured_keys.include?(config.server_key) ? 0 : 1, config.id ] }
|
|
171
|
+
.each_with_object({}) do |config, map|
|
|
172
|
+
config.tool_names_list.each { |tool| map[tool] ||= config.server_key }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
148
176
|
# The agent's mcp_servers as a list of entries. Agents store an Array of
|
|
149
177
|
# bare names or builder hashes, but an agent seeded from an older
|
|
150
178
|
# template carries a top-level Hash keyed by server name
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "resolv"
|
|
5
6
|
require "uri"
|
|
6
7
|
require "json"
|
|
@@ -17,12 +18,24 @@ module ActionAgent
|
|
|
17
18
|
|
|
18
19
|
class Error < StandardError; end
|
|
19
20
|
|
|
21
|
+
# Failures to reach the server at all, raised as Error so a caller
|
|
22
|
+
# handles one exception for a server that cannot be called.
|
|
23
|
+
NETWORK_ERRORS = [
|
|
24
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
|
|
25
|
+
Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, SocketError
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
20
28
|
# +headers+ are sent with every request: a checkout sandbox's runtime
|
|
21
29
|
# authenticates its MCP facade with a bearer token.
|
|
22
|
-
|
|
30
|
+
#
|
|
31
|
+
# +public_only+ refuses a host that resolves to anything but public
|
|
32
|
+
# addresses (MCPHostGuard#public_addresses!), and connects to the address
|
|
33
|
+
# that was checked. A URL a dashboard user supplied is called this way.
|
|
34
|
+
def initialize(url:, label: nil, headers: {}, public_only: false)
|
|
23
35
|
@uri = URI(url)
|
|
24
36
|
@label = label.presence || @uri.host
|
|
25
37
|
@headers = headers.to_h
|
|
38
|
+
@public_only = public_only
|
|
26
39
|
@mutex = Mutex.new
|
|
27
40
|
end
|
|
28
41
|
|
|
@@ -40,6 +53,8 @@ module ActionAgent
|
|
|
40
53
|
parameters: tool["inputSchema"] || { type: "object", properties: {} }
|
|
41
54
|
}
|
|
42
55
|
end
|
|
56
|
+
rescue *NETWORK_ERRORS => e
|
|
57
|
+
raise Error, "MCP server #{@label} unreachable at #{@uri} (#{e.class})"
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# Returns { text:, is_error: } — the tool result's text content.
|
|
@@ -63,7 +78,7 @@ module ActionAgent
|
|
|
63
78
|
|
|
64
79
|
text = Array(result["content"]).filter_map { |block| block["text"] }.join("\n")
|
|
65
80
|
{ text: text, is_error: result["isError"] ? true : false }
|
|
66
|
-
rescue
|
|
81
|
+
rescue *NETWORK_ERRORS => e
|
|
67
82
|
raise Error, "MCP server #{@label} unreachable at #{@uri} (#{e.class})"
|
|
68
83
|
end
|
|
69
84
|
|
|
@@ -101,6 +116,10 @@ module ActionAgent
|
|
|
101
116
|
# fiber, where Net::HTTP reads of SSE bodies misbehave (headers arrive,
|
|
102
117
|
# body comes back empty). A dedicated thread always does real blocking
|
|
103
118
|
# IO outside any fiber/scheduler context.
|
|
119
|
+
#
|
|
120
|
+
# The address is resolved first, on the caller's thread, so a host the
|
|
121
|
+
# guard refuses raises here rather than inside the thread.
|
|
122
|
+
connect_address
|
|
104
123
|
Thread.new { blocking_post_raw(payload, session: session) }.value
|
|
105
124
|
end
|
|
106
125
|
|
|
@@ -111,8 +130,8 @@ module ActionAgent
|
|
|
111
130
|
# Container->host bridge hostnames (host.orb.internal) publish an IPv6
|
|
112
131
|
# address whose path doesn't reach the server; dual-stack connects then
|
|
113
132
|
# fail intermittently. Pin to IPv4 while keeping the Host header.
|
|
114
|
-
if (
|
|
115
|
-
http.ipaddr =
|
|
133
|
+
if (address = connect_address)
|
|
134
|
+
http.ipaddr = address
|
|
116
135
|
end
|
|
117
136
|
http.open_timeout = OPEN_TIMEOUT_SECONDS
|
|
118
137
|
http.read_timeout = READ_TIMEOUT_SECONDS
|
|
@@ -161,6 +180,19 @@ module ActionAgent
|
|
|
161
180
|
{}
|
|
162
181
|
end
|
|
163
182
|
|
|
183
|
+
# The address to connect to. For a public-only client, a checked public
|
|
184
|
+
# address (IPv4 first), resolved once so every request of this client
|
|
185
|
+
# reaches the address that was checked; otherwise the host's IPv4
|
|
186
|
+
# address when it has one.
|
|
187
|
+
def connect_address
|
|
188
|
+
return ipv4_address unless @public_only
|
|
189
|
+
|
|
190
|
+
@checked_address ||= begin
|
|
191
|
+
addresses = MCPHostGuard.public_addresses!(@uri.hostname)
|
|
192
|
+
addresses.find { |address| address =~ Resolv::IPv4::Regex } || addresses.first
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
164
196
|
def ipv4_address
|
|
165
197
|
return @ipv4_address if defined?(@ipv4_address)
|
|
166
198
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "resolv"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module ActionAgent
|
|
8
|
+
# Decides whether the dashboard may call a user-supplied MCP server URL
|
|
9
|
+
# (MCPServerConfig). Those servers are reached from the dashboard's own
|
|
10
|
+
# network, so without a check any user could point one at an address only
|
|
11
|
+
# the dashboard can reach: a cloud metadata endpoint, a database admin page
|
|
12
|
+
# on the private network, the host's own loopback.
|
|
13
|
+
#
|
|
14
|
+
# The check runs twice:
|
|
15
|
+
#
|
|
16
|
+
# * When a configuration is saved, #url_refusal reads the URL alone, with
|
|
17
|
+
# no DNS lookup: the scheme is http or https, the URL carries no user
|
|
18
|
+
# name or password, and its host is not a literal non-public address or a
|
|
19
|
+
# localhost name.
|
|
20
|
+
# * When MCPClient connects (+public_only: true+), #public_addresses!
|
|
21
|
+
# resolves the host and refuses it unless every address it resolves to
|
|
22
|
+
# is public. The client then connects to the address that was checked,
|
|
23
|
+
# so a name whose DNS answer changes between the check and the connection
|
|
24
|
+
# still reaches only a checked address.
|
|
25
|
+
#
|
|
26
|
+
# ActionAgent.allow_private_mcp_hosts switches both off, for local
|
|
27
|
+
# development against a server on localhost.
|
|
28
|
+
class MCPHostGuard
|
|
29
|
+
SCHEMES = %w[http https].freeze
|
|
30
|
+
|
|
31
|
+
# Addresses no public server is reached at, beyond what IPAddr already
|
|
32
|
+
# names (loopback?, private?, link_local?): the unspecified "this host"
|
|
33
|
+
# ranges, carrier-grade NAT, benchmarking, multicast and reserved space.
|
|
34
|
+
NON_PUBLIC_RANGES = %w[
|
|
35
|
+
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4
|
|
36
|
+
::/128 ff00::/8
|
|
37
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
38
|
+
|
|
39
|
+
# Host names that resolve to the machine itself however DNS answers.
|
|
40
|
+
LOCAL_NAMES = /\A(?:localhost|.+\.localhost|localhost\.localdomain)\z/i
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Resolves a host name to its addresses. Replaceable so tests need no
|
|
44
|
+
# network.
|
|
45
|
+
# @return [#call]
|
|
46
|
+
attr_writer :resolver
|
|
47
|
+
|
|
48
|
+
def resolver
|
|
49
|
+
@resolver || Resolv.method(:getaddresses)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether private addresses are allowed (ActionAgent.allow_private_mcp_hosts).
|
|
53
|
+
def allow_private?
|
|
54
|
+
ActionAgent.allow_private_mcp_hosts ? true : false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Why +url+ cannot be saved as an MCP server URL, or nil when it can.
|
|
58
|
+
# Reads the URL alone and resolves nothing.
|
|
59
|
+
#
|
|
60
|
+
# @param url [String]
|
|
61
|
+
# @param allow_private [Boolean]
|
|
62
|
+
# @return [String, nil]
|
|
63
|
+
def url_refusal(url, allow_private: allow_private?)
|
|
64
|
+
uri = URI.parse(url.to_s.strip)
|
|
65
|
+
return "must be an http:// or https:// URL" unless SCHEMES.include?(uri.scheme.to_s.downcase)
|
|
66
|
+
return "must name a host" if uri.hostname.blank?
|
|
67
|
+
return "must not carry a user name or password; send credentials in a header instead" if uri.userinfo.present?
|
|
68
|
+
return nil if allow_private
|
|
69
|
+
|
|
70
|
+
host = uri.hostname
|
|
71
|
+
return "must not point at localhost" if LOCAL_NAMES.match?(host)
|
|
72
|
+
|
|
73
|
+
address = literal_address(host)
|
|
74
|
+
return "must not point at a private, loopback or link-local address" if address && !public_address?(address)
|
|
75
|
+
|
|
76
|
+
nil
|
|
77
|
+
rescue URI::InvalidURIError
|
|
78
|
+
"is not a valid URL"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The addresses +host+ resolves to, every one of them public.
|
|
82
|
+
#
|
|
83
|
+
# @param host [String]
|
|
84
|
+
# @return [Array<String>]
|
|
85
|
+
# @raise [MCPClient::Error] when the host does not resolve, or resolves
|
|
86
|
+
# to any non-public address
|
|
87
|
+
def public_addresses!(host)
|
|
88
|
+
addresses = Array(resolver.call(host.to_s)).map(&:to_s).uniq
|
|
89
|
+
raise MCPClient::Error, "#{host} does not resolve to an address" if addresses.empty?
|
|
90
|
+
|
|
91
|
+
refused = addresses.reject { |address| public_address?(address) }
|
|
92
|
+
if refused.any?
|
|
93
|
+
raise MCPClient::Error,
|
|
94
|
+
"#{host} resolves to a private, loopback or link-local address (#{refused.first}); " \
|
|
95
|
+
"set ActionAgent.allow_private_mcp_hosts to reach it"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
addresses
|
|
99
|
+
rescue Resolv::ResolvError => e
|
|
100
|
+
raise MCPClient::Error, "#{host} does not resolve: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Whether +address+ (an IP address string or IPAddr) is publicly
|
|
104
|
+
# routable. An IPv4 address written as IPv6 (::ffff:127.0.0.1) is read
|
|
105
|
+
# as the IPv4 address it carries. Anything that does not parse is not
|
|
106
|
+
# public.
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def public_address?(address)
|
|
110
|
+
ip = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
|
|
111
|
+
ip = ip.native
|
|
112
|
+
return false if ip.loopback? || ip.private? || ip.link_local?
|
|
113
|
+
|
|
114
|
+
NON_PUBLIC_RANGES.none? { |range| range.family == ip.family && range.include?(ip) }
|
|
115
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# +host+ as an IPAddr when it is an IP literal, nil when it is a name.
|
|
122
|
+
def literal_address(host)
|
|
123
|
+
IPAddr.new(host)
|
|
124
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|