lex-agentic-executive 0.2.1 → 0.2.3
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 +12 -0
- data/README.md +1 -1
- data/lib/legion/extensions/agentic/executive/executive_function/helpers/executive_controller.rb +2 -1
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/decomposer.rb +3 -3
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener.rb +22 -2
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal_engine.rb +8 -2
- data/lib/legion/extensions/agentic/executive/version.rb +1 -1
- data/lib/legion/extensions/agentic/executive/volition/helpers/intention_stack.rb +6 -0
- data/spec/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener_spec.rb +102 -0
- data/spec/legion/extensions/agentic/executive/volition/helpers/intention_stack_spec.rb +16 -0
- 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: 96780b1afcbb05fed19728d4ff7ce759d554c3f546341ae529fc858207c9d7d8
|
|
4
|
+
data.tar.gz: 240e134b5429af9de0ca9783f8632418b6968742b4921f9100acf857762d9909
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32967c12a79d49227bc65e8ad400ab36aafde6f52b306792df60d5c1655c895bb42c3cb135e9903cd6a4d993538f9ba5bc916763c097e17dd7407730ccec371a
|
|
7
|
+
data.tar.gz: 5c3c763991e6145292134255689f0defb9ee4b565be8098bd1a1d223b5f726d55c3774be7696b6591d8ca1bdf839ca420e7def0db7d22bccdd2717f883a425a7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.3] - 2026-10-29
|
|
4
|
+
### Fixed
|
|
5
|
+
- Reject unattributed goal feedback and preserve terminal goals and ancestor state when delayed task outcomes arrive.
|
|
6
|
+
- Reclaim completed intention history at capacity without discarding active or suspended commitments.
|
|
7
|
+
|
|
8
|
+
## [0.2.2] - 2026-05-08
|
|
9
|
+
### Fixed
|
|
10
|
+
- `Decomposer#parse_sub_goals` now accesses JSON sub-goal hashes using string keys (with symbol fallback), preventing LLM-decomposed goals from getting empty content/default domain/0.5 priority.
|
|
11
|
+
- `ExecutiveController#common_ef_level` formula corrected — blends minimum effective capacity (weighted 0.6) with average (weighted 0.4) instead of computing a no-op `avg * 0.6 + avg * 0.4`.
|
|
12
|
+
- `FeedbackListener` tracks event handler references, enabling `stop_listening` and `restart_listening` to prevent duplicate handler accumulation on repeated start calls.
|
|
13
|
+
|
|
3
14
|
## [0.2.1] - 2026-05-07
|
|
15
|
+
|
|
4
16
|
### Fixed
|
|
5
17
|
- Goal decomposer LLM strategy now parses native `Legion::LLM.chat` hash responses without requiring a legacy chat session.
|
|
6
18
|
- Added regression coverage for cognition-domain dispatch to the MindGrowth analyzer.
|
data/README.md
CHANGED
data/lib/legion/extensions/agentic/executive/executive_function/helpers/executive_controller.rb
CHANGED
|
@@ -72,8 +72,9 @@ module Legion
|
|
|
72
72
|
|
|
73
73
|
def common_ef_level
|
|
74
74
|
values = @components.values.map(&:effective_capacity)
|
|
75
|
+
min = values.min
|
|
75
76
|
avg = values.sum / values.size.to_f
|
|
76
|
-
(avg * (1.0 - COMMON_EF_WEIGHT)) + (
|
|
77
|
+
(avg * (1.0 - COMMON_EF_WEIGHT)) + (min * COMMON_EF_WEIGHT)
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
def can_inhibit?
|
|
@@ -92,9 +92,9 @@ module Legion
|
|
|
92
92
|
|
|
93
93
|
data.map do |sg|
|
|
94
94
|
{
|
|
95
|
-
content: sg
|
|
96
|
-
domain: (sg
|
|
97
|
-
priority: (sg
|
|
95
|
+
content: sg.fetch('content', sg.fetch(:content, '')).to_s,
|
|
96
|
+
domain: sg.fetch('domain', sg.fetch(:domain, domain)).to_sym,
|
|
97
|
+
priority: sg.fetch('priority', sg.fetch(:priority, 0.5)).to_f.clamp(0.0, 1.0)
|
|
98
98
|
}
|
|
99
99
|
end
|
|
100
100
|
rescue StandardError => e
|
|
@@ -10,6 +10,7 @@ module Legion
|
|
|
10
10
|
def initialize(engine:)
|
|
11
11
|
@engine = engine
|
|
12
12
|
@listening = false
|
|
13
|
+
@handlers = []
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def handle_task_event(task_id:, status:, result: nil)
|
|
@@ -26,7 +27,7 @@ module Legion
|
|
|
26
27
|
return if @listening
|
|
27
28
|
return unless defined?(Legion::Events)
|
|
28
29
|
|
|
29
|
-
Legion::Events.on('task.completed') do |event|
|
|
30
|
+
handler_completed = Legion::Events.on('task.completed') do |event|
|
|
30
31
|
handle_task_event(
|
|
31
32
|
task_id: event[:task_id],
|
|
32
33
|
status: event[:status] || 'task.completed',
|
|
@@ -34,7 +35,7 @@ module Legion
|
|
|
34
35
|
)
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
Legion::Events.on('task.failed') do |event|
|
|
38
|
+
handler_failed = Legion::Events.on('task.failed') do |event|
|
|
38
39
|
handle_task_event(
|
|
39
40
|
task_id: event[:task_id],
|
|
40
41
|
status: event[:status] || 'task.failed',
|
|
@@ -42,9 +43,28 @@ module Legion
|
|
|
42
43
|
)
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
@handlers << { event: 'task.completed', block: handler_completed }
|
|
47
|
+
@handlers << { event: 'task.failed', block: handler_failed }
|
|
45
48
|
@listening = true
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
def stop_listening
|
|
52
|
+
return unless @listening
|
|
53
|
+
|
|
54
|
+
@handlers.each do |entry|
|
|
55
|
+
Legion::Events.off(entry[:event], entry[:block])
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
log.debug "[feedback_listener] stop_listening cleanup failed: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
@handlers.clear
|
|
60
|
+
@listening = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def restart_listening
|
|
64
|
+
stop_listening
|
|
65
|
+
start_listening
|
|
66
|
+
end
|
|
67
|
+
|
|
48
68
|
def listening?
|
|
49
69
|
@listening
|
|
50
70
|
end
|
|
@@ -213,9 +213,15 @@ module Legion
|
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
def update_from_task_event(task_id:, status:, result: nil)
|
|
216
|
+
return { found: false } if task_id.nil?
|
|
217
|
+
|
|
216
218
|
@mutex.synchronize do
|
|
217
219
|
goal = @goals.values.find { |g| g.task_id == task_id }
|
|
218
220
|
return { found: false } unless goal
|
|
221
|
+
if %i[completed abandoned].include?(goal.status)
|
|
222
|
+
return { found: true, goal_id: goal.id, new_status: goal.status, progress: goal.progress,
|
|
223
|
+
ignored: :terminal_goal }
|
|
224
|
+
end
|
|
219
225
|
|
|
220
226
|
case status
|
|
221
227
|
when 'task.completed'
|
|
@@ -228,8 +234,8 @@ module Legion
|
|
|
228
234
|
when 'task.exception', 'task.failed'
|
|
229
235
|
goal.block!
|
|
230
236
|
persist_goal(goal)
|
|
231
|
-
log.info "[goal_engine] goal status change goal=#{goal.id} transition
|
|
232
|
-
{ found: true, goal_id: goal.id, new_status:
|
|
237
|
+
log.info "[goal_engine] goal status change goal=#{goal.id} transition=#{goal.status}"
|
|
238
|
+
{ found: true, goal_id: goal.id, new_status: goal.status, error: result }
|
|
233
239
|
else
|
|
234
240
|
{ found: true, goal_id: goal.id, unhandled_status: status }
|
|
235
241
|
end
|
|
@@ -15,6 +15,12 @@ module Legion
|
|
|
15
15
|
|
|
16
16
|
def push(intention)
|
|
17
17
|
return :duplicate if duplicate?(intention)
|
|
18
|
+
|
|
19
|
+
if @intentions.size >= Constants::MAX_INTENTIONS
|
|
20
|
+
completed_index = @intentions.index { |entry| entry[:state] == :completed }
|
|
21
|
+
@intentions.delete_at(completed_index) if completed_index
|
|
22
|
+
end
|
|
23
|
+
|
|
18
24
|
return :capacity_full if @intentions.size >= Constants::MAX_INTENTIONS
|
|
19
25
|
|
|
20
26
|
@intentions << intention
|
data/spec/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener_spec.rb
CHANGED
|
@@ -40,6 +40,41 @@ RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::
|
|
|
40
40
|
result = listener.handle_task_event(task_id: 'task-123', status: 'task.failed', result: 'timeout')
|
|
41
41
|
expect(result[:error]).to eq('timeout')
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
it 'does not attribute an event without a task ID to an undispatched goal' do
|
|
45
|
+
undispatched = engine.add_goal(content: 'awaiting authorized dispatch')[:goal][:id]
|
|
46
|
+
engine.activate_goal(goal_id: undispatched)
|
|
47
|
+
|
|
48
|
+
result = listener.handle_task_event(task_id: nil, status: 'task.completed')
|
|
49
|
+
|
|
50
|
+
expect(result).to eq(found: false)
|
|
51
|
+
expect(engine.goal_tree(goal_id: undispatched)[:tree]).to include(status: :active, progress: 0.0)
|
|
52
|
+
expect(engine.goal_tree(goal_id: @goal_id)[:tree]).to include(status: :active, progress: 0.0)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'preserves completion when a delayed failure arrives for the same task' do
|
|
56
|
+
listener.handle_task_event(task_id: 'task-123', status: 'task.completed')
|
|
57
|
+
|
|
58
|
+
result = listener.handle_task_event(task_id: 'task-123', status: 'task.failed', result: 'late failure')
|
|
59
|
+
|
|
60
|
+
expect(result).to include(new_status: :completed, ignored: :terminal_goal)
|
|
61
|
+
expect(engine.goal_tree(goal_id: @goal_id)[:tree]).to include(status: :completed, progress: 1.0)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'does not credit an abandoned child or its parent for a late completion' do
|
|
65
|
+
parent = engine.add_goal(content: 'parent goal')[:goal][:id]
|
|
66
|
+
engine.activate_goal(goal_id: parent)
|
|
67
|
+
child = engine.add_goal(content: 'cancelled work', parent_id: parent)[:goal][:id]
|
|
68
|
+
engine.activate_goal(goal_id: child)
|
|
69
|
+
engine.assign_task_to_goal(goal_id: child, task_id: 'cancelled-task', runner_mapping: 'TestRunner')
|
|
70
|
+
engine.abandon_goal(goal_id: child)
|
|
71
|
+
before = engine.goal_tree(goal_id: parent)[:tree]
|
|
72
|
+
|
|
73
|
+
result = listener.handle_task_event(task_id: 'cancelled-task', status: 'task.completed')
|
|
74
|
+
|
|
75
|
+
expect(result).to include(new_status: :abandoned, ignored: :terminal_goal)
|
|
76
|
+
expect(engine.goal_tree(goal_id: parent)[:tree]).to eq(before)
|
|
77
|
+
end
|
|
43
78
|
end
|
|
44
79
|
|
|
45
80
|
describe '#start_listening' do
|
|
@@ -101,4 +136,71 @@ RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::
|
|
|
101
136
|
expect(listener.listening?).to be false
|
|
102
137
|
end
|
|
103
138
|
end
|
|
139
|
+
|
|
140
|
+
describe '#stop_listening' do
|
|
141
|
+
it 'removes registered handlers and resets listening state' do
|
|
142
|
+
events_spy = Class.new do
|
|
143
|
+
attr_reader :registered, :removed
|
|
144
|
+
|
|
145
|
+
def initialize
|
|
146
|
+
@registered = []
|
|
147
|
+
@removed = []
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def on(event, &block)
|
|
151
|
+
@registered << { event: event, block: block }
|
|
152
|
+
block
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def off(event, block)
|
|
156
|
+
@removed << { event: event, block: block }
|
|
157
|
+
end
|
|
158
|
+
end.new
|
|
159
|
+
stub_const('Legion::Events', events_spy)
|
|
160
|
+
listener.start_listening
|
|
161
|
+
expect(listener.listening?).to be true
|
|
162
|
+
|
|
163
|
+
listener.stop_listening
|
|
164
|
+
expect(listener.listening?).to be false
|
|
165
|
+
expect(events_spy.removed.size).to eq(2)
|
|
166
|
+
removed_events = events_spy.removed.map { |r| r[:event] }
|
|
167
|
+
expect(removed_events).to include('task.completed')
|
|
168
|
+
expect(removed_events).to include('task.failed')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it 'does nothing when not listening' do
|
|
172
|
+
expect { listener.stop_listening }.not_to raise_error
|
|
173
|
+
expect(listener.listening?).to be false
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
describe '#restart_listening' do
|
|
178
|
+
it 'stops and starts listening' do
|
|
179
|
+
events_spy = Class.new do
|
|
180
|
+
attr_reader :registered, :removed
|
|
181
|
+
|
|
182
|
+
def initialize
|
|
183
|
+
@registered = []
|
|
184
|
+
@removed = []
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def on(event, &block)
|
|
188
|
+
@registered << { event: event, block: block }
|
|
189
|
+
block
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def off(event, block)
|
|
193
|
+
@removed << { event: event, block: block }
|
|
194
|
+
end
|
|
195
|
+
end.new
|
|
196
|
+
stub_const('Legion::Events', events_spy)
|
|
197
|
+
listener.start_listening
|
|
198
|
+
|
|
199
|
+
listener.restart_listening
|
|
200
|
+
expect(listener.listening?).to be true
|
|
201
|
+
# Original handlers removed, new ones registered
|
|
202
|
+
expect(events_spy.removed.size).to eq(2)
|
|
203
|
+
expect(events_spy.registered.size).to eq(4) # 2 original + 2 new
|
|
204
|
+
end
|
|
205
|
+
end
|
|
104
206
|
end
|
|
@@ -27,6 +27,22 @@ RSpec.describe Legion::Extensions::Agentic::Executive::Volition::Helpers::Intent
|
|
|
27
27
|
expect(stack.push(make_intention(goal: 'overflow'))).to eq(:capacity_full)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
it 'reclaims completed history at capacity without losing pending commitments' do
|
|
31
|
+
limit = Legion::Extensions::Agentic::Executive::Volition::Helpers::Constants::MAX_INTENTIONS
|
|
32
|
+
intentions = Array.new(limit) { |i| make_intention(goal: "goal #{i}") }
|
|
33
|
+
intentions.each { |intention| stack.push(intention) }
|
|
34
|
+
stack.complete(intentions.first[:intention_id])
|
|
35
|
+
stack.suspend(intentions.last[:intention_id])
|
|
36
|
+
replacement = make_intention(goal: 'next commitment')
|
|
37
|
+
|
|
38
|
+
expect(stack.push(replacement)).to eq(:pushed)
|
|
39
|
+
expect(stack.size).to eq(limit)
|
|
40
|
+
expect(stack.active.map { |intention| intention[:goal] })
|
|
41
|
+
.to contain_exactly(*intentions[1...-1].map { |intention| intention[:goal] }, 'next commitment')
|
|
42
|
+
expect(stack.resume(intentions.last[:intention_id])).to eq(:resumed)
|
|
43
|
+
expect(stack.push(make_intention(goal: 'overflow'))).to eq(:capacity_full)
|
|
44
|
+
end
|
|
45
|
+
|
|
30
46
|
it 'sorts by salience descending' do
|
|
31
47
|
stack.push(make_intention(goal: 'low', salience: 0.3))
|
|
32
48
|
stack.push(make_intention(goal: 'high', salience: 0.9))
|