legionio 1.4.71 → 1.4.72

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: 0e16727aaa70a3ffc2da0e6d3f3a6bce498e0052876e280c1d6cb42d50b3d594
4
- data.tar.gz: 57042f5deb5a35dd8456850606e7a1bf42e0e4d5235385a4abb7987d13831894
3
+ metadata.gz: d9c2f9048a25ea9cff5f0b8db43aaaf49ab40bb6c388b2f892c3f952fb7233f4
4
+ data.tar.gz: 02225b7029518b45f1d78f632cce4fdbaee631efb9c03cade5e575ac3c96e3cf
5
5
  SHA512:
6
- metadata.gz: b2c90fb0ba93a569c0dbd6c72f6e199fcebd6201fa4d003a114bc2d0f9bced639c28364ccb92aaf78f6ea1d226d1fa78b4a1c92538fa1002274f8656f1a8e0e1
7
- data.tar.gz: 47dd1c952fcf6fd5f0f70d277b7c03ced2632dfa13cf9c47db2d1e0e03d6f90c005a8f13776a03ed900cbdf2c5e27aa7ec8d62e36751260f207c561878ef78ba
6
+ metadata.gz: 758ee671a15875e26a60fe87b10b3499c5ef47f766d5cd3d43498b7c362be4a791defe79b99bda56ccbf263558fa7a4e8450b307f3bc29734c3c3d279bba4dd4
7
+ data.tar.gz: 2c04803613fe2a2ba39ef4aa29865f66b96b237ba7c3f74920cd784bedfebc9bc90df91042714a016f06587a6d234ed722cf29a5727c039e8f09fbddfb322184
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.72] - 2026-03-19
4
+
5
+ ### Added
6
+ - TBI Phase 0+2: MCP tool observation pipeline and usage-based filtering
7
+ - `Legion::MCP::Observer` module: in-memory tool call recording with counters, ring buffer, and intent tracking
8
+ - `Legion::MCP::UsageFilter` module: scores tools by frequency, recency, and keyword match; prunes dead tools
9
+ - MCP `instrumentation_callback` wiring: automatically records all `tools/call` invocations via Observer
10
+ - MCP `tools_list_handler` wiring: dynamically filters and ranks tools per-request based on usage data
11
+ - `legion observe` CLI command: `stats`, `recent`, `reset` subcommands for MCP tool usage inspection
12
+ - 96 new specs covering Observer, UsageFilter, CLI command, and integration wiring
13
+
3
14
  ## [1.4.71] - 2026-03-19
4
15
 
5
16
  ### Added
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'legion/mcp/observer'
5
+
6
+ module Legion
7
+ module CLI
8
+ class ObserveCommand < Thor
9
+ namespace :observe
10
+
11
+ desc 'stats', 'Show MCP tool usage statistics'
12
+ def stats
13
+ data = Legion::MCP::Observer.stats
14
+
15
+ if options['json']
16
+ puts ::JSON.pretty_generate(serialize_stats(data))
17
+ return
18
+ end
19
+
20
+ puts 'MCP Tool Observation Stats'
21
+ puts '=' * 40
22
+ puts "Total Calls: #{data[:total_calls]}"
23
+ puts "Tools Used: #{data[:tool_count]}"
24
+ puts "Failure Rate: #{(data[:failure_rate] * 100).round(1)}%"
25
+ puts "Since: #{data[:since]&.strftime('%Y-%m-%d %H:%M:%S')}"
26
+ puts
27
+
28
+ return if data[:top_tools].empty?
29
+
30
+ puts 'Top Tools:'
31
+ puts '-' * 60
32
+ puts 'Tool Calls Avg(ms) Fails'
33
+ puts '-' * 60
34
+ data[:top_tools].each do |tool|
35
+ puts format('%-30<name>s %6<calls>d %8<avg>d %6<fails>d',
36
+ name: tool[:name], calls: tool[:call_count],
37
+ avg: tool[:avg_latency_ms], fails: tool[:failure_count])
38
+ end
39
+ end
40
+
41
+ desc 'recent', 'Show recent MCP tool calls'
42
+ method_option :limit, type: :numeric, default: 20, aliases: '-n'
43
+ def recent
44
+ calls = Legion::MCP::Observer.recent(options['limit'] || 20)
45
+
46
+ if options['json']
47
+ puts ::JSON.pretty_generate(calls.map { |c| serialize_call(c) })
48
+ return
49
+ end
50
+
51
+ if calls.empty?
52
+ puts 'No recent tool calls recorded.'
53
+ return
54
+ end
55
+
56
+ puts 'Tool Duration Status Time'
57
+ puts '-' * 70
58
+ calls.reverse_each do |call|
59
+ status = call[:success] ? 'OK' : 'FAIL'
60
+ time = call[:timestamp]&.strftime('%H:%M:%S')
61
+ puts format('%-30<tool>s %6<dur>dms %7<st>s %<tm>s',
62
+ tool: call[:tool_name], dur: call[:duration_ms], st: status, tm: time)
63
+ end
64
+ end
65
+
66
+ desc 'reset', 'Clear all observation data'
67
+ def reset
68
+ print 'Clear all observation data? (yes/no): '
69
+ return unless $stdin.gets&.strip&.downcase == 'yes'
70
+
71
+ Legion::MCP::Observer.reset!
72
+ puts 'Observation data cleared.'
73
+ end
74
+
75
+ private
76
+
77
+ def serialize_stats(data)
78
+ {
79
+ total_calls: data[:total_calls],
80
+ tool_count: data[:tool_count],
81
+ failure_rate: data[:failure_rate],
82
+ since: data[:since]&.iso8601,
83
+ top_tools: data[:top_tools].map { |t| t.transform_keys(&:to_s) }
84
+ }
85
+ end
86
+
87
+ def serialize_call(call)
88
+ call.transform_keys(&:to_s).tap do |c|
89
+ c['timestamp'] = c['timestamp']&.iso8601 if c['timestamp']
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
data/lib/legion/cli.rb CHANGED
@@ -43,7 +43,8 @@ module Legion
43
43
  autoload :Marketplace, 'legion/cli/marketplace_command'
44
44
  autoload :Notebook, 'legion/cli/notebook_command'
45
45
  autoload :Llm, 'legion/cli/llm_command'
46
- autoload :Tty, 'legion/cli/tty_command'
46
+ autoload :Tty, 'legion/cli/tty_command'
47
+ autoload :ObserveCommand, 'legion/cli/observe_command'
47
48
  autoload :Interactive, 'legion/cli/interactive'
48
49
 
49
50
  class Main < Thor
@@ -241,6 +242,9 @@ module Legion
241
242
  desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)'
242
243
  subcommand 'tty', Legion::CLI::Tty
243
244
 
245
+ desc 'observe SUBCOMMAND', 'MCP tool observation stats'
246
+ subcommand 'observe', Legion::CLI::ObserveCommand
247
+
244
248
  desc 'tree', 'Print a tree of all available commands'
245
249
  def tree
246
250
  legion_print_command_tree(self.class, 'legion', '')
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module Legion
6
+ module MCP
7
+ module Observer
8
+ RING_BUFFER_MAX = 500
9
+ INTENT_BUFFER_MAX = 200
10
+
11
+ module_function
12
+
13
+ def record(tool_name:, duration_ms:, success:, params_keys: [], error: nil)
14
+ now = Time.now
15
+
16
+ counters_mutex.synchronize do
17
+ entry = counters[tool_name] || { call_count: 0, total_latency_ms: 0.0, failure_count: 0,
18
+ last_used: nil, last_error: nil }
19
+ counters[tool_name] = {
20
+ call_count: entry[:call_count] + 1,
21
+ total_latency_ms: entry[:total_latency_ms] + duration_ms.to_f,
22
+ failure_count: entry[:failure_count] + (success ? 0 : 1),
23
+ last_used: now,
24
+ last_error: success ? entry[:last_error] : error
25
+ }
26
+ end
27
+
28
+ buffer_mutex.synchronize do
29
+ ring_buffer << {
30
+ tool_name: tool_name,
31
+ duration_ms: duration_ms,
32
+ success: success,
33
+ params_keys: params_keys,
34
+ error: error,
35
+ recorded_at: now
36
+ }
37
+ ring_buffer.shift if ring_buffer.size > RING_BUFFER_MAX
38
+ end
39
+ end
40
+
41
+ def record_intent(intent, matched_tool_name)
42
+ intent_mutex.synchronize do
43
+ intent_buffer << { intent: intent, matched_tool: matched_tool_name, recorded_at: Time.now }
44
+ intent_buffer.shift if intent_buffer.size > INTENT_BUFFER_MAX
45
+ end
46
+ end
47
+
48
+ def tool_stats(tool_name)
49
+ entry = counters_mutex.synchronize { counters[tool_name] }
50
+ return nil unless entry
51
+
52
+ count = entry[:call_count]
53
+ avg = count.positive? ? (entry[:total_latency_ms] / count).round(2) : 0.0
54
+
55
+ {
56
+ name: tool_name,
57
+ call_count: count,
58
+ avg_latency_ms: avg,
59
+ failure_count: entry[:failure_count],
60
+ last_used: entry[:last_used],
61
+ last_error: entry[:last_error]
62
+ }
63
+ end
64
+
65
+ def all_tool_stats
66
+ names = counters_mutex.synchronize { counters.keys.dup }
67
+ names.to_h { |name| [name, tool_stats(name)] }
68
+ end
69
+
70
+ def stats
71
+ all_names = counters_mutex.synchronize { counters.keys.dup }
72
+ total = all_names.sum { |n| counters_mutex.synchronize { counters[n][:call_count] } }
73
+ failures = all_names.sum { |n| counters_mutex.synchronize { counters[n][:failure_count] } }
74
+ rate = total.positive? ? (failures.to_f / total).round(4) : 0.0
75
+
76
+ top = all_names
77
+ .map { |n| tool_stats(n) }
78
+ .sort_by { |s| -s[:call_count] }
79
+ .first(10)
80
+
81
+ {
82
+ total_calls: total,
83
+ tool_count: all_names.size,
84
+ failure_rate: rate,
85
+ top_tools: top,
86
+ since: started_at
87
+ }
88
+ end
89
+
90
+ def recent(limit = 10)
91
+ buffer_mutex.synchronize { ring_buffer.last(limit) }
92
+ end
93
+
94
+ def recent_intents(limit = 10)
95
+ intent_mutex.synchronize { intent_buffer.last(limit) }
96
+ end
97
+
98
+ def reset!
99
+ counters_mutex.synchronize { counters.clear }
100
+ buffer_mutex.synchronize { ring_buffer.clear }
101
+ intent_mutex.synchronize { intent_buffer.clear }
102
+ @started_at = Time.now
103
+ end
104
+
105
+ # Internal state accessors
106
+ def counters
107
+ @counters ||= {}
108
+ end
109
+
110
+ def counters_mutex
111
+ @counters_mutex ||= Mutex.new
112
+ end
113
+
114
+ def ring_buffer
115
+ @ring_buffer ||= []
116
+ end
117
+
118
+ def buffer_mutex
119
+ @buffer_mutex ||= Mutex.new
120
+ end
121
+
122
+ def intent_buffer
123
+ @intent_buffer ||= []
124
+ end
125
+
126
+ def intent_mutex
127
+ @intent_mutex ||= Mutex.new
128
+ end
129
+
130
+ def started_at
131
+ @started_at ||= Time.now
132
+ end
133
+ end
134
+ end
135
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'observer'
4
+ require_relative 'usage_filter'
3
5
  require_relative 'tools/run_task'
4
6
  require_relative 'tools/describe_runner'
5
7
  require_relative 'tools/list_tasks'
@@ -97,12 +99,44 @@ module Legion
97
99
  resource_templates: Resources::ExtensionInfo.resource_templates
98
100
  )
99
101
 
102
+ if defined?(Observer)
103
+ ::MCP.configure do |c|
104
+ c.instrumentation_callback = ->(idata) { Server.wire_observer(idata) }
105
+ end
106
+ end
107
+
108
+ server.tools_list_handler do |_params|
109
+ build_filtered_tool_list.map(&:to_h)
110
+ end
111
+
100
112
  Resources::RunnerCatalog.register(server)
101
113
  Resources::ExtensionInfo.register_read_handler(server)
102
114
 
103
115
  server
104
116
  end
105
117
 
118
+ def wire_observer(data)
119
+ return unless data[:method] == 'tools/call' && data[:tool_name]
120
+
121
+ duration_ms = (data[:duration].to_f * 1000).to_i
122
+ params_keys = data[:tool_arguments].respond_to?(:keys) ? data[:tool_arguments].keys : []
123
+ success = data[:error].nil?
124
+
125
+ Observer.record(
126
+ tool_name: data[:tool_name],
127
+ duration_ms: duration_ms,
128
+ success: success,
129
+ params_keys: params_keys,
130
+ error: data[:error]
131
+ )
132
+ end
133
+
134
+ def build_filtered_tool_list(keywords: [])
135
+ tool_names = TOOL_CLASSES.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
136
+ ranked = UsageFilter.ranked_tools(tool_names, keywords: keywords)
137
+ ranked.filter_map { |name| TOOL_CLASSES.find { |tc| (tc.respond_to?(:tool_name) ? tc.tool_name : tc.name) == name } }
138
+ end
139
+
106
140
  private
107
141
 
108
142
  def instructions
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module UsageFilter
6
+ ESSENTIAL_TOOLS = %w[
7
+ legion.do legion.tools legion.run_task legion.get_status legion.describe_runner
8
+ ].freeze
9
+
10
+ FREQUENCY_WEIGHT = 0.5
11
+ RECENCY_WEIGHT = 0.3
12
+ KEYWORD_WEIGHT = 0.2
13
+ BASELINE_SCORE = 0.1
14
+
15
+ module_function
16
+
17
+ def score_tools(tool_names, keywords: [])
18
+ all_stats = Observer.all_tool_stats
19
+ call_counts = tool_names.map { |n| all_stats.dig(n, :call_count) || 0 }
20
+ max_calls = call_counts.max || 0
21
+
22
+ tool_names.each_with_object({}) do |name, hash|
23
+ stats = all_stats[name]
24
+
25
+ freq_score = if max_calls.positive? && stats
26
+ (stats[:call_count].to_f / max_calls) * FREQUENCY_WEIGHT
27
+ else
28
+ 0.0
29
+ end
30
+
31
+ rec_score = if stats&.dig(:last_used)
32
+ recency_decay(stats[:last_used]) * RECENCY_WEIGHT
33
+ else
34
+ 0.0
35
+ end
36
+
37
+ kw_score = keyword_match(name, keywords) * KEYWORD_WEIGHT
38
+
39
+ total = freq_score + rec_score + kw_score
40
+ total = BASELINE_SCORE if total.zero?
41
+
42
+ hash[name] = total.round(6)
43
+ end
44
+ end
45
+
46
+ def ranked_tools(tool_names, limit: nil, keywords: [])
47
+ scores = score_tools(tool_names, keywords: keywords)
48
+ ranked = tool_names.sort_by { |n| -scores.fetch(n, BASELINE_SCORE) }
49
+ limit ? ranked.first(limit) : ranked
50
+ end
51
+
52
+ def prune_dead_tools(tool_names, prune_after_seconds: 86_400 * 30)
53
+ stats = Observer.stats
54
+ window = stats[:since]
55
+ elapsed = window ? (Time.now - window) : 0
56
+
57
+ return tool_names if elapsed < prune_after_seconds
58
+
59
+ all_stats = Observer.all_tool_stats
60
+ tool_names.reject do |name|
61
+ next false if ESSENTIAL_TOOLS.include?(name)
62
+
63
+ calls = all_stats.dig(name, :call_count) || 0
64
+ calls.zero?
65
+ end
66
+ end
67
+
68
+ def recency_decay(last_used)
69
+ return 0.0 unless last_used
70
+
71
+ age_seconds = Time.now - last_used
72
+ return 1.0 if age_seconds <= 0
73
+
74
+ decay = 1.0 - (age_seconds / 86_400.0)
75
+ decay.clamp(0.0, 1.0)
76
+ end
77
+
78
+ def keyword_match(tool_name, keywords)
79
+ return 0.0 if keywords.nil? || keywords.empty?
80
+
81
+ hits = keywords.count { |kw| tool_name.include?(kw.to_s) }
82
+ hits.to_f / keywords.size
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.71'
4
+ VERSION = '1.4.72'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.71
4
+ version: 1.4.72
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -490,6 +490,7 @@ files:
490
490
  - lib/legion/cli/mcp_command.rb
491
491
  - lib/legion/cli/memory_command.rb
492
492
  - lib/legion/cli/notebook_command.rb
493
+ - lib/legion/cli/observe_command.rb
493
494
  - lib/legion/cli/openapi_command.rb
494
495
  - lib/legion/cli/output.rb
495
496
  - lib/legion/cli/plan_command.rb
@@ -559,6 +560,7 @@ files:
559
560
  - lib/legion/mcp.rb
560
561
  - lib/legion/mcp/auth.rb
561
562
  - lib/legion/mcp/context_compiler.rb
563
+ - lib/legion/mcp/observer.rb
562
564
  - lib/legion/mcp/resources/extension_info.rb
563
565
  - lib/legion/mcp/resources/runner_catalog.rb
564
566
  - lib/legion/mcp/server.rb
@@ -598,6 +600,7 @@ files:
598
600
  - lib/legion/mcp/tools/update_schedule.rb
599
601
  - lib/legion/mcp/tools/worker_costs.rb
600
602
  - lib/legion/mcp/tools/worker_lifecycle.rb
603
+ - lib/legion/mcp/usage_filter.rb
601
604
  - lib/legion/metrics.rb
602
605
  - lib/legion/process.rb
603
606
  - lib/legion/readiness.rb