Module: Axn::Core::ToolDeclaration::ClassMethods
- Defined in:
- lib/axn/core/tool_declaration.rb
Instance Method Summary collapse
-
#inherited(subclass) ⇒ Object
A concrete tool commonly SUBCLASSES an Axn base (
class MyTool < ApplicationAction) rather than including Axn directly, soAxn.includednever re-fires for it and the registry would otherwise omit it. -
#tool(*adapters, name: nil, except: EXCEPT_OMITTED, **bags) ⇒ Object
Declares tool membership.
-
#tool_name(adapter = nil) ⇒ Object
The provider-facing tool name.
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.
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 |