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.
@@ -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 { |name| DEFINITIONS[name.to_s] || [] }
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 ||= AgentToolbox.definitions_for(@evaluation.agent.tools).to_h do |definition|
245
- [ definition[:name].to_s, definition[:description].to_s ]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
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.5.0
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: 4.0.16
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: []