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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a52da834db3ad024167228f957b019bfc33bb0a1cc86cc1cb58feabe4aa1bec4
4
- data.tar.gz: 16f3a79dee77a608932cb3b837ee55aac9bc4cfbd596beb42f29582b530c1b54
3
+ metadata.gz: 96780b1afcbb05fed19728d4ff7ce759d554c3f546341ae529fc858207c9d7d8
4
+ data.tar.gz: 240e134b5429af9de0ca9783f8632418b6968742b4921f9100acf857762d9909
5
5
  SHA512:
6
- metadata.gz: e44439bae29cbf8793b0f69ece036bb097051f0434b4d33f4e373f661c5203b51beabb58d9b9f78f17ab9b0fc0041c0565dfbd7d6b11804707e39aa0ef78784a
7
- data.tar.gz: 596a167041a980a4151babe00df9b876a0ebe647929fcea48e0cebe8b9b6704621a5499fcebde0494a8a9c3fff3000b22b2e29ce2da658f481a3ad1350400cd3
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
@@ -5,7 +5,7 @@ Domain consolidation gem for executive function, goal management, planning, and
5
5
  ## Overview
6
6
 
7
7
  **Gem**: `lex-agentic-executive`
8
- **Version**: 0.1.12
8
+ **Version**: 0.2.3
9
9
  **Namespace**: `Legion::Extensions::Agentic::Executive`
10
10
 
11
11
  ## Sub-Modules
@@ -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)) + (avg * 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[:content].to_s,
96
- domain: (sg[:domain] || domain).to_sym,
97
- priority: (sg[:priority] || 0.5).to_f.clamp(0.0, 1.0)
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=blocked"
232
- { found: true, goal_id: goal.id, new_status: :blocked, error: result }
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
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Executive
7
- VERSION = '0.2.1'
7
+ VERSION = '0.2.3'
8
8
  end
9
9
  end
10
10
  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
@@ -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))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-executive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity