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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +70 -48
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +116 -9
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +22 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- data/app/controllers/action_agent/dashboard_controller.rb +1 -0
- data/app/models/action_agent/agent.rb +60 -18
- data/app/models/action_agent/agent_run.rb +99 -0
- data/app/models/action_agent/evaluation_run.rb +14 -6
- data/app/models/action_agent/evaluation_scenario_result.rb +24 -4
- data/app/models/action_agent/telemetry_trace.rb +16 -1
- 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 +45 -3
- 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 +10 -2
- data/app/services/action_agent/mcp_client.rb +167 -0
- 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/scenario_evaluation_runner.rb +49 -15
- data/config/routes.rb +11 -1
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +5 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +32 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- 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
|
-
|
|
422
|
-
return nil unless
|
|
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
|
-
|
|
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" => "
|
|
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" => "
|
|
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
|