actionagent 1.2.2 → 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.
- checksums.yaml +4 -4
- data/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +69 -43
- data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
- data/app/controllers/action_agent/api/agents_controller.rb +191 -56
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +3 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +74 -23
- data/app/models/action_agent/agent_run.rb +99 -0
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +190 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +126 -3
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +294 -16
- data/app/services/action_agent/agent_registrar.rb +7 -6
- data/app/services/action_agent/agent_toolbox.rb +49 -7
- data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
- data/app/services/action_agent/evaluation_evidence.rb +234 -0
- data/app/services/action_agent/evaluation_runner_service.rb +13 -3
- data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_client.rb +167 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +36 -3
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +106 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +104 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +22 -5
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Names the MCP server behind a tool an evaluation run needs, for the
|
|
5
|
+
# report's fix items (ActiveAgent::Evals::Report#fix_items): a scenario
|
|
6
|
+
# that expected +search_slots+ and never got it is fixed by enabling the
|
|
7
|
+
# server that serves it, and the item can only say so — and deep-link to
|
|
8
|
+
# MCP Services — when something here can name that server.
|
|
9
|
+
#
|
|
10
|
+
# Resolution follows the order ToolDiscovery attributes traffic in: an
|
|
11
|
+
# explicit +mcp__server__tool+ namespace wins because the name said so,
|
|
12
|
+
# then MCPCatalog's hints for the bare names of well-known servers, then
|
|
13
|
+
# the servers the agent itself declares in +mcp_servers+ when one of them
|
|
14
|
+
# lists the tool. Nothing here reads telemetry: the resolver runs inside
|
|
15
|
+
# the request that serializes a run, and every lookup is a constant or a
|
|
16
|
+
# single agent attribute.
|
|
17
|
+
#
|
|
18
|
+
# The status is what the fix item's action turns on:
|
|
19
|
+
#
|
|
20
|
+
# "enabled" — the agent's mcp_servers configuration names the server
|
|
21
|
+
# "available" — the catalog (built-in or ActionAgent.mcp_catalog) knows
|
|
22
|
+
# the server and the agent has not enabled it
|
|
23
|
+
# nil — the namespace named a server nothing here knows; the
|
|
24
|
+
# report renders that as "unknown"
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# resolver = EvaluationToolResolver.new(agent)
|
|
28
|
+
# resolver.call("browser_navigate")
|
|
29
|
+
# # => { "key" => "playwright", "name" => "Playwright", "status" => "available" }
|
|
30
|
+
class EvaluationToolResolver
|
|
31
|
+
ENABLED = "enabled"
|
|
32
|
+
AVAILABLE = "available"
|
|
33
|
+
|
|
34
|
+
attr_reader :agent
|
|
35
|
+
|
|
36
|
+
# @param agent [ActionAgent::Agent, nil] the agent the run evaluated; nil
|
|
37
|
+
# resolves against the catalog alone
|
|
38
|
+
def initialize(agent)
|
|
39
|
+
@agent = agent
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The Report's +tool_resolver+ contract.
|
|
43
|
+
#
|
|
44
|
+
# @param tool_name [String, Symbol] a tool name as a scenario expected it
|
|
45
|
+
# or the model called it
|
|
46
|
+
# @return [Hash, nil] +{ "key", "name", "status" }+, or nil when no
|
|
47
|
+
# server can be named for the tool
|
|
48
|
+
def call(tool_name)
|
|
49
|
+
key = server_key_for(tool_name)
|
|
50
|
+
return nil if key.nil?
|
|
51
|
+
|
|
52
|
+
{ "key" => key, "name" => display_name_for(key), "status" => status_for(key) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param tool_name [String, Symbol]
|
|
56
|
+
# @return [String, nil] the server key the tool belongs to
|
|
57
|
+
def server_key_for(tool_name)
|
|
58
|
+
name = tool_name.to_s.strip
|
|
59
|
+
return nil if name.blank?
|
|
60
|
+
|
|
61
|
+
ActiveAgent::Telemetry::ToolOrigin.server_for(name).presence ||
|
|
62
|
+
MCPCatalog.server_for_tool(name).presence ||
|
|
63
|
+
configured_tools[name]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param key [String] a server key
|
|
67
|
+
# @return [String, nil] ENABLED, AVAILABLE, or nil when unknown
|
|
68
|
+
def status_for(key)
|
|
69
|
+
return ENABLED if configured_keys.include?(normalize(key))
|
|
70
|
+
return AVAILABLE if MCPCatalog.find(key)
|
|
71
|
+
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
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
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# The catalog's name when it has one; otherwise the name the agent's
|
|
86
|
+
# own configuration gives the server, and the key as a last resort
|
|
87
|
+
# (which is what MCPCatalog.display_name falls back to as well).
|
|
88
|
+
def display_name_for(key)
|
|
89
|
+
return MCPCatalog.display_name(key) if MCPCatalog.find(key)
|
|
90
|
+
|
|
91
|
+
configured_names[normalize(key)] || MCPCatalog.display_name(key)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Server keys the agent declares, normalized for comparison.
|
|
95
|
+
def configured_keys
|
|
96
|
+
@configured_keys ||= configured_entries.filter_map { |entry| normalize(entry_key(entry)) }.to_set
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# normalized key => the display name a configured hash entry carries
|
|
100
|
+
# alongside its key ({"key" => "booking", "name" => "Booking Service"}).
|
|
101
|
+
def configured_names
|
|
102
|
+
@configured_names ||= configured_entries.each_with_object({}) do |entry, map|
|
|
103
|
+
next unless entry.respond_to?(:key?)
|
|
104
|
+
|
|
105
|
+
key = normalize(entry_key(entry))
|
|
106
|
+
name = (entry["name"] || entry[:name]).to_s.strip
|
|
107
|
+
next if key.nil? || name.blank? || name.downcase == key
|
|
108
|
+
|
|
109
|
+
map[key] ||= name
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# bare tool name => server key, from configured entries that list the
|
|
114
|
+
# tools they serve ({"name" => "booking", "tools" => ["search_slots"]}),
|
|
115
|
+
# in the catalog's own +tool_hints+ spelling or as tool hashes.
|
|
116
|
+
def configured_tools
|
|
117
|
+
@configured_tools ||= configured_entries.each_with_object({}) do |entry, map|
|
|
118
|
+
next unless entry.respond_to?(:key?)
|
|
119
|
+
|
|
120
|
+
key = entry_key(entry)
|
|
121
|
+
next if key.nil?
|
|
122
|
+
|
|
123
|
+
Array(entry["tools"] || entry[:tools] || entry["tool_hints"] || entry[:tool_hints]).each do |tool|
|
|
124
|
+
name = (tool.respond_to?(:key?) ? tool["name"] || tool[:name] : tool).to_s.strip
|
|
125
|
+
map[name] ||= key unless name.blank?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The agent's mcp_servers as a list of entries. Agents store an Array of
|
|
131
|
+
# bare names or builder hashes, but an agent seeded from an older
|
|
132
|
+
# template carries a top-level Hash keyed by server name
|
|
133
|
+
# ({"playwright" => {"command" => ...}}) — the same shape ToolDiscovery
|
|
134
|
+
# tolerates — whose values become entries carrying that key.
|
|
135
|
+
def configured_entries
|
|
136
|
+
@configured_entries ||= begin
|
|
137
|
+
servers = agent&.mcp_servers
|
|
138
|
+
|
|
139
|
+
if servers.is_a?(Hash)
|
|
140
|
+
servers.map do |key, value|
|
|
141
|
+
value.respond_to?(:key?) ? value.to_h.stringify_keys.merge("key" => key.to_s) : key.to_s
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
Array(servers)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# An entry names its server as a bare string, or under +key+ or +name+
|
|
150
|
+
# in a builder hash. Anything else (a stray Array, a number) is skipped.
|
|
151
|
+
def entry_key(entry)
|
|
152
|
+
return entry.to_s.strip.presence if entry.is_a?(String) || entry.is_a?(Symbol)
|
|
153
|
+
return nil unless entry.respond_to?(:key?)
|
|
154
|
+
|
|
155
|
+
(entry["key"] || entry[:key] || entry["name"] || entry[:name]).to_s.strip.presence
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def normalize(key)
|
|
159
|
+
key.to_s.strip.downcase.presence
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -19,7 +19,7 @@ module ActionAgent
|
|
|
19
19
|
# shows up in the MCP Services view (as +known: false+) the moment a
|
|
20
20
|
# namespaced tool call from it is ingested. The catalog only adds names,
|
|
21
21
|
# descriptions, and the ability to launch.
|
|
22
|
-
class
|
|
22
|
+
class MCPCatalog
|
|
23
23
|
# Whether a server can be started inside a sandbox session. Servers that
|
|
24
24
|
# need workspace-specific credentials (github, slack, postgres) are
|
|
25
25
|
# listable and attributable but not launchable from the dashboard — there
|
|
@@ -156,7 +156,12 @@ module ActionAgent
|
|
|
156
156
|
sandbox: false,
|
|
157
157
|
first_party: true,
|
|
158
158
|
requires_credentials: [ "Platform API key" ],
|
|
159
|
-
tool_hints:
|
|
159
|
+
# No tool_hints: the facade's tools are run_<slug> (one per agent, see
|
|
160
|
+
# Api::MCPController#tools_list), which no static list can name. The
|
|
161
|
+
# old hints advertised call_agent and list_agents, which the facade
|
|
162
|
+
# never exposed, and would have misattributed an agent-defined tool
|
|
163
|
+
# of either name to this server.
|
|
164
|
+
tool_hints: []
|
|
160
165
|
}
|
|
161
166
|
].freeze
|
|
162
167
|
|
|
@@ -172,11 +177,12 @@ module ActionAgent
|
|
|
172
177
|
end.freeze
|
|
173
178
|
|
|
174
179
|
class << self
|
|
175
|
-
# Every catalog entry
|
|
180
|
+
# Every catalog entry — built-ins plus the host's
|
|
181
|
+
# +ActionAgent.mcp_catalog+ registrations — as API-shaped hashes.
|
|
176
182
|
#
|
|
177
183
|
# @return [Array<Hash>]
|
|
178
184
|
def all
|
|
179
|
-
|
|
185
|
+
entries.map { |server| present(server) }
|
|
180
186
|
end
|
|
181
187
|
|
|
182
188
|
# Entries that can be started inside a sandbox session.
|
|
@@ -189,14 +195,21 @@ module ActionAgent
|
|
|
189
195
|
# @param key [String, Symbol]
|
|
190
196
|
# @return [Hash, nil] the catalog entry, or nil when unknown
|
|
191
197
|
def find(key)
|
|
192
|
-
entry =
|
|
198
|
+
entry = index[key.to_s]
|
|
193
199
|
present(entry) if entry
|
|
194
200
|
end
|
|
195
201
|
|
|
202
|
+
# Every catalog key, built-ins first.
|
|
203
|
+
#
|
|
204
|
+
# @return [Array<String>]
|
|
205
|
+
def keys
|
|
206
|
+
entries.map { |server| server[:key] }
|
|
207
|
+
end
|
|
208
|
+
|
|
196
209
|
# @param key [String, Symbol]
|
|
197
210
|
# @return [Boolean] whether the server can be started in a sandbox
|
|
198
211
|
def launchable?(key)
|
|
199
|
-
|
|
212
|
+
index[key.to_s]&.fetch(:sandbox, false) || false
|
|
200
213
|
end
|
|
201
214
|
|
|
202
215
|
# The catalog server a bare (non-namespaced) tool name belongs to.
|
|
@@ -208,7 +221,7 @@ module ActionAgent
|
|
|
208
221
|
# @param tool_name [String, Symbol]
|
|
209
222
|
# @return [String, nil] the server key
|
|
210
223
|
def server_for_tool(tool_name)
|
|
211
|
-
|
|
224
|
+
tool_hints[tool_name.to_s]
|
|
212
225
|
end
|
|
213
226
|
|
|
214
227
|
# The display name for a server key, falling back to the key itself for
|
|
@@ -217,11 +230,36 @@ module ActionAgent
|
|
|
217
230
|
# @param key [String, Symbol]
|
|
218
231
|
# @return [String]
|
|
219
232
|
def display_name(key)
|
|
220
|
-
|
|
233
|
+
index[key.to_s]&.[](:name) || key.to_s
|
|
221
234
|
end
|
|
222
235
|
|
|
223
236
|
private
|
|
224
237
|
|
|
238
|
+
# Built-ins plus host registrations, deduplicated by key (first
|
|
239
|
+
# declaration wins, so a built-in keeps its key). Derived per call
|
|
240
|
+
# rather than memoized: the host list is tiny and tests reconfigure it.
|
|
241
|
+
def entries
|
|
242
|
+
(SERVERS + host_entries).uniq { |server| server[:key] }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def host_entries
|
|
246
|
+
Array(ActionAgent.mcp_catalog).filter_map do |entry|
|
|
247
|
+
normalized = entry.to_h.symbolize_keys
|
|
248
|
+
normalized[:key] = normalized[:key].to_s
|
|
249
|
+
normalized unless normalized[:key].blank?
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def index
|
|
254
|
+
entries.index_by { |server| server[:key] }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def tool_hints
|
|
258
|
+
entries.each_with_object({}) do |server, map|
|
|
259
|
+
Array(server[:tool_hints]).each { |tool| map[tool.to_s] ||= server[:key] }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
225
263
|
# Drops the internal-only tool_hints and normalizes optional keys so
|
|
226
264
|
# every entry serializes with the same shape.
|
|
227
265
|
def present(server)
|
|
@@ -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
|
|
@@ -7,7 +7,7 @@ module ActionAgent
|
|
|
7
7
|
# to a SessionRecording for playback and debugging.
|
|
8
8
|
#
|
|
9
9
|
# Usage:
|
|
10
|
-
# middleware =
|
|
10
|
+
# middleware = MCPRecordingMiddleware.new(session_recording: recording)
|
|
11
11
|
#
|
|
12
12
|
# # Process a tool call
|
|
13
13
|
# result = middleware.intercept(tool_call) do
|
|
@@ -15,7 +15,7 @@ module ActionAgent
|
|
|
15
15
|
# mcp_client.call_tool(tool_call)
|
|
16
16
|
# end
|
|
17
17
|
#
|
|
18
|
-
class
|
|
18
|
+
class MCPRecordingMiddleware
|
|
19
19
|
# Map of Playwright MCP tool names to our action types
|
|
20
20
|
PLAYWRIGHT_TOOLS = {
|
|
21
21
|
"browser_navigate" => "navigate",
|
|
@@ -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
|