legion-mcp 0.5.3 → 0.5.4

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: 918ab35b2f575f24b157f7f4aaf418e13a1f950b58bf387f17f921bd121f8b85
4
- data.tar.gz: d1de3e68b031d1c7a169db411d188e54ebd0107a191aec584981a95c852746f2
3
+ metadata.gz: 2c56ad22d31474ed9a1a64e6b97a4aea17a61a496162916a0d1f3c727c82abd2
4
+ data.tar.gz: 1d1ebbc6f7dcbcbf26a0a42fa7d92694b6b2f24dd7518089fe8132919d00a6f9
5
5
  SHA512:
6
- metadata.gz: 42f453718ebe7af183b93b86724f40ec31927fed7f562f17c7abe9732d7732df02f35108d498640a7f330425f5a24d4b3602e4f28cef9166af0d5751a3f49c1e
7
- data.tar.gz: ccfce5e16a125063d3e4b74dff0b03fd4a6f0eab6e80b5480539c5de6adc9e61b8466acff2762a2501456a6ada0cb7743f7d0fe6c930ef50786438e5cba93a60
6
+ metadata.gz: 8fa820c5e258c9fd3690d991b00aab73568c2e72189f5adf7511a264a10f9287f3ca87b96d2091bdb7a5fec8876fe5a54986f7d1067cf9e72c95b20f884039c7
7
+ data.tar.gz: 1f4111a18475467b942d3faa2628c23ac77ea0c4b7165b78dcc7c222908d7af97ae31eb3f58e610c598413238713d1c5f96fa6fee8201c7fc0144ba36165c336
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # legion-mcp Changelog
2
2
 
3
+ ## [0.5.4] - 2026-03-24
4
+
5
+ ### Added
6
+ - TBI Phase 5: `GapDetector` — detects unmatched intents, high-failure tools, and stale candidates from Observer/PatternStore data
7
+ - TBI Phase 5: `FunctionGenerator` — LLM-powered tool spec generation from detected gaps, with validation and pattern registration
8
+ - TBI Phase 5: `SelfGenerate` — orchestrates gap detection + function generation cycles with cooldown, history tracking, and status reporting
9
+ - 89 new specs across gap_detector, function_generator, and self_generate
10
+
11
+ ### Changed
12
+ - Rewrote `GapDetector` from frequency-based to gap-type-based detection (unmatched, failure, stale) with priority scoring
13
+
3
14
  ## [0.5.3] - 2026-03-23
4
15
 
5
16
  ### Changed
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module MCP
7
+ module FunctionGenerator
8
+ MAX_GENERATION_ATTEMPTS = 3
9
+ GENERATION_TIMEOUT = 60
10
+
11
+ module_function
12
+
13
+ def generate_from_gap(gap)
14
+ case gap[:type]
15
+ when :unmatched_intent
16
+ generate_tool_for_intent(intent: gap[:intent])
17
+ when :high_failure_tool
18
+ generate_fix_for_tool(tool_name: gap[:tool_name], last_error: gap[:last_error])
19
+ when :stale_candidate
20
+ generate_tool_for_candidate(intent_text: gap[:intent_text], tool_chain: gap[:tool_chain])
21
+ else
22
+ { success: false, reason: :unknown_gap_type }
23
+ end
24
+ rescue StandardError => e
25
+ { success: false, reason: :generation_failed, error: e.message }
26
+ end
27
+
28
+ def generate_tool_for_intent(intent:)
29
+ return { success: false, reason: :llm_not_available } unless llm_available?
30
+
31
+ spec = generate_tool_spec(intent: intent)
32
+ return spec unless spec[:success]
33
+
34
+ validate_spec(spec[:tool_spec])
35
+ end
36
+
37
+ def generate_fix_for_tool(tool_name:, last_error:)
38
+ return { success: false, reason: :llm_not_available } unless llm_available?
39
+
40
+ prompt = build_fix_prompt(tool_name: tool_name, error: last_error)
41
+ result = llm_ask(prompt)
42
+ return { success: false, reason: :llm_failed } unless result
43
+
44
+ {
45
+ success: true,
46
+ type: :fix_suggestion,
47
+ tool_name: tool_name,
48
+ suggestion: result,
49
+ requires_review: true
50
+ }
51
+ end
52
+
53
+ def generate_tool_for_candidate(intent_text:, tool_chain:)
54
+ return { success: false, reason: :llm_not_available } unless llm_available?
55
+
56
+ spec = generate_tool_spec(intent: intent_text, existing_chain: tool_chain)
57
+ return spec unless spec[:success]
58
+
59
+ register_generated_pattern(spec[:tool_spec], intent_text)
60
+
61
+ spec
62
+ end
63
+
64
+ def generate_tool_spec(intent:, existing_chain: nil)
65
+ prompt = build_generation_prompt(intent: intent, existing_chain: existing_chain)
66
+ result = llm_ask(prompt)
67
+ return { success: false, reason: :llm_failed } unless result
68
+
69
+ parsed = parse_tool_spec(result)
70
+ return { success: false, reason: :parse_failed, raw: result } unless parsed
71
+
72
+ { success: true, tool_spec: parsed }
73
+ end
74
+
75
+ def validate_spec(spec)
76
+ errors = []
77
+ errors << 'missing name' unless spec[:name]&.length&.positive?
78
+ errors << 'missing description' unless spec[:description]&.length&.positive?
79
+ errors << 'missing runner_function' unless spec[:runner_function]&.length&.positive?
80
+
81
+ if errors.empty?
82
+ { success: true, tool_spec: spec, valid: true }
83
+ else
84
+ { success: false, reason: :invalid_spec, errors: errors, tool_spec: spec }
85
+ end
86
+ end
87
+
88
+ def llm_available?
89
+ !!(defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat))
90
+ end
91
+
92
+ def llm_ask(prompt)
93
+ return nil unless llm_available?
94
+
95
+ response = Legion::LLM.chat(
96
+ message: prompt,
97
+ caller: { source: 'legion-mcp', component: 'function_generator' }
98
+ )
99
+ response&.content
100
+ rescue StandardError => e
101
+ Legion::Logging.warn("FunctionGenerator LLM call failed: #{e.message}") if defined?(Legion::Logging)
102
+ nil
103
+ end
104
+
105
+ def build_generation_prompt(intent:, existing_chain: nil)
106
+ chain_context = existing_chain ? "\nExisting tool chain that partially handles this: #{existing_chain.inspect}" : ''
107
+
108
+ <<~PROMPT
109
+ Generate a tool specification for a LegionIO MCP tool that handles this user intent:
110
+ "#{intent}"
111
+ #{chain_context}
112
+ Respond with ONLY a JSON object (no markdown, no explanation):
113
+ {
114
+ "name": "legion.tool_name",
115
+ "description": "What this tool does",
116
+ "runner_function": "extension_name/runner_name/method_name",
117
+ "parameters": [{"name": "param1", "type": "string", "required": true, "description": "..."}],
118
+ "category": "one of: query, action, analysis, utility"
119
+ }
120
+ PROMPT
121
+ end
122
+
123
+ def build_fix_prompt(tool_name:, error:)
124
+ <<~PROMPT
125
+ The MCP tool "#{tool_name}" has a high failure rate. Last error: #{error}
126
+ Suggest a fix or replacement approach. Respond concisely (2-3 sentences max).
127
+ PROMPT
128
+ end
129
+
130
+ def parse_tool_spec(raw)
131
+ json_match = raw.match(/\{[\s\S]*\}/)
132
+ return nil unless json_match
133
+
134
+ parsed = ::JSON.parse(json_match[0], symbolize_names: true)
135
+ return nil unless parsed.is_a?(Hash) && parsed[:name]
136
+
137
+ parsed
138
+ rescue ::JSON::ParserError
139
+ nil
140
+ end
141
+
142
+ def register_generated_pattern(spec, intent_text)
143
+ return unless defined?(PatternStore)
144
+
145
+ normalized = intent_text.to_s.strip.downcase.gsub(/\s+/, ' ')
146
+ intent_hash = Digest::SHA256.hexdigest(normalized)
147
+
148
+ PatternStore.promote_candidate(
149
+ intent_hash: intent_hash,
150
+ tool_chain: [spec[:runner_function] || spec[:name]],
151
+ intent_text: intent_text
152
+ )
153
+ rescue StandardError => e
154
+ Legion::Logging.warn("register_generated_pattern failed: #{e.message}") if defined?(Legion::Logging)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -5,48 +5,101 @@ require 'digest'
5
5
  module Legion
6
6
  module MCP
7
7
  module GapDetector
8
- FREQUENCY_THRESHOLD = 5
9
- CHAIN_THRESHOLD = 3
8
+ GAP_INTENT_THRESHOLD = 5
9
+ FAILURE_RATE_THRESHOLD = 0.4
10
+ STALE_CANDIDATE_HOURS = 24
11
+ MAX_GAPS = 20
10
12
 
11
13
  module_function
12
14
 
13
- def analyze
15
+ def detect_gaps
14
16
  gaps = []
15
- gaps.concat(detect_frequent_intents)
16
- gaps.concat(detect_repeated_chains)
17
- gaps
17
+ gaps.concat(detect_unmatched_intents)
18
+ gaps.concat(detect_high_failure_tools)
19
+ gaps.concat(detect_stale_candidates)
20
+
21
+ gaps.uniq { |g| g[:id] }.first(MAX_GAPS)
18
22
  end
19
23
 
20
- def detect_frequent_intents
21
- intents = Observer.recent_intents(Observer::INTENT_BUFFER_MAX)
22
- grouped = intents.group_by { |i| i[:matched_tool] }
24
+ def detect_unmatched_intents
25
+ return [] unless defined?(Observer)
26
+
27
+ recent = Observer.recent_intents(200)
28
+ unmatched = recent.select { |r| r[:matched_tool].nil? || r[:matched_tool] == 'none' }
29
+
30
+ grouped = unmatched.group_by { |r| normalize_intent(r[:intent]) }
23
31
 
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))
32
+ grouped.filter_map do |intent_text, occurrences|
33
+ next if occurrences.size < GAP_INTENT_THRESHOLD
27
34
 
28
- { type: :frequent_intent, tool: tool, count: occurrences.size,
29
- sample_intents: occurrences.last(3).map { |o| o[:intent] } }
35
+ {
36
+ id: "unmatched:#{Digest::SHA256.hexdigest(intent_text)[0, 12]}",
37
+ type: :unmatched_intent,
38
+ intent: intent_text,
39
+ occurrences: occurrences.size,
40
+ first_seen: occurrences.first[:recorded_at],
41
+ last_seen: occurrences.last[:recorded_at],
42
+ priority: calculate_priority(occurrences.size, :unmatched)
43
+ }
30
44
  end
31
45
  end
32
46
 
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
47
+ def detect_high_failure_tools
48
+ return [] unless defined?(Observer)
49
+
50
+ stats = Observer.all_tool_stats
51
+ stats.filter_map do |tool_name, tool_stat|
52
+ next unless tool_stat
53
+ next if tool_stat[:call_count] < 5
54
+
55
+ failure_rate = tool_stat[:failure_count].to_f / tool_stat[:call_count]
56
+ next if failure_rate < FAILURE_RATE_THRESHOLD
57
+
58
+ {
59
+ id: "failing:#{tool_name}",
60
+ type: :high_failure_tool,
61
+ tool_name: tool_name,
62
+ failure_rate: failure_rate.round(4),
63
+ call_count: tool_stat[:call_count],
64
+ failure_count: tool_stat[:failure_count],
65
+ last_error: tool_stat[:last_error],
66
+ priority: calculate_priority(tool_stat[:failure_count], :failure)
67
+ }
39
68
  end
69
+ end
70
+
71
+ def detect_stale_candidates
72
+ return [] unless defined?(PatternStore)
73
+
74
+ candidates = PatternStore.candidates
40
75
 
41
- chains.filter_map do |chain, count|
42
- next if count < CHAIN_THRESHOLD
76
+ candidates.filter_map do |intent_hash, entry|
77
+ next if entry[:count] < 2
43
78
 
44
- { type: :repeated_chain, chain: chain.split('->'), count: count }
79
+ {
80
+ id: "stale:#{intent_hash[0, 12]}",
81
+ type: :stale_candidate,
82
+ intent_hash: intent_hash,
83
+ intent_text: entry[:intent_text],
84
+ observation_count: entry[:count],
85
+ tool_chain: entry[:tool_chain],
86
+ priority: calculate_priority(entry[:count], :stale)
87
+ }
45
88
  end
46
89
  end
47
90
 
48
- def reset!
49
- # No persistent state to clear — analysis reads from Observer
91
+ def normalize_intent(text)
92
+ text.to_s.strip.downcase.gsub(/\s+/, ' ')
93
+ end
94
+
95
+ def calculate_priority(count, type)
96
+ base = case type
97
+ when :unmatched then 0.8
98
+ when :failure then 0.6
99
+ when :stale then 0.4
100
+ else 0.3
101
+ end
102
+ (base + [count * 0.02, 0.2].min).clamp(0.0, 1.0).round(4)
50
103
  end
51
104
  end
52
105
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module MCP
7
+ module SelfGenerate
8
+ MAX_GAPS_PER_CYCLE = 5
9
+ COOLDOWN_SECONDS = 300
10
+
11
+ module_function
12
+
13
+ def run_cycle
14
+ return { success: false, reason: :cooldown } if in_cooldown?
15
+
16
+ gaps = GapDetector.detect_gaps
17
+ return { success: true, gaps_found: 0, generated: 0 } if gaps.empty?
18
+
19
+ top_gaps = gaps.sort_by { |g| -g[:priority] }.first(MAX_GAPS_PER_CYCLE)
20
+
21
+ results = top_gaps.map do |gap|
22
+ result = FunctionGenerator.generate_from_gap(gap)
23
+ { gap: gap[:id], type: gap[:type], result: result }
24
+ end
25
+
26
+ record_cycle(results)
27
+
28
+ generated = results.count { |r| r[:result][:success] }
29
+ failed = results.count { |r| !r[:result][:success] }
30
+
31
+ {
32
+ success: true,
33
+ gaps_found: gaps.size,
34
+ processed: top_gaps.size,
35
+ generated: generated,
36
+ failed: failed,
37
+ results: results
38
+ }
39
+ end
40
+
41
+ def status
42
+ {
43
+ last_cycle_at: last_cycle_at,
44
+ total_cycles: cycle_count,
45
+ total_generated: total_generated,
46
+ cooldown_remaining: cooldown_remaining,
47
+ pending_gaps: GapDetector.detect_gaps.size
48
+ }
49
+ rescue StandardError => e
50
+ { error: e.message }
51
+ end
52
+
53
+ def reset!
54
+ mutex.synchronize do
55
+ @last_cycle_at = nil
56
+ @cycle_count = 0
57
+ @total_generated = 0
58
+ @cycle_history = []
59
+ end
60
+ end
61
+
62
+ def cycle_history(limit = 10)
63
+ mutex.synchronize { (@cycle_history || []).last(limit) }
64
+ end
65
+
66
+ def in_cooldown?
67
+ return false unless last_cycle_at
68
+
69
+ Time.now - last_cycle_at < COOLDOWN_SECONDS
70
+ end
71
+
72
+ def cooldown_remaining
73
+ return 0 unless last_cycle_at
74
+
75
+ remaining = COOLDOWN_SECONDS - (Time.now - last_cycle_at)
76
+ [remaining, 0].max.round(1)
77
+ end
78
+
79
+ def record_cycle(results)
80
+ mutex.synchronize do
81
+ @last_cycle_at = Time.now
82
+ @cycle_count = (@cycle_count || 0) + 1
83
+ @total_generated = (@total_generated || 0) + results.count { |r| r[:result][:success] }
84
+ @cycle_history ||= []
85
+ @cycle_history << {
86
+ at: Time.now,
87
+ results_count: results.size,
88
+ generated: results.count { |r| r[:result][:success] }
89
+ }
90
+ @cycle_history.shift if @cycle_history.size > 50
91
+ end
92
+ end
93
+
94
+ def last_cycle_at
95
+ mutex.synchronize { @last_cycle_at }
96
+ end
97
+
98
+ def cycle_count
99
+ mutex.synchronize { @cycle_count || 0 }
100
+ end
101
+
102
+ def total_generated
103
+ mutex.synchronize { @total_generated || 0 }
104
+ end
105
+
106
+ def mutex
107
+ @mutex ||= Mutex.new
108
+ end
109
+ end
110
+ end
111
+ end
@@ -47,6 +47,9 @@ require_relative 'tools/eval_results'
47
47
  require_relative 'context_compiler'
48
48
  require_relative 'embedding_index'
49
49
  require_relative 'cold_start'
50
+ require_relative 'gap_detector'
51
+ require_relative 'function_generator'
52
+ require_relative 'self_generate'
50
53
  require_relative 'tools/do_action'
51
54
  require_relative 'tools/plan_action'
52
55
  require_relative 'tools/discover_tools'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module MCP
5
- VERSION = '0.5.3'
5
+ VERSION = '0.5.4'
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.5.3
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -128,6 +128,7 @@ files:
128
128
  - lib/legion/mcp/context_compiler.rb
129
129
  - lib/legion/mcp/context_guard.rb
130
130
  - lib/legion/mcp/embedding_index.rb
131
+ - lib/legion/mcp/function_generator.rb
131
132
  - lib/legion/mcp/gap_detector.rb
132
133
  - lib/legion/mcp/observer.rb
133
134
  - lib/legion/mcp/override_broadcast.rb
@@ -138,6 +139,7 @@ files:
138
139
  - lib/legion/mcp/pattern_store.rb
139
140
  - lib/legion/mcp/resources/extension_info.rb
140
141
  - lib/legion/mcp/resources/runner_catalog.rb
142
+ - lib/legion/mcp/self_generate.rb
141
143
  - lib/legion/mcp/server.rb
142
144
  - lib/legion/mcp/settings.rb
143
145
  - lib/legion/mcp/tier_router.rb