Module: Axn::Core::ToolDeclaration::ClassMethods

Defined in:
lib/axn/core/tool_declaration.rb

Instance Method Summary collapse

Instance Method Details

#inherited(subclass) ⇒ Object

A concrete tool commonly SUBCLASSES an Axn base (class MyTool < ApplicationAction) rather than including Axn directly, so Axn.included never re-fires for it and the registry would otherwise omit it. Register every subclass here too. super runs FIRST so other libraries' inherited hooks (ActiveSupport::DescendantsTracker, Mountable's own, a user base class's) stay intact — Class#inherited is a no-op by default, so keeping it in the chain is safe. Registration is idempotent (Registry uses a Set).



40
41
42
43
# File 'lib/axn/core/tool_declaration.rb', line 40

def inherited(subclass)
  super
  Axn::Tools::Registry.register_class(subclass)
end

#tool(*adapters, name: nil, except: EXCEPT_OMITTED, **bags) ⇒ Object

Declares tool membership. Final membership is (directory grant ∪ this declaration) − except. tool -> grant every registered adapter (regardless of directory) tool :mcp, :ruby_llm -> add these adapters to the directory grant tool false -> opt out of every adapter (a helper Axn living under a tool root) tool except: :ruby_llm-> directory grant, minus :ruby_llm (pure narrowing; grants nothing itself) tool name: "…" -> grant all adapters, with a provider-name override tool mcp: { title: "…" } -> add :mcp with per-adapter config (sugar over configure(:mcp)); a bag name: overrides the provider name for that adapter only Unknown adapter symbols are stored as-is (adapters self-register at load; a hard check here would be load-order-hostile) and simply never match Axn::Tools.for.

Raises:

  • (ArgumentError)


55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/axn/core/tool_declaration.rb', line 55

def tool(*adapters, name: nil, except: EXCEPT_OMITTED, **bags)
  # Per-class guard (a plain ivar on the class object, which subclasses do NOT inherit):
  # a second `tool` on the SAME class would silently overwrite _tool_declaration (last-wins),
  # changing membership at enumeration time instead of failing here. Per axn's fail-at-declaration
  # doctrine, reject the repeat. A subclass declaring its own `tool` is a fresh first call
  # (fresh object, no ivar) and is fine.
  if instance_variable_defined?(:@__axn_tool_declared)
    raise ArgumentError, "`tool` was already declared on #{self}; declare all adapters, `name:`, `except:`, and " \
                         "per-adapter options in a single call (e.g. `tool :mcp, ruby_llm: { … }, name: \"...\"`)."
  end
  @__axn_tool_declared = true

  except_given = !except.equal?(EXCEPT_OMITTED)

  if adapters.include?(false)
    if adapters.length > 1 || !name.nil? || bags.any? || except_given
      raise ArgumentError, "`tool false` opts out; it can't be combined with adapters, `name:`, `except:`, or per-adapter options"
    end

    self._tool_name_override = nil
    self._tool_name_overrides = {}.freeze
    self._tool_except = [].freeze
    self._tool_declaration = false
    return
  end

  except_list = except_given ? Array(except).uniq : []

  # Adapter identity must be a Symbol everywhere it appears — positional, bag key, or except —
  # so membership stays Symbol-keyed end to end (a `**string_keyed` splat can smuggle a String).
  non_symbols = (adapters + bags.keys + except_list).reject { |a| a.is_a?(Symbol) }
  raise ArgumentError, "tool adapters must be Symbols (e.g. `tool :mcp`); got #{non_symbols.inspect}" if non_symbols.any?

  non_hash = bags.reject { |_adapter, opts| opts.is_a?(Hash) }
  unless non_hash.empty?
    raise ArgumentError,
          "tool per-adapter options must be Hashes (e.g. `tool mcp: { title: \"...\" }`); got #{non_hash.inspect}"
  end

  # A shared `name:` that sanitizes away entirely (e.g. "!!!" or whitespace-only) would yield a
  # blank tool_name, violating the never-blank contract. Fail at declaration. A nil name is not an error.
  if !name.nil? && _tool_name_sanitize(name).empty?
    raise ArgumentError,
          "tool name: #{name.inspect} has no provider-safe characters ([a-z0-9_]); " \
          "provide a name containing at least one such character"
  end

  # Always assign (even when name is nil): `_tool_name_override` is a class_attribute, so a fresh
  # `tool` without `name:` must clear an inherited override rather than let the parent's leak through.
  self._tool_name_override = name
  self._tool_except = except_list.freeze

  # Membership grant from the declaration:
  #   - an explicit list (positional adapters ∪ bag keys) grants exactly those adapters;
  #   - a broad gesture with no list — bare `tool`, or `tool name:` — grants every registered
  #     adapter (:all);
  #   - a bare `except:` (narrowing with no adapters/bags/name) grants nothing itself and relies
  #     on the directory grant (an empty Array — NOT :all, which would re-expose the tool to
  #     every adapter but the excepted one, defeating directory scoping). Its base is the
  #     directory grant whether `except:` is empty, populated, or an explicit nil — passing the
  #     keyword at all selects the narrowing form.
  # `name:` is a broad gesture, so `tool name:, except:` stays :all-minus-except rather than
  # collapsing to directory-only.
  declared = (adapters + bags.keys).uniq
  narrowing_only = declared.empty? && name.nil? && except_given
  self._tool_declaration =
    if declared.any?
      declared
    elsif narrowing_only
      []
    else
      :all
    end

  _apply_tool_bags!(bags)

  nil
end

#tool_name(adapter = nil) ⇒ Object

The provider-facing tool name. With an adapter, a per-adapter tool <adapter>: { name: } override wins first; then an explicit shared tool name:; then derivation from axn_name/class name (strip configured prefixes, snake_case, restrict to [a-z0-9_], never blank). Zero-arg tool_name skips the per-adapter tier and is unchanged. The adapter arg is consumed internally by the registry; users never pass it.



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/axn/core/tool_declaration.rb', line 139

def tool_name(adapter = nil)
  if adapter && (raw = _tool_name_overrides[adapter])
    sanitized = _tool_name_sanitize(raw)
    return sanitized unless sanitized.empty?
  end

  # Defense-in-depth: the `tool` DSL rejects an override that sanitizes to empty, but an
  # override set through some other path must still never produce a blank name — sanitize and fall through.
  override = _tool_name_override
  if override
    sanitized_override = _tool_name_sanitize(override)
    return sanitized_override unless sanitized_override.empty?
  end

  # `axn_name.presence || name.presence` — NOT `resolved_axn_name` — so a truly nameless class
  # falls back to "tool" below rather than deriving from the "Anonymous Axn" sentinel.
  source = axn_name.presence || name.presence
  return "tool" if source.nil? || source.strip.empty?

  # The `::Vn`-drop is a Ruby-constant convention (the filesystem promotion), so apply it
  # only when deriving from the class constant. An explicit `axn_name` is author-chosen and
  # taken literally: `axn_name "Payments::V2"` derives `payments_v2`, never `payments`.
  segments = source.split("::")
  segments = _apply_version_segment_rule(segments) if axn_name.blank?
  kept = _tool_name_strip_leading_prefixes(segments)
  derived = _tool_name_sanitize(kept.map(&:underscore).join("_"))
  return derived unless derived.empty?

  last = _tool_name_sanitize(segments.last.to_s.underscore)
  last.empty? ? "tool" : last
end