legion-llm 0.6.31 → 0.7.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 +13 -0
- data/lib/legion/llm/audit/skill_event.rb +44 -0
- data/lib/legion/llm/audit.rb +15 -0
- data/lib/legion/llm/conversation_store.rb +47 -0
- data/lib/legion/llm/pipeline/enrichment_injector.rb +5 -0
- data/lib/legion/llm/pipeline/executor.rb +3 -2
- data/lib/legion/llm/pipeline/steps/skill_injector.rb +181 -0
- data/lib/legion/llm/pipeline/steps.rb +1 -0
- data/lib/legion/llm/skills/base.rb +271 -0
- data/lib/legion/llm/skills/disk_loader.rb +91 -0
- data/lib/legion/llm/skills/errors.rb +24 -0
- data/lib/legion/llm/skills/external_discovery.rb +52 -0
- data/lib/legion/llm/skills/registry.rb +131 -0
- data/lib/legion/llm/skills/settings.rb +40 -0
- data/lib/legion/llm/skills/skill_run_result.rb +13 -0
- data/lib/legion/llm/skills/step_result.rb +13 -0
- data/lib/legion/llm/skills.rb +30 -0
- data/lib/legion/llm/version.rb +1 -1
- data/lib/legion/llm.rb +9 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9434ffcb40fb1c04975e72914ee52527be3cd88241ed9f173177abd67386e69
|
|
4
|
+
data.tar.gz: 1c56c1f6fb21f441d976f4cfc9012023c65f296693f2c18dde54b7dbe89e7f14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a2099e39f14bd3750728a6c3015b6cce4a84ea22beca9383ebe9455e5eb1452c0afc52ea380a90650e975a0b8cbe7f405e1a523dad902511eb1854be28f450b
|
|
7
|
+
data.tar.gz: b127e711a671a3f11ee40f7f854dc264a99d636be24b5f6ee7d46e943788247e669a65e1a6792745e91f4927c879e069e4e929b13f0ace4d30ab714b1beac792
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.0] - 2026-04-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Legion::LLM::Skills` subsystem — first-class daemon-side skill execution
|
|
9
|
+
- `Legion::LLM::Skills::Base` — DSL base class with `skill_name`, `namespace`, `description`, `trigger_words`, `trigger`, `steps`, `follows`, `file_change_triggers`, `content` DSL
|
|
10
|
+
- `Legion::LLM::Skills::Registry` — thread-safe registry with `register`, `find`, `all`, `by_trigger`, `chain_for`, `reset!`, trigger word index, file trigger index, and cycle detection
|
|
11
|
+
- `Legion::LLM::Skills::StepResult` — result struct from individual skill steps (`inject:`, `gate:`, `metadata:`)
|
|
12
|
+
- `Legion::LLM::Skills::SkillRunResult` — aggregated result struct from a full skill run (`complete:`, `gated:`, `inject:`, `gate:`, `resume_at:`)
|
|
13
|
+
- `Legion::LLM::Skills::InvalidSkill` and `Legion::LLM::Skills::StepError` — typed skill validation and execution errors
|
|
14
|
+
- `Legion::LLM::Pipeline::Steps::SkillInjector` — pipeline step (step 10.5) that matches trigger words and file change patterns, activates matching skills, and injects results as `skill:active` enrichment
|
|
15
|
+
- `Legion::LLM::ConversationStore` skill state methods: `set_skill_state`, `skill_state`, `clear_skill_state` for resumable multi-turn skills
|
|
16
|
+
- `Legion::LLM::Audit::SkillEvent` — audit event for skill invocations
|
|
17
|
+
|
|
5
18
|
## [0.6.31] - 2026-04-10
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../transport/message'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Audit
|
|
8
|
+
class SkillEvent < Legion::LLM::Transport::Message
|
|
9
|
+
def type = 'llm.audit.skill'
|
|
10
|
+
def exchange = Legion::LLM::Transport::Exchanges::Audit
|
|
11
|
+
def routing_key = "audit.skill.#{@options[:namespace]}.#{@options[:skill_name]}"
|
|
12
|
+
def priority = 0
|
|
13
|
+
def encrypt? = true
|
|
14
|
+
def expiration = nil
|
|
15
|
+
|
|
16
|
+
def headers
|
|
17
|
+
super.merge(skill_headers).merge(classification_headers)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def message_id_prefix = 'audit_skill'
|
|
23
|
+
|
|
24
|
+
def skill_headers
|
|
25
|
+
h = {}
|
|
26
|
+
h['x-legion-skill-name'] = @options[:skill_name].to_s if @options[:skill_name]
|
|
27
|
+
h['x-legion-skill-namespace'] = @options[:namespace].to_s if @options[:namespace]
|
|
28
|
+
h['x-legion-skill-step'] = @options[:step_name].to_s if @options[:step_name]
|
|
29
|
+
h['x-legion-skill-gate'] = @options[:gate].to_s if @options[:gate]
|
|
30
|
+
h['x-legion-skill-status'] = @options[:status].to_s if @options[:status]
|
|
31
|
+
h
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def classification_headers
|
|
35
|
+
cls = @options[:classification] || {}
|
|
36
|
+
h = {}
|
|
37
|
+
h['x-legion-classification'] = cls[:level].to_s if cls[:level]
|
|
38
|
+
h['x-legion-contains-phi'] = cls[:contains_phi].to_s unless cls[:contains_phi].nil?
|
|
39
|
+
h
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/legion/llm/audit.rb
CHANGED
|
@@ -6,6 +6,7 @@ if defined?(Legion::Transport::Message)
|
|
|
6
6
|
require_relative 'audit/exchange'
|
|
7
7
|
require_relative 'audit/prompt_event'
|
|
8
8
|
require_relative 'audit/tool_event'
|
|
9
|
+
require_relative 'audit/skill_event'
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
module Legion
|
|
@@ -43,6 +44,20 @@ module Legion
|
|
|
43
44
|
:dropped
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
def emit_skill(**event)
|
|
48
|
+
if transport_connected? && defined?(Legion::LLM::Audit::SkillEvent)
|
|
49
|
+
Legion::LLM::Audit::SkillEvent.new(**event).publish
|
|
50
|
+
log.info('[llm][audit] published skill audit')
|
|
51
|
+
:published
|
|
52
|
+
else
|
|
53
|
+
log.warn('[llm][audit] dropped skill audit: transport unavailable')
|
|
54
|
+
:dropped
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
handle_exception(e, level: :warn, operation: 'llm.audit.emit_skill')
|
|
58
|
+
:dropped
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
def transport_connected?
|
|
47
62
|
!!(defined?(Legion::Transport) &&
|
|
48
63
|
Legion::Transport.respond_to?(:connected?) &&
|
|
@@ -147,6 +147,53 @@ module Legion
|
|
|
147
147
|
@lru_counter = 0
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
def set_skill_state(conversation_id, skill_key:, resume_at:)
|
|
151
|
+
ensure_conversation(conversation_id)
|
|
152
|
+
conversations[conversation_id][:skill_state] = { skill_key: skill_key, resume_at: resume_at }
|
|
153
|
+
touch(conversation_id)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def skill_state(conversation_id)
|
|
157
|
+
return nil unless in_memory?(conversation_id)
|
|
158
|
+
|
|
159
|
+
touch(conversation_id)
|
|
160
|
+
conversations[conversation_id][:skill_state]&.dup
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def clear_skill_state(conversation_id)
|
|
164
|
+
return unless in_memory?(conversation_id)
|
|
165
|
+
|
|
166
|
+
conversations[conversation_id].delete(:skill_state)
|
|
167
|
+
touch(conversation_id)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Reads current state, clears :skill_state, sets :skill_cancelled flag.
|
|
171
|
+
# Returns the previous state (for use in skill.cancelled payload), or nil if none.
|
|
172
|
+
def cancel_skill!(conversation_id)
|
|
173
|
+
ensure_conversation(conversation_id)
|
|
174
|
+
state = conversations[conversation_id].delete(:skill_state)
|
|
175
|
+
if state
|
|
176
|
+
conversations[conversation_id][:skill_cancelled] = true
|
|
177
|
+
touch(conversation_id)
|
|
178
|
+
end
|
|
179
|
+
state
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# :skill_cancelled is distinct from a nil :skill_state.
|
|
183
|
+
# nil skill_state also occurs after normal completion — use this flag to detect cancel.
|
|
184
|
+
def skill_cancelled?(conversation_id)
|
|
185
|
+
return false unless in_memory?(conversation_id)
|
|
186
|
+
|
|
187
|
+
conversations[conversation_id][:skill_cancelled] == true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def clear_cancel_flag(conversation_id)
|
|
191
|
+
return unless in_memory?(conversation_id)
|
|
192
|
+
|
|
193
|
+
conversations[conversation_id].delete(:skill_cancelled)
|
|
194
|
+
touch(conversation_id)
|
|
195
|
+
end
|
|
196
|
+
|
|
150
197
|
# Migrate existing sequential messages to use parent links.
|
|
151
198
|
# Safe to call on already-migrated data (no-op when parent links present).
|
|
152
199
|
def migrate_parent_links!(conversation_id)
|
|
@@ -24,6 +24,11 @@ module Legion
|
|
|
24
24
|
parts << "Relevant context:\n#{context_text}" unless context_text.empty?
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Skill injection — active skill's step output appended after the RAG context
|
|
28
|
+
if (skill = enrichments['skill:active'])
|
|
29
|
+
parts << skill
|
|
30
|
+
end
|
|
31
|
+
|
|
27
32
|
return system if parts.empty?
|
|
28
33
|
|
|
29
34
|
parts << system if system
|
|
@@ -20,6 +20,7 @@ module Legion
|
|
|
20
20
|
attr_accessor :tool_event_handler
|
|
21
21
|
|
|
22
22
|
include Steps::TriggerMatch
|
|
23
|
+
include Steps::SkillInjector
|
|
23
24
|
include Steps::ToolDiscovery
|
|
24
25
|
include Steps::ToolCalls
|
|
25
26
|
include Steps::KnowledgeCapture
|
|
@@ -30,14 +31,14 @@ module Legion
|
|
|
30
31
|
|
|
31
32
|
STEPS = %i[
|
|
32
33
|
tracing_init idempotency conversation_uuid context_load
|
|
33
|
-
rbac classification billing gaia_advisory tier_assignment rag_context trigger_match tool_discovery
|
|
34
|
+
rbac classification billing gaia_advisory tier_assignment rag_context trigger_match skill_injector tool_discovery
|
|
34
35
|
routing request_normalization token_budget provider_call response_normalization
|
|
35
36
|
debate confidence_scoring tool_calls context_store post_response knowledge_capture response_return
|
|
36
37
|
].freeze
|
|
37
38
|
|
|
38
39
|
PRE_PROVIDER_STEPS = %i[
|
|
39
40
|
tracing_init idempotency conversation_uuid context_load
|
|
40
|
-
rbac classification billing gaia_advisory tier_assignment rag_context trigger_match tool_discovery
|
|
41
|
+
rbac classification billing gaia_advisory tier_assignment rag_context trigger_match skill_injector tool_discovery
|
|
41
42
|
routing request_normalization token_budget
|
|
42
43
|
].freeze
|
|
43
44
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Pipeline
|
|
8
|
+
module Steps
|
|
9
|
+
module SkillInjector
|
|
10
|
+
include Legion::Logging::Helper
|
|
11
|
+
|
|
12
|
+
def step_skill_injector
|
|
13
|
+
return unless skills_enabled?
|
|
14
|
+
|
|
15
|
+
conv_id = @request.conversation_id
|
|
16
|
+
return unless conv_id
|
|
17
|
+
|
|
18
|
+
if (state = ConversationStore.skill_state(conv_id))
|
|
19
|
+
resume_active_skill(conv_id, state)
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@skill_executed = false
|
|
24
|
+
|
|
25
|
+
check_trigger_words(conv_id)
|
|
26
|
+
return if @skill_executed
|
|
27
|
+
|
|
28
|
+
check_file_change_triggers(conv_id)
|
|
29
|
+
return if @skill_executed
|
|
30
|
+
|
|
31
|
+
check_auto_skills(conv_id)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
@warnings << "SkillInjector error: #{e.message}"
|
|
34
|
+
handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.skill_injector')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def skills_enabled?
|
|
40
|
+
defined?(Legion::LLM::Skills::Registry) &&
|
|
41
|
+
defined?(Legion::LLM) &&
|
|
42
|
+
Legion::LLM.respond_to?(:settings) &&
|
|
43
|
+
Legion::LLM.settings.dig(:skills, :enabled) != false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resume_active_skill(conv_id, state)
|
|
47
|
+
skill_class = Legion::LLM::Skills::Registry.find(state[:skill_key])
|
|
48
|
+
unless skill_class
|
|
49
|
+
ConversationStore.clear_skill_state(conv_id)
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
result = skill_class.new.run(from_step: state[:resume_at],
|
|
54
|
+
context: build_skill_context(conv_id))
|
|
55
|
+
inject_skill_result(result)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_trigger_words(conv_id)
|
|
59
|
+
return if at_max_active_skills?(conv_id)
|
|
60
|
+
|
|
61
|
+
text = extract_message_text
|
|
62
|
+
words = text.downcase.gsub(/[^a-z ]/, ' ').split.to_set
|
|
63
|
+
return if words.empty?
|
|
64
|
+
|
|
65
|
+
index = Legion::LLM::Skills::Registry.trigger_word_index
|
|
66
|
+
matched_keys = words.flat_map { |w| index[w] || [] }.uniq
|
|
67
|
+
return if matched_keys.empty?
|
|
68
|
+
|
|
69
|
+
matched_keys.each do |key|
|
|
70
|
+
skill_class = Legion::LLM::Skills::Registry.find(key)
|
|
71
|
+
next unless skill_class
|
|
72
|
+
next if skill_disabled?(key)
|
|
73
|
+
|
|
74
|
+
activate_skill(conv_id, skill_class)
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def check_file_change_triggers(conv_id)
|
|
80
|
+
return if at_max_active_skills?(conv_id)
|
|
81
|
+
|
|
82
|
+
changed = Array(@request.metadata&.dig(:changed_files) || [])
|
|
83
|
+
return if changed.empty?
|
|
84
|
+
|
|
85
|
+
Legion::LLM::Skills::Registry.file_trigger_skills.each do |skill_class|
|
|
86
|
+
key = "#{skill_class.namespace}:#{skill_class.skill_name}"
|
|
87
|
+
next if skill_disabled?(key)
|
|
88
|
+
|
|
89
|
+
matched = changed.any? do |path|
|
|
90
|
+
skill_class.file_change_trigger_patterns.any? do |pat|
|
|
91
|
+
::File.fnmatch(pat, path, ::File::FNM_DOTMATCH)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
next unless matched
|
|
95
|
+
|
|
96
|
+
activate_skill(conv_id, skill_class)
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_auto_skills(conv_id)
|
|
102
|
+
return if at_max_active_skills?(conv_id)
|
|
103
|
+
return if Legion::LLM.settings.dig(:skills, :auto_inject) == false
|
|
104
|
+
|
|
105
|
+
Legion::LLM::Skills::Registry.by_trigger(:auto).each do |skill_class|
|
|
106
|
+
key = "#{skill_class.namespace}:#{skill_class.skill_name}"
|
|
107
|
+
next if skill_disabled?(key)
|
|
108
|
+
next unless when_conditions_match?(skill_class)
|
|
109
|
+
|
|
110
|
+
activate_skill(conv_id, skill_class)
|
|
111
|
+
break
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def activate_skill(conv_id, skill_class)
|
|
116
|
+
result = skill_class.new.run(from_step: 0, context: build_skill_context(conv_id))
|
|
117
|
+
@skill_executed = true
|
|
118
|
+
inject_skill_result(result)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inject_skill_result(result)
|
|
122
|
+
return unless result.inject && !result.inject.empty?
|
|
123
|
+
|
|
124
|
+
@enrichments['skill:active'] = result.inject
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def when_conditions_match?(skill_class)
|
|
128
|
+
return true if skill_class.when_conditions.empty?
|
|
129
|
+
|
|
130
|
+
skill_class.when_conditions.all? do |key, expected|
|
|
131
|
+
unless @request.respond_to?(key)
|
|
132
|
+
log.warn("[skill_injector] unknown condition key #{key.inspect}, non-matching")
|
|
133
|
+
next false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
deep_subset_match?(@request.public_send(key), expected)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def deep_subset_match?(actual, expected)
|
|
141
|
+
return actual == expected unless expected.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
expected.all? do |k, v|
|
|
144
|
+
actual.is_a?(Hash) && actual.key?(k) && deep_subset_match?(actual[k], v)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def at_max_active_skills?(conv_id)
|
|
149
|
+
max = Legion::LLM.settings.dig(:skills, :max_active_skills) || 1
|
|
150
|
+
active = ConversationStore.skill_state(conv_id) ? 1 : 0
|
|
151
|
+
active >= max
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def skill_disabled?(key)
|
|
155
|
+
disabled = Array(Legion::LLM.settings.dig(:skills, :disabled_skills) || [])
|
|
156
|
+
enabled = Array(Legion::LLM.settings.dig(:skills, :enabled_skills) || [])
|
|
157
|
+
return true if disabled.include?(key)
|
|
158
|
+
return false if enabled.empty?
|
|
159
|
+
|
|
160
|
+
!enabled.include?(key)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def extract_message_text
|
|
164
|
+
@request.messages.last(2).map do |msg|
|
|
165
|
+
msg.is_a?(Hash) ? (msg[:content] || msg['content'] || '').to_s : msg.to_s
|
|
166
|
+
end.join(' ')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def build_skill_context(conv_id)
|
|
170
|
+
{
|
|
171
|
+
conversation_id: conv_id,
|
|
172
|
+
classification: @request.classification,
|
|
173
|
+
metadata: @request.metadata,
|
|
174
|
+
intent: @request.extra&.dig(:intent)
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -19,6 +19,7 @@ require_relative 'steps/tier_assigner'
|
|
|
19
19
|
require_relative 'steps/post_response'
|
|
20
20
|
require_relative 'steps/tool_discovery'
|
|
21
21
|
require_relative 'steps/trigger_match'
|
|
22
|
+
require_relative 'steps/skill_injector'
|
|
22
23
|
require_relative 'steps/tool_calls'
|
|
23
24
|
require_relative 'steps/rag_context'
|
|
24
25
|
require_relative 'steps/rag_guard'
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'step_result'
|
|
4
|
+
require_relative 'skill_run_result'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module LLM
|
|
9
|
+
module Skills
|
|
10
|
+
class Base
|
|
11
|
+
class << self
|
|
12
|
+
def skill_name(name = nil)
|
|
13
|
+
name ? (@skill_name = name.to_s) : @skill_name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def description(text = nil)
|
|
17
|
+
text ? (@description = text) : @description
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def trigger(type = nil)
|
|
21
|
+
type ? (@trigger = type) : (@trigger || :on_demand)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def namespace(nsp = nil)
|
|
25
|
+
nsp ? (@namespace = nsp.to_s) : @namespace
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def steps(*names)
|
|
29
|
+
if names.any?
|
|
30
|
+
@steps = names
|
|
31
|
+
validate_steps!
|
|
32
|
+
else
|
|
33
|
+
@steps || []
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def trigger_words(*words)
|
|
38
|
+
words.any? ? (@trigger_words_list = words.map(&:to_s)) : (@trigger_words_list || [])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def file_change_triggers(*patterns)
|
|
42
|
+
patterns.any? ? (@file_change_trigger_patterns = patterns.map(&:to_s)) : (@file_change_trigger_patterns || [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def file_change_trigger_patterns
|
|
46
|
+
@file_change_trigger_patterns || []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def follows(skill_key = nil)
|
|
50
|
+
skill_key ? (@follows_skill = skill_key.to_s) : @follows_skill
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :follows_skill
|
|
54
|
+
|
|
55
|
+
# `condition` is used instead of `when` because `when` is a Ruby reserved keyword.
|
|
56
|
+
# DSL: `condition classification: { level: 'internal' }`
|
|
57
|
+
def condition(**conds)
|
|
58
|
+
@when_conditions = conds
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def when_conditions
|
|
62
|
+
@when_conditions || {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def content(context: {}) # rubocop:disable Lint/UnusedMethodArgument
|
|
66
|
+
path = content_path
|
|
67
|
+
return ::File.read(path) if path && ::File.exist?(path)
|
|
68
|
+
|
|
69
|
+
generate_content_from_step_names
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def validate_steps!
|
|
75
|
+
missing = @steps.reject { |name| method_defined?(name) || private_method_defined?(name) }
|
|
76
|
+
return if missing.empty?
|
|
77
|
+
|
|
78
|
+
raise InvalidSkill, "#{self}: missing step methods: #{missing.join(', ')}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def content_path
|
|
82
|
+
return nil unless @skill_name
|
|
83
|
+
|
|
84
|
+
# Derive gem name: Legion::Extensions::SkillSuperpowers -> lex-skill-superpowers
|
|
85
|
+
parts = name.to_s.split('::').drop(2)
|
|
86
|
+
return nil if parts.empty?
|
|
87
|
+
|
|
88
|
+
gem_name = parts.first.gsub(/([A-Z])/) { "-#{::Regexp.last_match(1).downcase}" }.sub(/^-/, 'lex-')
|
|
89
|
+
spec = begin
|
|
90
|
+
::Gem::Specification.find_by_name(gem_name)
|
|
91
|
+
rescue ::Gem::MissingSpecError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
return nil unless spec
|
|
95
|
+
|
|
96
|
+
::File.join(spec.gem_dir, 'content', @skill_name, 'SKILL.md')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def generate_content_from_step_names
|
|
100
|
+
lines = ["# #{@skill_name} — #{@skill_name.to_s.tr('-', ' ').capitalize}", '',
|
|
101
|
+
@description.to_s, '', '## Steps', '']
|
|
102
|
+
(@steps || []).each_with_index { |n, i| lines << "#{i + 1}. #{n.to_s.tr('_', ' ').capitalize}" }
|
|
103
|
+
lines.join("\n")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run(from_step: 0, context: {})
|
|
108
|
+
inject_parts = []
|
|
109
|
+
total_duration = 0
|
|
110
|
+
classification = context[:classification]
|
|
111
|
+
conv_id = context[:conversation_id]
|
|
112
|
+
self_key = "#{self.class.namespace}:#{self.class.skill_name}"
|
|
113
|
+
|
|
114
|
+
emit_event(conv_id, 'skill.started',
|
|
115
|
+
skill_name: self.class.skill_name, namespace: self.class.namespace,
|
|
116
|
+
total_steps: self.class.steps.length)
|
|
117
|
+
|
|
118
|
+
remaining_steps = self.class.steps[from_step..] || []
|
|
119
|
+
|
|
120
|
+
remaining_steps.each_with_index do |method_name, offset|
|
|
121
|
+
step_idx = from_step + offset
|
|
122
|
+
if conv_id && Legion::LLM::ConversationStore.skill_cancelled?(conv_id)
|
|
123
|
+
Legion::LLM::ConversationStore.clear_cancel_flag(conv_id)
|
|
124
|
+
return SkillRunResult.build(inject: inject_parts.join("\n\n"),
|
|
125
|
+
gated: false, gate: nil, resume_at: nil, complete: false)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
result, duration_ms = execute_step(method_name, step_idx, context, conv_id, classification)
|
|
129
|
+
total_duration += duration_ms
|
|
130
|
+
inject_parts << result.inject if result.inject
|
|
131
|
+
|
|
132
|
+
emit_step_success(conv_id, method_name, step_idx, duration_ms, result, classification)
|
|
133
|
+
|
|
134
|
+
next unless result.gate
|
|
135
|
+
|
|
136
|
+
if conv_id
|
|
137
|
+
Legion::LLM::ConversationStore.set_skill_state(
|
|
138
|
+
conv_id, skill_key: self_key, resume_at: step_idx + 1
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
emit_event(conv_id, 'skill.step.gated',
|
|
142
|
+
step_name: method_name, gate_type: result.gate)
|
|
143
|
+
return SkillRunResult.build(
|
|
144
|
+
inject: inject_parts.join("\n\n"), gated: true,
|
|
145
|
+
gate: result.gate, resume_at: step_idx + 1, complete: false
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
finalize_run(conv_id, self_key, inject_parts, total_duration, context)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def execute_step(method_name, step_idx, context, conv_id, classification)
|
|
155
|
+
t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
156
|
+
emit_event(conv_id, 'skill.step.started',
|
|
157
|
+
step_name: method_name, step_index: step_idx)
|
|
158
|
+
Legion::LLM::Metering.emit(
|
|
159
|
+
request_type: 'skill.step.start', skill_name: self.class.skill_name,
|
|
160
|
+
namespace: self.class.namespace, step_name: method_name,
|
|
161
|
+
step_index: step_idx, tier: 'local'
|
|
162
|
+
)
|
|
163
|
+
result = public_send(method_name, context: context)
|
|
164
|
+
unless result.respond_to?(:inject) && result.respond_to?(:metadata) && result.respond_to?(:gate)
|
|
165
|
+
raise Legion::LLM::Skills::StepError.new(
|
|
166
|
+
"#{self.class.skill_name}##{method_name} returned #{result.class} instead of StepResult",
|
|
167
|
+
cause: nil
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
duration_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
172
|
+
[result, duration_ms]
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
duration_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
175
|
+
handle_step_error(e, method_name, step_idx, conv_id, duration_ms, classification)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def handle_step_error(err, method_name, step_idx, conv_id, duration_ms, classification)
|
|
179
|
+
Legion::LLM::ConversationStore.clear_skill_state(conv_id) if conv_id
|
|
180
|
+
emit_event(conv_id, 'skill.step.failed',
|
|
181
|
+
step_name: method_name, error: err.message)
|
|
182
|
+
Legion::LLM::Audit.emit_skill(
|
|
183
|
+
skill_name: self.class.skill_name, namespace: self.class.namespace,
|
|
184
|
+
step_name: method_name, gate: nil, status: :failed,
|
|
185
|
+
duration_ms: duration_ms, metadata: { error: err.message },
|
|
186
|
+
classification: classification
|
|
187
|
+
)
|
|
188
|
+
Legion::LLM::Metering.emit(
|
|
189
|
+
request_type: 'skill.step', skill_name: self.class.skill_name,
|
|
190
|
+
namespace: self.class.namespace, step_name: method_name,
|
|
191
|
+
step_index: step_idx, duration_ms: duration_ms, gate: nil, tier: 'local'
|
|
192
|
+
)
|
|
193
|
+
raise Legion::LLM::Skills::StepError.new(
|
|
194
|
+
"#{self.class.skill_name}##{method_name} failed: #{err.message}", cause: err
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def emit_step_success(conv_id, method_name, step_idx, duration_ms, result, classification)
|
|
199
|
+
emit_event(conv_id, 'skill.step.completed',
|
|
200
|
+
step_name: method_name, duration_ms: duration_ms,
|
|
201
|
+
metadata: result.metadata)
|
|
202
|
+
Legion::LLM::Audit.emit_skill(
|
|
203
|
+
skill_name: self.class.skill_name, namespace: self.class.namespace,
|
|
204
|
+
step_name: method_name, gate: result.gate,
|
|
205
|
+
status: :completed, duration_ms: duration_ms,
|
|
206
|
+
metadata: result.metadata, classification: classification
|
|
207
|
+
)
|
|
208
|
+
Legion::LLM::Metering.emit(
|
|
209
|
+
request_type: 'skill.step', skill_name: self.class.skill_name,
|
|
210
|
+
namespace: self.class.namespace, step_name: method_name,
|
|
211
|
+
step_index: step_idx, duration_ms: duration_ms,
|
|
212
|
+
gate: result.gate&.to_s, tier: 'local'
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def finalize_run(conv_id, self_key, inject_parts, total_duration, context)
|
|
217
|
+
chain_next = Legion::LLM::Skills::Registry.chain_for(self_key)
|
|
218
|
+
chained_class = chain_next ? Legion::LLM::Skills::Registry.find(chain_next) : nil
|
|
219
|
+
resolved_chain = chained_class ? chain_next : nil
|
|
220
|
+
|
|
221
|
+
Legion::LLM::ConversationStore.clear_skill_state(conv_id) if conv_id
|
|
222
|
+
emit_event(conv_id, 'skill.completed',
|
|
223
|
+
skill_name: self.class.skill_name, namespace: self.class.namespace,
|
|
224
|
+
total_duration_ms: total_duration, chained_to: resolved_chain)
|
|
225
|
+
|
|
226
|
+
return run_chained(chained_class, chain_next, conv_id, self_key, inject_parts, context) if chained_class
|
|
227
|
+
|
|
228
|
+
SkillRunResult.build(inject: inject_parts.join("\n\n"),
|
|
229
|
+
gated: false, gate: nil, resume_at: nil, complete: true)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def run_chained(chained_class, chain_key, conv_id, self_key, inject_parts, context)
|
|
233
|
+
emit_event(conv_id, 'skill.chained',
|
|
234
|
+
from_skill: self_key, to_skill: chain_key)
|
|
235
|
+
chained_result = chained_class.new.run(from_step: 0, context: context)
|
|
236
|
+
inject_parts << chained_result.inject if chained_result.inject
|
|
237
|
+
SkillRunResult.build(
|
|
238
|
+
inject: inject_parts.join("\n\n"),
|
|
239
|
+
gated: chained_result.gated,
|
|
240
|
+
gate: chained_result.gate,
|
|
241
|
+
resume_at: chained_result.resume_at,
|
|
242
|
+
complete: chained_result.complete
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def emit_event(conv_id, event, **payload)
|
|
247
|
+
return unless conv_id
|
|
248
|
+
|
|
249
|
+
Legion::Events.emit(event, { conversation_id: conv_id }.merge(payload))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
protected
|
|
253
|
+
|
|
254
|
+
def detect_project(context)
|
|
255
|
+
root = context[:project_root]
|
|
256
|
+
return 'unknown project' unless root
|
|
257
|
+
|
|
258
|
+
::File.basename(root.to_s)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def conversation_id(context)
|
|
262
|
+
context[:conversation_id]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def current_intent(context)
|
|
266
|
+
context[:intent]
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Skills
|
|
8
|
+
module DiskLoader
|
|
9
|
+
extend Legion::Logging::Helper
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def load_from_directories(directories)
|
|
14
|
+
loaded = 0
|
|
15
|
+
Array(directories).each do |dir|
|
|
16
|
+
expanded = ::File.expand_path(dir)
|
|
17
|
+
next unless ::File.directory?(expanded)
|
|
18
|
+
|
|
19
|
+
loaded += load_directory(expanded)
|
|
20
|
+
end
|
|
21
|
+
loaded
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def load_directory(dir)
|
|
25
|
+
loaded = 0
|
|
26
|
+
::Dir.glob(::File.join(dir, '*.rb')).each do |path|
|
|
27
|
+
require path
|
|
28
|
+
loaded += 1
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
log.warn("[skills][disk_loader] failed to load #{path}: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
::Dir.glob(::File.join(dir, '*.md')).each do |path|
|
|
33
|
+
load_md_skill(path)
|
|
34
|
+
loaded += 1
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
log.warn("[skills][disk_loader] failed to load #{path}: #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
::Dir.glob(::File.join(dir, '*/SKILL.md')).each do |path|
|
|
39
|
+
load_md_skill(path, skill_name: ::File.basename(::File.dirname(path)))
|
|
40
|
+
loaded += 1
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
log.warn("[skills][disk_loader] failed to load #{path}: #{e.message}")
|
|
43
|
+
end
|
|
44
|
+
loaded
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Public for testing. Accepts an optional content: kwarg to avoid disk reads in specs.
|
|
48
|
+
def load_md_skill(path, skill_name: nil, content: nil)
|
|
49
|
+
raw = content || ::File.read(path)
|
|
50
|
+
meta, body = parse_frontmatter(raw)
|
|
51
|
+
name = skill_name || meta[:name] || ::File.basename(path, '.md')
|
|
52
|
+
ns = (meta[:namespace] || 'disk').to_s
|
|
53
|
+
desc = (meta[:description] || '').to_s
|
|
54
|
+
trig = (meta[:trigger] || 'on_demand').to_sym
|
|
55
|
+
words = Array(meta[:trigger_words] || []).map(&:to_s)
|
|
56
|
+
klass = build_md_skill_class(name: name, namespace: ns, description: desc,
|
|
57
|
+
trigger: trig, trigger_words: words, content: body)
|
|
58
|
+
Registry.register(klass)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse_frontmatter(text)
|
|
62
|
+
return [{}, text] unless text.start_with?('---')
|
|
63
|
+
|
|
64
|
+
parts = text.split(/^---\s*$/, 3)
|
|
65
|
+
return [{}, text] unless parts.size >= 3
|
|
66
|
+
|
|
67
|
+
require 'yaml'
|
|
68
|
+
meta = ::YAML.safe_load(parts[1], permitted_classes: [], symbolize_names: true) || {}
|
|
69
|
+
[meta, parts[2].lstrip]
|
|
70
|
+
rescue StandardError
|
|
71
|
+
[{}, text]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_md_skill_class(name:, namespace:, description:, trigger:, trigger_words:, content:)
|
|
75
|
+
raw_content = content
|
|
76
|
+
klass = Class.new(Legion::LLM::Skills::Base)
|
|
77
|
+
klass.send(:define_method, :present_content) do |context: {}| # rubocop:disable Lint/UnusedBlockArgument
|
|
78
|
+
Legion::LLM::Skills::StepResult.build(inject: raw_content)
|
|
79
|
+
end
|
|
80
|
+
klass.skill_name(name)
|
|
81
|
+
klass.namespace(namespace)
|
|
82
|
+
klass.description(description)
|
|
83
|
+
klass.trigger(trigger)
|
|
84
|
+
klass.trigger_words(*trigger_words) if trigger_words.any?
|
|
85
|
+
klass.steps(:present_content)
|
|
86
|
+
klass
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/llm/errors'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Skills
|
|
8
|
+
class InvalidSkill < Legion::LLM::LLMError
|
|
9
|
+
def initialize(msg = 'Invalid skill definition')
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class StepError < Legion::LLM::LLMError
|
|
15
|
+
attr_reader :cause
|
|
16
|
+
|
|
17
|
+
def initialize(msg, cause: nil)
|
|
18
|
+
super(msg)
|
|
19
|
+
@cause = cause
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Skills
|
|
6
|
+
module ExternalDiscovery
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def discover
|
|
10
|
+
dirs = []
|
|
11
|
+
dirs.concat(claude_directories) if claude_auto_discover?
|
|
12
|
+
dirs.concat(codex_directories) if codex_auto_discover?
|
|
13
|
+
dirs
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def claude_directories
|
|
17
|
+
home = ::File.expand_path('~')
|
|
18
|
+
dirs = []
|
|
19
|
+
|
|
20
|
+
skills_dir = ::File.join(home, '.claude', 'skills')
|
|
21
|
+
dirs << skills_dir if ::File.directory?(skills_dir)
|
|
22
|
+
|
|
23
|
+
plugins_dir = ::File.join(home, '.claude', 'plugins')
|
|
24
|
+
if ::File.directory?(plugins_dir)
|
|
25
|
+
::Dir.glob(::File.join(plugins_dir, '*', 'skills')).each do |skill_subdir|
|
|
26
|
+
dirs << skill_subdir if ::File.directory?(skill_subdir)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
dirs.uniq
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def codex_directories
|
|
34
|
+
candidate = ::File.join(::File.expand_path('~'), '.codex', 'skills')
|
|
35
|
+
::File.directory?(candidate) ? [candidate] : []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def claude_auto_discover?
|
|
39
|
+
Legion::LLM.settings.dig(:skills, :auto_discover, :claude) != false
|
|
40
|
+
rescue StandardError
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def codex_auto_discover?
|
|
45
|
+
Legion::LLM.settings.dig(:skills, :auto_discover, :codex) != false
|
|
46
|
+
rescue StandardError
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module LLM
|
|
7
|
+
module Skills
|
|
8
|
+
module Registry
|
|
9
|
+
extend Legion::Logging::Helper
|
|
10
|
+
|
|
11
|
+
MUTEX = Mutex.new
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def register(skill_class)
|
|
15
|
+
MUTEX.synchronize do
|
|
16
|
+
validate!(skill_class)
|
|
17
|
+
key = registry_key(skill_class)
|
|
18
|
+
if (@by_key ||= {}).key?(key)
|
|
19
|
+
log.warn("[skills][registry] duplicate: #{key} replaced")
|
|
20
|
+
remove_from_indexes(@by_key[key], key)
|
|
21
|
+
end
|
|
22
|
+
@ordered ||= []
|
|
23
|
+
@ordered.reject! { |k| k == key }
|
|
24
|
+
@by_key[key] = skill_class
|
|
25
|
+
@ordered << key
|
|
26
|
+
index_trigger_words(key, skill_class)
|
|
27
|
+
index_file_triggers(skill_class)
|
|
28
|
+
index_chain(skill_class)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all
|
|
33
|
+
MUTEX.synchronize { (@ordered || []).filter_map { |k| (@by_key || {})[k] } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find(key)
|
|
37
|
+
MUTEX.synchronize { (@by_key || {})[key] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def by_trigger(type)
|
|
41
|
+
all.select { |c| c.trigger == type }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def chain_for(skill_key)
|
|
45
|
+
MUTEX.synchronize { (@chain_index || {})[skill_key] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def trigger_word_index
|
|
49
|
+
MUTEX.synchronize do
|
|
50
|
+
(@trigger_word_index || {}).each_with_object({}) do |(word, keys), copy|
|
|
51
|
+
copy[word] = keys.dup
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def file_trigger_skills
|
|
57
|
+
MUTEX.synchronize { (@file_trigger_skills || []).dup }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reset!
|
|
61
|
+
MUTEX.synchronize do
|
|
62
|
+
@by_key = {}
|
|
63
|
+
@ordered = []
|
|
64
|
+
@chain_index = {}
|
|
65
|
+
@trigger_word_index = {}
|
|
66
|
+
@file_trigger_skills = []
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def registry_key(skill_class)
|
|
73
|
+
"#{skill_class.namespace}:#{skill_class.skill_name}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate!(skill_class)
|
|
77
|
+
raise InvalidSkill, "#{skill_class}: skill_name required" if skill_class.skill_name.nil?
|
|
78
|
+
raise InvalidSkill, "#{skill_class}: namespace required" if skill_class.namespace.nil?
|
|
79
|
+
|
|
80
|
+
check_chain_cycle!(skill_class)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_chain_cycle!(skill_class)
|
|
84
|
+
return unless skill_class.follows_skill
|
|
85
|
+
|
|
86
|
+
self_key = registry_key(skill_class)
|
|
87
|
+
visited = Set.new([self_key])
|
|
88
|
+
current = skill_class.follows_skill
|
|
89
|
+
while current
|
|
90
|
+
raise InvalidSkill, "Cycle detected in skill chain involving #{self_key}" if visited.include?(current)
|
|
91
|
+
|
|
92
|
+
visited.add(current)
|
|
93
|
+
current = (@by_key || {})[current]&.follows_skill
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def index_trigger_words(key, skill_class)
|
|
98
|
+
@trigger_word_index ||= {}
|
|
99
|
+
skill_class.trigger_words.each do |word|
|
|
100
|
+
@trigger_word_index[word] ||= []
|
|
101
|
+
@trigger_word_index[word] << key unless @trigger_word_index[word].include?(key)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def index_file_triggers(skill_class)
|
|
106
|
+
@file_trigger_skills ||= []
|
|
107
|
+
return if skill_class.file_change_trigger_patterns.empty?
|
|
108
|
+
return if @file_trigger_skills.include?(skill_class)
|
|
109
|
+
|
|
110
|
+
@file_trigger_skills << skill_class
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def index_chain(skill_class)
|
|
114
|
+
@chain_index ||= {}
|
|
115
|
+
return unless skill_class.follows_skill
|
|
116
|
+
|
|
117
|
+
self_key = registry_key(skill_class)
|
|
118
|
+
@chain_index[skill_class.follows_skill] = self_key
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def remove_from_indexes(old_class, key)
|
|
122
|
+
old_class.trigger_words.each { |w| @trigger_word_index&.[](w)&.delete(key) }
|
|
123
|
+
@file_trigger_skills&.delete(old_class)
|
|
124
|
+
@chain_index&.delete_if { |_parent, child_key| child_key == key }
|
|
125
|
+
@chain_index&.delete(old_class.follows_skill) if old_class.follows_skill
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Skills
|
|
6
|
+
module Settings
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
enabled: true,
|
|
9
|
+
auto_inject: true,
|
|
10
|
+
on_demand: true,
|
|
11
|
+
max_active_skills: 1,
|
|
12
|
+
directories: ['.legion/skills', '~/.legionio/skills'],
|
|
13
|
+
auto_discover: { claude: false, codex: false },
|
|
14
|
+
enabled_skills: [],
|
|
15
|
+
disabled_skills: []
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def apply
|
|
21
|
+
return unless defined?(Legion::Settings)
|
|
22
|
+
|
|
23
|
+
llm_settings = (Legion::Settings[:llm] || {}).dup
|
|
24
|
+
current = llm_settings[:skills] || {}
|
|
25
|
+
merged = deep_merge(DEFAULTS, current)
|
|
26
|
+
llm_settings[:skills] = merged
|
|
27
|
+
Legion::Settings[:llm] = llm_settings
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def deep_merge(base, override)
|
|
31
|
+
result = base.dup
|
|
32
|
+
override.each do |key, val|
|
|
33
|
+
result[key] = val.is_a?(Hash) && result[key].is_a?(Hash) ? deep_merge(result[key], val) : val
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Skills
|
|
6
|
+
SkillRunResult = ::Data.define(:inject, :gated, :gate, :resume_at, :complete) do
|
|
7
|
+
def self.build(inject:, gated:, gate:, resume_at:, complete:)
|
|
8
|
+
new(inject: inject, gated: gated, gate: gate, resume_at: resume_at, complete: complete)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Skills
|
|
6
|
+
StepResult = ::Data.define(:inject, :gate, :metadata) do
|
|
7
|
+
def self.build(inject:, gate: nil, metadata: {})
|
|
8
|
+
new(inject: inject, gate: gate, metadata: metadata)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'skills/errors'
|
|
4
|
+
require_relative 'skills/step_result'
|
|
5
|
+
require_relative 'skills/skill_run_result'
|
|
6
|
+
require_relative 'skills/registry'
|
|
7
|
+
require_relative 'skills/settings'
|
|
8
|
+
require_relative 'skills/base'
|
|
9
|
+
require_relative 'skills/disk_loader'
|
|
10
|
+
require_relative 'skills/external_discovery'
|
|
11
|
+
|
|
12
|
+
module Legion
|
|
13
|
+
module LLM
|
|
14
|
+
module Skills
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def start
|
|
18
|
+
Settings.apply
|
|
19
|
+
directories = settings_directories + ExternalDiscovery.discover
|
|
20
|
+
DiskLoader.load_from_directories(directories)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def settings_directories
|
|
24
|
+
Array(Legion::LLM.settings.dig(:skills, :directories) || [])
|
|
25
|
+
rescue StandardError
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/legion/llm/version.rb
CHANGED
data/lib/legion/llm.rb
CHANGED
|
@@ -38,6 +38,12 @@ require_relative 'llm/token_tracker'
|
|
|
38
38
|
require_relative 'llm/override_confidence'
|
|
39
39
|
require_relative 'llm/routes'
|
|
40
40
|
|
|
41
|
+
begin
|
|
42
|
+
require_relative 'llm/skills'
|
|
43
|
+
rescue LoadError => e
|
|
44
|
+
Legion::Logging.debug "LLM: skills not loadable: #{e.message}" if defined?(Legion::Logging)
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
module Legion
|
|
42
48
|
module LLM
|
|
43
49
|
class EscalationExhausted < StandardError; end
|
|
@@ -72,6 +78,9 @@ module Legion
|
|
|
72
78
|
install_hooks
|
|
73
79
|
load_tool_interceptors
|
|
74
80
|
|
|
81
|
+
# Skills startup — load after settings, before pipeline is used
|
|
82
|
+
Legion::LLM::Skills.start if defined?(Legion::LLM::Skills) && settings.dig(:skills, :enabled) != false
|
|
83
|
+
|
|
75
84
|
@started = true
|
|
76
85
|
Legion::Settings[:llm][:connected] = true
|
|
77
86
|
log.info 'Legion::LLM started'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -227,6 +227,7 @@ files:
|
|
|
227
227
|
- lib/legion/llm/audit.rb
|
|
228
228
|
- lib/legion/llm/audit/exchange.rb
|
|
229
229
|
- lib/legion/llm/audit/prompt_event.rb
|
|
230
|
+
- lib/legion/llm/audit/skill_event.rb
|
|
230
231
|
- lib/legion/llm/audit/tool_event.rb
|
|
231
232
|
- lib/legion/llm/batch.rb
|
|
232
233
|
- lib/legion/llm/bedrock_bearer_auth.rb
|
|
@@ -295,6 +296,7 @@ files:
|
|
|
295
296
|
- lib/legion/llm/pipeline/steps/rag_context.rb
|
|
296
297
|
- lib/legion/llm/pipeline/steps/rag_guard.rb
|
|
297
298
|
- lib/legion/llm/pipeline/steps/rbac.rb
|
|
299
|
+
- lib/legion/llm/pipeline/steps/skill_injector.rb
|
|
298
300
|
- lib/legion/llm/pipeline/steps/span_annotator.rb
|
|
299
301
|
- lib/legion/llm/pipeline/steps/tier_assigner.rb
|
|
300
302
|
- lib/legion/llm/pipeline/steps/token_budget.rb
|
|
@@ -319,6 +321,15 @@ files:
|
|
|
319
321
|
- lib/legion/llm/scheduling.rb
|
|
320
322
|
- lib/legion/llm/settings.rb
|
|
321
323
|
- lib/legion/llm/shadow_eval.rb
|
|
324
|
+
- lib/legion/llm/skills.rb
|
|
325
|
+
- lib/legion/llm/skills/base.rb
|
|
326
|
+
- lib/legion/llm/skills/disk_loader.rb
|
|
327
|
+
- lib/legion/llm/skills/errors.rb
|
|
328
|
+
- lib/legion/llm/skills/external_discovery.rb
|
|
329
|
+
- lib/legion/llm/skills/registry.rb
|
|
330
|
+
- lib/legion/llm/skills/settings.rb
|
|
331
|
+
- lib/legion/llm/skills/skill_run_result.rb
|
|
332
|
+
- lib/legion/llm/skills/step_result.rb
|
|
322
333
|
- lib/legion/llm/structured_output.rb
|
|
323
334
|
- lib/legion/llm/token_tracker.rb
|
|
324
335
|
- lib/legion/llm/tools/adapter.rb
|