lex-conflict 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 65f2f15e0488f24bca7c7c48246a8bc580907a00c182d79a8ae0ab37b5f71dd8
4
+ data.tar.gz: 8e104dc880a3d63fa9a4484b6d191e585db5beffea4187708cd4fd5f9724b8f2
5
+ SHA512:
6
+ metadata.gz: 8444218ce37f8cf5108e6eb9783dcad8a48f02971ed2ede1718e405712e9448998ee128b198fac613e3dafc56f67a941248de16819b8ec665996bc822c8582c3
7
+ data.tar.gz: ea7cd9b0ef64a4ee8e4eaaef37414a455e503676653524798cb5b1a90ccb796ec0cba9d563ba0000268680169b3943022c949523fea850cf993445bdfcd283ea
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+
10
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/conflict/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-conflict'
7
+ spec.version = Legion::Extensions::Conflict::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Conflict'
12
+ spec.description = 'Conflict resolution with severity levels and postures for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-conflict'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-conflict'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-conflict'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-conflict'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-conflict/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-conflict.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Conflict
8
+ module Actor
9
+ class StaleCheck < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Conflict::Runners::Conflict
12
+ end
13
+
14
+ def runner_function
15
+ 'check_stale_conflicts'
16
+ end
17
+
18
+ def time
19
+ 3600
20
+ end
21
+
22
+ def run_now?
23
+ false
24
+ end
25
+
26
+ def use_runner?
27
+ false
28
+ end
29
+
30
+ def check_subtask?
31
+ false
32
+ end
33
+
34
+ def generate_task?
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/conflict/helpers/severity'
4
+ require 'legion/extensions/conflict/helpers/conflict_log'
5
+ require 'legion/extensions/conflict/runners/conflict'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Conflict
10
+ class Client
11
+ include Runners::Conflict
12
+
13
+ def initialize(**)
14
+ @conflict_log = Helpers::ConflictLog.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :conflict_log
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Conflict
8
+ module Helpers
9
+ class ConflictLog
10
+ attr_reader :conflicts
11
+
12
+ def initialize
13
+ @conflicts = {}
14
+ end
15
+
16
+ def record(parties:, severity:, description:, posture: nil)
17
+ id = SecureRandom.uuid
18
+ @conflicts[id] = {
19
+ conflict_id: id,
20
+ parties: parties,
21
+ severity: severity,
22
+ posture: posture || Severity.recommended_posture(severity),
23
+ description: description,
24
+ status: :active,
25
+ outcome: nil,
26
+ created_at: Time.now.utc,
27
+ resolved_at: nil,
28
+ exchanges: []
29
+ }
30
+ id
31
+ end
32
+
33
+ def add_exchange(conflict_id, speaker:, message:)
34
+ conflict = @conflicts[conflict_id]
35
+ return nil unless conflict
36
+
37
+ conflict[:exchanges] << { speaker: speaker, message: message, at: Time.now.utc }
38
+ end
39
+
40
+ def resolve(conflict_id, outcome:, resolution_notes: nil)
41
+ conflict = @conflicts[conflict_id]
42
+ return nil unless conflict
43
+
44
+ conflict[:status] = :resolved
45
+ conflict[:outcome] = outcome
46
+ conflict[:resolution_notes] = resolution_notes
47
+ conflict[:resolved_at] = Time.now.utc
48
+ conflict
49
+ end
50
+
51
+ def active_conflicts
52
+ @conflicts.values.select { |c| c[:status] == :active }
53
+ end
54
+
55
+ def get(conflict_id)
56
+ @conflicts[conflict_id]
57
+ end
58
+
59
+ def count
60
+ @conflicts.size
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Conflict
6
+ module Helpers
7
+ module LlmEnhancer
8
+ SYSTEM_PROMPT = <<~PROMPT
9
+ You are the conflict mediation processor for an autonomous AI agent built on LegionIO.
10
+ You analyze disagreements between the agent and human partners, then suggest resolution approaches.
11
+ Be neutral, constructive, and specific. Focus on finding common ground and actionable next steps.
12
+ Do not take sides. Identify the underlying needs behind each position.
13
+ PROMPT
14
+
15
+ module_function
16
+
17
+ def available?
18
+ defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
19
+ rescue StandardError
20
+ false
21
+ end
22
+
23
+ def suggest_resolution(description:, severity:, exchanges:)
24
+ prompt = build_suggest_resolution_prompt(description: description, severity: severity, exchanges: exchanges)
25
+ response = llm_ask(prompt)
26
+ parse_suggest_resolution_response(response)
27
+ rescue StandardError => e
28
+ Legion::Logging.warn "[conflict:llm] suggest_resolution failed: #{e.message}"
29
+ nil
30
+ end
31
+
32
+ def analyze_stale_conflict(description:, severity:, age_hours:, exchange_count:)
33
+ prompt = build_analyze_stale_conflict_prompt(
34
+ description: description,
35
+ severity: severity,
36
+ age_hours: age_hours,
37
+ exchange_count: exchange_count
38
+ )
39
+ response = llm_ask(prompt)
40
+ parse_analyze_stale_conflict_response(response)
41
+ rescue StandardError => e
42
+ Legion::Logging.warn "[conflict:llm] analyze_stale_conflict failed: #{e.message}"
43
+ nil
44
+ end
45
+
46
+ # --- Private helpers ---
47
+
48
+ def llm_ask(prompt)
49
+ chat = Legion::LLM.chat
50
+ chat.with_instructions(SYSTEM_PROMPT)
51
+ chat.ask(prompt)
52
+ end
53
+ private_class_method :llm_ask
54
+
55
+ def build_suggest_resolution_prompt(description:, severity:, exchanges:)
56
+ exchange_lines = exchanges.map { |e| "[#{e[:speaker]}]: #{e[:message]}" }.join("\n")
57
+
58
+ <<~PROMPT
59
+ Analyze this conflict and suggest a resolution.
60
+
61
+ DESCRIPTION: #{description}
62
+ SEVERITY: #{severity}
63
+ EXCHANGE HISTORY (#{exchanges.size} exchanges):
64
+ #{exchange_lines}
65
+
66
+ Suggest a constructive resolution approach.
67
+
68
+ Format EXACTLY as:
69
+ OUTCOME: resolved | deferred | escalated
70
+ NOTES: <2-3 sentences describing the resolution approach and next steps>
71
+ PROMPT
72
+ end
73
+ private_class_method :build_suggest_resolution_prompt
74
+
75
+ def parse_suggest_resolution_response(response)
76
+ return nil unless response&.content
77
+
78
+ text = response.content
79
+ outcome_match = text.match(/OUTCOME:\s*(resolved|deferred|escalated)/i)
80
+ notes_match = text.match(/NOTES:\s*(.+)/im)
81
+
82
+ return nil unless outcome_match && notes_match
83
+
84
+ outcome = outcome_match.captures.first.strip.downcase.to_sym
85
+ notes = notes_match.captures.first.strip
86
+
87
+ { resolution_notes: notes, suggested_outcome: outcome }
88
+ end
89
+ private_class_method :parse_suggest_resolution_response
90
+
91
+ def build_analyze_stale_conflict_prompt(description:, severity:, age_hours:, exchange_count:)
92
+ <<~PROMPT
93
+ A conflict has been unresolved for #{age_hours.round(1)} hours with #{exchange_count} exchanges.
94
+
95
+ DESCRIPTION: #{description}
96
+ SEVERITY: #{severity}
97
+
98
+ Recommend how to proceed with this stale conflict.
99
+
100
+ Format EXACTLY as:
101
+ RECOMMENDATION: escalate | retry | close
102
+ ANALYSIS: <2-3 sentences explaining the recommendation>
103
+ PROMPT
104
+ end
105
+ private_class_method :build_analyze_stale_conflict_prompt
106
+
107
+ def parse_analyze_stale_conflict_response(response)
108
+ return nil unless response&.content
109
+
110
+ text = response.content
111
+ rec_match = text.match(/RECOMMENDATION:\s*(escalate|retry|close)/i)
112
+ analysis_match = text.match(/ANALYSIS:\s*(.+)/im)
113
+
114
+ return nil unless rec_match && analysis_match
115
+
116
+ recommendation = rec_match.captures.first.strip.downcase.to_sym
117
+ analysis = analysis_match.captures.first.strip
118
+
119
+ { analysis: analysis, recommendation: recommendation }
120
+ end
121
+ private_class_method :parse_analyze_stale_conflict_response
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Conflict
6
+ module Helpers
7
+ module Severity
8
+ LEVELS = %i[low medium high critical].freeze
9
+ POSTURES = %i[speak_once persistent_engagement stubborn_presence].freeze
10
+
11
+ # Posture selection thresholds
12
+ PERSISTENT_THRESHOLD = :high
13
+ STUBBORN_THRESHOLD = :critical
14
+
15
+ LEVEL_ORDER = { low: 0, medium: 1, high: 2, critical: 3 }.freeze
16
+ STALE_CONFLICT_TIMEOUT = 86_400 # 24 hours
17
+
18
+ module_function
19
+
20
+ def valid_level?(level)
21
+ LEVELS.include?(level)
22
+ end
23
+
24
+ def valid_posture?(posture)
25
+ POSTURES.include?(posture)
26
+ end
27
+
28
+ def recommended_posture(severity)
29
+ case severity
30
+ when :critical then :stubborn_presence
31
+ when :high then :persistent_engagement
32
+ else :speak_once
33
+ end
34
+ end
35
+
36
+ def severity_gte?(left, right)
37
+ LEVEL_ORDER.fetch(left, 0) >= LEVEL_ORDER.fetch(right, 0)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Conflict
6
+ module Runners
7
+ module Conflict
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def register_conflict(parties:, severity:, description:, **)
12
+ return { error: :invalid_severity, valid: Helpers::Severity::LEVELS } unless Helpers::Severity.valid_level?(severity)
13
+
14
+ id = conflict_log.record(parties: parties, severity: severity, description: description)
15
+ conflict = conflict_log.get(id)
16
+ Legion::Logging.info "[conflict] registered: id=#{id[0..7]} severity=#{severity} posture=#{conflict[:posture]} parties=#{parties.join(',')}"
17
+ { conflict_id: id, severity: severity, posture: conflict[:posture] }
18
+ end
19
+
20
+ def add_exchange(conflict_id:, speaker:, message:, **)
21
+ result = conflict_log.add_exchange(conflict_id, speaker: speaker, message: message)
22
+ if result
23
+ Legion::Logging.debug "[conflict] exchange: id=#{conflict_id[0..7]} speaker=#{speaker}"
24
+ { recorded: true }
25
+ else
26
+ Legion::Logging.debug "[conflict] exchange failed: id=#{conflict_id[0..7]} not found"
27
+ { error: :not_found }
28
+ end
29
+ end
30
+
31
+ def resolve_conflict(conflict_id:, outcome:, resolution_notes: nil, **)
32
+ conflict = conflict_log.get(conflict_id)
33
+ unless conflict
34
+ Legion::Logging.debug "[conflict] resolve failed: id=#{conflict_id[0..7]} not found"
35
+ return { error: :not_found }
36
+ end
37
+
38
+ if resolution_notes.nil? && Helpers::LlmEnhancer.available?
39
+ llm_result = Helpers::LlmEnhancer.suggest_resolution(
40
+ description: conflict[:description],
41
+ severity: conflict[:severity],
42
+ exchanges: conflict[:exchanges]
43
+ )
44
+ resolution_notes = llm_result[:resolution_notes] if llm_result
45
+ end
46
+
47
+ result = conflict_log.resolve(conflict_id, outcome: outcome, resolution_notes: resolution_notes)
48
+ if result
49
+ Legion::Logging.info "[conflict] resolved: id=#{conflict_id[0..7]} outcome=#{outcome}"
50
+ { resolved: true, outcome: outcome }
51
+ else
52
+ Legion::Logging.debug "[conflict] resolve failed: id=#{conflict_id[0..7]} not found"
53
+ { error: :not_found }
54
+ end
55
+ end
56
+
57
+ def get_conflict(conflict_id:, **)
58
+ conflict = conflict_log.get(conflict_id)
59
+ Legion::Logging.debug "[conflict] get: id=#{conflict_id[0..7]} found=#{!conflict.nil?}"
60
+ conflict ? { found: true, conflict: conflict } : { found: false }
61
+ end
62
+
63
+ def active_conflicts(**)
64
+ conflicts = conflict_log.active_conflicts
65
+ Legion::Logging.debug "[conflict] active: count=#{conflicts.size}"
66
+ { conflicts: conflicts, count: conflicts.size }
67
+ end
68
+
69
+ def check_stale_conflicts(**)
70
+ active = conflict_log.active_conflicts
71
+ stale = active.select { |c| Time.now.utc - c[:created_at] > Helpers::Severity::STALE_CONFLICT_TIMEOUT }
72
+ stale.each do |c|
73
+ message = 'conflict marked stale — no resolution after 24h'
74
+
75
+ if Helpers::LlmEnhancer.available?
76
+ age_hours = (Time.now.utc - c[:created_at]) / 3600.0
77
+ analysis = Helpers::LlmEnhancer.analyze_stale_conflict(
78
+ description: c[:description],
79
+ severity: c[:severity],
80
+ age_hours: age_hours,
81
+ exchange_count: c[:exchanges].size
82
+ )
83
+ message = "conflict marked stale — #{analysis[:analysis]} (recommendation: #{analysis[:recommendation]})" if analysis
84
+ end
85
+
86
+ conflict_log.add_exchange(c[:conflict_id], speaker: :system, message: message)
87
+ end
88
+ stale_ids = stale.map { |c| c[:conflict_id] }
89
+ Legion::Logging.debug "[conflict] stale check: active=#{active.size} stale=#{stale.size}"
90
+ { checked: active.size, stale_count: stale.size, stale_ids: stale_ids }
91
+ end
92
+
93
+ def recommended_posture(severity:, **)
94
+ posture = Helpers::Severity.recommended_posture(severity)
95
+ Legion::Logging.debug "[conflict] posture: severity=#{severity} posture=#{posture}"
96
+ { severity: severity, posture: posture }
97
+ end
98
+
99
+ private
100
+
101
+ def conflict_log
102
+ @conflict_log ||= Helpers::ConflictLog.new
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Conflict
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/conflict/version'
4
+ require 'legion/extensions/conflict/helpers/severity'
5
+ require 'legion/extensions/conflict/helpers/conflict_log'
6
+ require 'legion/extensions/conflict/helpers/llm_enhancer'
7
+ require 'legion/extensions/conflict/runners/conflict'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Conflict
12
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Actors
6
+ class Every; end # rubocop:disable Lint/EmptyClass
7
+ end
8
+ end
9
+ end
10
+
11
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
12
+
13
+ require_relative '../../../../../lib/legion/extensions/conflict/actors/stale_check'
14
+
15
+ RSpec.describe Legion::Extensions::Conflict::Actor::StaleCheck do
16
+ subject(:actor) { described_class.new }
17
+
18
+ describe '#runner_class' do
19
+ it { expect(actor.runner_class).to eq Legion::Extensions::Conflict::Runners::Conflict }
20
+ end
21
+
22
+ describe '#runner_function' do
23
+ it { expect(actor.runner_function).to eq 'check_stale_conflicts' }
24
+ end
25
+
26
+ describe '#time' do
27
+ it { expect(actor.time).to eq 3600 }
28
+ end
29
+
30
+ describe '#run_now?' do
31
+ it { expect(actor.run_now?).to be false }
32
+ end
33
+
34
+ describe '#use_runner?' do
35
+ it { expect(actor.use_runner?).to be false }
36
+ end
37
+
38
+ describe '#check_subtask?' do
39
+ it { expect(actor.check_subtask?).to be false }
40
+ end
41
+
42
+ describe '#generate_task?' do
43
+ it { expect(actor.generate_task?).to be false }
44
+ end
45
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/conflict/client'
4
+
5
+ RSpec.describe Legion::Extensions::Conflict::Client do
6
+ it 'responds to conflict runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:register_conflict)
9
+ expect(client).to respond_to(:add_exchange)
10
+ expect(client).to respond_to(:resolve_conflict)
11
+ expect(client).to respond_to(:get_conflict)
12
+ expect(client).to respond_to(:active_conflicts)
13
+ expect(client).to respond_to(:recommended_posture)
14
+ end
15
+ end