actionagent 1.5.0 → 1.6.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 +54 -54
- data/app/controllers/action_agent/api/agents_controller.rb +40 -6
- data/app/controllers/action_agent/api/base_controller.rb +17 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +115 -4
- data/app/controllers/action_agent/dashboard_controller.rb +1 -1
- data/app/jobs/action_agent/agent_execution_job.rb +4 -1
- data/app/models/action_agent/agent.rb +83 -8
- data/app/models/action_agent/agent_run.rb +59 -0
- data/app/services/action_agent/agent_execution_service.rb +28 -2
- data/app/services/action_agent/agent_tool_roster.rb +269 -0
- data/app/services/action_agent/agent_toolbox.rb +33 -2
- data/app/services/action_agent/mcp_tool_dispatcher.rb +32 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +58 -3
- data/config/routes.rb +3 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +143 -0
- metadata +3 -2
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# One agent's tool roster, as the agent editor's Tools tab reads it: every
|
|
5
|
+
# tool the agent can be offered and every MCP service it can be given, each
|
|
6
|
+
# carrying what the window actually recorded for it.
|
|
7
|
+
#
|
|
8
|
+
# Three groups, one row vocabulary — the same one the Tools and MCP
|
|
9
|
+
# Services pages use, so a roster reads the way the observability views do:
|
|
10
|
+
#
|
|
11
|
+
# * **Agent-defined** — tools discovered from the rosters this agent's own
|
|
12
|
+
# generations offered (ToolDiscovery's +agent+ origin). Schema-derived:
|
|
13
|
+
# the agent class declares them, so they are reported here rather than
|
|
14
|
+
# selected. Read-only for the same reason MCP rows are — a checkbox that
|
|
15
|
+
# cannot add or remove the tool is a control that changes nothing.
|
|
16
|
+
# * **Dashboard** — Agent::AVAILABLE_TOOLS, the capabilities the builder
|
|
17
|
+
# offers every agent. These are the roster: +agent.tools+ is what
|
|
18
|
+
# AgentToolbox turns into function schemas at generation time.
|
|
19
|
+
# * **MCP** — never stored on the roster. Computed from the services the
|
|
20
|
+
# agent enables, which is where they are edited.
|
|
21
|
+
#
|
|
22
|
+
# Enablement reads the agent's own configuration: +tools+ for the dashboard
|
|
23
|
+
# capabilities, +mcp_servers+ for services and their per-server allow-lists
|
|
24
|
+
# (an entry with no +tools+ key offers everything the server serves).
|
|
25
|
+
class AgentToolRoster
|
|
26
|
+
AGENT_DEFINED = "agent_defined"
|
|
27
|
+
DASHBOARD = "dashboard"
|
|
28
|
+
MCP = "mcp"
|
|
29
|
+
|
|
30
|
+
# Ordering for the services list: what this agent uses, then what the
|
|
31
|
+
# workspace already talks to, then the rest of the catalog.
|
|
32
|
+
STATUS_RANK = { "active" => 0, "configured" => 1, "available" => 2, "idle" => 3 }.freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :agent, :discovery
|
|
35
|
+
|
|
36
|
+
# @param agent [ActionAgent::Agent] the agent being edited
|
|
37
|
+
# @param traces [ActiveRecord::Relation] the traces the caller may read
|
|
38
|
+
# @param hours [Integer] the window the usage columns are scoped to
|
|
39
|
+
def initialize(agent:, traces:, hours: ToolDiscovery::DEFAULT_WINDOW_HOURS)
|
|
40
|
+
@agent = agent
|
|
41
|
+
@discovery = ToolDiscovery.new(
|
|
42
|
+
traces: traces.for_agent(agent.telemetry_agent_class),
|
|
43
|
+
agents: Agent.where(id: agent.id),
|
|
44
|
+
hours: hours
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def as_json(*)
|
|
49
|
+
{
|
|
50
|
+
window_hours: discovery.window_hours,
|
|
51
|
+
# Whether any record source had rows in this window. False means the
|
|
52
|
+
# usage columns have nothing behind them, and the view renders them
|
|
53
|
+
# as "—" rather than as a row of honest-looking zeroes.
|
|
54
|
+
usage_available: inventory[:sources].values.any?,
|
|
55
|
+
services: services,
|
|
56
|
+
tools: tools
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def inventory
|
|
63
|
+
@inventory ||= discovery.inventory
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def detected
|
|
67
|
+
inventory[:tools]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def saved_tools
|
|
71
|
+
@saved_tools ||= Array(agent.tools).map(&:to_s)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# --- services ------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def services
|
|
77
|
+
rows = service_keys.map { |key| service_row(key) }
|
|
78
|
+
rows.sort_by do |row|
|
|
79
|
+
[ row[:enabled] ? 0 : 1, STATUS_RANK.fetch(row[:status], 9), -row[:calls], row[:name].to_s.downcase ]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The catalog, plus anything this agent's traffic or configuration names
|
|
84
|
+
# that the catalog doesn't describe.
|
|
85
|
+
def service_keys
|
|
86
|
+
(MCPCatalog.keys + detected_by_server.keys + configured_servers.keys).uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def detected_by_server
|
|
90
|
+
@detected_by_server ||= detected.reject { |tool| tool[:mcp_server].blank? }.group_by { |tool| tool[:mcp_server] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def service_row(key)
|
|
94
|
+
catalog = MCPCatalog.find(key)
|
|
95
|
+
used = detected_by_server[key] || []
|
|
96
|
+
calls = used.sum { |tool| tool[:calls] }
|
|
97
|
+
enabled = configured_servers.key?(key)
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
key: key,
|
|
101
|
+
name: catalog ? catalog[:name] : key,
|
|
102
|
+
description: catalog&.dig(:description),
|
|
103
|
+
docs_url: catalog&.dig(:docs_url),
|
|
104
|
+
first_party: catalog ? catalog[:first_party] : false,
|
|
105
|
+
known: !catalog.nil?,
|
|
106
|
+
transport: transport_label(catalog),
|
|
107
|
+
status: service_status(calls, enabled, catalog),
|
|
108
|
+
enabled: enabled,
|
|
109
|
+
calls: calls,
|
|
110
|
+
errors: used.sum { |tool| tool[:errors] },
|
|
111
|
+
last_seen: used.filter_map { |tool| tool[:last_seen] }.max,
|
|
112
|
+
tools: service_tools(key, catalog, used)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# How to reach the server, in the one line the expanded panel shows:
|
|
117
|
+
# "Streamable HTTP · <url>" for the ones the dashboard can call,
|
|
118
|
+
# "sandbox · <command>" for the ones it can start, "stdio · <command>"
|
|
119
|
+
# for the rest.
|
|
120
|
+
def transport_label(catalog)
|
|
121
|
+
return nil if catalog.nil?
|
|
122
|
+
|
|
123
|
+
transport = catalog[:transport].to_s
|
|
124
|
+
return [ "Streamable HTTP", catalog[:url] ].compact.join(" · ") if transport == "http"
|
|
125
|
+
|
|
126
|
+
prefix = catalog[:sandbox] ? "sandbox" : transport.presence
|
|
127
|
+
[ prefix, catalog[:command] ].compact.join(" · ").presence
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def service_status(calls, enabled, catalog)
|
|
131
|
+
return "active" if calls.positive?
|
|
132
|
+
return "configured" if enabled
|
|
133
|
+
return "available" if catalog
|
|
134
|
+
|
|
135
|
+
"idle"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# What the service offers: the catalog's tool hints unioned with the
|
|
139
|
+
# tools this agent was actually seen calling on it, so a server whose
|
|
140
|
+
# roster has drifted from the catalog still lists what it really serves.
|
|
141
|
+
def service_tools(key, catalog, used)
|
|
142
|
+
by_name = used.index_by { |tool| tool[:base_name] }
|
|
143
|
+
allowed = allowed_tools(key)
|
|
144
|
+
names = (Array(catalog&.dig(:tools)) + by_name.keys).uniq
|
|
145
|
+
|
|
146
|
+
names.map do |name|
|
|
147
|
+
usage_row(by_name[name]).merge(
|
|
148
|
+
name: name,
|
|
149
|
+
description: by_name[name]&.dig(:description),
|
|
150
|
+
enabled: allowed.nil? || allowed.include?(name)
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- tools ---------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def tools
|
|
158
|
+
agent_defined_rows + dashboard_rows
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Schema-derived tools: whatever this agent's generations offered that
|
|
162
|
+
# is neither an MCP tool nor one of the dashboard's own.
|
|
163
|
+
def agent_defined_rows
|
|
164
|
+
detected
|
|
165
|
+
.select { |tool| tool[:origin] == ToolDiscovery::ORIGIN_AGENT }
|
|
166
|
+
.reject { |tool| dashboard_function_names.include?(tool[:name]) }
|
|
167
|
+
.map do |tool|
|
|
168
|
+
usage_row(tool).merge(
|
|
169
|
+
key: tool[:name],
|
|
170
|
+
name: tool[:name],
|
|
171
|
+
source: AGENT_DEFINED,
|
|
172
|
+
description: tool[:description],
|
|
173
|
+
# The agent class declares these; the dashboard reports them.
|
|
174
|
+
enabled: true,
|
|
175
|
+
editable: false
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def dashboard_rows
|
|
181
|
+
Agent::AVAILABLE_TOOLS.map do |capability|
|
|
182
|
+
usage_row(capability_usage(capability)).merge(
|
|
183
|
+
key: capability,
|
|
184
|
+
name: capability,
|
|
185
|
+
source: DASHBOARD,
|
|
186
|
+
description: Agent::TOOL_DESCRIPTIONS[capability],
|
|
187
|
+
enabled: saved_tools.include?(capability),
|
|
188
|
+
editable: true
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A capability is one checkbox over the several functions it exposes
|
|
194
|
+
# ("memory" is save_memory + recall_memory), so its usage is their sum.
|
|
195
|
+
def capability_usage(capability)
|
|
196
|
+
names = (AgentToolbox::DEFINITIONS[capability]&.map { |definition| definition[:name].to_s } || []) + [ capability ]
|
|
197
|
+
rows = detected.select { |tool| names.include?(tool[:name]) }
|
|
198
|
+
return nil if rows.empty?
|
|
199
|
+
|
|
200
|
+
timed = rows.filter_map { |row| [ row[:avg_duration_ms], row[:calls] ] if row[:avg_duration_ms] }
|
|
201
|
+
weighted = timed.sum { |average, calls| average * [ calls, 1 ].max }
|
|
202
|
+
samples = timed.sum { |_average, calls| [ calls, 1 ].max }
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
calls: rows.sum { |row| row[:calls] },
|
|
206
|
+
errors: rows.sum { |row| row[:errors] },
|
|
207
|
+
avg_duration_ms: samples.positive? ? (weighted / samples).round : nil,
|
|
208
|
+
last_seen: rows.filter_map { |row| row[:last_seen] }.max
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def usage_row(tool)
|
|
213
|
+
{
|
|
214
|
+
calls: tool ? tool[:calls] : 0,
|
|
215
|
+
errors: tool ? tool[:errors] : 0,
|
|
216
|
+
avg_duration_ms: tool ? tool[:avg_duration_ms] : nil,
|
|
217
|
+
last_seen: tool ? tool[:last_seen] : nil
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Every function name the dashboard's own toolbox implements, so a
|
|
222
|
+
# builtin never lands in the agent-defined group under its bare name.
|
|
223
|
+
def dashboard_function_names
|
|
224
|
+
@dashboard_function_names ||= ToolDiscovery.builtin_tools | Agent::AVAILABLE_TOOLS.to_set
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# --- the agent's MCP configuration ---------------------------------
|
|
228
|
+
|
|
229
|
+
# server key => the entry the agent stores for it. Entries are bare
|
|
230
|
+
# strings or builder hashes, and an agent seeded from an older template
|
|
231
|
+
# carries a top-level Hash keyed by server name — the same three shapes
|
|
232
|
+
# EvaluationToolResolver tolerates.
|
|
233
|
+
def configured_servers
|
|
234
|
+
@configured_servers ||= configured_entries.each_with_object({}) do |entry, map|
|
|
235
|
+
key = entry_key(entry)
|
|
236
|
+
map[key] = entry if key.present?
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def configured_entries
|
|
241
|
+
servers = agent.mcp_servers
|
|
242
|
+
|
|
243
|
+
if servers.is_a?(Hash)
|
|
244
|
+
servers.map { |key, value| value.respond_to?(:key?) ? value.to_h.stringify_keys.merge("key" => key.to_s) : key.to_s }
|
|
245
|
+
else
|
|
246
|
+
Array(servers)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def entry_key(entry)
|
|
251
|
+
return entry.to_s.strip.presence if entry.is_a?(String) || entry.is_a?(Symbol)
|
|
252
|
+
return nil unless entry.respond_to?(:key?)
|
|
253
|
+
|
|
254
|
+
(entry["key"] || entry[:key] || entry["name"] || entry[:name]).to_s.strip.presence
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The per-server allow-list, or nil when the entry names none — which
|
|
258
|
+
# means the agent is offered every tool the server serves.
|
|
259
|
+
def allowed_tools(key)
|
|
260
|
+
entry = configured_servers[key]
|
|
261
|
+
return nil unless entry.respond_to?(:key?)
|
|
262
|
+
|
|
263
|
+
names = entry["tools"] || entry[:tools]
|
|
264
|
+
return nil if names.nil?
|
|
265
|
+
|
|
266
|
+
Array(names).filter_map { |tool| (tool.respond_to?(:key?) ? tool["name"] || tool[:name] : tool).to_s.presence }
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -203,11 +203,23 @@ module ActionAgent
|
|
|
203
203
|
# Tool definitions for the subset of an agent's enabled tools that have
|
|
204
204
|
# server-side implementations.
|
|
205
205
|
def definitions_for(tool_names)
|
|
206
|
-
Array(tool_names).flat_map
|
|
206
|
+
Array(tool_names).flat_map do |name|
|
|
207
|
+
DEFINITIONS[name.to_s] || schema_tool_definitions(name.to_s)
|
|
208
|
+
end
|
|
207
209
|
end
|
|
208
210
|
|
|
209
211
|
def function?(name)
|
|
210
|
-
FUNCTIONS.key?(name.to_s)
|
|
212
|
+
FUNCTIONS.key?(name.to_s) || ActionAgent.schema_tool_class_for(name.to_s).present?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Definitions for a host-declared schema tool, or [] when the name is
|
|
216
|
+
# not one. Each generated tool is its own entry so an agent enables them
|
|
217
|
+
# individually rather than as a group.
|
|
218
|
+
def schema_tool_definitions(name)
|
|
219
|
+
klass = ActionAgent.schema_tool_class_for(name)
|
|
220
|
+
return [] unless klass
|
|
221
|
+
|
|
222
|
+
Array(klass.tool_definitions).select { |definition| definition[:name].to_s == name }
|
|
211
223
|
end
|
|
212
224
|
|
|
213
225
|
# Executes a tool call. Returns a result hash; errors are returned as
|
|
@@ -218,6 +230,25 @@ module ActionAgent
|
|
|
218
230
|
# instead of re-running the side effect.
|
|
219
231
|
def call(name, **kwargs)
|
|
220
232
|
return { error: "Unknown tool: #{name}" } unless function?(name)
|
|
233
|
+
|
|
234
|
+
# Who the call is for is never one of the call's arguments: it comes
|
|
235
|
+
# off here, before a tool sees them. That keeps a built-in from
|
|
236
|
+
# meeting an unexpected keyword, and keeps the cache key below over
|
|
237
|
+
# the arguments alone.
|
|
238
|
+
actor = kwargs.delete(:actor)
|
|
239
|
+
|
|
240
|
+
if (schema_tool = ActionAgent.schema_tool_class_for(name.to_s))
|
|
241
|
+
# `actor:` is the host's authorization seam — the scope block runs
|
|
242
|
+
# inside the call. It is passed through untouched, including nil,
|
|
243
|
+
# so a host scope decides what an unattributed run may read rather
|
|
244
|
+
# than the engine widening it.
|
|
245
|
+
#
|
|
246
|
+
# Deliberately not cached: a scoped read is one caller's answer,
|
|
247
|
+
# and replaying it for the next caller would hand them rows their
|
|
248
|
+
# own scope would have refused.
|
|
249
|
+
return schema_tool.call(name.to_s, actor: actor, **kwargs)
|
|
250
|
+
end
|
|
251
|
+
|
|
221
252
|
return public_send(FUNCTIONS.fetch(name.to_s), **kwargs) if UNCACHED_FUNCTIONS.include?(name.to_s)
|
|
222
253
|
|
|
223
254
|
cached_fetch(name, kwargs) do
|
|
@@ -57,7 +57,15 @@ module ActionAgent
|
|
|
57
57
|
# contributes nothing rather than failing the run — the tools it serves
|
|
58
58
|
# then simply are not offered, and a scenario expecting them fails with a
|
|
59
59
|
# fault naming them.
|
|
60
|
+
#
|
|
61
|
+
# A server that fails discovery also records why, in +discovery_errors+.
|
|
62
|
+
# Contributing nothing keeps the run alive, but silence is indistinguishable
|
|
63
|
+
# from a server that legitimately serves no tools — and an agent offered no
|
|
64
|
+
# tools answers from the model alone, which reads as a confident, fabricated
|
|
65
|
+
# result rather than a transport failure (#425).
|
|
60
66
|
def tool_definitions
|
|
67
|
+
@discovery_errors = {}
|
|
68
|
+
|
|
61
69
|
resolver.declared_server_keys.flat_map do |key|
|
|
62
70
|
entry = MCPCatalog.find(key)
|
|
63
71
|
next [] unless entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
|
|
@@ -66,11 +74,35 @@ module ActionAgent
|
|
|
66
74
|
client_for(entry).list_tools
|
|
67
75
|
rescue MCPClient::Error => e
|
|
68
76
|
Rails.logger.warn("[MCPToolDispatcher] #{key} tools/list failed: #{e.message}")
|
|
77
|
+
@discovery_errors[key] =
|
|
78
|
+
"Cannot load tools from MCP server '#{key}' (#{entry[:url]}): #{e.message}. " \
|
|
79
|
+
"Check its URL, transport and credentials."
|
|
69
80
|
[]
|
|
70
81
|
end
|
|
71
82
|
end
|
|
72
83
|
end
|
|
73
84
|
|
|
85
|
+
# Why each declared server contributed no tools, keyed by server. Empty
|
|
86
|
+
# until +tool_definitions+ has run, and empty after a run where every
|
|
87
|
+
# declared server answered.
|
|
88
|
+
#
|
|
89
|
+
# @return [Hash{String => String}]
|
|
90
|
+
def discovery_errors
|
|
91
|
+
@discovery_errors ||= {}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Whether every server the agent declares failed discovery. The tool-less
|
|
95
|
+
# execution that follows cannot produce a meaningful result, so a caller
|
|
96
|
+
# can fail loudly instead of scoring an answer the model invented.
|
|
97
|
+
def all_servers_failed?
|
|
98
|
+
keys = resolver.declared_server_keys.select do |key|
|
|
99
|
+
entry = MCPCatalog.find(key)
|
|
100
|
+
entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
keys.any? && keys.all? { |key| discovery_errors.key?(key) }
|
|
104
|
+
end
|
|
105
|
+
|
|
74
106
|
private
|
|
75
107
|
|
|
76
108
|
attr_reader :agent, :resolver
|
|
@@ -80,6 +80,7 @@ module ActionAgent
|
|
|
80
80
|
scores: scores_for(report, run),
|
|
81
81
|
samples_evaluated: report.results.size,
|
|
82
82
|
samples_passed: report.results.count(&:passed?),
|
|
83
|
+
error_message: discovery_warning,
|
|
83
84
|
completed_at: Time.current
|
|
84
85
|
)
|
|
85
86
|
run
|
|
@@ -146,7 +147,8 @@ module ActionAgent
|
|
|
146
147
|
agent_run = @evaluation.agent.test_execute(
|
|
147
148
|
scenario.prompt,
|
|
148
149
|
model_override: spec.model,
|
|
149
|
-
provider_override: spec.provider
|
|
150
|
+
provider_override: spec.provider,
|
|
151
|
+
actor: replay_actor
|
|
150
152
|
)
|
|
151
153
|
|
|
152
154
|
Evals::Replay.new(
|
|
@@ -161,6 +163,21 @@ module ActionAgent
|
|
|
161
163
|
)
|
|
162
164
|
end
|
|
163
165
|
|
|
166
|
+
# The caller a replay runs on behalf of: the evaluation's owner, when the
|
|
167
|
+
# install owns agents per user. A run with no caller reads, through any
|
|
168
|
+
# host scope, as "no access" — every tool answers empty and the suite
|
|
169
|
+
# grades an agent that never saw a row — so the person the evaluation
|
|
170
|
+
# belongs to is the right default, as the key's owner is over MCP. An
|
|
171
|
+
# account is who is billed, not who is allowed (see Api::BaseController
|
|
172
|
+
# #agent_actor), so a multi-tenant install replays unattributed unless a
|
|
173
|
+
# host adapter (ActionAgent.scenario_evaluation_adapter_resolver) runs
|
|
174
|
+
# the suite itself.
|
|
175
|
+
def replay_actor
|
|
176
|
+
return nil if ActionAgent.multi_tenant? || ActionAgent.user_class.blank?
|
|
177
|
+
|
|
178
|
+
owner
|
|
179
|
+
end
|
|
180
|
+
|
|
164
181
|
# Each tool call the run made, rebuilt from the run's progress events
|
|
165
182
|
# (AgentRun#append_event pairs a "started" event with its "done"/"error"
|
|
166
183
|
# by eid). Falls back to the bare names in the run's metadata for a run
|
|
@@ -240,10 +257,48 @@ module ActionAgent
|
|
|
240
257
|
end
|
|
241
258
|
end
|
|
242
259
|
|
|
260
|
+
# Every tool the agent could actually call, for the diagnosis roster: the
|
|
261
|
+
# engine's own toolbox plus whatever its MCP servers serve.
|
|
262
|
+
#
|
|
263
|
+
# Listing only the toolbox understated the roster, so a diagnosis could
|
|
264
|
+
# report "none of the available tools covers this task" while naming a list
|
|
265
|
+
# the agent's MCP tools were missing from.
|
|
243
266
|
def tool_roster
|
|
244
|
-
@tool_roster ||=
|
|
245
|
-
|
|
267
|
+
@tool_roster ||= begin
|
|
268
|
+
definitions = mcp_dispatcher.tool_definitions +
|
|
269
|
+
AgentToolbox.definitions_for(@evaluation.agent.tools)
|
|
270
|
+
|
|
271
|
+
definitions.to_h { |definition| [ definition[:name].to_s, definition[:description].to_s ] }
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Discovery failures from the roster above, keyed by server. Reading them
|
|
276
|
+
# requires tool_definitions to have run, which tool_roster does.
|
|
277
|
+
def mcp_discovery_errors
|
|
278
|
+
tool_roster
|
|
279
|
+
mcp_dispatcher.discovery_errors
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def mcp_dispatcher
|
|
283
|
+
@mcp_dispatcher ||= MCPToolDispatcher.new(@evaluation.agent)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# A run whose every declared MCP server failed discovery scored an agent
|
|
287
|
+
# that had no tools to call. The scores are real but meaningless — the
|
|
288
|
+
# model answered from its own weights — so the run says so rather than
|
|
289
|
+
# leaving the cause in a log line (#425).
|
|
290
|
+
def discovery_warning
|
|
291
|
+
errors = mcp_discovery_errors
|
|
292
|
+
return nil if errors.empty?
|
|
293
|
+
|
|
294
|
+
prefix = if mcp_dispatcher.all_servers_failed?
|
|
295
|
+
"Every MCP server this agent declares failed tool discovery, so it ran with no MCP tools " \
|
|
296
|
+
"and any specifics in its answers are unverified."
|
|
297
|
+
else
|
|
298
|
+
"Some of this agent's MCP servers failed tool discovery, so part of its toolset was unavailable."
|
|
246
299
|
end
|
|
300
|
+
|
|
301
|
+
"#{prefix} #{errors.values.join(' ')}"
|
|
247
302
|
end
|
|
248
303
|
|
|
249
304
|
# The judge the evaluation's owner has credentials for, wrapped for the
|
data/config/routes.rb
CHANGED
|
@@ -38,6 +38,9 @@ ActionAgent::Engine.routes.draw do
|
|
|
38
38
|
post :duplicate
|
|
39
39
|
get :export
|
|
40
40
|
get :analytics
|
|
41
|
+
# The Tools tab's roster: offerable tools and MCP services, each
|
|
42
|
+
# with what the window recorded for it.
|
|
43
|
+
get :tool_roster
|
|
41
44
|
# The runner's conversation picker: this agent's persisted contexts,
|
|
42
45
|
# and a fresh one to pin a first message to.
|
|
43
46
|
get :conversations
|
data/lib/action_agent/version.rb
CHANGED
data/lib/action_agent.rb
CHANGED
|
@@ -145,6 +145,26 @@ module ActionAgent
|
|
|
145
145
|
# @return [Proc, nil]
|
|
146
146
|
attr_accessor :current_user_resolver
|
|
147
147
|
|
|
148
|
+
# Resolves the caller an agent run executes on behalf of — what reaches
|
|
149
|
+
# +ActiveAgent::Base#current_user+, a SchemaTools +scope+ block, and any
|
|
150
|
+
# authorization gem an agent calls from its callbacks.
|
|
151
|
+
#
|
|
152
|
+
# Called with the controller, so the same seam covers a browser session
|
|
153
|
+
# and an MCP request. Whatever it returns is passed through untouched:
|
|
154
|
+
# the engine never interprets an actor, and never widens one.
|
|
155
|
+
#
|
|
156
|
+
# config.agent_actor_resolver = ->(controller) { controller.current_user }
|
|
157
|
+
#
|
|
158
|
+
# Unset means the dashboard's signed-in user, and, for the MCP endpoint,
|
|
159
|
+
# the API key's owner — the identity that authenticated the call. A host
|
|
160
|
+
# whose keys are issued per end user overrides this to return that user.
|
|
161
|
+
#
|
|
162
|
+
# Returning nil runs the agent unattributed, which a correctly written
|
|
163
|
+
# host scope reads as "no access". That is the safe direction, and it is
|
|
164
|
+
# why this is never defaulted to something more privileged.
|
|
165
|
+
# @return [Proc, nil]
|
|
166
|
+
attr_accessor :agent_actor_resolver
|
|
167
|
+
|
|
148
168
|
# Resolves the current tenant from the controller. See
|
|
149
169
|
# current_user_resolver.
|
|
150
170
|
# @return [Proc, nil]
|
|
@@ -330,6 +350,42 @@ module ActionAgent
|
|
|
330
350
|
# @return [Array<Hash>]
|
|
331
351
|
attr_accessor :mcp_catalog
|
|
332
352
|
|
|
353
|
+
# Host-declared ActiveAgent::SchemaTools subclasses whose generated tools
|
|
354
|
+
# are offered alongside AgentToolbox's built-ins: each generated tool is
|
|
355
|
+
# individually selectable in the agent editor, dispatched by name at
|
|
356
|
+
# execution, and named in an evaluation's +tools:+ expectation.
|
|
357
|
+
#
|
|
358
|
+
# ActionAgent.configure do |config|
|
|
359
|
+
# config.schema_tools = [TicketTools, TaskTools, MilestoneTools]
|
|
360
|
+
# end
|
|
361
|
+
#
|
|
362
|
+
# Accepts classes or class-name strings; strings are resolved lazily so a
|
|
363
|
+
# host can declare them from an initializer before autoloading has run.
|
|
364
|
+
#
|
|
365
|
+
# Leave it unset and every ActiveAgent::SchemaTools subclass found in
|
|
366
|
+
# {#schema_tools_path} is offered instead — a host adds a tool by adding a
|
|
367
|
+
# file, without naming it twice.
|
|
368
|
+
# @return [Array<Class, String>, nil]
|
|
369
|
+
attr_accessor :schema_tools
|
|
370
|
+
|
|
371
|
+
# Whether the MCP facade (POST <mount>/mcp) offers the host's schema tools
|
|
372
|
+
# directly — find_<records>, count_<records>, get_<record> — beside the
|
|
373
|
+
# run_<slug> agent tools. Each call runs as the key's caller, through the
|
|
374
|
+
# host's own scope, exactly as it would inside an agent run. On by
|
|
375
|
+
# default: the host declared the tools; set it to false to keep them
|
|
376
|
+
# reachable only through agents.
|
|
377
|
+
# @return [Boolean]
|
|
378
|
+
attr_accessor :mcp_schema_tools
|
|
379
|
+
|
|
380
|
+
# Directory scanned for SchemaTools subclasses when {#schema_tools} is
|
|
381
|
+
# unset. Relative to the host's root. Set to nil to disable discovery and
|
|
382
|
+
# require an explicit declaration. Classes built at runtime with
|
|
383
|
+
# +ActiveAgent::SchemaTools.define+ are discovered alongside the files
|
|
384
|
+
# whatever this is set to; only an explicit {#schema_tools} list excludes
|
|
385
|
+
# them.
|
|
386
|
+
# @return [String, nil]
|
|
387
|
+
attr_accessor :schema_tools_path
|
|
388
|
+
|
|
333
389
|
# Value stored in polymorphic *_type columns for dashboard agents
|
|
334
390
|
# (agent_memories.memorable_type, agent_contexts.contextable_type).
|
|
335
391
|
# Unset means the class name. A host app whose existing rows were
|
|
@@ -344,6 +400,13 @@ module ActionAgent
|
|
|
344
400
|
@multi_tenant == true
|
|
345
401
|
end
|
|
346
402
|
|
|
403
|
+
# Whether the MCP facade serves the host's schema tools directly.
|
|
404
|
+
#
|
|
405
|
+
# @return [Boolean]
|
|
406
|
+
def mcp_schema_tools?
|
|
407
|
+
@mcp_schema_tools != false
|
|
408
|
+
end
|
|
409
|
+
|
|
347
410
|
# Returns whether agent execution is permitted.
|
|
348
411
|
#
|
|
349
412
|
# @return [Boolean]
|
|
@@ -495,6 +558,86 @@ module ActionAgent
|
|
|
495
558
|
@sign_out_path = nil
|
|
496
559
|
@sign_in_path = nil
|
|
497
560
|
@mcp_catalog = []
|
|
561
|
+
@agent_actor_resolver = nil
|
|
562
|
+
@schema_tools = nil
|
|
563
|
+
@schema_tools_path = "app/agent_tools"
|
|
564
|
+
@mcp_schema_tools = nil
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Host-declared schema tool classes, resolved from names and filtered to
|
|
568
|
+
# those that are usable (declared a model and generated a roster).
|
|
569
|
+
#
|
|
570
|
+
# Resolution happens per call rather than at configure time: a host
|
|
571
|
+
# declares these in an initializer, before its own classes are autoloaded.
|
|
572
|
+
# @return [Array<Class>]
|
|
573
|
+
def schema_tool_classes
|
|
574
|
+
declared = @schema_tools.nil? ? discovered_schema_tools : Array(@schema_tools)
|
|
575
|
+
|
|
576
|
+
declared.filter_map do |entry|
|
|
577
|
+
klass = entry.is_a?(String) ? entry.safe_constantize : entry
|
|
578
|
+
next unless klass.respond_to?(:tool_definitions) && klass.respond_to?(:model)
|
|
579
|
+
next if klass.model.blank?
|
|
580
|
+
# An anonymous class built at runtime is usable but not discoverable —
|
|
581
|
+
# it would accumulate across reloads with no way to supersede itself.
|
|
582
|
+
next if entry.is_a?(Class) && klass.name.blank? && @schema_tools.nil?
|
|
583
|
+
|
|
584
|
+
klass
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Every tool name generated by the declared schema tool classes.
|
|
589
|
+
# @return [Array<String>]
|
|
590
|
+
def schema_tool_names
|
|
591
|
+
schema_tool_classes.flat_map(&:tool_names).map(&:to_s)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# SchemaTools classes on offer when {#schema_tools} is unset: the files
|
|
595
|
+
# under {#schema_tools_path}, and every runtime definition in
|
|
596
|
+
# +ActiveAgent::SchemaTools.registry+.
|
|
597
|
+
#
|
|
598
|
+
# The files are loaded before reading +descendants+: in development nothing
|
|
599
|
+
# has referenced those constants yet, so the list would otherwise be empty
|
|
600
|
+
# at boot and fill in only once something happened to touch them.
|
|
601
|
+
# Runtime-built classes are read from the registry, never from
|
|
602
|
+
# +descendants+, where every class ever built stays until collected and a
|
|
603
|
+
# superseded definition would be offered beside its replacement. A runtime
|
|
604
|
+
# definition for a model also supersedes a file for that model: it is the
|
|
605
|
+
# more recent intent.
|
|
606
|
+
# @return [Array<Class>]
|
|
607
|
+
def discovered_schema_tools
|
|
608
|
+
return [] unless defined?(ActiveAgent::SchemaTools)
|
|
609
|
+
|
|
610
|
+
from_registry = ActiveAgent::SchemaTools.respond_to?(:registry) ? ActiveAgent::SchemaTools.registry.values : []
|
|
611
|
+
(from_registry + file_defined_schema_tools).uniq { |klass| klass.model.name }
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# The named SchemaTools subclasses under {#schema_tools_path}; none when
|
|
615
|
+
# the path is unset or the directory does not exist.
|
|
616
|
+
# @return [Array<Class>]
|
|
617
|
+
def file_defined_schema_tools
|
|
618
|
+
return [] if @schema_tools_path.blank?
|
|
619
|
+
return [] unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
620
|
+
|
|
621
|
+
root = Rails.root.join(@schema_tools_path)
|
|
622
|
+
return [] unless Dir.exist?(root)
|
|
623
|
+
|
|
624
|
+
Dir[root.join("**/*.rb")].sort.each do |path|
|
|
625
|
+
require_dependency path
|
|
626
|
+
rescue StandardError, ScriptError => e
|
|
627
|
+
warn "[ActionAgent] could not load #{path}: #{e.class} - #{e.message}"
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
ActiveAgent::SchemaTools.descendants.select { |klass| klass.name.present? && !runtime_schema_tools?(klass) }
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def runtime_schema_tools?(klass)
|
|
634
|
+
klass.respond_to?(:runtime?) && klass.runtime?
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# The schema tool class that generated +name+, or nil.
|
|
638
|
+
# @return [Class, nil]
|
|
639
|
+
def schema_tool_class_for(name)
|
|
640
|
+
schema_tool_classes.find { |klass| klass.tool?(name) }
|
|
498
641
|
end
|
|
499
642
|
end
|
|
500
643
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: actionagent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Justin Bowen
|
|
@@ -193,6 +193,7 @@ files:
|
|
|
193
193
|
- app/services/action_agent/agent_execution_service.rb
|
|
194
194
|
- app/services/action_agent/agent_registrar.rb
|
|
195
195
|
- app/services/action_agent/agent_scorecard.rb
|
|
196
|
+
- app/services/action_agent/agent_tool_roster.rb
|
|
196
197
|
- app/services/action_agent/agent_toolbox.rb
|
|
197
198
|
- app/services/action_agent/dashboard_assistant_service.rb
|
|
198
199
|
- app/services/action_agent/evaluation_evidence.rb
|
|
@@ -251,7 +252,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
251
252
|
- !ruby/object:Gem::Version
|
|
252
253
|
version: '0'
|
|
253
254
|
requirements: []
|
|
254
|
-
rubygems_version:
|
|
255
|
+
rubygems_version: 3.6.9
|
|
255
256
|
specification_version: 4
|
|
256
257
|
summary: The Active Agent dashboard, as a mountable Rails engine
|
|
257
258
|
test_files: []
|