lex-agentic-executive 0.1.12 → 0.2.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 +4 -4
- data/CHANGELOG.md +18 -0
- data/lib/legion/extensions/agentic/executive/goal_management/client.rb +23 -0
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/decomposer.rb +126 -0
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener.rb +63 -0
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal.rb +41 -12
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal_engine.rb +99 -10
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/goal_persistence.rb +145 -0
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher.rb +109 -0
- data/lib/legion/extensions/agentic/executive/goal_management/runners/goal_management.rb +96 -36
- data/lib/legion/extensions/agentic/executive/goal_management.rb +3 -0
- data/lib/legion/extensions/agentic/executive/version.rb +1 -1
- data/lib/legion/extensions/agentic/executive/volition/client.rb +3 -1
- data/lib/legion/extensions/agentic/executive/volition/helpers/goal_bridge.rb +86 -0
- data/lib/legion/extensions/agentic/executive/volition/runners/volition.rb +13 -6
- data/lib/legion/extensions/agentic/executive/volition.rb +1 -0
- data/spec/integration/autonomous_goal_pipeline_spec.rb +107 -0
- data/spec/legion/extensions/agentic/executive/goal_management/helpers/decomposer_spec.rb +167 -0
- data/spec/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener_spec.rb +104 -0
- data/spec/legion/extensions/agentic/executive/goal_management/helpers/goal_engine_spec.rb +32 -0
- data/spec/legion/extensions/agentic/executive/goal_management/helpers/goal_persistence_spec.rb +119 -0
- data/spec/legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher_spec.rb +184 -0
- data/spec/legion/extensions/agentic/executive/volition/helpers/goal_bridge_spec.rb +58 -0
- data/spec/legion/extensions/agentic/executive/volition/runners/volition_spec.rb +26 -0
- metadata +12 -1
|
@@ -17,10 +17,10 @@ module Legion
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
new_intentions = Helpers::DriveSynthesizer.generate_intentions(drives, cognitive_state: cognitive_state)
|
|
20
|
-
|
|
20
|
+
pushed_intentions = []
|
|
21
21
|
new_intentions.each do |intention|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
outcome = intention_stack.push(intention)
|
|
23
|
+
pushed_intentions << intention if outcome == :pushed
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
expired = intention_stack.decay_all
|
|
@@ -28,18 +28,25 @@ module Legion
|
|
|
28
28
|
current = intention_stack.top
|
|
29
29
|
proactive = evaluate_proactive_outreach(tick_results, bond_state)
|
|
30
30
|
|
|
31
|
-
log.debug "[volition] drives=#{format_drives(drives)} pushed=#{
|
|
31
|
+
log.debug "[volition] drives=#{format_drives(drives)} pushed=#{pushed_intentions.size} expired=#{expired} " \
|
|
32
32
|
"active=#{intention_stack.active_count} top=#{current&.dig(:goal)}"
|
|
33
33
|
|
|
34
|
-
{
|
|
34
|
+
result = {
|
|
35
35
|
drives: drives,
|
|
36
36
|
dominant_drive: dominant,
|
|
37
|
-
new_intentions:
|
|
37
|
+
new_intentions: pushed_intentions.map { |i| format_intention(i) },
|
|
38
38
|
expired: expired,
|
|
39
39
|
active_intentions: intention_stack.active_count,
|
|
40
40
|
current_intention: format_intention(current),
|
|
41
41
|
proactive_outreach: proactive
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
if defined?(@goal_bridge) && @goal_bridge
|
|
45
|
+
bridge_result = @goal_bridge.bridge_intentions(pushed_intentions)
|
|
46
|
+
result[:bridge_result] = bridge_result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
result
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
def current_intention(**)
|
|
@@ -5,6 +5,7 @@ require 'legion/extensions/agentic/executive/volition/helpers/constants'
|
|
|
5
5
|
require 'legion/extensions/agentic/executive/volition/helpers/intention'
|
|
6
6
|
require 'legion/extensions/agentic/executive/volition/helpers/intention_stack'
|
|
7
7
|
require 'legion/extensions/agentic/executive/volition/helpers/drive_synthesizer'
|
|
8
|
+
require 'legion/extensions/agentic/executive/volition/helpers/goal_bridge'
|
|
8
9
|
require 'legion/extensions/agentic/executive/volition/runners/volition'
|
|
9
10
|
require 'legion/extensions/agentic/executive/volition/client'
|
|
10
11
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'Autonomous Goal Pipeline Integration' do
|
|
6
|
+
let(:goal_client) { Legion::Extensions::Agentic::Executive::GoalManagement::Client.new }
|
|
7
|
+
let(:bridge) do
|
|
8
|
+
Legion::Extensions::Agentic::Executive::Volition::Helpers::GoalBridge.new(
|
|
9
|
+
goal_client: goal_client
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
let(:volition_client) do
|
|
13
|
+
Legion::Extensions::Agentic::Executive::Volition::Client.new(
|
|
14
|
+
stack: Legion::Extensions::Agentic::Executive::Volition::Helpers::IntentionStack.new,
|
|
15
|
+
goal_bridge: bridge
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
let(:tick_results) do
|
|
20
|
+
{ working_memory_integration: { curiosity: { intensity: 0.9, count: 5 } } }
|
|
21
|
+
end
|
|
22
|
+
let(:cognitive_state) do
|
|
23
|
+
{ health: 0.6, pending_goals: 0, arousal: 0.5, gut: { signal: :explore },
|
|
24
|
+
confidence: 0.4, pending_questions: 3, peer_interactions: 0,
|
|
25
|
+
trust: { avg_composite: 0.5 } }
|
|
26
|
+
end
|
|
27
|
+
let(:bond_state) { {} }
|
|
28
|
+
|
|
29
|
+
it 'flows from intention through goal to dispatch-ready state' do
|
|
30
|
+
# Step 1: Form intentions (triggers goal bridge)
|
|
31
|
+
volition_result = volition_client.form_intentions(
|
|
32
|
+
tick_results: tick_results, cognitive_state: cognitive_state, bond_state: bond_state
|
|
33
|
+
)
|
|
34
|
+
expect(volition_result[:bridge_result][:bridged]).to be >= 1
|
|
35
|
+
|
|
36
|
+
# Step 2: Verify goals were created
|
|
37
|
+
goal_ids = volition_result[:bridge_result][:goal_ids]
|
|
38
|
+
expect(goal_ids).not_to be_empty
|
|
39
|
+
|
|
40
|
+
# Step 3: Auto-decompose the first goal
|
|
41
|
+
first_goal_id = goal_ids.first
|
|
42
|
+
goal_client.activate_goal(goal_id: first_goal_id)
|
|
43
|
+
decomp = goal_client.auto_decompose_goal(goal_id: first_goal_id, strategy: :heuristic)
|
|
44
|
+
expect(decomp[:success]).to be true
|
|
45
|
+
|
|
46
|
+
# Step 4: Verify goal tree has children
|
|
47
|
+
tree = goal_client.get_goal_tree(goal_id: first_goal_id)
|
|
48
|
+
expect(tree[:tree]).not_to be_nil
|
|
49
|
+
|
|
50
|
+
# Step 5: Check goal status shows goals exist
|
|
51
|
+
status = goal_client.goal_status
|
|
52
|
+
expect(status[:total]).to be >= 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'updates goal on simulated task completion' do
|
|
56
|
+
# Create and activate a leaf goal with task assignment
|
|
57
|
+
add_result = goal_client.add_goal(
|
|
58
|
+
content: 'simple leaf goal', domain: :general, priority: 0.5,
|
|
59
|
+
parent_id: nil, deadline: nil
|
|
60
|
+
)
|
|
61
|
+
goal_id = add_result[:goal][:id]
|
|
62
|
+
goal_client.activate_goal(goal_id: goal_id)
|
|
63
|
+
|
|
64
|
+
engine = goal_client.send(:engine)
|
|
65
|
+
goal = engine.goals[goal_id]
|
|
66
|
+
goal.assign_task!(task_id: 'task-sim-001', runner_mapping: { runner_class: 'Test', function: :test })
|
|
67
|
+
|
|
68
|
+
# Simulate task completion
|
|
69
|
+
update = engine.update_from_task_event(task_id: 'task-sim-001', status: 'task.completed')
|
|
70
|
+
expect(update[:found]).to be true
|
|
71
|
+
expect(update[:new_status]).to eq(:completed)
|
|
72
|
+
expect(goal.progress).to eq(1.0)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'blocks goal on simulated task failure' do
|
|
76
|
+
add_result = goal_client.add_goal(
|
|
77
|
+
content: 'failing goal', domain: :general, priority: 0.5,
|
|
78
|
+
parent_id: nil, deadline: nil
|
|
79
|
+
)
|
|
80
|
+
goal_id = add_result[:goal][:id]
|
|
81
|
+
goal_client.activate_goal(goal_id: goal_id)
|
|
82
|
+
|
|
83
|
+
engine = goal_client.send(:engine)
|
|
84
|
+
goal = engine.goals[goal_id]
|
|
85
|
+
goal.assign_task!(task_id: 'task-fail-001', runner_mapping: { runner_class: 'Test', function: :test })
|
|
86
|
+
|
|
87
|
+
update = engine.update_from_task_event(task_id: 'task-fail-001', status: 'task.exception')
|
|
88
|
+
expect(update[:found]).to be true
|
|
89
|
+
expect(update[:new_status]).to eq(:blocked)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'reprioritizes goals on urgency signal' do
|
|
93
|
+
goal_client.add_goal(content: 'safety issue', domain: :safety, priority: 0.4, parent_id: nil, deadline: nil)
|
|
94
|
+
goal_client.add_goal(content: 'general task', domain: :general, priority: 0.4, parent_id: nil, deadline: nil)
|
|
95
|
+
|
|
96
|
+
# Activate both
|
|
97
|
+
engine = goal_client.send(:engine)
|
|
98
|
+
engine.goals.each_value(&:activate!)
|
|
99
|
+
|
|
100
|
+
result = engine.reprioritize!(signal: { domain: :safety, urgency: :critical })
|
|
101
|
+
expect(result[:adjusted]).to eq(1)
|
|
102
|
+
|
|
103
|
+
safety = engine.goals.values.find { |g| g.domain == :safety }
|
|
104
|
+
general = engine.goals.values.find { |g| g.domain == :general }
|
|
105
|
+
expect(safety.priority).to be > general.priority
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::Decomposer do
|
|
4
|
+
describe '.decompose' do
|
|
5
|
+
let(:goal_hash) do
|
|
6
|
+
{ id: 'goal-001', content: 'fix degraded safety monitor', domain: :safety, priority: 0.7, status: :active }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
context 'with heuristic strategy' do
|
|
10
|
+
it 'returns sub-goals array' do
|
|
11
|
+
result = described_class.decompose(goal: goal_hash, strategy: :heuristic)
|
|
12
|
+
expect(result[:success]).to be true
|
|
13
|
+
expect(result[:sub_goals]).to be_an(Array)
|
|
14
|
+
expect(result[:sub_goals]).not_to be_empty
|
|
15
|
+
expect(result[:sub_goals].first).to have_key(:content)
|
|
16
|
+
expect(result[:sub_goals].first).to have_key(:domain)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'preserves parent domain' do
|
|
20
|
+
result = described_class.decompose(goal: goal_hash, strategy: :heuristic)
|
|
21
|
+
result[:sub_goals].each { |sg| expect(sg[:domain]).to eq(:safety) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'reports heuristic as strategy_used' do
|
|
25
|
+
result = described_class.decompose(goal: goal_hash, strategy: :heuristic)
|
|
26
|
+
expect(result[:strategy_used]).to eq(:heuristic)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
context 'with llm strategy when llm unavailable' do
|
|
31
|
+
it 'falls back to heuristic' do
|
|
32
|
+
result = described_class.decompose(goal: goal_hash, strategy: :llm)
|
|
33
|
+
expect(result[:success]).to be true
|
|
34
|
+
expect(result[:strategy_used]).to eq(:heuristic)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'parses native hash responses from Legion::LLM.chat' do
|
|
38
|
+
llm = Module.new
|
|
39
|
+
llm.define_singleton_method(:chat) do |message:, **|
|
|
40
|
+
raise 'missing prompt' if message.to_s.empty?
|
|
41
|
+
|
|
42
|
+
{ content: '[{"content":"inspect controls","domain":"safety","priority":0.8}]' }
|
|
43
|
+
end
|
|
44
|
+
stub_const('Legion::LLM', llm)
|
|
45
|
+
|
|
46
|
+
result = described_class.decompose(goal: goal_hash, strategy: :llm)
|
|
47
|
+
|
|
48
|
+
expect(result[:strategy_used]).to eq(:llm)
|
|
49
|
+
expect(result[:sub_goals].first[:content]).to eq('inspect controls')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
context 'with default strategy' do
|
|
54
|
+
it 'uses heuristic when strategy is omitted' do
|
|
55
|
+
result = described_class.decompose(goal: goal_hash)
|
|
56
|
+
expect(result[:success]).to be true
|
|
57
|
+
expect(result[:strategy_used]).to eq(:heuristic)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe '.decompose_heuristic' do
|
|
63
|
+
context 'with a known domain' do
|
|
64
|
+
it 'uses the safety domain template' do
|
|
65
|
+
goal = { content: 'monitor failure', domain: :safety, priority: 0.8 }
|
|
66
|
+
steps = described_class.decompose_heuristic(goal)
|
|
67
|
+
expect(steps.size).to eq(3)
|
|
68
|
+
expect(steps.map { |s| s[:content] }).to all(be_a(String))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'uses the cognition domain template' do
|
|
72
|
+
goal = { content: 'knowledge gap', domain: :cognition, priority: 0.6 }
|
|
73
|
+
steps = described_class.decompose_heuristic(goal)
|
|
74
|
+
expect(steps.size).to eq(3)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'uses the perception domain template' do
|
|
78
|
+
goal = { content: 'sensor drift', domain: :perception, priority: 0.5 }
|
|
79
|
+
steps = described_class.decompose_heuristic(goal)
|
|
80
|
+
expect(steps.size).to eq(3)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
context 'with an unknown domain' do
|
|
85
|
+
it 'falls back to default four-step template' do
|
|
86
|
+
goal = { content: 'unknown task', domain: :general, priority: 0.5 }
|
|
87
|
+
steps = described_class.decompose_heuristic(goal)
|
|
88
|
+
expect(steps.size).to eq(4)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'sets priority from parent goal' do
|
|
93
|
+
goal = { content: 'some task', domain: :safety, priority: 0.9 }
|
|
94
|
+
steps = described_class.decompose_heuristic(goal)
|
|
95
|
+
steps.each { |s| expect(s[:priority]).to eq(0.9) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'defaults priority to 0.5 when not provided' do
|
|
99
|
+
goal = { content: 'some task', domain: :general }
|
|
100
|
+
steps = described_class.decompose_heuristic(goal)
|
|
101
|
+
steps.each { |s| expect(s[:priority]).to eq(0.5) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe '.decompose_by_domain' do
|
|
106
|
+
it 'returns nil for unknown domain' do
|
|
107
|
+
result = described_class.decompose_by_domain('content', :unknown)
|
|
108
|
+
expect(result).to be_nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns array of strings for known domain' do
|
|
112
|
+
result = described_class.decompose_by_domain('test content', :safety)
|
|
113
|
+
expect(result).to be_an(Array)
|
|
114
|
+
expect(result).to all(be_a(String))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe '.default_steps' do
|
|
119
|
+
it 'returns four steps' do
|
|
120
|
+
steps = described_class.default_steps('do something')
|
|
121
|
+
expect(steps.size).to eq(4)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'interpolates content into each step' do
|
|
125
|
+
steps = described_class.default_steps('repair the system')
|
|
126
|
+
expect(steps).to all(include('repair the system'))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
describe '.parse_sub_goals' do
|
|
131
|
+
it 'parses valid JSON array' do
|
|
132
|
+
json = '[{"content":"step one","domain":"safety","priority":0.8}]'
|
|
133
|
+
result = described_class.parse_sub_goals(json, :safety)
|
|
134
|
+
expect(result).to be_an(Array)
|
|
135
|
+
expect(result.first[:content]).to eq('step one')
|
|
136
|
+
expect(result.first[:domain]).to eq(:safety)
|
|
137
|
+
expect(result.first[:priority]).to eq(0.8)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'returns nil for invalid JSON' do
|
|
141
|
+
expect(described_class.parse_sub_goals('not json', :general)).to be_nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'returns nil for empty array' do
|
|
145
|
+
expect(described_class.parse_sub_goals('[]', :general)).to be_nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'strips markdown code fences' do
|
|
149
|
+
json = "```json\n[{\"content\":\"step\",\"domain\":\"general\",\"priority\":0.5}]\n```"
|
|
150
|
+
result = described_class.parse_sub_goals(json, :general)
|
|
151
|
+
expect(result).not_to be_nil
|
|
152
|
+
expect(result.first[:content]).to eq('step')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'clamps priority to 0.0..1.0' do
|
|
156
|
+
json = '[{"content":"x","priority":2.5}]'
|
|
157
|
+
result = described_class.parse_sub_goals(json, :general)
|
|
158
|
+
expect(result.first[:priority]).to eq(1.0)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'uses fallback domain when sub-goal omits domain' do
|
|
162
|
+
json = '[{"content":"x"}]'
|
|
163
|
+
result = described_class.parse_sub_goals(json, :cognition)
|
|
164
|
+
expect(result.first[:domain]).to eq(:cognition)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
data/spec/legion/extensions/agentic/executive/goal_management/helpers/feedback_listener_spec.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::FeedbackListener do
|
|
4
|
+
let(:engine) { Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::GoalEngine.new }
|
|
5
|
+
let(:listener) { described_class.new(engine: engine) }
|
|
6
|
+
|
|
7
|
+
describe '#handle_task_event' do
|
|
8
|
+
before do
|
|
9
|
+
result = engine.add_goal(content: 'test goal', domain: :general, priority: 0.5, parent_id: nil, deadline: nil)
|
|
10
|
+
@goal_id = result[:goal][:id]
|
|
11
|
+
goal = engine.goals[@goal_id]
|
|
12
|
+
goal.activate!
|
|
13
|
+
goal.instance_variable_set(:@task_id, 'task-123')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'completes goal on task.completed' do
|
|
17
|
+
result = listener.handle_task_event(task_id: 'task-123', status: 'task.completed')
|
|
18
|
+
expect(result[:found]).to be true
|
|
19
|
+
expect(result[:new_status]).to eq(:completed)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'blocks goal on task.exception' do
|
|
23
|
+
result = listener.handle_task_event(task_id: 'task-123', status: 'task.exception')
|
|
24
|
+
expect(result[:found]).to be true
|
|
25
|
+
expect(result[:new_status]).to eq(:blocked)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'blocks goal on task.failed' do
|
|
29
|
+
result = listener.handle_task_event(task_id: 'task-123', status: 'task.failed')
|
|
30
|
+
expect(result[:found]).to be true
|
|
31
|
+
expect(result[:new_status]).to eq(:blocked)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'returns not found for unknown task_id' do
|
|
35
|
+
result = listener.handle_task_event(task_id: 'unknown', status: 'task.completed')
|
|
36
|
+
expect(result[:found]).to be false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'passes result through on failure status' do
|
|
40
|
+
result = listener.handle_task_event(task_id: 'task-123', status: 'task.failed', result: 'timeout')
|
|
41
|
+
expect(result[:error]).to eq('timeout')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#start_listening' do
|
|
46
|
+
it 'registers event listeners when Legion::Events is available' do
|
|
47
|
+
events_spy = Class.new do
|
|
48
|
+
attr_reader :registered
|
|
49
|
+
|
|
50
|
+
def initialize
|
|
51
|
+
@registered = []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def on(event, &block)
|
|
55
|
+
@registered << { event: event, block: block }
|
|
56
|
+
end
|
|
57
|
+
end.new
|
|
58
|
+
stub_const('Legion::Events', events_spy)
|
|
59
|
+
listener.start_listening
|
|
60
|
+
event_names = events_spy.registered.map { |r| r[:event] }
|
|
61
|
+
expect(event_names).to include('task.completed')
|
|
62
|
+
expect(event_names).to include('task.failed')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'sets listening? to true after start' do
|
|
66
|
+
events_spy = Class.new do
|
|
67
|
+
def on(_event, &); end
|
|
68
|
+
end.new
|
|
69
|
+
stub_const('Legion::Events', events_spy)
|
|
70
|
+
listener.start_listening
|
|
71
|
+
expect(listener.listening?).to be true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'does not register twice if called again' do
|
|
75
|
+
events_spy = Class.new do
|
|
76
|
+
attr_reader :registered
|
|
77
|
+
|
|
78
|
+
def initialize
|
|
79
|
+
@registered = []
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def on(event, &block)
|
|
83
|
+
@registered << { event: event, block: block }
|
|
84
|
+
end
|
|
85
|
+
end.new
|
|
86
|
+
stub_const('Legion::Events', events_spy)
|
|
87
|
+
listener.start_listening
|
|
88
|
+
listener.start_listening
|
|
89
|
+
expect(events_spy.registered.size).to eq(2)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'does nothing when Legion::Events is not defined' do
|
|
93
|
+
hide_const('Legion::Events') if defined?(Legion::Events)
|
|
94
|
+
expect { listener.start_listening }.not_to raise_error
|
|
95
|
+
expect(listener.listening?).to be false
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe '#listening?' do
|
|
100
|
+
it 'returns false before start_listening' do
|
|
101
|
+
expect(listener.listening?).to be false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -318,6 +318,38 @@ RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::
|
|
|
318
318
|
end
|
|
319
319
|
end
|
|
320
320
|
|
|
321
|
+
describe '#reprioritize!' do
|
|
322
|
+
let(:engine) { described_class.new }
|
|
323
|
+
|
|
324
|
+
before do
|
|
325
|
+
result = engine.add_goal(content: 'low priority task', domain: :general, priority: 0.3, parent_id: nil,
|
|
326
|
+
deadline: nil)
|
|
327
|
+
engine.activate_goal(goal_id: result[:goal][:id])
|
|
328
|
+
result = engine.add_goal(content: 'safety monitor degraded', domain: :safety, priority: 0.5, parent_id: nil,
|
|
329
|
+
deadline: nil)
|
|
330
|
+
engine.activate_goal(goal_id: result[:goal][:id])
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it 'boosts goals matching the signal domain' do
|
|
334
|
+
safety_goal = engine.active_goals.find { |g| g.domain == :safety }
|
|
335
|
+
old_priority = safety_goal.priority
|
|
336
|
+
engine.reprioritize!(signal: { domain: :safety, urgency: :critical })
|
|
337
|
+
expect(safety_goal.priority).to be > old_priority
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it 'does not affect goals in other domains' do
|
|
341
|
+
general_goal = engine.active_goals.find { |g| g.domain == :general }
|
|
342
|
+
old_priority = general_goal.priority
|
|
343
|
+
engine.reprioritize!(signal: { domain: :safety, urgency: :critical })
|
|
344
|
+
expect(general_goal.priority).to eq(old_priority)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it 'handles unknown domains gracefully' do
|
|
348
|
+
result = engine.reprioritize!(signal: { domain: :unknown, urgency: :low })
|
|
349
|
+
expect(result[:adjusted]).to eq(0)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
321
353
|
describe '#goal_report' do
|
|
322
354
|
it 'reports total goal count' do
|
|
323
355
|
3.times { engine.add_goal(content: 'goal') }
|
data/spec/legion/extensions/agentic/executive/goal_management/helpers/goal_persistence_spec.rb
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Agentic::Executive::GoalManagement::Helpers::GoalPersistence do
|
|
4
|
+
let(:persistence) { described_class.new(namespace: 'test_goals') }
|
|
5
|
+
|
|
6
|
+
before do
|
|
7
|
+
stub_const('Legion::Cache', Class.new do
|
|
8
|
+
def self.connected? = true
|
|
9
|
+
def self.get(key) = (@store ||= {})[key]
|
|
10
|
+
def self.set(key, value, **_opts) = ((@store ||= {})[key] = value)
|
|
11
|
+
def self.set_sync(key, value, **_opts) = ((@store ||= {})[key] = value)
|
|
12
|
+
def self.delete(key, **_opts) = (@store ||= {}).delete(key)
|
|
13
|
+
def self.delete_sync(key) = (@store ||= {}).delete(key)
|
|
14
|
+
def self.flush = (@store = {})
|
|
15
|
+
end)
|
|
16
|
+
Legion::Cache.flush
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe '#save_goal / #load_goal' do
|
|
20
|
+
it 'round-trips a goal hash' do
|
|
21
|
+
goal_hash = { id: 'g-001', content: 'test', domain: :safety, priority: 0.7,
|
|
22
|
+
status: :active, progress: 0.0, sub_goal_ids: [] }
|
|
23
|
+
persistence.save_goal(goal_hash)
|
|
24
|
+
loaded = persistence.load_goal('g-001')
|
|
25
|
+
expect(loaded[:content]).to eq('test')
|
|
26
|
+
# JSON round-trip: symbol values become strings; Goal.from_h re-symbolizes via to_sym
|
|
27
|
+
expect(loaded[:domain].to_sym).to eq(:safety)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'returns nil for missing goal' do
|
|
31
|
+
expect(persistence.load_goal('nonexistent')).to be_nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'updates the index on save' do
|
|
35
|
+
persistence.save_goal({ id: 'g-idx', content: 'indexed', domain: :general,
|
|
36
|
+
priority: 0.5, status: :proposed, progress: 0.0, sub_goal_ids: [] })
|
|
37
|
+
all = persistence.load_all
|
|
38
|
+
expect(all.keys).to include('g-idx')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#save_all / #load_all' do
|
|
43
|
+
it 'round-trips multiple goals' do
|
|
44
|
+
goals = {
|
|
45
|
+
'g-001' => { id: 'g-001', content: 'goal one', domain: :safety, priority: 0.5,
|
|
46
|
+
status: :active, progress: 0.0, sub_goal_ids: [] },
|
|
47
|
+
'g-002' => { id: 'g-002', content: 'goal two', domain: :cognition, priority: 0.8,
|
|
48
|
+
status: :proposed, progress: 0.0, sub_goal_ids: [] }
|
|
49
|
+
}
|
|
50
|
+
persistence.save_all(goals)
|
|
51
|
+
loaded = persistence.load_all
|
|
52
|
+
expect(loaded.size).to eq(2)
|
|
53
|
+
expect(loaded['g-001'][:content]).to eq('goal one')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'returns empty hash when nothing saved' do
|
|
57
|
+
expect(persistence.load_all).to eq({})
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe '#delete_goal' do
|
|
62
|
+
it 'removes a goal from cache' do
|
|
63
|
+
persistence.save_goal({ id: 'g-del', content: 'delete me', domain: :general,
|
|
64
|
+
priority: 0.5, status: :active, progress: 0.0, sub_goal_ids: [] })
|
|
65
|
+
persistence.delete_goal('g-del')
|
|
66
|
+
expect(persistence.load_goal('g-del')).to be_nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'removes the goal id from the index' do
|
|
70
|
+
persistence.save_goal({ id: 'g-rm', content: 'remove from index', domain: :general,
|
|
71
|
+
priority: 0.5, status: :active, progress: 0.0, sub_goal_ids: [] })
|
|
72
|
+
persistence.delete_goal('g-rm')
|
|
73
|
+
all = persistence.load_all
|
|
74
|
+
expect(all.keys).not_to include('g-rm')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe '#save_bridge_state / #load_bridge_state' do
|
|
79
|
+
it 'round-trips bridge tracking hash' do
|
|
80
|
+
# JSON round-trip with symbolize_names: true turns string keys into symbols
|
|
81
|
+
state = { 'curiosity:cognition:explore gaps' => 'g-001' }
|
|
82
|
+
persistence.save_bridge_state(state)
|
|
83
|
+
loaded = persistence.load_bridge_state
|
|
84
|
+
expect(loaded.values.first).to eq('g-001')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'returns empty hash when nothing saved' do
|
|
88
|
+
expect(persistence.load_bridge_state).to eq({})
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe 'when cache unavailable' do
|
|
93
|
+
before { allow(Legion::Cache).to receive(:connected?).and_return(false) }
|
|
94
|
+
|
|
95
|
+
it 'returns nil on load_goal' do
|
|
96
|
+
expect(persistence.load_goal('g-001')).to be_nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'returns false on save_goal' do
|
|
100
|
+
expect(persistence.save_goal({ id: 'g-001', content: 'x' })).to be false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'returns empty hash on load_all' do
|
|
104
|
+
expect(persistence.load_all).to eq({})
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'returns empty hash on load_bridge_state' do
|
|
108
|
+
expect(persistence.load_bridge_state).to eq({})
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns false on save_bridge_state' do
|
|
112
|
+
expect(persistence.save_bridge_state({ 'k' => 'v' })).to be false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'returns false on delete_goal' do
|
|
116
|
+
expect(persistence.delete_goal('g-001')).to be false
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|