lex-planning 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5d3a843a1b4b2613a45996d9c12206f3a0f9387fb55a53688a09ccc254503c09
4
+ data.tar.gz: bc73bac9c97162ce4e471daa009242797d64b3a939de353f8a1395c96e04904f
5
+ SHA512:
6
+ metadata.gz: 427f9cbf65397f9520b22f883bd0e406e9a26b4ecc483b11ae5ccfdb3205151bef3a5611da724b0fdb1ef204861d7b78defd4f5e1c924c1e7ccce49daa9c9e40
7
+ data.tar.gz: c31c83212f3d078206d6fcd714571c40b7313b4aecf534acc902677e4d566207a1f6fd238defdf3f9a103376891ea9d3fd6e4a5960acca308b270e5ae7d85d78
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/planning/helpers/constants'
4
+ require 'legion/extensions/planning/helpers/plan_step'
5
+ require 'legion/extensions/planning/helpers/plan'
6
+ require 'legion/extensions/planning/helpers/plan_store'
7
+ require 'legion/extensions/planning/runners/planning'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Planning
12
+ class Client
13
+ include Runners::Planning
14
+
15
+ attr_reader :plan_store
16
+
17
+ def initialize(plan_store: nil, **)
18
+ @plan_store = plan_store || Helpers::PlanStore.new
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Planning
6
+ module Helpers
7
+ module Constants
8
+ PLAN_STATUSES = %i[forming active executing completed failed abandoned].freeze
9
+ STEP_STATUSES = %i[pending active completed failed skipped blocked].freeze
10
+ PRIORITIES = { critical: 1.0, high: 0.75, medium: 0.5, low: 0.25 }.freeze
11
+ MAX_PLANS = 50
12
+ MAX_STEPS_PER_PLAN = 100
13
+ MAX_CONTINGENCIES = 20
14
+ REPLAN_LIMIT = 3
15
+ STALE_PLAN_THRESHOLD = 3600
16
+ COMPLETION_THRESHOLD = 0.95
17
+ PLANNING_HORIZON = 10
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Planning
8
+ module Helpers
9
+ class Plan
10
+ attr_reader :id, :goal, :description, :priority, :steps, :contingencies,
11
+ :parent_plan_id, :created_at, :replan_count
12
+ attr_accessor :status, :updated_at
13
+
14
+ def initialize(goal:, steps: [], priority: :medium, contingencies: {}, parent_plan_id: nil, description: nil, **)
15
+ @id = SecureRandom.uuid
16
+ @goal = goal
17
+ @description = description
18
+ @priority = priority
19
+ @status = :forming
20
+ @steps = steps.dup
21
+ @contingencies = contingencies.dup
22
+ @parent_plan_id = parent_plan_id
23
+ @created_at = Time.now.utc
24
+ @updated_at = Time.now.utc
25
+ @replan_count = 0
26
+ end
27
+
28
+ def progress
29
+ return 0.0 if @steps.empty?
30
+
31
+ done = @steps.count { |s| %i[completed skipped].include?(s.status) }
32
+ done.to_f / @steps.size
33
+ end
34
+
35
+ def active_step
36
+ @steps.find { |s| s.status == :active }
37
+ end
38
+
39
+ def completed_step_ids
40
+ @steps.select { |s| %i[completed skipped].include?(s.status) }.map(&:id)
41
+ end
42
+
43
+ def advance!(step_id, result: nil)
44
+ step = @steps.find { |s| s.id == step_id }
45
+ return nil unless step
46
+
47
+ step.complete!(result: result)
48
+ @updated_at = Time.now.utc
49
+ @status = :completed if progress >= Constants::COMPLETION_THRESHOLD
50
+ step
51
+ end
52
+
53
+ def fail_step!(step_id, reason: nil)
54
+ step = @steps.find { |s| s.id == step_id }
55
+ return nil unless step
56
+
57
+ step.fail!(reason: reason)
58
+ @updated_at = Time.now.utc
59
+ step
60
+ end
61
+
62
+ def complete?
63
+ @status == :completed || progress >= Constants::COMPLETION_THRESHOLD
64
+ end
65
+
66
+ def failed?
67
+ @status == :failed
68
+ end
69
+
70
+ def stale?
71
+ (Time.now.utc - @updated_at) > Constants::STALE_PLAN_THRESHOLD
72
+ end
73
+
74
+ def increment_replan!
75
+ @replan_count += 1
76
+ @updated_at = Time.now.utc
77
+ end
78
+
79
+ def replace_remaining_steps!(new_steps)
80
+ pending = @steps.reject { |s| %i[completed skipped failed].include?(s.status) }
81
+ pending.each { |s| @steps.delete(s) }
82
+ new_steps.each { |s| @steps << s }
83
+ @updated_at = Time.now.utc
84
+ end
85
+
86
+ def to_h
87
+ {
88
+ id: @id,
89
+ goal: @goal,
90
+ description: @description,
91
+ priority: @priority,
92
+ status: @status,
93
+ progress: progress.round(4),
94
+ steps: @steps.map(&:to_h),
95
+ contingencies: @contingencies,
96
+ parent_plan_id: @parent_plan_id,
97
+ replan_count: @replan_count,
98
+ created_at: @created_at,
99
+ updated_at: @updated_at
100
+ }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Planning
8
+ module Helpers
9
+ class PlanStep
10
+ attr_reader :id, :action, :description, :depends_on, :estimated_effort,
11
+ :started_at, :completed_at, :result
12
+ attr_accessor :status, :actual_effort
13
+
14
+ def initialize(action:, description: nil, depends_on: [], estimated_effort: 1, **)
15
+ @id = SecureRandom.uuid
16
+ @action = action
17
+ @description = description
18
+ @status = :pending
19
+ @depends_on = Array(depends_on).dup
20
+ @estimated_effort = estimated_effort
21
+ @actual_effort = nil
22
+ @result = nil
23
+ @started_at = nil
24
+ @completed_at = nil
25
+ end
26
+
27
+ def ready?(completed_step_ids)
28
+ return false if %i[completed failed skipped].include?(@status)
29
+
30
+ @depends_on.all? { |dep_id| completed_step_ids.include?(dep_id) }
31
+ end
32
+
33
+ def duration
34
+ return nil unless @started_at && @completed_at
35
+
36
+ @completed_at - @started_at
37
+ end
38
+
39
+ def blocked?
40
+ @status == :blocked
41
+ end
42
+
43
+ def complete!(result: nil)
44
+ @status = :completed
45
+ @result = result
46
+ @completed_at = Time.now.utc
47
+ end
48
+
49
+ def fail!(reason: nil)
50
+ @status = :failed
51
+ @result = { reason: reason }
52
+ @completed_at = Time.now.utc
53
+ end
54
+
55
+ def start!
56
+ @status = :active
57
+ @started_at = Time.now.utc
58
+ end
59
+
60
+ def to_h
61
+ {
62
+ id: @id,
63
+ action: @action,
64
+ description: @description,
65
+ status: @status,
66
+ depends_on: @depends_on,
67
+ estimated_effort: @estimated_effort,
68
+ actual_effort: @actual_effort,
69
+ result: @result,
70
+ started_at: @started_at,
71
+ completed_at: @completed_at
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Planning
6
+ module Helpers
7
+ class PlanStore
8
+ attr_reader :plans, :plan_history
9
+
10
+ def initialize
11
+ @plans = {}
12
+ @plan_history = []
13
+ end
14
+
15
+ def create_plan(goal:, steps: [], priority: :medium, contingencies: {}, parent_plan_id: nil, **)
16
+ step_objects = steps.map do |s|
17
+ s.is_a?(PlanStep) ? s : PlanStep.new(**s)
18
+ end
19
+ plan = Plan.new(
20
+ goal: goal,
21
+ steps: step_objects,
22
+ priority: priority,
23
+ contingencies: contingencies,
24
+ parent_plan_id: parent_plan_id
25
+ )
26
+ plan.status = :active
27
+ @plans[plan.id] = plan
28
+ trim_history
29
+ plan
30
+ end
31
+
32
+ def find_plan(plan_id)
33
+ @plans[plan_id]
34
+ end
35
+
36
+ def advance_step(plan_id:, step_id:, result: {})
37
+ plan = @plans[plan_id]
38
+ return { error: 'plan not found' } unless plan
39
+
40
+ step = plan.advance!(step_id, result: result)
41
+ return { error: 'step not found' } unless step
42
+
43
+ if plan.complete?
44
+ plan.status = :completed
45
+ archive_plan(plan_id)
46
+ end
47
+
48
+ { success: true, step_id: step_id, plan_progress: plan.progress.round(4), plan_status: plan.status }
49
+ end
50
+
51
+ def fail_step(plan_id:, step_id:, reason: nil)
52
+ plan = @plans[plan_id]
53
+ return { error: 'plan not found' } unless plan
54
+
55
+ step = plan.fail_step!(step_id, reason: reason)
56
+ return { error: 'step not found' } unless step
57
+
58
+ contingency = plan.contingencies[step.action] || plan.contingencies[step_id]
59
+ { success: true, step_id: step_id, contingency: contingency, plan_status: plan.status }
60
+ end
61
+
62
+ def replan(plan_id:, new_steps:, reason: nil)
63
+ plan = @plans[plan_id]
64
+ return { error: 'plan not found' } unless plan
65
+ return { error: 'replan limit reached' } if plan.replan_count >= Constants::REPLAN_LIMIT
66
+
67
+ step_objects = new_steps.map do |s|
68
+ s.is_a?(PlanStep) ? s : PlanStep.new(**s)
69
+ end
70
+ plan.increment_replan!
71
+ plan.replace_remaining_steps!(step_objects)
72
+ plan.status = :active
73
+
74
+ { success: true, plan_id: plan_id, replan_count: plan.replan_count, reason: reason }
75
+ end
76
+
77
+ def abandon_plan(plan_id:, reason: nil)
78
+ plan = @plans[plan_id]
79
+ return { error: 'plan not found' } unless plan
80
+
81
+ plan.status = :abandoned
82
+ archive_plan(plan_id)
83
+ { success: true, plan_id: plan_id, reason: reason }
84
+ end
85
+
86
+ def active_plans
87
+ @plans.values.select { |p| %i[active executing].include?(p.status) }
88
+ end
89
+
90
+ def completed_plans(limit: 10)
91
+ @plan_history.last(limit)
92
+ end
93
+
94
+ def plan_progress(plan_id)
95
+ plan = @plans[plan_id] || @plan_history.find { |p| p.id == plan_id }
96
+ return { error: 'plan not found' } unless plan
97
+
98
+ ready_ids = plan.completed_step_ids
99
+ {
100
+ plan_id: plan_id,
101
+ goal: plan.goal,
102
+ status: plan.status,
103
+ progress: plan.progress.round(4),
104
+ total_steps: plan.steps.size,
105
+ completed: plan.steps.count { |s| s.status == :completed },
106
+ failed: plan.steps.count { |s| s.status == :failed },
107
+ pending: plan.steps.count { |s| s.status == :pending },
108
+ blocked: plan.steps.count { |s| s.status == :blocked },
109
+ next_ready: plan.steps.select { |s| s.status == :pending && s.ready?(ready_ids) }.map(&:id),
110
+ replan_count: plan.replan_count,
111
+ stale: plan.stale?
112
+ }
113
+ end
114
+
115
+ def plans_by_priority
116
+ priority_value = ->(p) { Constants::PRIORITIES.fetch(p.priority, 0.5) }
117
+ @plans.values.sort_by { |p| -priority_value.call(p) }
118
+ end
119
+
120
+ def stats
121
+ all = @plans.values
122
+ total = all.size + @plan_history.size
123
+ by_status = Constants::PLAN_STATUSES.to_h { |s| [s, all.count { |p| p.status == s }] }
124
+ replanned = all.count { |p| p.replan_count.positive? }
125
+ avg_progress = all.empty? ? 0.0 : (all.sum(&:progress) / all.size).round(4)
126
+ replan_rate = total.zero? ? 0.0 : (replanned.to_f / [all.size, 1].max).round(4)
127
+
128
+ {
129
+ active_plan_count: all.size,
130
+ archived_plan_count: @plan_history.size,
131
+ by_status: by_status,
132
+ avg_progress: avg_progress,
133
+ replan_rate: replan_rate
134
+ }
135
+ end
136
+
137
+ def trim_history
138
+ return unless @plans.size > Constants::MAX_PLANS
139
+
140
+ oldest_id = @plans.keys.min_by { |k| @plans[k].created_at }
141
+ archive_plan(oldest_id)
142
+ end
143
+
144
+ private
145
+
146
+ def archive_plan(plan_id)
147
+ plan = @plans.delete(plan_id)
148
+ return unless plan
149
+
150
+ @plan_history << plan
151
+ @plan_history.shift while @plan_history.size > Constants::MAX_PLANS
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Planning
6
+ module Runners
7
+ module Planning
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def update_planning(tick_results: {}, **)
12
+ check_stale_plans
13
+ advance_ready_steps(tick_results)
14
+
15
+ active = plan_store.active_plans
16
+ Legion::Logging.debug "[planning] active_plans=#{active.size} stats=#{plan_store.stats[:by_status]}"
17
+
18
+ {
19
+ active_plans: active.size,
20
+ total_steps: active.sum { |p| p.steps.size },
21
+ stale_checked: true
22
+ }
23
+ end
24
+
25
+ def create_plan(goal:, steps: [], priority: :medium, contingencies: {}, parent_plan_id: nil, **)
26
+ steps_data = steps.map { |s| s.is_a?(Hash) ? s : s.to_h }
27
+ plan = plan_store.create_plan(
28
+ goal: goal,
29
+ steps: steps_data,
30
+ priority: priority,
31
+ contingencies: contingencies,
32
+ parent_plan_id: parent_plan_id
33
+ )
34
+ Legion::Logging.info "[planning] created plan=#{plan.id} goal=#{goal} steps=#{plan.steps.size}"
35
+ { success: true, plan_id: plan.id, goal: goal, steps: plan.steps.size }
36
+ end
37
+
38
+ def advance_plan(plan_id:, step_id:, result: {}, **)
39
+ outcome = plan_store.advance_step(plan_id: plan_id, step_id: step_id, result: result)
40
+ Legion::Logging.debug "[planning] advance plan=#{plan_id} step=#{step_id} outcome=#{outcome[:plan_status]}"
41
+ outcome
42
+ end
43
+
44
+ def fail_plan_step(plan_id:, step_id:, reason: nil, **)
45
+ outcome = plan_store.fail_step(plan_id: plan_id, step_id: step_id, reason: reason)
46
+ Legion::Logging.warn "[planning] step failed plan=#{plan_id} step=#{step_id} reason=#{reason}"
47
+ outcome
48
+ end
49
+
50
+ def replan(plan_id:, new_steps: [], reason: nil, **)
51
+ steps_data = new_steps.map { |s| s.is_a?(Hash) ? s : s.to_h }
52
+ outcome = plan_store.replan(plan_id: plan_id, new_steps: steps_data, reason: reason)
53
+ if outcome[:success]
54
+ Legion::Logging.info "[planning] replan plan=#{plan_id} count=#{outcome[:replan_count]} reason=#{reason}"
55
+ else
56
+ Legion::Logging.warn "[planning] replan rejected plan=#{plan_id} reason=#{outcome[:error]}"
57
+ end
58
+ outcome
59
+ end
60
+
61
+ def abandon_plan(plan_id:, reason: nil, **)
62
+ outcome = plan_store.abandon_plan(plan_id: plan_id, reason: reason)
63
+ Legion::Logging.info "[planning] abandoned plan=#{plan_id} reason=#{reason}"
64
+ outcome
65
+ end
66
+
67
+ def plan_status(plan_id:, **)
68
+ outcome = plan_store.plan_progress(plan_id)
69
+ Legion::Logging.debug "[planning] status plan=#{plan_id} progress=#{outcome[:progress]}"
70
+ outcome
71
+ end
72
+
73
+ def active_plans(**)
74
+ plans = plan_store.active_plans
75
+ Legion::Logging.debug "[planning] active_plans=#{plans.size}"
76
+ {
77
+ plans: plans.map(&:to_h),
78
+ count: plans.size
79
+ }
80
+ end
81
+
82
+ def planning_stats(**)
83
+ stats = plan_store.stats
84
+ Legion::Logging.debug "[planning] stats=#{stats}"
85
+ stats
86
+ end
87
+
88
+ private
89
+
90
+ def plan_store
91
+ @plan_store ||= Helpers::PlanStore.new
92
+ end
93
+
94
+ def check_stale_plans
95
+ plan_store.active_plans.each do |plan|
96
+ Legion::Logging.warn "[planning] stale plan detected plan=#{plan.id} goal=#{plan.goal}" if plan.stale?
97
+ end
98
+ end
99
+
100
+ def advance_ready_steps(tick_results)
101
+ completed_actions = tick_results.dig(:action_selection, :completed_actions)
102
+ return unless completed_actions.is_a?(Array)
103
+
104
+ plan_store.active_plans.each do |plan|
105
+ ready_ids = plan.completed_step_ids
106
+ plan.steps.each do |step|
107
+ next unless step.status == :pending && step.ready?(ready_ids)
108
+ next unless completed_actions.any? { |a| a[:action] == step.action }
109
+
110
+ result = completed_actions.find { |a| a[:action] == step.action }
111
+ plan_store.advance_step(plan_id: plan.id, step_id: step.id, result: result)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Planning
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/planning/version'
4
+ require 'legion/extensions/planning/helpers/constants'
5
+ require 'legion/extensions/planning/helpers/plan_step'
6
+ require 'legion/extensions/planning/helpers/plan'
7
+ require 'legion/extensions/planning/helpers/plan_store'
8
+ require 'legion/extensions/planning/runners/planning'
9
+ require 'legion/extensions/planning/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Planning
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core)
15
+ end
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-planning
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Iverson
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: 'Models the prefrontal cortex planning function: plan trees, progress
27
+ tracking, contingencies, re-planning'
28
+ email:
29
+ - matt@legionIO.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/legion/extensions/planning.rb
35
+ - lib/legion/extensions/planning/client.rb
36
+ - lib/legion/extensions/planning/helpers/constants.rb
37
+ - lib/legion/extensions/planning/helpers/plan.rb
38
+ - lib/legion/extensions/planning/helpers/plan_step.rb
39
+ - lib/legion/extensions/planning/helpers/plan_store.rb
40
+ - lib/legion/extensions/planning/runners/planning.rb
41
+ - lib/legion/extensions/planning/version.rb
42
+ homepage: https://github.com/LegionIO/lex-planning
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ rubygems_mfa_required: 'true'
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.4'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.6.9
62
+ specification_version: 4
63
+ summary: Hierarchical goal decomposition and plan formation for LegionIO cognitive
64
+ agents
65
+ test_files: []