lex-agentic-executive 0.1.12 → 0.2.0
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 +13 -0
- data/lib/legion/extensions/agentic/executive/goal_management/client.rb +23 -0
- data/lib/legion/extensions/agentic/executive/goal_management/helpers/decomposer.rb +107 -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 +152 -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 +176 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa8f955fd551b961fd735e5bbf9a0dc33b54134b4d5e2ab1506221687aa3195
|
|
4
|
+
data.tar.gz: 862042eb5a340dcacce8ec8f2c7cb5f2b9501640ca3d0f39e3307ab69c87d571
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51cbce9e25c1edb78855eeb7304663855d5f3b4cfc0072b074476f16c230786ea9bd7dfff15c43f10a55558463642218835d3ed3e9f1fcc7a444fbcce1893a74
|
|
7
|
+
data.tar.gz: 69f0e5e91e4f98c9b2a46d518301674bedf0bedf7ee04adb4a8b5117c450afedf0ebbe8dc2eef73143a6773e96df5bf2a13e6cdf0ab9c4a590b97c4af821559a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-04-21
|
|
4
|
+
### Added
|
|
5
|
+
- **Autonomous Goal-Setting Pipeline (G1-G5)** — GAIA can now convert intentions into goals, decompose them, dispatch execution, and receive feedback with no human intervention
|
|
6
|
+
- `Volition::Helpers::GoalBridge` — converts volition intentions into proposed goals via GoalManagement (G1)
|
|
7
|
+
- `GoalManagement::Helpers::Decomposer` — autonomous goal decomposition with domain-aware heuristic templates and optional LLM strategy (G2)
|
|
8
|
+
- `GoalManagement::Helpers::TaskDispatcher` — dispatches leaf goals to runners via `Legion::Runner.run` or Client instantiation, with per-domain argument builders and load-order guards (G3)
|
|
9
|
+
- `GoalEngine#reprioritize!` — event-driven priority adjustment with urgency-calibrated boost levels (G4)
|
|
10
|
+
- `GoalManagement::Helpers::FeedbackListener` — subscribes to `Legion::Events` for `task.completed`/`task.failed` and updates goal progress/status (G5)
|
|
11
|
+
- `GoalEngine#update_from_task_event` — thread-safe (Mutex) goal status update from task completion events
|
|
12
|
+
- `Goal#assign_task!` — associates a dispatched task_id and runner_mapping with a goal
|
|
13
|
+
- Auto-activation of proposed leaf goals before dispatch
|
|
14
|
+
- Auto-start of FeedbackListener on GoalManagement::Client initialization
|
|
15
|
+
|
|
3
16
|
## [0.1.12] - 2026-04-15
|
|
4
17
|
### Changed
|
|
5
18
|
- Set `mcp_tools?`, `mcp_tools_deferred?`, and `transport_required?` to `false` — internal cognitive pipeline extension
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require 'legion/extensions/agentic/executive/goal_management/helpers/constants'
|
|
4
4
|
require 'legion/extensions/agentic/executive/goal_management/helpers/goal'
|
|
5
|
+
require 'legion/extensions/agentic/executive/goal_management/helpers/goal_persistence'
|
|
5
6
|
require 'legion/extensions/agentic/executive/goal_management/helpers/goal_engine'
|
|
7
|
+
require 'legion/extensions/agentic/executive/goal_management/helpers/task_dispatcher'
|
|
8
|
+
require 'legion/extensions/agentic/executive/goal_management/helpers/feedback_listener'
|
|
9
|
+
require 'legion/extensions/agentic/executive/goal_management/helpers/decomposer'
|
|
6
10
|
require 'legion/extensions/agentic/executive/goal_management/runners/goal_management'
|
|
7
11
|
|
|
8
12
|
module Legion
|
|
@@ -13,13 +17,32 @@ module Legion
|
|
|
13
17
|
class Client
|
|
14
18
|
include Runners::GoalManagement
|
|
15
19
|
|
|
20
|
+
LISTENER_MUTEX = Mutex.new
|
|
21
|
+
private_constant :LISTENER_MUTEX
|
|
22
|
+
|
|
23
|
+
@listener_started = false
|
|
24
|
+
class << self
|
|
25
|
+
attr_accessor :listener_started
|
|
26
|
+
end
|
|
27
|
+
|
|
16
28
|
def initialize(**)
|
|
17
29
|
@engine = Helpers::GoalEngine.new
|
|
30
|
+
@feedback_listener = Helpers::FeedbackListener.new(engine: @engine)
|
|
31
|
+
start_listener_once
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
private
|
|
21
35
|
|
|
22
36
|
attr_reader :engine
|
|
37
|
+
|
|
38
|
+
def start_listener_once
|
|
39
|
+
LISTENER_MUTEX.synchronize do
|
|
40
|
+
return if self.class.listener_started
|
|
41
|
+
|
|
42
|
+
@feedback_listener.start_listening
|
|
43
|
+
self.class.listener_started = true
|
|
44
|
+
end
|
|
45
|
+
end
|
|
23
46
|
end
|
|
24
47
|
end
|
|
25
48
|
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Executive
|
|
7
|
+
module GoalManagement
|
|
8
|
+
module Helpers
|
|
9
|
+
module Decomposer
|
|
10
|
+
extend Legion::JSON::Helper if defined?(Legion::JSON::Helper)
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def log
|
|
15
|
+
Legion::Logging
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
DOMAIN_TEMPLATES = {
|
|
19
|
+
safety: ->(c) { ["diagnose: #{c}", "implement fix for: #{c}", "verify health after: #{c}"] },
|
|
20
|
+
cognition: ->(c) { ["analyze gaps in: #{c}", "design approach for: #{c}", "validate: #{c}"] },
|
|
21
|
+
perception: ->(c) { ["observe current state of: #{c}", "identify patterns in: #{c}", "calibrate: #{c}"] }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def decompose(goal:, strategy: :heuristic)
|
|
25
|
+
sub_goals, strategy_used = case strategy
|
|
26
|
+
when :llm
|
|
27
|
+
result = decompose_with_llm(goal)
|
|
28
|
+
result ? [result, :llm] : [decompose_heuristic(goal), :heuristic]
|
|
29
|
+
else
|
|
30
|
+
[decompose_heuristic(goal), :heuristic]
|
|
31
|
+
end
|
|
32
|
+
log.info "[decomposer] decomposed goal=#{goal[:id]} strategy=#{strategy_used}"
|
|
33
|
+
{ success: true, sub_goals: sub_goals, strategy_used: strategy_used }
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
log.error "Decomposer: #{e.message}"
|
|
36
|
+
{ success: false, error: e.message }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def decompose_heuristic(goal)
|
|
40
|
+
content = goal[:content].to_s.downcase
|
|
41
|
+
domain = (goal[:domain] || :general).to_sym
|
|
42
|
+
steps = decompose_by_domain(content, domain) || default_steps(content)
|
|
43
|
+
steps.map { |step| { content: step, domain: domain, priority: goal[:priority] || 0.5 } }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def decompose_with_llm(goal)
|
|
47
|
+
return nil unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat)
|
|
48
|
+
|
|
49
|
+
prompt = build_decomposition_prompt(goal)
|
|
50
|
+
response = Legion::LLM.chat(
|
|
51
|
+
caller: { extension: 'lex-agentic-executive', operation: 'decompose' }
|
|
52
|
+
).ask(prompt)
|
|
53
|
+
parse_sub_goals(response.content, goal[:domain])
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
log.error "Decomposer#decompose_with_llm: #{e.message}"
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def decompose_by_domain(content, domain)
|
|
60
|
+
template = DOMAIN_TEMPLATES[domain]
|
|
61
|
+
return nil unless template
|
|
62
|
+
|
|
63
|
+
template.call(content)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def default_steps(content)
|
|
67
|
+
[
|
|
68
|
+
"analyze current state of: #{content}",
|
|
69
|
+
"plan approach for: #{content}",
|
|
70
|
+
"execute: #{content}",
|
|
71
|
+
"verify result of: #{content}"
|
|
72
|
+
]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_decomposition_prompt(goal)
|
|
76
|
+
<<~PROMPT
|
|
77
|
+
Decompose this goal into 2-5 concrete sub-goals. Return JSON array.
|
|
78
|
+
Goal: #{goal[:content]}
|
|
79
|
+
Domain: #{goal[:domain]}
|
|
80
|
+
Each sub-goal: {"content": "action description", "domain": "#{goal[:domain]}", "priority": 0.0-1.0}
|
|
81
|
+
Return ONLY the JSON array, no other text.
|
|
82
|
+
PROMPT
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_sub_goals(content, domain)
|
|
86
|
+
cleaned = content.gsub(/```(?:json)?\s*\n?/, '').strip
|
|
87
|
+
data = json_load(cleaned)
|
|
88
|
+
return nil unless data.is_a?(Array) && !data.empty?
|
|
89
|
+
|
|
90
|
+
data.map do |sg|
|
|
91
|
+
{
|
|
92
|
+
content: sg[:content].to_s,
|
|
93
|
+
domain: (sg[:domain] || domain).to_sym,
|
|
94
|
+
priority: (sg[:priority] || 0.5).to_f.clamp(0.0, 1.0)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
log.error "Decomposer#parse_sub_goals: #{e.message}"
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Executive
|
|
7
|
+
module GoalManagement
|
|
8
|
+
module Helpers
|
|
9
|
+
class FeedbackListener
|
|
10
|
+
def initialize(engine:)
|
|
11
|
+
@engine = engine
|
|
12
|
+
@listening = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def handle_task_event(task_id:, status:, result: nil)
|
|
16
|
+
update = @engine.update_from_task_event(task_id: task_id, status: status, result: result)
|
|
17
|
+
goal_id = update[:goal_id] if update.is_a?(Hash)
|
|
18
|
+
new_status = update[:new_status] if update.is_a?(Hash)
|
|
19
|
+
unhandled_status = update[:unhandled_status] if update.is_a?(Hash)
|
|
20
|
+
log.info "[feedback_listener] task event task_id=#{task_id} goal_id=#{goal_id} " \
|
|
21
|
+
"task_status=#{status} new_status=#{new_status} unhandled_status=#{unhandled_status}"
|
|
22
|
+
update
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start_listening
|
|
26
|
+
return if @listening
|
|
27
|
+
return unless defined?(Legion::Events)
|
|
28
|
+
|
|
29
|
+
Legion::Events.on('task.completed') do |event|
|
|
30
|
+
handle_task_event(
|
|
31
|
+
task_id: event[:task_id],
|
|
32
|
+
status: event[:status] || 'task.completed',
|
|
33
|
+
result: event[:result]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Legion::Events.on('task.failed') do |event|
|
|
38
|
+
handle_task_event(
|
|
39
|
+
task_id: event[:task_id],
|
|
40
|
+
status: event[:status] || 'task.failed',
|
|
41
|
+
result: event[:result]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@listening = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def listening?
|
|
49
|
+
@listening
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def log
|
|
55
|
+
Legion::Logging
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require 'time'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
module Extensions
|
|
@@ -13,20 +14,40 @@ module Legion
|
|
|
13
14
|
|
|
14
15
|
attr_reader :id, :content, :parent_id, :sub_goal_ids, :status,
|
|
15
16
|
:priority, :progress, :domain, :deadline,
|
|
16
|
-
:created_at, :updated_at
|
|
17
|
+
:created_at, :updated_at, :task_id, :runner_mapping
|
|
18
|
+
|
|
19
|
+
def self.from_h(hash)
|
|
20
|
+
goal = allocate
|
|
21
|
+
goal.instance_variable_set(:@id, hash[:id])
|
|
22
|
+
goal.instance_variable_set(:@content, hash[:content])
|
|
23
|
+
goal.instance_variable_set(:@parent_id, hash[:parent_id])
|
|
24
|
+
goal.instance_variable_set(:@sub_goal_ids, hash[:sub_goal_ids] || [])
|
|
25
|
+
goal.instance_variable_set(:@status, hash[:status]&.to_sym || :proposed)
|
|
26
|
+
goal.instance_variable_set(:@priority, hash[:priority]&.to_f || 0.5)
|
|
27
|
+
goal.instance_variable_set(:@progress, (hash[:progress] || 0.0).to_f)
|
|
28
|
+
goal.instance_variable_set(:@domain, hash[:domain]&.to_sym || :general)
|
|
29
|
+
goal.instance_variable_set(:@deadline, hash[:deadline])
|
|
30
|
+
goal.instance_variable_set(:@task_id, hash[:task_id])
|
|
31
|
+
goal.instance_variable_set(:@runner_mapping, hash[:runner_mapping])
|
|
32
|
+
goal.instance_variable_set(:@created_at, hash[:created_at] ? Time.parse(hash[:created_at].to_s) : Time.now)
|
|
33
|
+
goal.instance_variable_set(:@updated_at, hash[:updated_at] ? Time.parse(hash[:updated_at].to_s) : Time.now)
|
|
34
|
+
goal
|
|
35
|
+
end
|
|
17
36
|
|
|
18
37
|
def initialize(content:, parent_id: nil, domain: :general, priority: DEFAULT_PRIORITY, deadline: nil)
|
|
19
|
-
@id
|
|
20
|
-
@content
|
|
21
|
-
@parent_id
|
|
22
|
-
@sub_goal_ids
|
|
23
|
-
@status
|
|
24
|
-
@priority
|
|
25
|
-
@progress
|
|
26
|
-
@domain
|
|
27
|
-
@deadline
|
|
28
|
-
@created_at
|
|
29
|
-
@updated_at
|
|
38
|
+
@id = SecureRandom.uuid
|
|
39
|
+
@content = content
|
|
40
|
+
@parent_id = parent_id
|
|
41
|
+
@sub_goal_ids = []
|
|
42
|
+
@status = :proposed
|
|
43
|
+
@priority = priority.clamp(0.0, 1.0)
|
|
44
|
+
@progress = 0.0
|
|
45
|
+
@domain = domain
|
|
46
|
+
@deadline = deadline
|
|
47
|
+
@created_at = Time.now
|
|
48
|
+
@updated_at = Time.now
|
|
49
|
+
@task_id = nil
|
|
50
|
+
@runner_mapping = nil
|
|
30
51
|
end
|
|
31
52
|
|
|
32
53
|
def activate!
|
|
@@ -88,6 +109,12 @@ module Legion
|
|
|
88
109
|
@updated_at = Time.now
|
|
89
110
|
end
|
|
90
111
|
|
|
112
|
+
def assign_task!(task_id:, runner_mapping:)
|
|
113
|
+
@task_id = task_id
|
|
114
|
+
@runner_mapping = runner_mapping
|
|
115
|
+
@updated_at = Time.now
|
|
116
|
+
end
|
|
117
|
+
|
|
91
118
|
def add_sub_goal(goal_id)
|
|
92
119
|
@sub_goal_ids << goal_id unless @sub_goal_ids.include?(goal_id)
|
|
93
120
|
end
|
|
@@ -140,6 +167,8 @@ module Legion
|
|
|
140
167
|
overdue: overdue?,
|
|
141
168
|
root: root?,
|
|
142
169
|
leaf: leaf?,
|
|
170
|
+
task_id: @task_id,
|
|
171
|
+
runner_mapping: @runner_mapping,
|
|
143
172
|
created_at: @created_at,
|
|
144
173
|
updated_at: @updated_at
|
|
145
174
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/extensions/agentic/executive/goal_management/helpers/goal_persistence'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Extensions
|
|
5
7
|
module Agentic
|
|
@@ -14,6 +16,9 @@ module Legion
|
|
|
14
16
|
def initialize
|
|
15
17
|
@goals = {}
|
|
16
18
|
@root_goal_ids = []
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@persistence = GoalPersistence.new
|
|
21
|
+
rehydrate_from_cache
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
def add_goal(content:, parent_id: nil, domain: :general, priority: DEFAULT_PRIORITY, deadline: nil)
|
|
@@ -36,7 +41,8 @@ module Legion
|
|
|
36
41
|
@root_goal_ids << goal.id
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
persist_goal(goal)
|
|
45
|
+
log.debug "[goal_management] add_goal id=#{goal.id} domain=#{domain} priority=#{priority.round(2)}"
|
|
40
46
|
{ success: true, goal: goal.to_h }
|
|
41
47
|
end
|
|
42
48
|
|
|
@@ -55,7 +61,7 @@ module Legion
|
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
failures = created.reject { |r| r[:success] }
|
|
58
|
-
|
|
64
|
+
log.debug "[goal_management] decompose parent=#{goal_id} created=#{created.size - failures.size} failed=#{failures.size}"
|
|
59
65
|
{ success: true, parent_id: goal_id, created: created, failures: failures.size }
|
|
60
66
|
end
|
|
61
67
|
|
|
@@ -64,7 +70,8 @@ module Legion
|
|
|
64
70
|
return { success: false, error: "goal #{goal_id} not found" } unless goal
|
|
65
71
|
|
|
66
72
|
activated = goal.activate!
|
|
67
|
-
|
|
73
|
+
persist_goal(goal) if activated
|
|
74
|
+
log.debug "[goal_management] activate goal=#{goal_id} result=#{activated}"
|
|
68
75
|
{ success: activated, goal_id: goal_id, status: goal.status }
|
|
69
76
|
end
|
|
70
77
|
|
|
@@ -73,7 +80,8 @@ module Legion
|
|
|
73
80
|
return { success: false, error: "goal #{goal_id} not found" } unless goal
|
|
74
81
|
|
|
75
82
|
completed = goal.complete!
|
|
76
|
-
|
|
83
|
+
persist_goal(goal) if completed
|
|
84
|
+
log.debug "[goal_management] complete goal=#{goal_id} result=#{completed}"
|
|
77
85
|
{ success: completed, goal_id: goal_id, status: goal.status }
|
|
78
86
|
end
|
|
79
87
|
|
|
@@ -82,7 +90,8 @@ module Legion
|
|
|
82
90
|
return { success: false, error: "goal #{goal_id} not found" } unless goal
|
|
83
91
|
|
|
84
92
|
abandoned = goal.abandon!
|
|
85
|
-
|
|
93
|
+
persist_goal(goal) if abandoned
|
|
94
|
+
log.debug "[goal_management] abandon goal=#{goal_id} result=#{abandoned}"
|
|
86
95
|
{ success: abandoned, goal_id: goal_id, status: goal.status }
|
|
87
96
|
end
|
|
88
97
|
|
|
@@ -91,7 +100,8 @@ module Legion
|
|
|
91
100
|
return { success: false, error: "goal #{goal_id} not found" } unless goal
|
|
92
101
|
|
|
93
102
|
blocked = goal.block!
|
|
94
|
-
|
|
103
|
+
persist_goal(goal) if blocked
|
|
104
|
+
log.debug "[goal_management] block goal=#{goal_id} result=#{blocked}"
|
|
95
105
|
{ success: blocked, goal_id: goal_id, status: goal.status }
|
|
96
106
|
end
|
|
97
107
|
|
|
@@ -100,7 +110,8 @@ module Legion
|
|
|
100
110
|
return { success: false, error: "goal #{goal_id} not found" } unless goal
|
|
101
111
|
|
|
102
112
|
unblocked = goal.unblock!
|
|
103
|
-
|
|
113
|
+
persist_goal(goal) if unblocked
|
|
114
|
+
log.debug "[goal_management] unblock goal=#{goal_id} result=#{unblocked}"
|
|
104
115
|
{ success: unblocked, goal_id: goal_id, status: goal.status }
|
|
105
116
|
end
|
|
106
117
|
|
|
@@ -110,7 +121,8 @@ module Legion
|
|
|
110
121
|
|
|
111
122
|
goal.advance_progress!(amount)
|
|
112
123
|
propagate_progress_to_parent(goal_id)
|
|
113
|
-
|
|
124
|
+
persist_goal(goal)
|
|
125
|
+
log.debug "[goal_management] advance_progress goal=#{goal_id} progress=#{goal.progress.round(2)}"
|
|
114
126
|
{ success: true, goal_id: goal_id, progress: goal.progress }
|
|
115
127
|
end
|
|
116
128
|
|
|
@@ -131,7 +143,7 @@ module Legion
|
|
|
131
143
|
conflicts = raw.select { |c| c[:conflict_score] > 0.0 }
|
|
132
144
|
.sort_by { |c| -c[:conflict_score] }
|
|
133
145
|
|
|
134
|
-
|
|
146
|
+
log.debug "[goal_management] detect_conflicts goal=#{goal_id} conflicts=#{conflicts.size}"
|
|
135
147
|
{ success: true, goal_id: goal_id, conflicts: conflicts, count: conflicts.size }
|
|
136
148
|
end
|
|
137
149
|
|
|
@@ -163,13 +175,67 @@ module Legion
|
|
|
163
175
|
active.sort_by { |g| -g.priority }.first(limit)
|
|
164
176
|
end
|
|
165
177
|
|
|
178
|
+
URGENCY_BOOST = { critical: 0.3, high: 0.2, moderate: 0.1, low: 0.05 }.freeze
|
|
179
|
+
|
|
180
|
+
def reprioritize!(signal:)
|
|
181
|
+
domain = signal[:domain]&.to_sym
|
|
182
|
+
boost = URGENCY_BOOST[signal[:urgency]&.to_sym] || 0.05
|
|
183
|
+
adjusted = 0
|
|
184
|
+
|
|
185
|
+
@goals.each_value do |goal|
|
|
186
|
+
next unless goal.domain == domain && goal.active?
|
|
187
|
+
|
|
188
|
+
times = (boost / Constants::PRIORITY_BOOST).ceil
|
|
189
|
+
times.times { goal.boost_priority! }
|
|
190
|
+
persist_goal(goal)
|
|
191
|
+
adjusted += 1
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
log.info "[goal_engine] reprioritize! domain=#{domain} boost=#{boost} adjusted=#{adjusted}"
|
|
195
|
+
{ adjusted: adjusted, domain: domain, boost: boost }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def assign_task_to_goal(goal_id:, task_id:, runner_mapping:)
|
|
199
|
+
goal = @goals[goal_id]
|
|
200
|
+
return { success: false, error: "goal #{goal_id} not found" } unless goal
|
|
201
|
+
|
|
202
|
+
goal.assign_task!(task_id: task_id, runner_mapping: runner_mapping)
|
|
203
|
+
persist_goal(goal)
|
|
204
|
+
log.debug "[goal_management] assign_task goal=#{goal_id} task_id=#{task_id}"
|
|
205
|
+
{ success: true, goal_id: goal_id, task_id: task_id }
|
|
206
|
+
end
|
|
207
|
+
|
|
166
208
|
def decay_all_priorities!
|
|
167
209
|
inactive = @goals.values.reject { |g| g.status == :active }
|
|
168
210
|
inactive.each(&:decay_priority!)
|
|
169
|
-
|
|
211
|
+
log.debug "[goal_management] decay_all inactive=#{inactive.size}"
|
|
170
212
|
{ decayed: inactive.size }
|
|
171
213
|
end
|
|
172
214
|
|
|
215
|
+
def update_from_task_event(task_id:, status:, result: nil)
|
|
216
|
+
@mutex.synchronize do
|
|
217
|
+
goal = @goals.values.find { |g| g.task_id == task_id }
|
|
218
|
+
return { found: false } unless goal
|
|
219
|
+
|
|
220
|
+
case status
|
|
221
|
+
when 'task.completed'
|
|
222
|
+
goal.advance_progress!(1.0 - goal.progress)
|
|
223
|
+
goal.complete! if goal.progress >= Constants::PROGRESS_THRESHOLD
|
|
224
|
+
propagate_progress_to_parent(goal.id) if goal.parent_id
|
|
225
|
+
persist_goal(goal)
|
|
226
|
+
log.info "[goal_engine] goal status change goal=#{goal.id} transition=completed"
|
|
227
|
+
{ found: true, goal_id: goal.id, new_status: goal.status, progress: goal.progress }
|
|
228
|
+
when 'task.exception', 'task.failed'
|
|
229
|
+
goal.block!
|
|
230
|
+
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 }
|
|
233
|
+
else
|
|
234
|
+
{ found: true, goal_id: goal.id, unhandled_status: status }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
173
239
|
def goal_report
|
|
174
240
|
statuses = GOAL_STATUSES.to_h { |s| [s, 0] }
|
|
175
241
|
@goals.each_value { |g| statuses[g.status] += 1 }
|
|
@@ -192,6 +258,28 @@ module Legion
|
|
|
192
258
|
|
|
193
259
|
private
|
|
194
260
|
|
|
261
|
+
def log
|
|
262
|
+
Legion::Logging
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def persist_goal(goal)
|
|
266
|
+
@persistence.save_goal(goal.to_h)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def rehydrate_from_cache
|
|
270
|
+
cached = @persistence.load_all
|
|
271
|
+
return if cached.empty?
|
|
272
|
+
|
|
273
|
+
cached.each do |id, goal_hash|
|
|
274
|
+
goal = Goal.from_h(goal_hash)
|
|
275
|
+
@goals[id] = goal
|
|
276
|
+
@root_goal_ids << id if goal.root?
|
|
277
|
+
end
|
|
278
|
+
log.info "[goal_engine] rehydrated #{cached.size} goals from cache"
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
log.error "GoalEngine: rehydration failed: #{e.message}"
|
|
281
|
+
end
|
|
282
|
+
|
|
195
283
|
def depth_of(goal_id, current_depth = 0)
|
|
196
284
|
goal = @goals[goal_id]
|
|
197
285
|
return current_depth unless goal&.parent_id
|
|
@@ -212,6 +300,7 @@ module Legion
|
|
|
212
300
|
avg = children.sum(&:progress).round(10) / children.size
|
|
213
301
|
parent.instance_variable_set(:@progress, avg.round(10))
|
|
214
302
|
parent.instance_variable_set(:@updated_at, Time.now)
|
|
303
|
+
persist_goal(parent)
|
|
215
304
|
propagate_progress_to_parent(goal.parent_id)
|
|
216
305
|
end
|
|
217
306
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Executive
|
|
7
|
+
module GoalManagement
|
|
8
|
+
module Helpers
|
|
9
|
+
class GoalPersistence
|
|
10
|
+
include Legion::Cache::Helper if defined?(Legion::Cache::Helper)
|
|
11
|
+
include Legion::JSON::Helper if defined?(Legion::JSON::Helper)
|
|
12
|
+
|
|
13
|
+
GOAL_TTL = 86_400
|
|
14
|
+
INDEX_KEY_SUFFIX = ':index'
|
|
15
|
+
BRIDGE_KEY_SUFFIX = ':bridge'
|
|
16
|
+
|
|
17
|
+
def initialize(namespace: 'legion_goals')
|
|
18
|
+
@namespace = namespace
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save_goal(goal_hash)
|
|
22
|
+
return false unless persistence_available?
|
|
23
|
+
|
|
24
|
+
cache_set(goal_key(goal_hash[:id]), json_dump(goal_hash), ttl: GOAL_TTL)
|
|
25
|
+
update_index(goal_hash[:id], :add)
|
|
26
|
+
true
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
log.error "[goal_persistence] save_goal failed: #{e.message}"
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load_goal(id)
|
|
33
|
+
return nil unless persistence_available?
|
|
34
|
+
|
|
35
|
+
raw = cache_get(goal_key(id))
|
|
36
|
+
return nil unless raw
|
|
37
|
+
|
|
38
|
+
json_load(raw)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
log.error "[goal_persistence] load_goal failed: #{e.message}"
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete_goal(id)
|
|
45
|
+
return false unless persistence_available?
|
|
46
|
+
|
|
47
|
+
cache_delete(goal_key(id))
|
|
48
|
+
update_index(id, :remove)
|
|
49
|
+
true
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
log.error "[goal_persistence] delete_goal failed: #{e.message}"
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def save_all(goals_hash)
|
|
56
|
+
return false unless persistence_available?
|
|
57
|
+
|
|
58
|
+
goals_hash.each_value { |g| save_goal(g.is_a?(Hash) ? g : g.to_h) }
|
|
59
|
+
true
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
log.error "[goal_persistence] save_all failed: #{e.message}"
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_all
|
|
66
|
+
return {} unless persistence_available?
|
|
67
|
+
|
|
68
|
+
ids = load_index
|
|
69
|
+
return {} if ids.empty?
|
|
70
|
+
|
|
71
|
+
goals = ids.each_with_object({}) do |id, result|
|
|
72
|
+
goal = load_goal(id)
|
|
73
|
+
result[id] = goal if goal
|
|
74
|
+
end
|
|
75
|
+
log.info "[goal_persistence] rehydrated #{goals.size} goals from cache"
|
|
76
|
+
goals
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
log.error "[goal_persistence] load_all failed: #{e.message}"
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def save_bridge_state(bridge_hash)
|
|
83
|
+
return false unless persistence_available?
|
|
84
|
+
|
|
85
|
+
cache_set(bridge_key, json_dump(bridge_hash), ttl: GOAL_TTL)
|
|
86
|
+
true
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
log.error "[goal_persistence] save_bridge_state failed: #{e.message}"
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def load_bridge_state
|
|
93
|
+
return {} unless persistence_available?
|
|
94
|
+
|
|
95
|
+
raw = cache_get(bridge_key)
|
|
96
|
+
return {} unless raw
|
|
97
|
+
|
|
98
|
+
json_load(raw)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
log.error "[goal_persistence] load_bridge_state failed: #{e.message}"
|
|
101
|
+
{}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def log
|
|
107
|
+
Legion::Logging
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def persistence_available?
|
|
111
|
+
respond_to?(:cache_connected?) && cache_connected?
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
log.debug "[goal_persistence] cache not available: #{e.message}"
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def goal_key(id) = "#{@namespace}:goal:#{id}"
|
|
118
|
+
def index_key = "#{@namespace}#{INDEX_KEY_SUFFIX}"
|
|
119
|
+
def bridge_key = "#{@namespace}#{BRIDGE_KEY_SUFFIX}"
|
|
120
|
+
|
|
121
|
+
def update_index(id, operation)
|
|
122
|
+
ids = load_index
|
|
123
|
+
case operation
|
|
124
|
+
when :add then ids << id unless ids.include?(id)
|
|
125
|
+
when :remove then ids.delete(id)
|
|
126
|
+
end
|
|
127
|
+
cache_set(index_key, json_dump(ids), ttl: GOAL_TTL)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def load_index
|
|
131
|
+
raw = cache_get(index_key)
|
|
132
|
+
return [] unless raw
|
|
133
|
+
|
|
134
|
+
json_load(raw)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
log.error "[goal_persistence] load_index failed: #{e.message}"
|
|
137
|
+
[]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|