Module: Legion::Settings::Extensions::Normalizer

Defined in:
lib/legion/settings/extensions/normalizer.rb

Overview

Normalizes tool, runner, and extension entries into complete, known schemas.

This is the single source of truth for what fields exist on each entry type. Every field that ANY consumer reads is defined here. Consumers can access any field without defensive nil-checks — absent values are explicitly nil, empty arrays, or empty hashes.

If a new consumer needs a field, add it here — don’t rely on passthrough.

Class Method Summary collapse

Class Method Details

.normalize_extension(name, metadata) ⇒ Object


Extension: a loaded LEX gem with runners, actors, and tools


Consumers:

LegionIO boot pipeline: phased loading, lifecycle management
LegionIO HandleRegistry: state tracking, hot reload
LegionIO Registry::Governance: approval, risk tier, naming
LegionIO Registry::SecurityScanner: checksum, static analysis
LegionIO Catalog::Available: static listing
LegionIO API: extension listing, diagnostics
legion-mcp: extension_info resource
legion-llm: extension filter in tool queries


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
133
134
135
136
137
138
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
170
171
172
173
174
175
176
# File 'lib/legion/settings/extensions/normalizer.rb', line 99

def normalize_extension(name, ) # rubocop:disable Metrics/AbcSize
  segments = resolve_segments(name, )
  {
    # Identity (derived from gem name via Helpers::Segments conventions)
    name:                     name.to_s,
    gem_name:                 resolve_string(, :gem_name) || name.to_s,
    description:              resolve_string(, :description),
    version:                  resolve_string(, :version),
    const_path:               resolve_string(, :const_path),
    segments:                 segments,
    lex_name:                 resolve_string(, :lex_name) || segments.join('_'),
    lex_slug:                 resolve_string(, :lex_slug) || segments.join('.'),
    amqp_prefix:              resolve_string(, :amqp_prefix),
    settings_path:            resolve_string(, :settings_path),
    table_prefix:             resolve_string(, :table_prefix),

    # Lifecycle state
    state:                    .fetch(:state, :discovered).to_sym,
    loaded_at:                [:loaded_at],
    last_error:               [:last_error],

    # Boot classification
    category:                 [:category],
    tier:                     [:tier],
    phase:                    [:phase],

    # Requirement flags — queryable WITHOUT loading the extension module.
    # LegionIO boot checks these to skip extensions whose deps aren't ready.
    # Defaults match Core module defaults so unset flags behave identically.
    data_required:            .fetch(:data_required, false) == true,
    cache_required:           .fetch(:cache_required, false) == true,
    transport_required:       .fetch(:transport_required, true) == true,
    crypt_required:           .fetch(:crypt_required, false) == true,
    vault_required:           .fetch(:vault_required, false) == true,
    llm_required:             .fetch(:llm_required, false) == true,
    skills_required:          .fetch(:skills_required, false) == true,
    remote_invocable:         .fetch(:remote_invocable, true) == true,

    # Extension contents
    runners:                  Array([:runners]),
    actors:                   Array([:actors]),
    tools:                    Array([:tools]),
    absorbers:                Array([:absorbers]),
    routes:                   Array([:routes]),

    # Gem metadata
    spec:                     [:spec],
    gem_dir:                  resolve_string(, :gem_dir),
    active_version:           resolve_string(, :active_version),
    latest_installed_version: resolve_string(, :latest_installed_version),
    loaded_features:          Array([:loaded_features]),

    # Reload support
    reload_state:             .fetch(:reload_state, :idle),
    hot_reloadable:           [:hot_reloadable] == true,

    # Governance / security
    author:                   resolve_string(, :author),
    risk_tier:                resolve_string(, :risk_tier),
    airb_status:              resolve_string(, :airb_status),
    permissions:              Array([:permissions]),
    checksum:                 resolve_string(, :checksum),

    # Tool behavior defaults
    mcp_tools:                .fetch(:mcp_tools, true) == true,
    mcp_tools_deferred:       .fetch(:mcp_tools_deferred, true) == true,
    sticky_tools:             .fetch(:sticky_tools, true) == true,

    # Extension settings — the complete declared configuration with
    # effective runtime values (defaults merged with user overrides).
    # Enables introspection: "what can I configure?" and "what's the
    # current value?" without loading the extension module.
    # Populated by LegionIO from default_settings merged with
    # Legion::Settings[:extensions][:lex_name] at registration time.
    settings_schema:          [:settings_schema] || {},
    settings:                 [:settings] || {}
  }
end

.normalize_runner(name, metadata) ⇒ Object


Runner: a module on an extension that exposes callable functions


Consumers:

LegionIO Tools::Discovery: function synthesis, schema building
legion-mcp runner_catalog: runner listing
legion-mcp FunctionDiscovery: tool building from runner methods


66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/legion/settings/extensions/normalizer.rb', line 66

def normalize_runner(name, )
  {
    # Identity
    name:          name.to_s,
    extension:     resolve_string(, :extension),
    runner_module: resolve_string(, :runner_module),

    # Functions exposed by this runner
    function:      resolve_string(, :function),
    functions:     [:functions] || [:class_methods] || {},
    exposed:       .fetch(:exposed, true) == true,
    definition:    [:definition],

    # MCP/tool behavior inherited by functions on this runner
    mcp_tools:     [:mcp_tools],
    mcp_deferred:  [:mcp_deferred],
    trigger_words: Array([:trigger_words]).map(&:to_s)
  }
end

.normalize_tool(name, metadata) ⇒ Object


Tool: generated from an exposed runner function or hand-authored


Consumers:

legion-llm: ToolDefinition wire format, executor tool loop, dispatcher
legion-mcp: ToolAdapter, server registration, deferred registry
legion-rbac: access control by extension/function
LegionIO API: tool listing, diagnostics


26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/legion/settings/extensions/normalizer.rb', line 26

def normalize_tool(name, )
  {
    # Identity
    name:          name.to_s,
    description:   resolve_string(, :description),
    input_schema:  resolve_schema(),

    # Execution
    tool_class:    [:tool_class],
    dispatch_type: resolve_dispatch_type(),

    # Back-references to owning extension/runner/function
    extension:     resolve_string(, :extension) || resolve_string(, :ext_name),
    runner:        resolve_string(, :runner) || resolve_string(, :runner_snake),
    function:      resolve_string(, :function),

    # Classification
    deferred:      [:deferred] == true,
    sticky:        .fetch(:sticky, true) == true,
    mcp_tier:      [:mcp_tier],
    mcp_category:  resolve_string(, :mcp_category),
    trigger_words: Array([:trigger_words]).map(&:to_s),
    tags:          Array([:tags]).map(&:to_s),
    source:        .fetch(:source, :unknown).to_sym,

    # Confidence / override tracking (written by Tools::Confidence)
    confidence:    [:confidence],
    hit_count:     [:hit_count],
    miss_count:    [:miss_count]
  }
end

.resolve_dispatch_type(metadata) ⇒ Object


Dispatch type detection




191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/legion/settings/extensions/normalizer.rb', line 191

def resolve_dispatch_type()
  return [:dispatch_type].to_sym if [:dispatch_type]

  tool_class = [:tool_class]
  return :runner if tool_class.nil? && [:extension] && [:function]
  return :none unless tool_class

  if tool_class.respond_to?(:new) && tool_class.method_defined?(:execute)
    :instance
  elsif tool_class.respond_to?(:call)
    :class_call
  else
    :none
  end
end

.resolve_schema(metadata) ⇒ Object


Schema resolution




182
183
184
185
# File 'lib/legion/settings/extensions/normalizer.rb', line 182

def resolve_schema()
  schema = [:input_schema] || [:parameters] || [:params_schema]
  schema.is_a?(Hash) ? schema : {}
end

.resolve_segments(name, metadata) ⇒ Object

Derive segments from the published gem name. No magic, no lookup tables.

Rules (matching Ruby gem → module conventions):

dash '-'       = module boundary:   lex-agentic-learning → ['agentic', 'learning'] → Agentic::Learning
underscore '_' = CamelCase inside:  lex-microsoft_teams  → ['microsoft_teams']     → MicrosoftTeams

Examples:

lex-github              → ['github']                       → Legion::Extensions::Github
lex-agentic-learning    → ['agentic', 'learning']          → Legion::Extensions::Agentic::Learning
lex-llm-openai          → ['llm', 'openai']               → Legion::Extensions::Llm::Openai
lex-llm-azure-foundry   → ['llm', 'azure', 'foundry']     → Legion::Extensions::Llm::Azure::Foundry
lex-llm-azure_foundry   → ['llm', 'azure_foundry']        → Legion::Extensions::Llm::AzureFoundry
lex-microsoft_teams     → ['microsoft_teams']              → Legion::Extensions::MicrosoftTeams


225
226
227
228
229
230
231
# File 'lib/legion/settings/extensions/normalizer.rb', line 225

def resolve_segments(name, )
  return Array([:segments]) if [:segments]&.any?

  gem = ([:gem_name] || name).to_s
  base = gem.start_with?('lex-') ? gem.sub(/\Alex-/, '') : gem
  base.split('-')
end

.resolve_string(metadata, key) ⇒ Object



207
208
209
210
# File 'lib/legion/settings/extensions/normalizer.rb', line 207

def resolve_string(, key)
  value = [key]
  value&.to_s
end