actionagent 1.9.5 → 1.9.7
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 +62 -61
- 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/projects_controller.rb +29 -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/api/sandboxes_controller.rb +31 -6
- 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/catalog_scenario.rb +8 -2
- data/app/models/action_agent/evaluation_run.rb +11 -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/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/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 +14 -2
- 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 +2 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +69 -13
- 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
- metadata +8 -2
|
@@ -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,
|
|
@@ -613,6 +616,7 @@ module ActionAgent
|
|
|
613
616
|
recommendation: truncated(result["recommendation"], TEXT_BYTES),
|
|
614
617
|
diagnosis: (result["diagnosis"] || {}).merge(
|
|
615
618
|
"_replay_metadata" => result["metadata"] || {},
|
|
619
|
+
"_trial" => trial_of(result),
|
|
616
620
|
"_scenario_snapshot" => {
|
|
617
621
|
"key" => result["scenario_key"], "group" => result["group"], "prompt" => result["prompt"],
|
|
618
622
|
"position" => scenario.position, "expectations" => {}
|
|
@@ -622,6 +626,12 @@ module ActionAgent
|
|
|
622
626
|
)
|
|
623
627
|
end
|
|
624
628
|
|
|
629
|
+
# Which replay of its scenario under its model a result is: 1 for a
|
|
630
|
+
# result that names none (an absent or null `trial`).
|
|
631
|
+
def trial_of(result)
|
|
632
|
+
result["trial"].nil? ? 1 : result["trial"]
|
|
633
|
+
end
|
|
634
|
+
|
|
625
635
|
# A judge usage as the engine stores it: the keys the dashboard reads,
|
|
626
636
|
# each cut to its type, or nil for none.
|
|
627
637
|
def bounded_judge_usage(usage)
|
|
@@ -735,7 +745,9 @@ module ActionAgent
|
|
|
735
745
|
object!(result, "result #{index}")
|
|
736
746
|
%w[scenario_key label provider model].each { |key| string!(result[key], "result.#{key}", 200, required: true) }
|
|
737
747
|
raise Invalid, "result label #{result['label']} is missing from report.models" unless report["models"].key?(result["label"])
|
|
738
|
-
|
|
748
|
+
trial = trial_of(result)
|
|
749
|
+
raise Invalid, "result.trial must be an integer from 1 to #{MAX_TRIALS}" unless trial.is_a?(Integer) && trial.between?(1, MAX_TRIALS)
|
|
750
|
+
raise Invalid, "duplicate scenario/model result" unless pairs.add?([ result["scenario_key"], result["label"], trial ])
|
|
739
751
|
|
|
740
752
|
spec = result.values_at("provider", "model")
|
|
741
753
|
raise Invalid, "result label #{result['label']} names more than one provider/model" if label_specs.fetch(result["label"], spec) != spec
|
|
@@ -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
|
|
@@ -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
|
|
@@ -213,6 +213,8 @@ module ActionAgent
|
|
|
213
213
|
prompt: scenario.prompt, notes: scenario.notes, expectations: scenario.expectations,
|
|
214
214
|
tags: scenario.tags, params: scenario.params, production_only: scenario.production_only?
|
|
215
215
|
)
|
|
216
|
+
# Until add_catalog_scenario_metadata runs, imports keep working without extensions.
|
|
217
|
+
record.metadata = scenario.metadata if record.has_attribute?(:metadata)
|
|
216
218
|
record.position = position_for(record, scenario.position, offset)
|
|
217
219
|
record.save!
|
|
218
220
|
end
|