legion-mcp 0.1.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bef62b03828f3bda4594cb1eb2fa020da240b3df72db55412a65f854a515f40
4
- data.tar.gz: fb5272985978c3d78b03c20b4dafc724cd869f20e7cf25daabdbacae715a889a
3
+ metadata.gz: bc7341c88afa89639cebd05ccffc1dfb00792062373fe295743f525c9c0380c9
4
+ data.tar.gz: d2b832be2e2942286489edad666215e13f8de19b888a32551bdedef4e70b1f7f
5
5
  SHA512:
6
- metadata.gz: 46793c8c4aa53e27f5831cbfec97984209fda9233325001b2fd2f87bf34502e1ee5a7196243ac90fb79cd5dd2351f2fc3078f952680ee25c735534150bc5b984
7
- data.tar.gz: 4cd1ff55314687a3d5aa7028b53393d3997c53e455bd170cf96b1a93a1486dd1b56dab51c4fba9481d194639068e44622ba991caa5b8497099f411b8b051870b
6
+ metadata.gz: 89874ef7825b398392ed7966573379a9c9edc55500cb874bb79897b3b1f65631ff1c594c90420948468d00d9cbe9e8a83838d482d491d7260cea76450b977358
7
+ data.tar.gz: 58b8e23f9a5a37a87f2d50150cdc8872deaa654524c1ce92a507ac7b157aa9b687a6b99a591d3841f94e37a567dc7f30a4922ca55e5e1646072cf84331947354
data/CHANGELOG.md CHANGED
@@ -1,6 +1,36 @@
1
1
  # legion-mcp Changelog
2
2
 
3
- ## v0.1.0
3
+ ## [0.4.0] - 2026-03-20
4
+
5
+ ### Added
6
+ - PatternSchema v1: portable pattern format with trust-level confidence capping on import/export
7
+ - PatternExchange: bulk import/export of patterns via JSON files with deduplication
8
+ - PatternGossip: AMQP-based pattern sharing between instances (org trust level)
9
+ - ColdStart: community pattern loading on first boot when PatternStore is empty
10
+
11
+ ## [0.3.0] - 2026-03-20
12
+
13
+ ### Added
14
+ - GapDetector: analyzes observations for repeated manual patterns and frequent unpatched intents
15
+ - PatternCompiler: generates compressed tool definitions and compiled workflows from promoted patterns
16
+ - CapabilityGenerator: autonomous function generation from detected gaps with LLM code generation
17
+ - Validation pipeline with Ruby syntax check and optional lex-eval integration
18
+
19
+ ## [0.2.0] - 2026-03-20
20
+
21
+ ### Fixed
22
+ - Observer feedback bug: records actual matched tool name instead of 'legion.do'
23
+ - wire_observer skips legion.do calls (feedback handled inside DoAction with correct tool name)
24
+
25
+ ### Added
26
+ - Boot-time L2 → L0 pattern hydration (`PatternStore.hydrate_from_l2`)
27
+ - Pattern confidence decay with archive threshold (`PatternStore.decay_all`)
28
+ - Tier 1 execution: pattern-hinted local/fleet LLM routing in DoAction
29
+ - Tier 2 execution: cloud LLM with compressed catalog context in DoAction
30
+ - `legion.plan` meta-tool for multi-step workflow planning (36 tools total)
31
+ - Response template learning from observed Tier 0 outputs (`PatternStore.learn_response_template`)
32
+
33
+ ## [0.1.0]
4
34
 
5
35
  ### Added
6
36
  - Initial extraction from LegionIO
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module CapabilityGenerator
6
+ module_function
7
+
8
+ def generate_from_gap(gap)
9
+ name = infer_name(gap)
10
+ description = infer_description(gap)
11
+
12
+ proposal = {
13
+ name: name,
14
+ description: description,
15
+ source_gap: gap,
16
+ runner_code: nil,
17
+ spec_code: nil,
18
+ confidence: :sandbox,
19
+ generated_at: Time.now
20
+ }
21
+
22
+ if llm_available?
23
+ proposal[:runner_code] = generate_runner(name, description, gap)
24
+ proposal[:spec_code] = generate_spec(name, description)
25
+ end
26
+
27
+ proposal
28
+ rescue StandardError => e
29
+ { error: e.message, source_gap: gap }
30
+ end
31
+
32
+ def validate(runner_code:, spec_code:) # rubocop:disable Lint/UnusedMethodArgument
33
+ result = { syntax_valid: false, eval_score: nil }
34
+
35
+ result[:syntax_valid] = syntax_valid?(runner_code) if runner_code
36
+
37
+ if runner_code && defined?(Legion::Extensions::Eval::Client)
38
+ begin
39
+ client = Legion::Extensions::Eval::Client.new
40
+ eval_result = client.evaluate(code: runner_code, criteria: 'code_quality')
41
+ result[:eval_score] = eval_result[:score] if eval_result[:success]
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ end
46
+
47
+ result
48
+ end
49
+
50
+ def infer_name(gap)
51
+ case gap[:type]
52
+ when :frequent_intent
53
+ gap[:sample_intents].first.to_s.gsub(/\s+/, '_').downcase.slice(0, 30)
54
+ when :repeated_chain
55
+ gap[:chain].join('_then_').slice(0, 30)
56
+ else
57
+ "generated_#{Time.now.to_i}"
58
+ end
59
+ end
60
+
61
+ def infer_description(gap)
62
+ case gap[:type]
63
+ when :frequent_intent
64
+ "Auto-generated from #{gap[:count]} observed intents: #{gap[:sample_intents].first(3).join(', ')}"
65
+ when :repeated_chain
66
+ "Auto-generated from #{gap[:count]} observed sequences: #{gap[:chain].join(' -> ')}"
67
+ else
68
+ 'Auto-generated capability'
69
+ end
70
+ end
71
+
72
+ def generate_runner(name, description, _gap)
73
+ return nil unless llm_available?
74
+
75
+ prompt = "Generate a Ruby module for a LegionIO runner named '#{name}'. " \
76
+ "Description: #{description}. " \
77
+ 'Follow the pattern: module with module_function methods returning hashes. ' \
78
+ 'Include proper error handling. Return ONLY the Ruby code.'
79
+
80
+ Legion::LLM.ask(prompt)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ def generate_spec(name, description)
86
+ return nil unless llm_available?
87
+
88
+ prompt = "Generate RSpec tests for a Ruby module named '#{name}'. " \
89
+ "Description: #{description}. " \
90
+ 'Use described_class pattern. Return ONLY the Ruby code.'
91
+
92
+ Legion::LLM.ask(prompt)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ def syntax_valid?(code)
98
+ RubyVM::InstructionSequence.compile(code)
99
+ true
100
+ rescue SyntaxError
101
+ false
102
+ end
103
+
104
+ def llm_available?
105
+ defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pattern_store'
4
+ require_relative 'pattern_exchange'
5
+
6
+ module Legion
7
+ module MCP
8
+ module ColdStart
9
+ module_function
10
+
11
+ def load_community_patterns(path: nil)
12
+ return { skipped: true, reason: 'store not empty' } unless PatternStore.empty?
13
+
14
+ path ||= configured_path
15
+ return { skipped: true, reason: 'no path configured' } unless path
16
+
17
+ PatternExchange.import_from_file(path, trust_level: :community)
18
+ rescue StandardError => e
19
+ { error: e.message, imported: 0 }
20
+ end
21
+
22
+ def configured_path
23
+ return nil unless defined?(Legion::Settings)
24
+
25
+ Legion::Settings.dig(:mcp, :cold_start, :patterns_path)
26
+ rescue StandardError
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module MCP
7
+ module GapDetector
8
+ FREQUENCY_THRESHOLD = 5
9
+ CHAIN_THRESHOLD = 3
10
+
11
+ module_function
12
+
13
+ def analyze
14
+ gaps = []
15
+ gaps.concat(detect_frequent_intents)
16
+ gaps.concat(detect_repeated_chains)
17
+ gaps
18
+ end
19
+
20
+ def detect_frequent_intents
21
+ intents = Observer.recent_intents(Observer::INTENT_BUFFER_MAX)
22
+ grouped = intents.group_by { |i| i[:matched_tool] }
23
+
24
+ grouped.filter_map do |tool, occurrences|
25
+ next if occurrences.size < FREQUENCY_THRESHOLD
26
+ next if PatternStore.pattern_exists?(Digest::SHA256.hexdigest(tool.to_s))
27
+
28
+ { type: :frequent_intent, tool: tool, count: occurrences.size,
29
+ sample_intents: occurrences.last(3).map { |o| o[:intent] } }
30
+ end
31
+ end
32
+
33
+ def detect_repeated_chains
34
+ recent = Observer.recent(Observer::RING_BUFFER_MAX)
35
+ chains = {}
36
+ recent.each_cons(2) do |a, b|
37
+ key = "#{a[:tool_name]}->#{b[:tool_name]}"
38
+ chains[key] = (chains[key] || 0) + 1
39
+ end
40
+
41
+ chains.filter_map do |chain, count|
42
+ next if count < CHAIN_THRESHOLD
43
+
44
+ { type: :repeated_chain, chain: chain.split('->'), count: count }
45
+ end
46
+ end
47
+
48
+ def reset!
49
+ # No persistent state to clear — analysis reads from Observer
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module PatternCompiler
6
+ module_function
7
+
8
+ def compile_tool_definitions
9
+ return [] unless defined?(Legion::MCP::Server::TOOL_CLASSES)
10
+
11
+ Legion::MCP::Server::TOOL_CLASSES.map do |klass|
12
+ name = klass.respond_to?(:tool_name) ? klass.tool_name : klass.name
13
+ desc = klass.respond_to?(:description) ? klass.description : ''
14
+ params = extract_params(klass)
15
+
16
+ compressed = "#{name}(#{params.join(', ')}) -- #{desc.split('.').first}"
17
+ { name: name, compressed: compressed.slice(0, 200), full_description: desc }
18
+ end
19
+ end
20
+
21
+ def compile_workflows
22
+ PatternStore.patterns.filter_map do |_hash, pattern|
23
+ next if (pattern[:confidence] || 0) < 0.6
24
+
25
+ {
26
+ intent: pattern[:intent_text],
27
+ tools: pattern[:tool_chain],
28
+ confidence: pattern[:confidence],
29
+ template: pattern[:response_template]
30
+ }
31
+ end
32
+ end
33
+
34
+ def extract_params(klass)
35
+ return [] unless klass.respond_to?(:input_schema)
36
+
37
+ schema = klass.input_schema
38
+ props = if schema.is_a?(Hash)
39
+ schema[:properties] || schema['properties']
40
+ elsif schema.respond_to?(:to_h)
41
+ schema.to_h[:properties]
42
+ end
43
+ return [] unless props
44
+
45
+ props.keys.map(&:to_s)
46
+ rescue StandardError
47
+ []
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'pattern_schema'
5
+ require_relative 'pattern_store'
6
+
7
+ module Legion
8
+ module MCP
9
+ module PatternExchange
10
+ module_function
11
+
12
+ def export_all(min_confidence: 0.5)
13
+ PatternStore.patterns.filter_map do |_hash, pattern|
14
+ next if (pattern[:confidence] || 0) < min_confidence
15
+
16
+ PatternSchema.export(pattern)
17
+ end
18
+ end
19
+
20
+ def import_all(patterns, trust_level: :community)
21
+ imported = 0
22
+ skipped = 0
23
+
24
+ Array(patterns).each do |external|
25
+ next unless PatternSchema.validate_schema(external)
26
+
27
+ internal = PatternSchema.import(external, trust_level: trust_level)
28
+ if PatternStore.pattern_exists?(internal[:intent_hash])
29
+ skipped += 1
30
+ next
31
+ end
32
+
33
+ PatternStore.store(internal)
34
+ imported += 1
35
+ end
36
+
37
+ { imported: imported, skipped: skipped }
38
+ end
39
+
40
+ def export_to_file(path, min_confidence: 0.5)
41
+ data = export_all(min_confidence: min_confidence)
42
+ File.write(path, ::JSON.pretty_generate(data))
43
+ { exported: data.size, path: path }
44
+ end
45
+
46
+ def import_from_file(path, trust_level: :community)
47
+ raw = File.read(path)
48
+ patterns = ::JSON.parse(raw, symbolize_names: true)
49
+ patterns = [patterns] if patterns.is_a?(Hash)
50
+ import_all(patterns, trust_level: trust_level)
51
+ rescue StandardError => e
52
+ { error: e.message, imported: 0 }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'pattern_schema'
5
+
6
+ module Legion
7
+ module MCP
8
+ module PatternGossip
9
+ EXCHANGE_NAME = 'tbi.patterns'
10
+ ANNOUNCE_TTL = 86_400
11
+
12
+ module_function
13
+
14
+ def announce(pattern)
15
+ return nil unless transport_available?
16
+
17
+ exported = PatternSchema.export(pattern)
18
+ message = {
19
+ action: 'announce',
20
+ pattern_id: exported[:pattern_id],
21
+ pattern: exported,
22
+ origin: { instance_id: instance_id },
23
+ ttl: ANNOUNCE_TTL,
24
+ version: 1
25
+ }
26
+
27
+ Legion::Transport::Messages::Dynamic.new(
28
+ function: "#{EXCHANGE_NAME}.announce",
29
+ data: message
30
+ ).publish
31
+
32
+ { published: true, pattern_id: exported[:pattern_id] }
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def receive(message)
38
+ return nil unless message.is_a?(Hash) && message[:pattern]
39
+
40
+ PatternSchema.import(message[:pattern], trust_level: :org)
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ def transport_available?
46
+ defined?(Legion::Transport) &&
47
+ Legion::Transport.respond_to?(:connected?) &&
48
+ Legion::Transport.connected?
49
+ end
50
+
51
+ def instance_id
52
+ @instance_id ||= SecureRandom.uuid
53
+ end
54
+
55
+ def reset!
56
+ @instance_id = nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module MCP
7
+ module PatternSchema
8
+ SCHEMA_VERSION = '1.0'
9
+ REQUIRED_FIELDS = %i[schema_version pattern_id intent capability_chain confidence metadata].freeze
10
+
11
+ TRUST_LEVELS = {
12
+ local: 0.5,
13
+ org: 0.4,
14
+ community: 0.3
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def export(pattern)
20
+ {
21
+ schema_version: SCHEMA_VERSION,
22
+ pattern_id: pattern[:intent_hash],
23
+ intent: {
24
+ description: pattern[:intent_text],
25
+ keywords: extract_keywords(pattern[:intent_text])
26
+ },
27
+ capability_chain: Array(pattern[:tool_chain]).map { |t| { tool: t, params_template: {} } },
28
+ response_template: pattern[:response_template] ? { engine: 'mustache', template: pattern[:response_template] } : nil,
29
+ confidence: {
30
+ suggested_initial: [pattern[:confidence], 0.5].min,
31
+ source_hits: pattern[:hit_count] || 0,
32
+ source_misses: pattern[:miss_count] || 0
33
+ },
34
+ metadata: {
35
+ source: 'local',
36
+ sensitivity: 'public',
37
+ created_at: pattern[:created_at]&.iso8601
38
+ }
39
+ }
40
+ end
41
+
42
+ def import(external, trust_level: :community)
43
+ confidence = external.dig(:confidence, :suggested_initial) || TRUST_LEVELS.fetch(trust_level, 0.3)
44
+ confidence = [confidence, TRUST_LEVELS.fetch(trust_level, 0.3)].min
45
+
46
+ intent_text = external.dig(:intent, :description) || ''
47
+ intent_hash = external[:pattern_id] || Digest::SHA256.hexdigest(intent_text.downcase.strip)
48
+ tool_chain = Array(external[:capability_chain]).map { |c| c[:tool] || c.to_s }
49
+
50
+ template = external.dig(:response_template, :template)
51
+
52
+ {
53
+ intent_hash: intent_hash,
54
+ intent_text: intent_text,
55
+ intent_vector: nil,
56
+ tool_chain: tool_chain,
57
+ response_template: template,
58
+ confidence: confidence,
59
+ hit_count: 0,
60
+ miss_count: 0,
61
+ last_hit_at: nil,
62
+ created_at: Time.now,
63
+ context_requirements: nil
64
+ }
65
+ end
66
+
67
+ def validate_schema(data)
68
+ return false unless data.is_a?(Hash)
69
+
70
+ REQUIRED_FIELDS.all? { |f| data.key?(f) }
71
+ end
72
+
73
+ def extract_keywords(text)
74
+ return [] unless text
75
+
76
+ text.downcase.split(/\s+/).uniq.reject { |w| w.length < 3 }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -4,10 +4,11 @@ require 'json'
4
4
 
5
5
  module Legion
6
6
  module MCP
7
- module PatternStore
7
+ module PatternStore # rubocop:disable Metrics/ModuleLength
8
8
  CONFIDENCE_SUCCESS_DELTA = 0.02
9
9
  CONFIDENCE_FAILURE_DELTA = -0.05
10
10
  SEEDED_CONFIDENCE = 0.5
11
+ DECAY_ARCHIVE_THRESHOLD = 0.1
11
12
 
12
13
  module_function
13
14
 
@@ -129,6 +130,10 @@ module Legion
129
130
  mutex.synchronize { patterns_l0.size }
130
131
  end
131
132
 
133
+ def empty?
134
+ size.zero?
135
+ end
136
+
132
137
  def stats
133
138
  total_hits = 0
134
139
  total_conf = 0.0
@@ -149,9 +154,59 @@ module Legion
149
154
  }
150
155
  end
151
156
 
157
+ def learn_response_template(intent_hash, result_data, threshold: 3)
158
+ return unless result_data.is_a?(Hash)
159
+
160
+ template_mutex.synchronize do
161
+ buffer = template_observations[intent_hash] ||= []
162
+ buffer << result_data.keys.sort
163
+ buffer.shift if buffer.size > 10
164
+
165
+ return unless buffer.size >= threshold
166
+
167
+ if buffer.last(threshold).uniq.size == 1
168
+ keys = buffer.last.sort
169
+ template = keys.map { |k| "#{k}: {{#{k}}}" }.join(', ')
170
+ mutex.synchronize do
171
+ pattern = patterns_l0[intent_hash]
172
+ pattern[:response_template] = template if pattern
173
+ end
174
+ sync_to_persistence(intent_hash)
175
+ end
176
+ end
177
+ end
178
+
179
+ def decay_all(factor: 0.998)
180
+ archived = []
181
+ mutex.synchronize do
182
+ patterns_l0.each do |hash, pattern|
183
+ pattern[:confidence] = (pattern[:confidence] * factor).clamp(0.0, 1.0)
184
+ archived << hash if pattern[:confidence] < DECAY_ARCHIVE_THRESHOLD
185
+ end
186
+ archived.each { |hash| patterns_l0.delete(hash) }
187
+ end
188
+
189
+ archived.each { |hash| archive_l2(hash) }
190
+ sync_all_to_persistence unless archived.empty?
191
+ end
192
+
193
+ def hydrate_from_l2
194
+ return unless local_db_available?
195
+
196
+ table = ensure_local_table
197
+ table.each do |row|
198
+ pattern = deserialize_pattern(row)
199
+ mutex.synchronize { patterns_l0[pattern[:intent_hash]] = pattern }
200
+ persist_l1(pattern[:intent_hash], pattern)
201
+ end
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
152
206
  def reset!
153
207
  mutex.synchronize { patterns_l0.clear }
154
208
  candidates_mutex.synchronize { candidates_buffer.clear }
209
+ template_mutex.synchronize { template_observations.clear }
155
210
  end
156
211
 
157
212
  # --- Private helpers ---
@@ -224,6 +279,19 @@ module Legion
224
279
  persist_l2(intent_hash, pattern)
225
280
  end
226
281
 
282
+ def archive_l2(intent_hash)
283
+ return unless local_db_available?
284
+
285
+ table = ensure_local_table
286
+ table.where(intent_hash: intent_hash).delete
287
+ rescue StandardError
288
+ nil
289
+ end
290
+
291
+ def sync_all_to_persistence
292
+ mutex.synchronize { patterns_l0.keys.dup }.each { |h| sync_to_persistence(h) }
293
+ end
294
+
227
295
  def local_db_available?
228
296
  defined?(Legion::Data::Local) &&
229
297
  Legion::Data::Local.respond_to?(:connected?) &&
@@ -298,6 +366,14 @@ module Legion
298
366
  def candidates_mutex
299
367
  @candidates_mutex ||= Mutex.new
300
368
  end
369
+
370
+ def template_observations
371
+ @template_observations ||= {}
372
+ end
373
+
374
+ def template_mutex
375
+ @template_mutex ||= Mutex.new
376
+ end
301
377
  end
302
378
  end
303
379
  end
@@ -37,7 +37,9 @@ require_relative 'tools/rbac_assignments'
37
37
  require_relative 'tools/rbac_grants'
38
38
  require_relative 'context_compiler'
39
39
  require_relative 'embedding_index'
40
+ require_relative 'cold_start'
40
41
  require_relative 'tools/do_action'
42
+ require_relative 'tools/plan_action'
41
43
  require_relative 'tools/discover_tools'
42
44
  require_relative 'resources/runner_catalog'
43
45
  require_relative 'resources/extension_info'
@@ -80,6 +82,7 @@ module Legion
80
82
  Tools::RbacAssignments,
81
83
  Tools::RbacGrants,
82
84
  Tools::DoAction,
85
+ Tools::PlanAction,
83
86
  Tools::DiscoverTools
84
87
  ].freeze
85
88
 
@@ -110,6 +113,12 @@ module Legion
110
113
  build_filtered_tool_list.map(&:to_h)
111
114
  end
112
115
 
116
+ # Hydrate pattern store from L2 persistence (SQLite) on boot
117
+ PatternStore.hydrate_from_l2 if defined?(PatternStore)
118
+
119
+ # Cold-start: load community patterns if store is still empty after hydration
120
+ ColdStart.load_community_patterns if defined?(ColdStart)
121
+
113
122
  # Populate embedding index for semantic tool matching (lazy — no-op if LLM unavailable)
114
123
  populate_embedding_index
115
124
 
@@ -141,8 +150,11 @@ module Legion
141
150
  error: data[:error]
142
151
  )
143
152
 
144
- # Wire pattern promotion feedback loop for do_action calls
145
- return unless data[:tool_name] == 'legion.do' && data[:tool_arguments]&.dig(:intent)
153
+ # Pattern promotion for legion.do is handled inside DoAction itself
154
+ # (which knows the actual resolved tool name). For other tools called
155
+ # directly, we record the intent+result here if an intent is present.
156
+ return if data[:tool_name] == 'legion.do'
157
+ return unless data[:tool_arguments]&.dig(:intent)
146
158
 
147
159
  Observer.record_intent_with_result(
148
160
  intent: data[:tool_arguments][:intent],
@@ -37,6 +37,7 @@ module Legion
37
37
  results = execute_tool_chain(pattern[:tool_chain], params)
38
38
  response = generate_response(results, pattern)
39
39
  PatternStore.record_hit(intent_hash)
40
+ PatternStore.learn_response_template(intent_hash, results.first) if results.size == 1
40
41
 
41
42
  elapsed_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
42
43
  {
@@ -30,35 +30,74 @@ module Legion
30
30
  )
31
31
 
32
32
  class << self
33
- def call(intent:, params: {}, context: {})
34
- # Try Tier 0 first (learned patterns)
33
+ def call(intent:, params: {}, context: {}) # rubocop:disable Metrics/CyclomaticComplexity
35
34
  tier_result = try_tier0(intent, params, context)
36
- if tier_result && tier_result[:tier].zero?
35
+
36
+ case tier_result&.dig(:tier)
37
+ when 0
37
38
  return text_response(tier_result[:response].merge(
38
39
  _meta: { tier: 0,
39
40
  latency_ms: tier_result[:latency_ms],
40
41
  confidence: tier_result[:pattern_confidence] }
41
42
  ))
43
+ when 1
44
+ llm_result = try_tier1(intent, tier_result[:pattern])
45
+ if llm_result
46
+ return text_response({ result: llm_result,
47
+ _meta: { tier: 1, pattern_hint: tier_result[:pattern][:intent_text] } })
48
+ end
49
+ when 2
50
+ llm_result = try_tier2(intent)
51
+ return text_response({ result: llm_result, _meta: { tier: 2 } }) if llm_result
42
52
  end
43
53
 
44
- # Fall back to ContextCompiler tool matching (original behavior)
54
+ # Fall back to ContextCompiler tool matching
45
55
  matched = ContextCompiler.match_tool(intent)
46
56
  return error_response("No matching tool found for intent: #{intent}") if matched.nil?
47
57
 
48
- Legion::MCP::Observer.record_intent(intent, matched) if defined?(Legion::MCP::Observer)
58
+ matched_name = matched.respond_to?(:tool_name) ? matched.tool_name : matched.to_s
49
59
 
50
60
  tool_params = params.transform_keys(&:to_sym)
51
- if tool_params.empty?
52
- matched.call
53
- else
54
- matched.call(**tool_params)
55
- end
61
+ result = tool_params.empty? ? matched.call : matched.call(**tool_params)
62
+
63
+ record_feedback(intent, matched_name, success: true)
64
+ result
56
65
  rescue StandardError => e
66
+ record_feedback(intent, matched_name, success: false) if defined?(matched_name)
57
67
  error_response("Failed: #{e.message}")
58
68
  end
59
69
 
60
70
  private
61
71
 
72
+ def record_feedback(intent, tool_name, success:)
73
+ return unless defined?(Legion::MCP::Observer)
74
+
75
+ Legion::MCP::Observer.record_intent_with_result(
76
+ intent: intent,
77
+ tool_name: tool_name,
78
+ success: success
79
+ )
80
+ end
81
+
82
+ def try_tier1(intent, pattern)
83
+ return nil unless defined?(Legion::LLM) && Legion::LLM.started?
84
+
85
+ hint = "Known pattern: #{pattern[:intent_text]}. Tools: #{Array(pattern[:tool_chain]).join(', ')}. "
86
+ Legion::LLM.ask("#{hint}User intent: #{intent}")
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ def try_tier2(intent)
92
+ return nil unless defined?(Legion::LLM) && Legion::LLM.started?
93
+
94
+ catalog = ContextCompiler.respond_to?(:compressed_catalog) ? ContextCompiler.compressed_catalog : []
95
+ context_str = catalog.any? ? "Available tools: #{Legion::JSON.dump(catalog)}. " : ''
96
+ Legion::LLM.ask("#{context_str}User intent: #{intent}")
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
62
101
  def try_tier0(intent, params, context)
63
102
  return nil unless defined?(Legion::MCP::TierRouter)
64
103
 
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class PlanAction < ::MCP::Tool
7
+ tool_name 'legion.plan'
8
+ description 'Get a multi-step workflow plan for a complex goal. Returns ordered steps with tools and parameters.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ goal: { type: 'string', description: 'Natural language description of the goal' },
13
+ context: { type: 'object', description: 'Additional context', additionalProperties: true }
14
+ },
15
+ required: ['goal']
16
+ )
17
+
18
+ class << self
19
+ def call(goal:, context: {}) # rubocop:disable Lint/UnusedMethodArgument
20
+ matched = ContextCompiler.match_tools(goal, limit: 10)
21
+ steps = matched.map.with_index(1) do |tool, idx|
22
+ { step: idx, tool: tool[:name], relevance: tool[:score].round(3) }
23
+ end
24
+
25
+ return text_response({ plan: nil, reason: 'no matching tools found for goal' }) if steps.empty?
26
+
27
+ plan = { goal: goal, steps: steps, tool_count: steps.size }
28
+ plan[:narrative] = generate_narrative(goal, steps) if llm_available?
29
+
30
+ text_response(plan)
31
+ rescue StandardError => e
32
+ error_response("Plan failed: #{e.message}")
33
+ end
34
+
35
+ private
36
+
37
+ def generate_narrative(goal, steps)
38
+ tool_list = steps.map { |s| s[:tool] }.join(', ')
39
+ Legion::LLM.ask("Create a brief execution plan for: #{goal}. Available tools: #{tool_list}")
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def llm_available?
45
+ defined?(Legion::LLM) && Legion::LLM.started?
46
+ end
47
+
48
+ def text_response(data)
49
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
50
+ end
51
+
52
+ def error_response(msg)
53
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module MCP
5
- VERSION = '0.1.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -117,10 +117,17 @@ files:
117
117
  - legion-mcp.gemspec
118
118
  - lib/legion/mcp.rb
119
119
  - lib/legion/mcp/auth.rb
120
+ - lib/legion/mcp/capability_generator.rb
121
+ - lib/legion/mcp/cold_start.rb
120
122
  - lib/legion/mcp/context_compiler.rb
121
123
  - lib/legion/mcp/context_guard.rb
122
124
  - lib/legion/mcp/embedding_index.rb
125
+ - lib/legion/mcp/gap_detector.rb
123
126
  - lib/legion/mcp/observer.rb
127
+ - lib/legion/mcp/pattern_compiler.rb
128
+ - lib/legion/mcp/pattern_exchange.rb
129
+ - lib/legion/mcp/pattern_gossip.rb
130
+ - lib/legion/mcp/pattern_schema.rb
124
131
  - lib/legion/mcp/pattern_store.rb
125
132
  - lib/legion/mcp/resources/extension_info.rb
126
133
  - lib/legion/mcp/resources/runner_catalog.rb
@@ -150,6 +157,7 @@ files:
150
157
  - lib/legion/mcp/tools/list_schedules.rb
151
158
  - lib/legion/mcp/tools/list_tasks.rb
152
159
  - lib/legion/mcp/tools/list_workers.rb
160
+ - lib/legion/mcp/tools/plan_action.rb
153
161
  - lib/legion/mcp/tools/rbac_assignments.rb
154
162
  - lib/legion/mcp/tools/rbac_check.rb
155
163
  - lib/legion/mcp/tools/rbac_grants.rb