actionagent 1.3.0 → 1.5.0

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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +70 -48
  4. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  5. data/app/controllers/action_agent/api/agents_controller.rb +116 -9
  6. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
  7. data/app/controllers/action_agent/api/evaluations_controller.rb +22 -7
  8. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  9. data/app/controllers/action_agent/dashboard_controller.rb +1 -0
  10. data/app/models/action_agent/agent.rb +60 -18
  11. data/app/models/action_agent/agent_run.rb +99 -0
  12. data/app/models/action_agent/evaluation_run.rb +14 -6
  13. data/app/models/action_agent/evaluation_scenario_result.rb +24 -4
  14. data/app/models/action_agent/telemetry_trace.rb +16 -1
  15. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  16. data/app/services/action_agent/agent_execution_service.rb +294 -16
  17. data/app/services/action_agent/agent_registrar.rb +7 -6
  18. data/app/services/action_agent/agent_toolbox.rb +45 -3
  19. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  20. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  21. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  22. data/app/services/action_agent/evaluation_tool_resolver.rb +10 -2
  23. data/app/services/action_agent/mcp_client.rb +167 -0
  24. data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
  25. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  26. data/app/services/action_agent/scenario_evaluation_runner.rb +49 -15
  27. data/config/routes.rb +11 -1
  28. data/lib/action_agent/assistant_request_filter.rb +22 -0
  29. data/lib/action_agent/engine.rb +5 -0
  30. data/lib/action_agent/version.rb +1 -1
  31. data/lib/action_agent.rb +32 -0
  32. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  33. metadata +10 -6
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Read-only, owner-scoped facts for the dashboard assistant. Cards and links
5
+ # come from records, not from model output. Existing runs lack repository and
6
+ # immutable rubric provenance, so none can establish current-branch behavior.
7
+ class EvaluationEvidence
8
+ MAX_EVALUATIONS = 20
9
+ MAX_CANDIDATES = 10
10
+ MAX_SCAN_RESULTS = 200
11
+ MAX_REPORT_RESULTS = 20
12
+ TEXT_LIMIT = 1200
13
+ SCORE_LIMIT = 20
14
+
15
+ PROVENANCE_CAVEAT = "Historical evidence only: repository, tested commit, tool contract, and fixture snapshots were not recorded. This does not verify current main."
16
+ RUBRIC_CAVEAT = "Scenario expectations and evaluation criteria are mutable; their current definitions are not a snapshot of the checks used for this result."
17
+ REPORT_CAVEAT = "The linked report may display current scenario text and configuration. This card uses the recorded replay prompt when available."
18
+ WEAK_CHECK_CAVEAT = "Recorded scores show only response shape or runtime checks; they do not establish answer correctness."
19
+ CONTEXT_CAVEAT = "No matching recorded replay from this evaluation's agent supplies prompt context."
20
+ REDACTED_ERROR = "Recorded error details withheld because they may contain credentials. Open the report for details."
21
+
22
+ SHAPE_SCORE_KEYS = %w[response_present response_length min_length latency max_latency_ms token_budget tools_succeeded].freeze
23
+ EXPECTATION_SCORE_KEYS = %w[expected_tools expected_content forbidden_content].freeze
24
+
25
+ def initialize(owner:)
26
+ @agents = ActionAgent.agents_for(owner)
27
+ end
28
+
29
+ def list_evaluations(agent_id: nil, query: nil, limit: MAX_EVALUATIONS)
30
+ limit = bounded_limit(limit, MAX_EVALUATIONS)
31
+ scope = evaluations_scope(agent_id: agent_id)
32
+ if query.present?
33
+ pattern = "%#{Evaluation.sanitize_sql_like(query.to_s.first(200).downcase, '!')}%"
34
+ scope = scope.where("LOWER(#{Evaluation.quoted_table_name}.name) LIKE ? ESCAPE '!'", pattern)
35
+ end
36
+ rows = scope.includes(:agent).order(updated_at: :desc, id: :desc).limit(limit + 1).to_a
37
+ evaluations = rows.first(limit)
38
+ latest = latest_runs(evaluations.map(&:id))
39
+ report_runs = EvaluationScenarioResult.where(evaluation_run_id: latest.values.map(&:id)).distinct.pluck(:evaluation_run_id)
40
+
41
+ {
42
+ cards: evaluations.map do |evaluation|
43
+ run = latest[evaluation.id]
44
+ {
45
+ id: "evaluation-#{evaluation.id}", type: "evaluation", title: text(evaluation.name),
46
+ evaluation_id: evaluation.id, agent_id: evaluation.agent_id, agent_name: text(evaluation.agent.name),
47
+ path: "/evaluations", latest_run: run && run_summary(run, report: report_runs.include?(run.id)),
48
+ caveats: [ PROVENANCE_CAVEAT, RUBRIC_CAVEAT ]
49
+ }
50
+ end,
51
+ coverage: { returned: evaluations.size, limit: limit, truncated: rows.size > limit },
52
+ caveats: [ PROVENANCE_CAVEAT ]
53
+ }
54
+ end
55
+
56
+ def find_demo_candidates(agent_id: nil, evaluation_id: nil, limit: MAX_CANDIDATES)
57
+ limit = bounded_limit(limit, MAX_CANDIDATES)
58
+ evaluations = evaluations_scope(agent_id: agent_id, evaluation_id: evaluation_id)
59
+ scope = terminal_results(evaluations)
60
+ # Read newest attempts first, including failures. Filtering to passed
61
+ # before deduplication would resurrect a pass superseded by a regression.
62
+ rows = scope.preload(:agent_run, evaluation_run: { evaluation: :agent })
63
+ .limit(MAX_SCAN_RESULTS + 1).to_a
64
+ scanned = rows.first(MAX_SCAN_RESULTS)
65
+ latest = scanned.uniq { |result| [ result.evaluation_scenario_id, result.provider, result.model ] }
66
+ candidates = latest.select { |result| candidate?(result) }
67
+
68
+ {
69
+ cards: candidates.first(limit).map { |result| result_card(result, type: "demo_candidate") },
70
+ coverage: {
71
+ scanned: scanned.size, scan_limit: MAX_SCAN_RESULTS, cohorts: latest.size,
72
+ eligible: candidates.size, returned: [ candidates.size, limit ].min, limit: limit,
73
+ truncated: rows.size > MAX_SCAN_RESULTS || candidates.size > limit
74
+ },
75
+ caveats: [ PROVENANCE_CAVEAT, RUBRIC_CAVEAT,
76
+ "Search covers the newest recorded terminal results within the scan limit; untested scenarios and runs without results provide no passing evidence." ]
77
+ }
78
+ end
79
+
80
+ def read_evaluation_run(evaluation_id:, run_id:)
81
+ evaluation = evaluations_scope(evaluation_id: evaluation_id).includes(:agent).first!
82
+ run = evaluation.evaluation_runs.find(run_id)
83
+ # A long suite's first questions may all pass. Keep failures visible in
84
+ # the bounded excerpt, then pending and successful cases, rather than
85
+ # making an error late in the suite impossible for the assistant to read.
86
+ rows = run.scenario_results.preload(:agent_run).order(result_priority, :id).limit(MAX_REPORT_RESULTS + 1).to_a
87
+ results = rows.first(MAX_REPORT_RESULTS)
88
+ # Avoid fetching the same parent and agent for every result card.
89
+ results.each { |result| result.association(:evaluation_run).target = run }
90
+ run.association(:evaluation).target = evaluation
91
+
92
+ {
93
+ cards: [ run_summary(run, report: results.any?).merge(
94
+ id: "evaluation-run-#{run.id}", type: "evaluation_run", title: "#{text(evaluation.name)} · run #{run.id}",
95
+ agent_id: evaluation.agent_id, agent_name: text(evaluation.agent.name),
96
+ caveats: [ PROVENANCE_CAVEAT, RUBRIC_CAVEAT, REPORT_CAVEAT ]
97
+ ) ] + results.map { |result| result_card(result, type: "evaluation_result") },
98
+ coverage: {
99
+ scanned: results.size, limit: MAX_REPORT_RESULTS, truncated: rows.size > MAX_REPORT_RESULTS,
100
+ selection: "failures_first", recorded_status_counts: run.scenario_results.group(:status).count
101
+ },
102
+ caveats: [ PROVENANCE_CAVEAT, RUBRIC_CAVEAT, REPORT_CAVEAT ]
103
+ }
104
+ end
105
+
106
+ private
107
+
108
+ def evaluations_scope(agent_id: nil, evaluation_id: nil)
109
+ agents = agent_id.present? ? @agents.where(id: @agents.find(agent_id).id) : @agents
110
+ scope = Evaluation.where(agent_id: agents.select(:id))
111
+ return scope if evaluation_id.blank?
112
+
113
+ scope.where(id: scope.find(evaluation_id).id)
114
+ end
115
+
116
+ def latest_runs(evaluation_ids)
117
+ return {} if evaluation_ids.empty?
118
+
119
+ table = EvaluationRun.quoted_table_name
120
+ EvaluationRun.where(evaluation_id: evaluation_ids).where(
121
+ "#{table}.id = (SELECT latest.id FROM #{table} latest WHERE latest.evaluation_id = #{table}.evaluation_id ORDER BY latest.created_at DESC, latest.id DESC LIMIT 1)"
122
+ ).index_by(&:evaluation_id)
123
+ end
124
+
125
+ def terminal_results(evaluations)
126
+ runs = EvaluationRun.where(evaluation_id: evaluations.select(:id), status: [ :complete, :failed ])
127
+ run_table = EvaluationRun.arel_table
128
+ EvaluationScenarioResult.joins(:evaluation_run)
129
+ .where(evaluation_run_id: runs.select(:id), status: [ :passed, :failed, :errored ])
130
+ .order(run_table[:created_at].desc, run_table[:id].desc, EvaluationScenarioResult.arel_table[:id].desc)
131
+ end
132
+
133
+ def result_priority
134
+ statuses = EvaluationScenarioResult.statuses
135
+ Arel::Nodes::Case.new(EvaluationScenarioResult.arel_table[:status])
136
+ .when(statuses.fetch("errored")).then(0)
137
+ .when(statuses.fetch("failed")).then(1)
138
+ .when(statuses.fetch("pending")).then(2)
139
+ .else(3)
140
+ end
141
+
142
+ def linked_replay(result)
143
+ replay = result.agent_run
144
+ return unless replay && replay.agent_id == result.evaluation_run.evaluation.agent_id
145
+ return if replay.input_prompt.blank?
146
+
147
+ replay
148
+ end
149
+
150
+ def candidate?(result)
151
+ replay = linked_replay(result)
152
+ result.passed? && result.output.present? && result.error_message.blank? && replay&.complete? &&
153
+ result.provider != "mock" && replay.output_metadata&.dig("provider") != "mock"
154
+ end
155
+
156
+ def result_card(result, type:)
157
+ run = result.evaluation_run
158
+ replay = linked_replay(result)
159
+ strength = check_strength(result)
160
+ caveats = [ PROVENANCE_CAVEAT, RUBRIC_CAVEAT, REPORT_CAVEAT ]
161
+ caveats << CONTEXT_CAVEAT unless replay
162
+ caveats << "The replay did not record its instructions; the current agent instructions are not historical evidence." if replay && instructions_digest(replay).nil?
163
+ caveats << "This evaluation run did not complete successfully; this result covers only its own recorded replay." unless run.complete?
164
+ caveats << "The mock provider produces simulated test output, not real model evidence." if result.provider == "mock" || replay&.output_metadata&.dig("provider") == "mock"
165
+ caveats << WEAK_CHECK_CAVEAT if strength == "shape_or_runtime_checks_only"
166
+ caveats << "Recorded score names do not establish the rubric's meaning or strength." if strength == "unknown_rubric"
167
+
168
+ {
169
+ id: "evaluation-result-#{result.id}", type: type,
170
+ title: replay ? text(replay.input_prompt) : "Recorded result #{result.id}",
171
+ evaluation_id: run.evaluation_id, run_id: run.id, result_id: result.id,
172
+ agent_id: run.evaluation.agent_id, agent_name: text(run.evaluation.agent.name),
173
+ agent_run_id: replay&.id, replay_status: replay&.status, scenario_id: result.evaluation_scenario_id,
174
+ path: "/evaluations/#{run.evaluation_id}/runs/#{run.id}/report",
175
+ report_path: "/api/evaluations/#{run.evaluation_id}/runs/#{run.id}/report",
176
+ status: result.status, evidence_status: candidate?(result) ? "historical_pass" : "recorded_result",
177
+ recorded_prompt: replay && text(replay.input_prompt), output_excerpt: text(result.output),
178
+ prompt_truncated: replay ? replay.input_prompt.length > TEXT_LIMIT : false,
179
+ output_truncated: result.output.to_s.length > TEXT_LIMIT,
180
+ provider: text(result.provider), model: text(result.model), recorded_at: result.created_at&.iso8601,
181
+ completed_at: run.completed_at&.iso8601, score: result.score,
182
+ recorded_scores: bounded_scores(result.scores), check_strength: strength,
183
+ instructions_digest: instructions_digest(replay),
184
+ tool_names: result.tool_names.first(20).map { |name| text(name, limit: 100) },
185
+ fault: text(result.fault), error: safe_error(result.error_message), recommendation: text(result.recommendation),
186
+ caveats: caveats
187
+ }
188
+ end
189
+
190
+ def run_summary(run, report: false)
191
+ {
192
+ evaluation_id: run.evaluation_id, run_id: run.id, status: run.status,
193
+ samples_evaluated: run.samples_evaluated, samples_passed: run.samples_passed,
194
+ created_at: run.created_at&.iso8601, completed_at: run.completed_at&.iso8601,
195
+ error: safe_error(run.error_message),
196
+ path: report ? "/evaluations/#{run.evaluation_id}/runs/#{run.id}/report" : "/evaluations"
197
+ }
198
+ end
199
+
200
+ def check_strength(result)
201
+ keys = result.scores.to_h.keys.map(&:to_s)
202
+ return "expectation_scores_recorded" if (keys & EXPECTATION_SCORE_KEYS).any?
203
+ return "shape_or_runtime_checks_only" if keys.any? && (keys - SHAPE_SCORE_KEYS).empty?
204
+
205
+ "unknown_rubric"
206
+ end
207
+
208
+ # Stored exceptions are arbitrary provider text, sometimes including keys,
209
+ # authenticated URLs or entire request bodies. Keep status/fault categories
210
+ # and the report link, but never export the exception to another provider.
211
+ def safe_error(value)
212
+ REDACTED_ERROR if value.present?
213
+ end
214
+
215
+ def bounded_scores(scores)
216
+ scores.to_h.first(SCORE_LIMIT).to_h.transform_keys { |key| text(key, limit: 100) }.transform_values do |value|
217
+ value.is_a?(Numeric) ? value : text(value, limit: 100)
218
+ end
219
+ end
220
+
221
+ def instructions_digest(replay)
222
+ instructions = replay&.output_metadata&.dig("instructions")
223
+ Digest::SHA256.hexdigest(instructions) if instructions.is_a?(String) && instructions.present?
224
+ end
225
+
226
+ def bounded_limit(value, maximum)
227
+ value.to_i.clamp(1, maximum)
228
+ end
229
+
230
+ def text(value, limit: TEXT_LIMIT)
231
+ value&.to_s&.first(limit)
232
+ end
233
+ end
234
+ end
@@ -417,11 +417,21 @@ module ActionAgent
417
417
  PROMPT
418
418
  end
419
419
 
420
+ # The judge answers with a JSON number, so "9e-2" is 0.09; a digit-only
421
+ # regex read that as 9 and clamped a near-zero score to a perfect 1.0.
422
+ # Mirrors ActiveAgent::Evals::Judge#parse_score, which is private there
423
+ # and may be the older, unfixed one when the host pins activeagent 1.4.0.
420
424
  def parse_judge_score(content)
421
- match = content.to_s.match(/"score"\s*:\s*(\d+(?:\.\d+)?)/)
422
- return nil unless match
425
+ json = content.to_s[/\{.*\}/m]
426
+ return nil unless json
427
+
428
+ parsed = JSON.parse(json)
429
+ value = parsed.is_a?(Hash) ? parsed["score"] : nil
430
+ return nil unless value.is_a?(Numeric) && value.finite?
423
431
 
424
- match[1].to_f.clamp(0.0, 1.0)
432
+ value.to_f.clamp(0.0, 1.0)
433
+ rescue JSON::ParserError
434
+ nil
425
435
  end
426
436
 
427
437
  # The judge needs real provider credentials; scoring with the mock
@@ -72,6 +72,14 @@ module ActionAgent
72
72
  nil
73
73
  end
74
74
 
75
+ # The server keys the agent declares, normalized. Callers that need to know
76
+ # what an agent is wired to — rather than where one tool lives — read this.
77
+ #
78
+ # @return [Array<String>]
79
+ def declared_server_keys
80
+ configured_keys.to_a
81
+ end
82
+
75
83
  private
76
84
 
77
85
  # The catalog's name when it has one; otherwise the name the agent's
@@ -89,7 +97,7 @@ module ActionAgent
89
97
  end
90
98
 
91
99
  # normalized key => the display name a configured hash entry carries
92
- # alongside its key ({"key" => "sparkle", "name" => "Sparkle Match"}).
100
+ # alongside its key ({"key" => "booking", "name" => "Booking Service"}).
93
101
  def configured_names
94
102
  @configured_names ||= configured_entries.each_with_object({}) do |entry, map|
95
103
  next unless entry.respond_to?(:key?)
@@ -103,7 +111,7 @@ module ActionAgent
103
111
  end
104
112
 
105
113
  # bare tool name => server key, from configured entries that list the
106
- # tools they serve ({"name" => "sparkle", "tools" => ["search_slots"]}),
114
+ # tools they serve ({"name" => "booking", "tools" => ["search_slots"]}),
107
115
  # in the catalog's own +tool_hints+ spelling or as tool hashes.
108
116
  def configured_tools
109
117
  @configured_tools ||= configured_entries.each_with_object({}) do |entry, map|
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "resolv"
5
+ require "uri"
6
+ require "json"
7
+
8
+ module ActionAgent
9
+ # Speaks Streamable HTTP MCP: initialize once per client, then call tools
10
+ # under the session id the server hands back. One instance per server url.
11
+ #
12
+ # A server answers as plain JSON or as an SSE stream whose data: lines carry
13
+ # the JSON-RPC response; both are accepted.
14
+ class MCPClient
15
+ OPEN_TIMEOUT_SECONDS = 5
16
+ READ_TIMEOUT_SECONDS = 60
17
+
18
+ class Error < StandardError; end
19
+
20
+ def initialize(url:, label: nil)
21
+ @uri = URI(url)
22
+ @label = label.presence || @uri.host
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # The server's own tool definitions, as the provider expects them:
27
+ # { name:, description:, parameters: }. An MCP server describes its tools
28
+ # in tools/list, so the model is told about them in the server's words
29
+ # rather than a copy kept in the dashboard.
30
+ def list_tools
31
+ ensure_session!
32
+ response = post({ jsonrpc: "2.0", id: next_id, method: "tools/list", params: {} }, session: @session_id)
33
+ Array(response.dig("result", "tools")).map do |tool|
34
+ {
35
+ name: tool["name"],
36
+ description: tool["description"].to_s,
37
+ parameters: tool["inputSchema"] || { type: "object", properties: {} }
38
+ }
39
+ end
40
+ end
41
+
42
+ # Returns { text:, is_error: } — the tool result's text content.
43
+ def call_tool(name, arguments = {})
44
+ Rails.logger.debug("[MCPClient] call #{name} args=#{arguments.inspect[0, 200]}")
45
+ ensure_session!
46
+ response = post(
47
+ { jsonrpc: "2.0", id: next_id, method: "tools/call",
48
+ params: { name: name, arguments: arguments } },
49
+ session: @session_id
50
+ )
51
+ result = response["result"]
52
+ unless result
53
+ Rails.logger.warn("[MCPClient] #{name} unexpected response: #{response.inspect[0, 500]}")
54
+ raise Error, (response.dig("error", "message") || "empty MCP response")
55
+ end
56
+
57
+ text = Array(result["content"]).filter_map { |block| block["text"] }.join("\n")
58
+ { text: text, is_error: result["isError"] ? true : false }
59
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, SocketError => e
60
+ raise Error, "MCP server #{@label} unreachable at #{@uri} (#{e.class})"
61
+ end
62
+
63
+ private
64
+
65
+ # Opens the session once. A server that keeps per-session state answers
66
+ # initialize with an Mcp-Session-Id and expects it on every later request;
67
+ # a stateless server (the shape a multi-worker Rails host serves) returns
68
+ # none, and later requests carry no session header. Both are the protocol's
69
+ # own contract, so an absent id is not an error — @initialized records that
70
+ # the handshake happened either way.
71
+ def ensure_session!
72
+ @mutex.synchronize do
73
+ next if @initialized
74
+
75
+ _body, response = post_raw(
76
+ { jsonrpc: "2.0", id: next_id, method: "initialize",
77
+ params: { protocolVersion: "2025-03-26", capabilities: {},
78
+ clientInfo: { name: "activeagents", version: "1.0" } } }
79
+ )
80
+ @session_id = response["mcp-session-id"].presence
81
+ @initialized = true
82
+
83
+ post({ jsonrpc: "2.0", method: "notifications/initialized" }, session: @session_id)
84
+ end
85
+ end
86
+
87
+ def post(payload, session: nil)
88
+ body, _response = post_raw(payload, session: session)
89
+ body
90
+ end
91
+
92
+ def post_raw(payload, session: nil)
93
+ # Tool calls run inside the provider SDK's streaming enumerator — a
94
+ # fiber, where Net::HTTP reads of SSE bodies misbehave (headers arrive,
95
+ # body comes back empty). A dedicated thread always does real blocking
96
+ # IO outside any fiber/scheduler context.
97
+ Thread.new { blocking_post_raw(payload, session: session) }.value
98
+ end
99
+
100
+ def blocking_post_raw(payload, session: nil)
101
+ http = Net::HTTP.new(@uri.host, @uri.port)
102
+ # Without this an https:// endpoint is sent as plaintext to port 443.
103
+ http.use_ssl = @uri.scheme == "https"
104
+ # Container->host bridge hostnames (host.orb.internal) publish an IPv6
105
+ # address whose path doesn't reach the server; dual-stack connects then
106
+ # fail intermittently. Pin to IPv4 while keeping the Host header.
107
+ if (ipv4 = ipv4_address)
108
+ http.ipaddr = ipv4
109
+ end
110
+ http.open_timeout = OPEN_TIMEOUT_SECONDS
111
+ http.read_timeout = READ_TIMEOUT_SECONDS
112
+ request = Net::HTTP::Post.new(@uri.request_uri)
113
+ request["Content-Type"] = "application/json"
114
+ request["Accept"] = "application/json, text/event-stream"
115
+ request["Mcp-Session-Id"] = session if session
116
+ request.body = payload.to_json
117
+
118
+ response = http.request(request)
119
+ Rails.logger.debug(
120
+ "[MCPClient] #{payload[:method]} -> #{response.code} " \
121
+ "ct=#{response['Content-Type']} bytes=#{response.body.to_s.bytesize} session=#{session ? 'yes' : 'no'}"
122
+ )
123
+ unless response.code.to_i.between?(200, 299)
124
+ Rails.logger.warn("[MCPClient] HTTP #{response.code}: #{response.body.to_s[0, 300]}")
125
+ raise Error, "MCP server returned HTTP #{response.code}"
126
+ end
127
+
128
+ parsed = parse_body(response)
129
+ if parsed.empty? && payload[:id]
130
+ Rails.logger.warn("[MCPClient] unparsed body (#{response['Content-Type']}): #{response.body.to_s[0, 500]}")
131
+ end
132
+ [ parsed, response ]
133
+ end
134
+
135
+ # Streamable HTTP answers as plain JSON or as an SSE stream whose data:
136
+ # lines carry the JSON-RPC response — accept both.
137
+ def parse_body(response)
138
+ body = response.body.to_s
139
+ return {} if body.empty?
140
+
141
+ if response["Content-Type"].to_s.include?("text/event-stream")
142
+ body.lines
143
+ .select { |line| line.start_with?("data:") }
144
+ .filter_map { |line| JSON.parse(line.delete_prefix("data:").strip) rescue nil }
145
+ .find { |json| json["result"] || json["error"] } || {}
146
+ else
147
+ # A notification carries no id, and a server may answer it with a bare
148
+ # `null` body — JSON, but not an object.
149
+ JSON.parse(body) || {}
150
+ end
151
+ rescue JSON::ParserError
152
+ {}
153
+ end
154
+
155
+ def ipv4_address
156
+ return @ipv4_address if defined?(@ipv4_address)
157
+
158
+ @ipv4_address = Resolv.getaddresses(@uri.host).find { |address| address =~ Resolv::IPv4::Regex }
159
+ rescue Resolv::ResolvError
160
+ @ipv4_address = nil
161
+ end
162
+
163
+ def next_id
164
+ @id = (@id || 0) + 1
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Routes a tool call to the MCP server that serves it.
5
+ #
6
+ # An agent names the servers it uses in +mcp_servers+, and a catalog entry
7
+ # carries the url to reach one over HTTP. A tool the agent's servers claim is
8
+ # called there; anything else returns nil, and the caller falls back to the
9
+ # engine's own AgentToolbox.
10
+ #
11
+ # Only HTTP transports are dispatchable. A stdio server runs as a child
12
+ # process of whatever launched it, so the dashboard has no address to call —
13
+ # those stay listable and attributable without being callable.
14
+ class MCPToolDispatcher
15
+ HTTP_TRANSPORTS = %w[http streamable_http sse].freeze
16
+
17
+ def initialize(agent)
18
+ @agent = agent
19
+ @resolver = EvaluationToolResolver.new(agent)
20
+ @clients = {}
21
+ end
22
+
23
+ # Whether this tool belongs to one of the agent's own reachable servers.
24
+ def dispatchable?(tool_name)
25
+ endpoint_for(tool_name).present?
26
+ end
27
+
28
+ # Whether the agent names any server the dashboard can call. An agent with
29
+ # none has nothing to execute beyond the engine's own toolbox.
30
+ def any_reachable_server?
31
+ resolver.declared_server_keys.any? do |key|
32
+ entry = MCPCatalog.find(key)
33
+ entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
34
+ end
35
+ end
36
+
37
+ # Calls the tool on its server. Returns the same shape AgentToolbox
38
+ # produces for a text result, or an { error: } hash when the server
39
+ # refuses — a failing tool is a result to score, not an exception to
40
+ # abort the run.
41
+ #
42
+ # @return [Hash, nil] nil when no configured server claims the tool
43
+ def call(tool_name, arguments = {})
44
+ endpoint = endpoint_for(tool_name)
45
+ return nil unless endpoint
46
+
47
+ result = client_for(endpoint).call_tool(tool_name.to_s, arguments)
48
+ return { error: "#{tool_name} failed: #{result[:text]}" } if result[:is_error]
49
+
50
+ { text: result[:text] }
51
+ rescue MCPClient::Error => e
52
+ { error: "#{tool_name} failed: #{e.message}" }
53
+ end
54
+
55
+ # Tool definitions from every reachable server the agent declares, in the
56
+ # shape tool_schemas hands the provider. A server that cannot be reached
57
+ # contributes nothing rather than failing the run — the tools it serves
58
+ # then simply are not offered, and a scenario expecting them fails with a
59
+ # fault naming them.
60
+ def tool_definitions
61
+ resolver.declared_server_keys.flat_map do |key|
62
+ entry = MCPCatalog.find(key)
63
+ next [] unless entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
64
+
65
+ begin
66
+ client_for(entry).list_tools
67
+ rescue MCPClient::Error => e
68
+ Rails.logger.warn("[MCPToolDispatcher] #{key} tools/list failed: #{e.message}")
69
+ []
70
+ end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :agent, :resolver
77
+
78
+ # The catalog entry for the server that serves this tool, but only when the
79
+ # agent configured that server and the entry carries an http url. Scoping to
80
+ # the agent's own servers is what keeps one agent's tools from reaching
81
+ # another's.
82
+ def endpoint_for(tool_name)
83
+ key = resolver.server_key_for(tool_name)
84
+ return nil if key.blank?
85
+ return nil unless resolver.status_for(key) == EvaluationToolResolver::ENABLED
86
+
87
+ entry = MCPCatalog.find(key)
88
+ return nil unless entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS)
89
+ return nil if entry[:url].blank?
90
+
91
+ entry
92
+ end
93
+
94
+ # One client per server for the life of this dispatcher, so a run's tool
95
+ # calls share the MCP session the first call opens.
96
+ def client_for(entry)
97
+ @clients[entry[:key]] ||= MCPClient.new(url: absolute_url(entry[:url]), label: entry[:name] || entry[:key])
98
+ end
99
+
100
+ # A host registers its own servers with a path ("/mcp/diagnostic"), since it
101
+ # does not know the origin it will be served under. ACTIONAGENT_MCP_ORIGIN
102
+ # names that origin; without it a relative path is not reachable.
103
+ def absolute_url(url)
104
+ return url if url.to_s.start_with?("http://", "https://")
105
+
106
+ origin = ENV["ACTIONAGENT_MCP_ORIGIN"].presence
107
+ raise MCPClient::Error, "set ACTIONAGENT_MCP_ORIGIN to reach #{url}" if origin.blank?
108
+
109
+ # URI.join, not File.join: a path is a URL reference, and only URI
110
+ # resolves one against an origin that carries its own path.
111
+ URI.join(origin, url).to_s
112
+ rescue URI::Error => e
113
+ raise MCPClient::Error, "ACTIONAGENT_MCP_ORIGIN #{origin.inspect} cannot reach #{url}: #{e.message}"
114
+ end
115
+ end
116
+ end