goapauto 0.1.1__py3-none-any.whl
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.
- goapauto/__init__.py +55 -0
- goapauto/models/actions.py +231 -0
- goapauto/models/goal.py +165 -0
- goapauto/models/goap_planner.py +297 -0
- goapauto/models/node.py +213 -0
- goapauto/models/worldstate.py +273 -0
- goapauto/py.typed +0 -0
- goapauto-0.1.1.dist-info/METADATA +292 -0
- goapauto-0.1.1.dist-info/RECORD +10 -0
- goapauto-0.1.1.dist-info/WHEEL +4 -0
goapauto/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GOAP (Goal-Oriented Action Planning) implementation for AI agents.
|
|
3
|
+
|
|
4
|
+
This package provides a flexible framework for creating goal-driven AI agents
|
|
5
|
+
using the GOAP (Goal-Oriented Action Planning) architecture.
|
|
6
|
+
|
|
7
|
+
Key Components:
|
|
8
|
+
- Planner: The main planning engine that finds optimal action sequences
|
|
9
|
+
- Action: Base class for defining actions with preconditions and effects
|
|
10
|
+
- Actions: Collection of available actions
|
|
11
|
+
- Goal: Represents objectives that the agent wants to achieve
|
|
12
|
+
- WorldState: Tracks the current state of the world
|
|
13
|
+
- PlanResult: Type alias for the planning result (plan, status) tuple
|
|
14
|
+
- PlanStats: Statistics about the planning process
|
|
15
|
+
- Plan: Type alias for a list of action names
|
|
16
|
+
|
|
17
|
+
Example usage:
|
|
18
|
+
>>> from goapauto import Planner, Goal, WorldState, Actions
|
|
19
|
+
>>>
|
|
20
|
+
>>> # Define initial state
|
|
21
|
+
>>> world_state = WorldState({'has_key': False, 'door_open': False})
|
|
22
|
+
>>>
|
|
23
|
+
>>> # Create actions
|
|
24
|
+
>>> actions = Actions()
|
|
25
|
+
>>> actions.add_action(
|
|
26
|
+
... name="pickup_key",
|
|
27
|
+
... preconditions={'key_available': True},
|
|
28
|
+
... effects={'has_key': True},
|
|
29
|
+
... cost=1.0
|
|
30
|
+
... )
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Create goal
|
|
33
|
+
>>> goal = Goal("OpenDoor", 1, {'door_open': True})
|
|
34
|
+
>>>
|
|
35
|
+
>>> # Plan and execute
|
|
36
|
+
>>> planner = Planner(actions)
|
|
37
|
+
>>> plan, status = planner.generate_plan(world_state, goal)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from .models.goap_planner import Planner, PlanResult, PlanStats, Plan
|
|
41
|
+
from .models.goal import Goal
|
|
42
|
+
from .models.actions import Action, Actions
|
|
43
|
+
from .models.worldstate import WorldState
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.1"
|
|
46
|
+
__all__ = [
|
|
47
|
+
'Planner',
|
|
48
|
+
'Goal',
|
|
49
|
+
'Action',
|
|
50
|
+
'Actions',
|
|
51
|
+
'WorldState',
|
|
52
|
+
'PlanResult',
|
|
53
|
+
'PlanStats',
|
|
54
|
+
'Plan'
|
|
55
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, List, Optional, Type, TypeVar, Union
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from models.worldstate import WorldState
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
T = TypeVar('T', bound='Action')
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Action:
|
|
13
|
+
"""Represents an action the agent can take.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
name: Unique identifier for the action
|
|
17
|
+
preconditions: Dictionary of state requirements that must be met for the action to be applicable
|
|
18
|
+
effects: Dictionary of state changes that result from applying this action
|
|
19
|
+
cost: The cost of executing this action (used for pathfinding)
|
|
20
|
+
"""
|
|
21
|
+
name: str
|
|
22
|
+
preconditions: Dict[str, Any]
|
|
23
|
+
effects: Dict[str, Any]
|
|
24
|
+
cost: int = 1
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
"""Validate the action after initialization."""
|
|
28
|
+
if not isinstance(self.name, str) or not self.name.strip():
|
|
29
|
+
raise ValueError("Action name must be a non-empty string")
|
|
30
|
+
if not isinstance(self.preconditions, dict):
|
|
31
|
+
raise TypeError("Preconditions must be a dictionary")
|
|
32
|
+
if not isinstance(self.effects, dict):
|
|
33
|
+
raise TypeError("Effects must be a dictionary")
|
|
34
|
+
if not isinstance(self.cost, (int, float)) or self.cost <= 0:
|
|
35
|
+
raise ValueError("Cost must be a positive number")
|
|
36
|
+
|
|
37
|
+
def is_applicable(self, state: Any) -> bool:
|
|
38
|
+
"""Check if this action can be applied to the given state.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
state: The current world state to check against
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
bool: True if all preconditions are met, False otherwise
|
|
45
|
+
"""
|
|
46
|
+
if not hasattr(state, '__dict__'):
|
|
47
|
+
raise TypeError("State must be an object with attributes")
|
|
48
|
+
|
|
49
|
+
logger.debug('Checking applicability of action: %s', self.name)
|
|
50
|
+
try:
|
|
51
|
+
for attr, expected_value in self.preconditions.items():
|
|
52
|
+
if not hasattr(state, attr):
|
|
53
|
+
logger.debug('State missing required attribute: %s', attr)
|
|
54
|
+
return False
|
|
55
|
+
if getattr(state, attr) != expected_value:
|
|
56
|
+
logger.debug('Precondition not met: %s != %s',
|
|
57
|
+
getattr(state, attr), expected_value)
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.error('Error checking action applicability: %s', str(e), exc_info=True)
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def apply(self, state: Any) -> Any:
|
|
65
|
+
"""Apply this action to the current state and return a new state.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
state: The current world state to apply the action to
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A new state with the action's effects applied
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If the action cannot be applied to the current state
|
|
75
|
+
"""
|
|
76
|
+
if not self.is_applicable(state):
|
|
77
|
+
raise ValueError(f"Action {self.name} is not applicable to the current state")
|
|
78
|
+
|
|
79
|
+
logger.info('Applying action: %s', self.name)
|
|
80
|
+
try:
|
|
81
|
+
# Create a deep copy to avoid modifying the original state
|
|
82
|
+
new_state = copy.deepcopy(state)
|
|
83
|
+
|
|
84
|
+
# Apply each effect to the new state
|
|
85
|
+
for attr, value in self.effects.items():
|
|
86
|
+
setattr(new_state, attr, value)
|
|
87
|
+
|
|
88
|
+
logger.debug('New state after %s: %s', self.name, new_state)
|
|
89
|
+
return new_state
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error('Failed to apply action %s: %s', self.name, str(e), exc_info=True)
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
def __str__(self) -> str:
|
|
96
|
+
"""Return a string representation of the action."""
|
|
97
|
+
return (f"{self.__class__.__name__}('{self.name}', "
|
|
98
|
+
f"preconditions={self.preconditions}, "
|
|
99
|
+
f"effects={self.effects}, cost={self.cost})")
|
|
100
|
+
|
|
101
|
+
def __repr__(self) -> str:
|
|
102
|
+
"""Return the canonical string representation of the action."""
|
|
103
|
+
return str(self)
|
|
104
|
+
|
|
105
|
+
class Actions:
|
|
106
|
+
"""Manages a collection of available actions for the GOAP planner.
|
|
107
|
+
|
|
108
|
+
This class provides methods to add, retrieve, and manage actions that can be
|
|
109
|
+
used by the planner to achieve goals. It ensures that all actions are valid
|
|
110
|
+
and provides efficient lookup and iteration capabilities.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self) -> None:
|
|
114
|
+
"""Initialize an empty collection of actions."""
|
|
115
|
+
self._actions: List[Action] = []
|
|
116
|
+
|
|
117
|
+
def add_action(self, name: str, preconditions: Dict[str, Any],
|
|
118
|
+
effects: Dict[str, Any], cost: int = 1) -> None:
|
|
119
|
+
"""Add a single action to the collection.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
name: Unique identifier for the action
|
|
123
|
+
preconditions: Dictionary of state requirements for the action
|
|
124
|
+
effects: Dictionary of state changes caused by the action
|
|
125
|
+
cost: The cost of executing this action (default: 1)
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If an action with the same name already exists
|
|
129
|
+
TypeError: If any parameter has an invalid type
|
|
130
|
+
"""
|
|
131
|
+
if not isinstance(name, str) or not name.strip():
|
|
132
|
+
raise ValueError("Action name must be a non-empty string")
|
|
133
|
+
|
|
134
|
+
if self.get_action(name) is not None:
|
|
135
|
+
raise ValueError(f"Action with name '{name}' already exists")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
action = Action(name, preconditions, effects, cost)
|
|
139
|
+
self._actions.append(action)
|
|
140
|
+
logger.debug('Added action: %s', name)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error('Failed to add action %s: %s', name, str(e))
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
def add_actions(self, action_definitions: List[tuple]) -> None:
|
|
146
|
+
"""Add multiple actions to the collection.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
action_definitions: List of action definitions where each definition is a tuple
|
|
150
|
+
in the format (name: str, preconditions: dict, effects: dict, cost: int)
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
actions = Actions()
|
|
154
|
+
actions.add_actions([
|
|
155
|
+
("open_door", {"door_locked": False}, {"door_open": True}, 1),
|
|
156
|
+
("unlock_door", {"has_key": True}, {"door_locked": False}, 2)
|
|
157
|
+
])
|
|
158
|
+
"""
|
|
159
|
+
if not isinstance(action_definitions, (list, tuple)):
|
|
160
|
+
raise TypeError("action_definitions must be a list or tuple")
|
|
161
|
+
|
|
162
|
+
for i, action_def in enumerate(action_definitions):
|
|
163
|
+
try:
|
|
164
|
+
if not isinstance(action_def, (list, tuple)) or len(action_def) != 4:
|
|
165
|
+
raise ValueError(f"Action definition at index {i} must be a 4-tuple "
|
|
166
|
+
"(name, preconditions, effects, cost)")
|
|
167
|
+
self.add_action(*action_def)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error('Error adding action at index %d: %s', i, str(e))
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
def get_action(self, name: str) -> Optional[Action]:
|
|
173
|
+
"""Retrieve an action by its name.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
name: The name of the action to retrieve
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The Action object if found, None otherwise
|
|
180
|
+
"""
|
|
181
|
+
if not isinstance(name, str):
|
|
182
|
+
raise TypeError("Action name must be a string")
|
|
183
|
+
|
|
184
|
+
for action in self._actions:
|
|
185
|
+
if action.name == name:
|
|
186
|
+
return action
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def get_actions(self) -> List[Action]:
|
|
190
|
+
"""Get a list of all actions in the collection.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
A new list containing all Action objects
|
|
194
|
+
"""
|
|
195
|
+
return self._actions.copy()
|
|
196
|
+
|
|
197
|
+
def clear_actions(self) -> None:
|
|
198
|
+
"""Remove all actions from the collection."""
|
|
199
|
+
self._actions.clear()
|
|
200
|
+
logger.info('Cleared all actions')
|
|
201
|
+
|
|
202
|
+
def filter_actions(self, state: Any) -> List[Action]:
|
|
203
|
+
"""Get a list of all actions that can be applied to the given state.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
state: The state to check against action preconditions
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
A list of applicable Action objects
|
|
210
|
+
"""
|
|
211
|
+
return [action for action in self._actions if action.is_applicable(state)]
|
|
212
|
+
|
|
213
|
+
def __iter__(self) -> 'Actions':
|
|
214
|
+
"""Return an iterator over all actions."""
|
|
215
|
+
return iter(self._actions)
|
|
216
|
+
|
|
217
|
+
def __len__(self) -> int:
|
|
218
|
+
"""Return the number of actions in the collection."""
|
|
219
|
+
return len(self._actions)
|
|
220
|
+
|
|
221
|
+
def __contains__(self, name: str) -> bool:
|
|
222
|
+
"""Check if an action with the given name exists."""
|
|
223
|
+
return self.get_action(name) is not None
|
|
224
|
+
|
|
225
|
+
def __str__(self) -> str:
|
|
226
|
+
"""Return a string representation of the actions collection."""
|
|
227
|
+
return f"Actions({len(self._actions)} actions available)"
|
|
228
|
+
|
|
229
|
+
def __repr__(self) -> str:
|
|
230
|
+
"""Return a detailed string representation of the actions collection."""
|
|
231
|
+
return f"<{self.__class__.__name__} with {len(self._actions)} actions>"
|
goapauto/models/goal.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, Optional, Type, TypeVar, Union
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
T = TypeVar('T', bound='Goal')
|
|
9
|
+
|
|
10
|
+
class Goal:
|
|
11
|
+
"""Represents a goal with a target state and optional priority.
|
|
12
|
+
|
|
13
|
+
A goal defines a desired state that the planner should try to achieve.
|
|
14
|
+
Goals can have different priorities, with lower numbers indicating
|
|
15
|
+
higher priority (e.g., priority 1 is higher than priority 2).
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
target_state: A dictionary mapping state attributes to desired values
|
|
19
|
+
priority: The priority level (lower number = higher priority)
|
|
20
|
+
name: A human-readable name for the goal (defaults to string representation of target_state)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self,
|
|
24
|
+
target_state: Dict[str, Any],
|
|
25
|
+
priority: int = 1,
|
|
26
|
+
name: Optional[str] = None):
|
|
27
|
+
"""Initialize a new Goal instance.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
target_state: Dictionary mapping state attributes to desired values.
|
|
31
|
+
Must be a non-empty dictionary.
|
|
32
|
+
priority: Priority level (lower number = higher priority). Must be >= 1.
|
|
33
|
+
name: Optional name for the goal. If not provided, will use string
|
|
34
|
+
representation of target_state.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If target_state is empty or priority is invalid.
|
|
38
|
+
TypeError: If target_state is not a dictionary or priority is not an integer.
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(target_state, dict):
|
|
41
|
+
raise TypeError(f"target_state must be a dictionary, got {type(target_state)}")
|
|
42
|
+
if not target_state:
|
|
43
|
+
raise ValueError("target_state cannot be empty")
|
|
44
|
+
if not isinstance(priority, int):
|
|
45
|
+
raise TypeError(f"priority must be an integer, got {type(priority)}")
|
|
46
|
+
if priority < 1:
|
|
47
|
+
raise ValueError(f"priority must be >= 1, got {priority}")
|
|
48
|
+
|
|
49
|
+
self._target_state = target_state.copy() # Store a copy to prevent external modification
|
|
50
|
+
self._priority = priority
|
|
51
|
+
self._name = name or str(target_state)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def target_state(self) -> Dict[str, Any]:
|
|
55
|
+
"""Get the target state dictionary (read-only)."""
|
|
56
|
+
return self._target_state.copy()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def priority(self) -> int:
|
|
60
|
+
"""Get the priority of this goal (read-only)."""
|
|
61
|
+
return self._priority
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
"""Get the name of this goal (read-only)."""
|
|
66
|
+
return self._name
|
|
67
|
+
|
|
68
|
+
def is_satisfied(self, world_state: Any) -> bool:
|
|
69
|
+
"""Check if this goal is satisfied by the given world state.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
world_state: The world state to check against. Should be an object with
|
|
73
|
+
attributes matching the target_state keys.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
bool: True if all conditions in target_state are satisfied, False otherwise.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
AttributeError: If world_state is not an object with attributes.
|
|
80
|
+
"""
|
|
81
|
+
if not hasattr(world_state, '__dict__'):
|
|
82
|
+
raise TypeError("world_state must be an object with attributes")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
return all(
|
|
86
|
+
getattr(world_state, attr) == value
|
|
87
|
+
for attr, value in self._target_state.items()
|
|
88
|
+
)
|
|
89
|
+
except AttributeError as e:
|
|
90
|
+
logger.error("Error checking goal satisfaction: %s", str(e))
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def get_unsatisfied_conditions(self, world_state: Any) -> Dict[str, tuple[Any, Any]]:
|
|
94
|
+
"""Get the conditions that are not satisfied in the current world state.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
world_state: The world state to check against.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A dictionary mapping attribute names to tuples of (current_value, desired_value)
|
|
101
|
+
for all conditions that are not satisfied.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
AttributeError: If world_state is not an object with attributes.
|
|
105
|
+
"""
|
|
106
|
+
if not hasattr(world_state, '__dict__'):
|
|
107
|
+
raise TypeError("world_state must be an object with attributes")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
return {
|
|
111
|
+
attr: (getattr(world_state, attr, None), desired_value)
|
|
112
|
+
for attr, desired_value in self._target_state.items()
|
|
113
|
+
if getattr(world_state, attr, None) != desired_value
|
|
114
|
+
}
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error("Error getting unsatisfied conditions: %s", str(e))
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
def copy(self: T) -> T:
|
|
120
|
+
"""Create a deep copy of this goal.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A new Goal instance with the same target_state, priority, and name.
|
|
124
|
+
"""
|
|
125
|
+
return self.__class__(
|
|
126
|
+
target_state=deepcopy(self._target_state),
|
|
127
|
+
priority=self._priority,
|
|
128
|
+
name=self._name
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def __eq__(self, other: object) -> bool:
|
|
132
|
+
"""Check if two goals are equal.
|
|
133
|
+
|
|
134
|
+
Two goals are considered equal if they have the same target_state,
|
|
135
|
+
priority, and name.
|
|
136
|
+
"""
|
|
137
|
+
if not isinstance(other, Goal):
|
|
138
|
+
return NotImplemented
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
self._target_state == other._target_state
|
|
142
|
+
and self._priority == other._priority
|
|
143
|
+
and self._name == other._name
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def __hash__(self) -> int:
|
|
147
|
+
"""Compute a hash value for this goal."""
|
|
148
|
+
return hash((
|
|
149
|
+
frozenset(self._target_state.items()),
|
|
150
|
+
self._priority,
|
|
151
|
+
self._name
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
def __str__(self) -> str:
|
|
155
|
+
"""Return a string representation of the goal."""
|
|
156
|
+
return f"{self.__class__.__name__}({self._name}, priority={self._priority})"
|
|
157
|
+
|
|
158
|
+
def __repr__(self) -> str:
|
|
159
|
+
"""Return a detailed string representation of the goal."""
|
|
160
|
+
return (
|
|
161
|
+
f"<{self.__class__.__name__} "
|
|
162
|
+
f"name='{self._name}', "
|
|
163
|
+
f"priority={self._priority}, "
|
|
164
|
+
f"target_state={self._target_state}>"
|
|
165
|
+
)
|