lex-agentic-social 0.1.18 → 0.1.19
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 +7 -0
- data/lib/legion/extensions/agentic/social/calibration/runners/calibration.rb +7 -1
- data/lib/legion/extensions/agentic/social/conflict/helpers/conflict_log.rb +36 -0
- data/lib/legion/extensions/agentic/social/conflict/runners/conflict.rb +1 -0
- data/lib/legion/extensions/agentic/social/consent/helpers/consent_map.rb +44 -18
- data/lib/legion/extensions/agentic/social/governance/helpers/proposal.rb +62 -5
- data/lib/legion/extensions/agentic/social/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49f5148e440e4e58060bf6ff47c9dc8d31bfde5092ab17b7ad078fb95344e711
|
|
4
|
+
data.tar.gz: 191fddf9d835866196a20ed7efafcbfb4ef34729d7d5b585f7d68a95dc225b00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e052b2d246149565565a6f0a7654bb7d7bf689bc149c2bb17b22fdcc59c15339178805127b1a07e8a602a3d9fd1fcebb3e5b3a1bacef01622f6f4ac934be120f
|
|
7
|
+
data.tar.gz: 92f66448450959900ac87054fc9a5b4e9e442b3217f52d35e7b8a6e17c377317932c6f92ed129d64c82c177b101d7c95d30ce0a7e52a818f4d74c49edc87a695
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.19] - 2026-06-01
|
|
4
|
+
### Fixed
|
|
5
|
+
- Calibration LLM preference extraction now uses `Legion::LLM.chat` with explicit system/user messages instead of legacy `Legion::LLM.ask`.
|
|
6
|
+
- Governance Proposal store now persists to Apollo Local on create, vote, and resolution — safety-critical proposal state survives restarts.
|
|
7
|
+
- ConsentMap changes (tier changes, action outcomes, pending approvals) are now saved to local storage automatically on every mutation.
|
|
8
|
+
- ConflictLog now evicts old resolved conflicts (30-day retention) and enforces a 1000-entry cap to prevent unbounded memory growth.
|
|
9
|
+
|
|
3
10
|
## [0.1.18] - 2026-05-29
|
|
4
11
|
### Added
|
|
5
12
|
- Database index on `trust_entries.domain` column for faster filtered domain lookups
|
|
@@ -65,7 +65,13 @@ module Legion
|
|
|
65
65
|
|
|
66
66
|
context = summarize_traces(traces)
|
|
67
67
|
prompt = build_preference_prompt(context)
|
|
68
|
-
result = Legion::LLM.
|
|
68
|
+
result = Legion::LLM.chat(
|
|
69
|
+
message: [
|
|
70
|
+
{ role: 'system', content: 'You are a preference inference engine. Respond ONLY with JSON.' },
|
|
71
|
+
{ role: 'user', content: prompt }
|
|
72
|
+
],
|
|
73
|
+
caller: { extension: 'lex-agentic-social', mode: :calibration }
|
|
74
|
+
)
|
|
69
75
|
return { success: false, error: :llm_failed } unless result&.content
|
|
70
76
|
|
|
71
77
|
parsed = parse_preference_response(result.content)
|
|
@@ -9,6 +9,9 @@ module Legion
|
|
|
9
9
|
module Conflict
|
|
10
10
|
module Helpers
|
|
11
11
|
class ConflictLog
|
|
12
|
+
MAX_CONFLICTS = 1000
|
|
13
|
+
RESOLVED_RETENTION_DAYS = 30
|
|
14
|
+
|
|
12
15
|
attr_reader :conflicts
|
|
13
16
|
|
|
14
17
|
def initialize
|
|
@@ -61,6 +64,39 @@ module Legion
|
|
|
61
64
|
def count
|
|
62
65
|
@conflicts.size
|
|
63
66
|
end
|
|
67
|
+
|
|
68
|
+
def evict
|
|
69
|
+
evict_expired_resolved
|
|
70
|
+
evict_overflow
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def evict_expired_resolved
|
|
76
|
+
cutoff = Time.now.utc - (RESOLVED_RETENTION_DAYS * 86_400)
|
|
77
|
+
@conflicts.reject! do |_id, conflict|
|
|
78
|
+
conflict[:status] == :resolved && conflict[:resolved_at] && conflict[:resolved_at] < cutoff
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def evict_overflow
|
|
83
|
+
return if @conflicts.size <= MAX_CONFLICTS
|
|
84
|
+
|
|
85
|
+
# Evict oldest resolved conflicts first, then oldest active
|
|
86
|
+
resolved = @conflicts.select { |_, c| c[:status] == :resolved }
|
|
87
|
+
active = @conflicts.select { |_, c| c[:status] == :active }
|
|
88
|
+
|
|
89
|
+
# Remove oldest resolved until under limit
|
|
90
|
+
resolved.sort_by { |_, c| c[:resolved_at] || c[:created_at] }
|
|
91
|
+
.first(resolved.size - 50)
|
|
92
|
+
.each_key { |id| @conflicts.delete(id) }
|
|
93
|
+
return if @conflicts.size <= MAX_CONFLICTS
|
|
94
|
+
|
|
95
|
+
# Still over? Remove oldest active
|
|
96
|
+
active.sort_by { |_, c| c[:created_at] }
|
|
97
|
+
.first(active.size - 10)
|
|
98
|
+
.each_key { |id| @conflicts.delete(id) }
|
|
99
|
+
end
|
|
64
100
|
end
|
|
65
101
|
end
|
|
66
102
|
end
|
|
@@ -17,6 +17,7 @@ module Legion
|
|
|
17
17
|
|
|
18
18
|
id = conflict_log.record(parties: parties, severity: severity, description: description)
|
|
19
19
|
conflict = conflict_log.get(id)
|
|
20
|
+
conflict_log.evict
|
|
20
21
|
log.info "[conflict] registered: id=#{id[0..7]} severity=#{severity} posture=#{conflict[:posture]} parties=#{parties.join(',')}"
|
|
21
22
|
{ conflict_id: id, severity: severity, posture: conflict[:posture] }
|
|
22
23
|
end
|
|
@@ -14,38 +14,32 @@ module Legion
|
|
|
14
14
|
attr_reader :domains
|
|
15
15
|
|
|
16
16
|
def initialize
|
|
17
|
-
@domains =
|
|
18
|
-
h[k] = {
|
|
19
|
-
tier: Tiers::DEFAULT_TIER,
|
|
20
|
-
success_count: 0,
|
|
21
|
-
failure_count: 0,
|
|
22
|
-
total_actions: 0,
|
|
23
|
-
last_changed_at: nil,
|
|
24
|
-
history: [],
|
|
25
|
-
pending_tier: nil,
|
|
26
|
-
pending_since: nil,
|
|
27
|
-
pending_requested_by: nil
|
|
28
|
-
}
|
|
29
|
-
end
|
|
17
|
+
@domains = {}
|
|
30
18
|
load_from_local
|
|
31
19
|
end
|
|
32
20
|
|
|
33
21
|
def get_tier(domain)
|
|
34
|
-
@domains[domain]
|
|
22
|
+
entry = @domains[domain]
|
|
23
|
+
return Tiers::DEFAULT_TIER unless entry
|
|
24
|
+
|
|
25
|
+
entry[:tier]
|
|
35
26
|
end
|
|
36
27
|
|
|
37
28
|
def set_tier(domain, tier)
|
|
38
29
|
return unless Tiers.valid_tier?(tier)
|
|
39
30
|
|
|
31
|
+
ensure_domain(domain)
|
|
40
32
|
entry = @domains[domain]
|
|
41
33
|
old_tier = entry[:tier]
|
|
42
34
|
entry[:tier] = tier
|
|
43
35
|
entry[:last_changed_at] = Time.now.utc
|
|
44
36
|
entry[:history] << { from: old_tier, to: tier, at: Time.now.utc }
|
|
45
37
|
entry[:history].shift while entry[:history].size > 50
|
|
38
|
+
persist!
|
|
46
39
|
end
|
|
47
40
|
|
|
48
41
|
def record_outcome(domain, success:)
|
|
42
|
+
ensure_domain(domain)
|
|
49
43
|
entry = @domains[domain]
|
|
50
44
|
entry[:total_actions] += 1
|
|
51
45
|
if success
|
|
@@ -53,18 +47,19 @@ module Legion
|
|
|
53
47
|
else
|
|
54
48
|
entry[:failure_count] += 1
|
|
55
49
|
end
|
|
50
|
+
persist!
|
|
56
51
|
end
|
|
57
52
|
|
|
58
53
|
def success_rate(domain)
|
|
59
54
|
entry = @domains[domain]
|
|
60
|
-
return 0.0
|
|
55
|
+
return 0.0 unless entry && entry[:total_actions].positive?
|
|
61
56
|
|
|
62
57
|
entry[:success_count].to_f / entry[:total_actions]
|
|
63
58
|
end
|
|
64
59
|
|
|
65
60
|
def eligible_for_change?(domain)
|
|
66
61
|
entry = @domains[domain]
|
|
67
|
-
return false
|
|
62
|
+
return false unless entry && entry[:total_actions] >= Tiers::MIN_ACTIONS_TO_PROMOTE
|
|
68
63
|
|
|
69
64
|
if entry[:last_changed_at]
|
|
70
65
|
(Time.now.utc - entry[:last_changed_at]) >= Tiers::PROMOTION_COOLDOWN
|
|
@@ -106,28 +101,36 @@ module Legion
|
|
|
106
101
|
end
|
|
107
102
|
|
|
108
103
|
def set_pending(domain, proposed_tier:, requested_by: 'system')
|
|
104
|
+
ensure_domain(domain)
|
|
109
105
|
entry = @domains[domain]
|
|
110
106
|
entry[:pending_tier] = proposed_tier
|
|
111
107
|
entry[:pending_since] = Time.now
|
|
112
108
|
entry[:pending_requested_by] = requested_by
|
|
109
|
+
persist!
|
|
113
110
|
entry
|
|
114
111
|
end
|
|
115
112
|
|
|
116
113
|
def clear_pending(domain)
|
|
117
114
|
entry = @domains[domain]
|
|
115
|
+
return unless entry
|
|
116
|
+
|
|
118
117
|
entry[:pending_tier] = nil
|
|
119
118
|
entry[:pending_since] = nil
|
|
120
119
|
entry[:pending_requested_by] = nil
|
|
120
|
+
persist!
|
|
121
121
|
entry
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
def pending?(domain)
|
|
125
|
-
|
|
125
|
+
entry = @domains[domain]
|
|
126
|
+
return false unless entry
|
|
127
|
+
|
|
128
|
+
!entry[:pending_tier].nil?
|
|
126
129
|
end
|
|
127
130
|
|
|
128
131
|
def pending_expired?(domain, timeout: APPROVAL_TIMEOUT)
|
|
129
132
|
entry = @domains[domain]
|
|
130
|
-
return false unless entry[:pending_since]
|
|
133
|
+
return false unless entry && entry[:pending_since]
|
|
131
134
|
|
|
132
135
|
Time.now - entry[:pending_since] > timeout
|
|
133
136
|
end
|
|
@@ -170,6 +173,7 @@ module Legion
|
|
|
170
173
|
[]
|
|
171
174
|
end
|
|
172
175
|
|
|
176
|
+
@domains[key] = new_entry
|
|
173
177
|
@domains[key].merge!(
|
|
174
178
|
tier: row[:tier].to_sym,
|
|
175
179
|
success_count: row[:success_count].to_i,
|
|
@@ -185,6 +189,28 @@ module Legion
|
|
|
185
189
|
|
|
186
190
|
private
|
|
187
191
|
|
|
192
|
+
def ensure_domain(domain)
|
|
193
|
+
@domains[domain] = new_entry unless @domains.key?(domain)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def new_entry
|
|
197
|
+
{
|
|
198
|
+
tier: Tiers::DEFAULT_TIER,
|
|
199
|
+
success_count: 0,
|
|
200
|
+
failure_count: 0,
|
|
201
|
+
total_actions: 0,
|
|
202
|
+
last_changed_at: nil,
|
|
203
|
+
history: [],
|
|
204
|
+
pending_tier: nil,
|
|
205
|
+
pending_since: nil,
|
|
206
|
+
pending_requested_by: nil
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def persist!
|
|
211
|
+
save_to_local
|
|
212
|
+
end
|
|
213
|
+
|
|
188
214
|
def success_rate_from(entry)
|
|
189
215
|
return 0.0 if entry[:total_actions].zero?
|
|
190
216
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
3
4
|
require 'securerandom'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
@@ -8,16 +9,14 @@ module Legion
|
|
|
8
9
|
module Social
|
|
9
10
|
module Governance
|
|
10
11
|
module Helpers
|
|
11
|
-
# NOTE: Proposal state is stored in-memory only (@proposals hash).
|
|
12
|
-
# This is a safety-critical system (controls consent and containment approval).
|
|
13
|
-
# Persistence to a durable store (e.g. legion-data) is required so that
|
|
14
|
-
# proposals survive process restarts. Implementing persistence is tracked
|
|
15
|
-
# as a separate task.
|
|
16
12
|
class Proposal
|
|
13
|
+
PROPOSAL_TAG_BASE = %w[governance proposal].freeze
|
|
14
|
+
|
|
17
15
|
attr_reader :proposals
|
|
18
16
|
|
|
19
17
|
def initialize
|
|
20
18
|
@proposals = {}
|
|
19
|
+
load_from_apollo
|
|
21
20
|
end
|
|
22
21
|
|
|
23
22
|
def create(category:, description:, proposer:, council_size: Layers::MIN_COUNCIL_SIZE)
|
|
@@ -34,6 +33,7 @@ module Legion
|
|
|
34
33
|
created_at: Time.now.utc,
|
|
35
34
|
resolved_at: nil
|
|
36
35
|
}
|
|
36
|
+
save_proposal(id)
|
|
37
37
|
id
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -51,6 +51,7 @@ module Legion
|
|
|
51
51
|
prop[:votes_against] << voter
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
save_proposal(proposal_id)
|
|
54
55
|
check_resolution(proposal_id)
|
|
55
56
|
end
|
|
56
57
|
|
|
@@ -68,11 +69,65 @@ module Legion
|
|
|
68
69
|
|
|
69
70
|
prop[:status] = :timed_out
|
|
70
71
|
prop[:resolved_at] = Time.now.utc
|
|
72
|
+
save_proposal(proposal_id)
|
|
71
73
|
prop
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
def load_from_apollo
|
|
77
|
+
return false unless defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started?
|
|
78
|
+
|
|
79
|
+
result = Legion::Apollo::Local.query_by_tags(tags: PROPOSAL_TAG_BASE, limit: 1000)
|
|
80
|
+
return false unless result[:success] && result[:results].is_a?(Array)
|
|
81
|
+
|
|
82
|
+
result[:results].each do |entry|
|
|
83
|
+
data = ::JSON.parse(entry[:content], symbolize_names: true)
|
|
84
|
+
pid = data[:proposal_id]
|
|
85
|
+
next unless pid
|
|
86
|
+
|
|
87
|
+
# Convert string timestamps back to Time objects
|
|
88
|
+
data[:created_at] = Time.parse(data[:created_at].to_s) if data[:created_at]
|
|
89
|
+
data[:resolved_at] = data[:resolved_at] ? Time.parse(data[:resolved_at].to_s) : nil
|
|
90
|
+
|
|
91
|
+
# Ensure status is a symbol
|
|
92
|
+
data[:status] = data[:status].to_sym if data[:status].is_a?(String)
|
|
93
|
+
|
|
94
|
+
@proposals[pid] = data
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Legion::Logging.warn "[governance] load_from_apollo parse error: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@proposals.any?
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Legion::Logging.warn "[governance] load_from_apollo failed: #{e.message}"
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
74
105
|
private
|
|
75
106
|
|
|
107
|
+
def save_proposal(proposal_id)
|
|
108
|
+
return unless defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started?
|
|
109
|
+
|
|
110
|
+
prop = @proposals[proposal_id]
|
|
111
|
+
return unless prop
|
|
112
|
+
|
|
113
|
+
# Convert to JSON-safe format
|
|
114
|
+
serializable = prop.dup
|
|
115
|
+
serializable[:created_at] = prop[:created_at].is_a?(Time) ? prop[:created_at].iso8601 : prop[:created_at]
|
|
116
|
+
serializable[:resolved_at] = prop[:resolved_at]&.iso8601
|
|
117
|
+
|
|
118
|
+
content = Legion::JSON.dump(serializable)
|
|
119
|
+
tags = PROPOSAL_TAG_BASE + ["category:#{prop[:category]}", "status:#{prop[:status]}"]
|
|
120
|
+
|
|
121
|
+
Legion::Apollo::Local.upsert(
|
|
122
|
+
content: content,
|
|
123
|
+
tags: tags,
|
|
124
|
+
access_scope: 'private',
|
|
125
|
+
identity_principal_id: nil
|
|
126
|
+
)
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
Legion::Logging.warn "[governance] save_proposal failed: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
76
131
|
def check_resolution(proposal_id)
|
|
77
132
|
prop = @proposals[proposal_id]
|
|
78
133
|
total_votes = prop[:votes_for].size + prop[:votes_against].size
|
|
@@ -80,11 +135,13 @@ module Legion
|
|
|
80
135
|
if Layers.quorum_met?(prop[:votes_for].size, prop[:council_size])
|
|
81
136
|
prop[:status] = :approved
|
|
82
137
|
prop[:resolved_at] = Time.now.utc
|
|
138
|
+
save_proposal(proposal_id)
|
|
83
139
|
:approved
|
|
84
140
|
elsif Layers.quorum_met?(prop[:votes_against].size, prop[:council_size]) ||
|
|
85
141
|
total_votes >= prop[:council_size]
|
|
86
142
|
prop[:status] = :rejected
|
|
87
143
|
prop[:resolved_at] = Time.now.utc
|
|
144
|
+
save_proposal(proposal_id)
|
|
88
145
|
:rejected
|
|
89
146
|
else
|
|
90
147
|
:pending
|