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 +4 -4
- data/CHANGELOG.md +31 -1
- data/lib/legion/mcp/capability_generator.rb +109 -0
- data/lib/legion/mcp/cold_start.rb +31 -0
- data/lib/legion/mcp/gap_detector.rb +53 -0
- data/lib/legion/mcp/pattern_compiler.rb +51 -0
- data/lib/legion/mcp/pattern_exchange.rb +56 -0
- data/lib/legion/mcp/pattern_gossip.rb +60 -0
- data/lib/legion/mcp/pattern_schema.rb +80 -0
- data/lib/legion/mcp/pattern_store.rb +77 -1
- data/lib/legion/mcp/server.rb +14 -2
- data/lib/legion/mcp/tier_router.rb +1 -0
- data/lib/legion/mcp/tools/do_action.rb +49 -10
- data/lib/legion/mcp/tools/plan_action.rb +59 -0
- data/lib/legion/mcp/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc7341c88afa89639cebd05ccffc1dfb00792062373fe295743f525c9c0380c9
|
|
4
|
+
data.tar.gz: d2b832be2e2942286489edad666215e13f8de19b888a32551bdedef4e70b1f7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
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
|
data/lib/legion/mcp/server.rb
CHANGED
|
@@ -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
|
-
#
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
data/lib/legion/mcp/version.rb
CHANGED
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.
|
|
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
|