actionagent 1.5.0 → 1.5.2

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.
@@ -66,7 +66,7 @@ module ActionAgent
66
66
  providers: Agent::PROVIDERS,
67
67
  preset_types: Agent::PRESET_TYPES,
68
68
  instruction_sets: Agent::INSTRUCTION_SETS,
69
- available_tools: Agent::AVAILABLE_TOOLS
69
+ available_tools: Agent.available_tools
70
70
  }
71
71
  }
72
72
  end
@@ -41,7 +41,7 @@ module ActionAgent
41
41
  providers: Agent::PROVIDERS,
42
42
  presetTypes: Agent::PRESET_TYPES,
43
43
  instructionSets: Agent::INSTRUCTION_SETS,
44
- availableTools: Agent::AVAILABLE_TOOLS,
44
+ availableTools: Agent.available_tools,
45
45
  executionEnabled: ActionAgent.execution_enabled?,
46
46
  assistantEnabled: ActionAgent.assistant_enabled?,
47
47
  multiTenant: ActionAgent.multi_tenant?,
@@ -41,6 +41,7 @@ module ActionAgent
41
41
 
42
42
  # Callbacks
43
43
  before_validation :generate_slug, on: :create
44
+ before_validation :apply_conventional_schema_tools, on: :create
44
45
  after_create :create_initial_version
45
46
  after_update :create_version_on_config_change, if: :configuration_changed?
46
47
 
@@ -72,17 +73,62 @@ module ActionAgent
72
73
  github ruby rails aws gcp python typescript docker kubernetes
73
74
  ].freeze
74
75
 
75
- # Available tools/MCPs
76
+ # Built-in tools/MCPs. Host-declared schema tools are offered alongside
77
+ # these — see .available_tools, which is what the editor and the APIs
78
+ # serialize. This constant stays the built-in set so existing references
79
+ # keep their meaning.
76
80
  AVAILABLE_TOOLS = %w[
77
81
  terminal playwright filesystem code database slack fetch search edit translate memory agents ui
78
82
  ].freeze
79
83
 
84
+ # Every tool an agent may enable: the built-ins plus each tool generated by
85
+ # the host's declared ActiveAgent::SchemaTools classes (ActionAgent.schema_tools).
86
+ #
87
+ # Computed per call, never memoized: in development the host's tool classes
88
+ # are autoloaded and reloaded, so a cached list would either miss them at
89
+ # boot or go stale after a reload.
90
+ # @return [Array<String>]
91
+ def self.available_tools
92
+ AVAILABLE_TOOLS | ActionAgent.schema_tool_names
93
+ end
94
+
80
95
  # Available providers
81
96
  PROVIDERS = %w[openai anthropic ollama openrouter].freeze
82
97
 
83
98
  # The ActiveAgent class name this agent's runs are recorded under — the
84
99
  # correlation key between platform Agent records and telemetry traces
85
100
  # (TelemetryTrace#agent_class) and solid_agent contexts.
101
+ # Tools a schema tool class claims for this agent by naming convention:
102
+ # Reservation -> ReservationTools -> ReservationAgent.
103
+ #
104
+ # This is a DEFAULT SELECTION, never a restriction. Any agent may enable
105
+ # any tool in .available_tools; the convention only decides what a newly
106
+ # created ReservationAgent starts with.
107
+ # @return [Array<String>]
108
+ def conventional_schema_tools
109
+ # Compared on letters only. `telemetry_agent_class` is not reliable here:
110
+ # it runs `parameterize.camelize`, which turns an already-camelised
111
+ # "TicketAgent" into "Ticketagent" and matches nothing, while
112
+ # "Milestone Agent" happens to survive. Normalising both sides makes
113
+ # "TicketAgent", "Ticket Agent" and "ticket_agent" all match.
114
+ identifier = (agent_class_name.presence || name.to_s).gsub(/[^a-z]/i, "").downcase
115
+ return [] if identifier.blank?
116
+
117
+ ActionAgent.schema_tool_classes.select do |klass|
118
+ "#{klass.model.name}Agent".downcase == identifier
119
+ end.flat_map(&:tool_names).map(&:to_s)
120
+ end
121
+
122
+ # Seeds a new agent named after a model with that model's tools. Only on
123
+ # create, and only when none were chosen — a deliberate selection, empty
124
+ # included, is never overwritten, and the editor can deselect afterwards.
125
+ def apply_conventional_schema_tools
126
+ return if tools.present?
127
+
128
+ defaults = conventional_schema_tools
129
+ self.tools = defaults if defaults.any?
130
+ end
131
+
86
132
  def telemetry_agent_class
87
133
  base = agent_class_name.presence || name.parameterize(separator: "_").camelize
88
134
  base.end_with?("Agent") ? base : "#{base}Agent"
@@ -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,16 @@ 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
+ schema_tool = ActionAgent.schema_tool_class_for(name.to_s)
235
+ if schema_tool
236
+ # `actor:` is the host's authorization seam — the scope block runs
237
+ # inside the call. It is passed through untouched, including nil,
238
+ # so a host scope decides what an unattributed run may read rather
239
+ # than the engine widening it.
240
+ return schema_tool.call(name.to_s, actor: kwargs.delete(:actor), **kwargs)
241
+ end
242
+
221
243
  return public_send(FUNCTIONS.fetch(name.to_s), **kwargs) if UNCACHED_FUNCTIONS.include?(name.to_s)
222
244
 
223
245
  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
@@ -240,10 +241,48 @@ module ActionAgent
240
241
  end
241
242
  end
242
243
 
244
+ # Every tool the agent could actually call, for the diagnosis roster: the
245
+ # engine's own toolbox plus whatever its MCP servers serve.
246
+ #
247
+ # Listing only the toolbox understated the roster, so a diagnosis could
248
+ # report "none of the available tools covers this task" while naming a list
249
+ # the agent's MCP tools were missing from.
243
250
  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 ]
251
+ @tool_roster ||= begin
252
+ definitions = mcp_dispatcher.tool_definitions +
253
+ AgentToolbox.definitions_for(@evaluation.agent.tools)
254
+
255
+ definitions.to_h { |definition| [ definition[:name].to_s, definition[:description].to_s ] }
256
+ end
257
+ end
258
+
259
+ # Discovery failures from the roster above, keyed by server. Reading them
260
+ # requires tool_definitions to have run, which tool_roster does.
261
+ def mcp_discovery_errors
262
+ tool_roster
263
+ mcp_dispatcher.discovery_errors
264
+ end
265
+
266
+ def mcp_dispatcher
267
+ @mcp_dispatcher ||= MCPToolDispatcher.new(@evaluation.agent)
268
+ end
269
+
270
+ # A run whose every declared MCP server failed discovery scored an agent
271
+ # that had no tools to call. The scores are real but meaningless — the
272
+ # model answered from its own weights — so the run says so rather than
273
+ # leaving the cause in a log line (#425).
274
+ def discovery_warning
275
+ errors = mcp_discovery_errors
276
+ return nil if errors.empty?
277
+
278
+ prefix = if mcp_dispatcher.all_servers_failed?
279
+ "Every MCP server this agent declares failed tool discovery, so it ran with no MCP tools " \
280
+ "and any specifics in its answers are unverified."
281
+ else
282
+ "Some of this agent's MCP servers failed tool discovery, so part of its toolset was unavailable."
246
283
  end
284
+
285
+ "#{prefix} #{errors.values.join(' ')}"
247
286
  end
248
287
 
249
288
  # The judge the evaluation's owner has credentials for, wrapped for the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.5.0"
4
+ VERSION = "1.5.2"
5
5
  end
data/lib/action_agent.rb CHANGED
@@ -330,6 +330,30 @@ module ActionAgent
330
330
  # @return [Array<Hash>]
331
331
  attr_accessor :mcp_catalog
332
332
 
333
+ # Host-declared ActiveAgent::SchemaTools subclasses whose generated tools
334
+ # are offered alongside AgentToolbox's built-ins: each generated tool is
335
+ # individually selectable in the agent editor, dispatched by name at
336
+ # execution, and named in an evaluation's +tools:+ expectation.
337
+ #
338
+ # ActionAgent.configure do |config|
339
+ # config.schema_tools = [TicketTools, TaskTools, MilestoneTools]
340
+ # end
341
+ #
342
+ # Accepts classes or class-name strings; strings are resolved lazily so a
343
+ # host can declare them from an initializer before autoloading has run.
344
+ #
345
+ # Leave it unset and every ActiveAgent::SchemaTools subclass found in
346
+ # {#schema_tools_path} is offered instead — a host adds a tool by adding a
347
+ # file, without naming it twice.
348
+ # @return [Array<Class, String>, nil]
349
+ attr_accessor :schema_tools
350
+
351
+ # Directory scanned for SchemaTools subclasses when {#schema_tools} is
352
+ # unset. Relative to the host's root. Set to nil to disable discovery and
353
+ # require an explicit declaration.
354
+ # @return [String, nil]
355
+ attr_accessor :schema_tools_path
356
+
333
357
  # Value stored in polymorphic *_type columns for dashboard agents
334
358
  # (agent_memories.memorable_type, agent_contexts.contextable_type).
335
359
  # Unset means the class name. A host app whose existing rows were
@@ -495,6 +519,63 @@ module ActionAgent
495
519
  @sign_out_path = nil
496
520
  @sign_in_path = nil
497
521
  @mcp_catalog = []
522
+ @schema_tools = nil
523
+ @schema_tools_path = "app/agent_tools"
524
+ end
525
+
526
+ # Host-declared schema tool classes, resolved from names and filtered to
527
+ # those that are usable (declared a model and generated a roster).
528
+ #
529
+ # Resolution happens per call rather than at configure time: a host
530
+ # declares these in an initializer, before its own classes are autoloaded.
531
+ # @return [Array<Class>]
532
+ def schema_tool_classes
533
+ declared = @schema_tools.nil? ? discovered_schema_tools : Array(@schema_tools)
534
+
535
+ declared.filter_map do |entry|
536
+ klass = entry.is_a?(String) ? entry.safe_constantize : entry
537
+ next unless klass.respond_to?(:tool_definitions) && klass.respond_to?(:model)
538
+ next if klass.model.blank?
539
+ # An anonymous class built at runtime is usable but not discoverable —
540
+ # it would accumulate across reloads with no way to supersede itself.
541
+ next if entry.is_a?(Class) && klass.name.blank? && @schema_tools.nil?
542
+
543
+ klass
544
+ end
545
+ end
546
+
547
+ # Every tool name generated by the declared schema tool classes.
548
+ # @return [Array<String>]
549
+ def schema_tool_names
550
+ schema_tool_classes.flat_map(&:tool_names).map(&:to_s)
551
+ end
552
+
553
+ # SchemaTools subclasses defined under {#schema_tools_path}.
554
+ #
555
+ # The files are loaded before reading +descendants+: in development nothing
556
+ # has referenced those constants yet, so the list would otherwise be empty
557
+ # at boot and fill in only once something happened to touch them.
558
+ # @return [Array<Class>]
559
+ def discovered_schema_tools
560
+ return [] if @schema_tools_path.blank? || !defined?(ActiveAgent::SchemaTools)
561
+ return [] unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
562
+
563
+ root = Rails.root.join(@schema_tools_path)
564
+ return [] unless Dir.exist?(root)
565
+
566
+ Dir[root.join("**/*.rb")].sort.each do |path|
567
+ require_dependency path
568
+ rescue StandardError, ScriptError => e
569
+ warn "[ActionAgent] could not load #{path}: #{e.class} - #{e.message}"
570
+ end
571
+
572
+ ActiveAgent::SchemaTools.descendants.select { |klass| klass.name.present? }
573
+ end
574
+
575
+ # The schema tool class that generated +name+, or nil.
576
+ # @return [Class, nil]
577
+ def schema_tool_class_for(name)
578
+ schema_tool_classes.find { |klass| klass.tool?(name) }
498
579
  end
499
580
  end
500
581
 
metadata CHANGED
@@ -1,13 +1,14 @@
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.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activeagent
@@ -237,6 +238,7 @@ metadata:
237
238
  documentation_uri: https://docs.activeagents.ai/framework/self-hosted-observability
238
239
  source_code_uri: https://github.com/activeagents/activeagent
239
240
  rubygems_mfa_required: 'true'
241
+ post_install_message:
240
242
  rdoc_options: []
241
243
  require_paths:
242
244
  - lib
@@ -251,7 +253,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
251
253
  - !ruby/object:Gem::Version
252
254
  version: '0'
253
255
  requirements: []
254
- rubygems_version: 4.0.16
256
+ rubygems_version: 3.5.22
257
+ signing_key:
255
258
  specification_version: 4
256
259
  summary: The Active Agent dashboard, as a mountable Rails engine
257
260
  test_files: []