noesium 0.1.0__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.
- noesium/core/__init__.py +4 -0
- noesium/core/agent/__init__.py +14 -0
- noesium/core/agent/base.py +227 -0
- noesium/core/consts.py +6 -0
- noesium/core/goalith/conflict/conflict.py +104 -0
- noesium/core/goalith/conflict/detector.py +53 -0
- noesium/core/goalith/decomposer/__init__.py +6 -0
- noesium/core/goalith/decomposer/base.py +46 -0
- noesium/core/goalith/decomposer/callable_decomposer.py +65 -0
- noesium/core/goalith/decomposer/llm_decomposer.py +326 -0
- noesium/core/goalith/decomposer/prompts.py +140 -0
- noesium/core/goalith/decomposer/simple_decomposer.py +61 -0
- noesium/core/goalith/errors.py +22 -0
- noesium/core/goalith/goalgraph/graph.py +526 -0
- noesium/core/goalith/goalgraph/node.py +179 -0
- noesium/core/goalith/replanner/base.py +31 -0
- noesium/core/goalith/replanner/replanner.py +36 -0
- noesium/core/goalith/service.py +26 -0
- noesium/core/llm/__init__.py +154 -0
- noesium/core/llm/base.py +152 -0
- noesium/core/llm/litellm.py +528 -0
- noesium/core/llm/llamacpp.py +487 -0
- noesium/core/llm/message.py +184 -0
- noesium/core/llm/ollama.py +459 -0
- noesium/core/llm/openai.py +520 -0
- noesium/core/llm/openrouter.py +89 -0
- noesium/core/llm/prompt.py +551 -0
- noesium/core/memory/__init__.py +11 -0
- noesium/core/memory/base.py +464 -0
- noesium/core/memory/memu/__init__.py +24 -0
- noesium/core/memory/memu/config/__init__.py +26 -0
- noesium/core/memory/memu/config/activity/config.py +46 -0
- noesium/core/memory/memu/config/event/config.py +46 -0
- noesium/core/memory/memu/config/markdown_config.py +241 -0
- noesium/core/memory/memu/config/profile/config.py +48 -0
- noesium/core/memory/memu/llm_adapter.py +129 -0
- noesium/core/memory/memu/memory/__init__.py +31 -0
- noesium/core/memory/memu/memory/actions/__init__.py +40 -0
- noesium/core/memory/memu/memory/actions/add_activity_memory.py +299 -0
- noesium/core/memory/memu/memory/actions/base_action.py +342 -0
- noesium/core/memory/memu/memory/actions/cluster_memories.py +262 -0
- noesium/core/memory/memu/memory/actions/generate_suggestions.py +198 -0
- noesium/core/memory/memu/memory/actions/get_available_categories.py +66 -0
- noesium/core/memory/memu/memory/actions/link_related_memories.py +515 -0
- noesium/core/memory/memu/memory/actions/run_theory_of_mind.py +254 -0
- noesium/core/memory/memu/memory/actions/update_memory_with_suggestions.py +514 -0
- noesium/core/memory/memu/memory/embeddings.py +130 -0
- noesium/core/memory/memu/memory/file_manager.py +306 -0
- noesium/core/memory/memu/memory/memory_agent.py +578 -0
- noesium/core/memory/memu/memory/recall_agent.py +376 -0
- noesium/core/memory/memu/memory_store.py +628 -0
- noesium/core/memory/models.py +149 -0
- noesium/core/msgbus/__init__.py +12 -0
- noesium/core/msgbus/base.py +395 -0
- noesium/core/orchestrix/__init__.py +0 -0
- noesium/core/py.typed +0 -0
- noesium/core/routing/__init__.py +20 -0
- noesium/core/routing/base.py +66 -0
- noesium/core/routing/router.py +241 -0
- noesium/core/routing/strategies/__init__.py +9 -0
- noesium/core/routing/strategies/dynamic_complexity.py +361 -0
- noesium/core/routing/strategies/self_assessment.py +147 -0
- noesium/core/routing/types.py +38 -0
- noesium/core/toolify/__init__.py +39 -0
- noesium/core/toolify/base.py +360 -0
- noesium/core/toolify/config.py +138 -0
- noesium/core/toolify/mcp_integration.py +275 -0
- noesium/core/toolify/registry.py +214 -0
- noesium/core/toolify/toolkits/__init__.py +1 -0
- noesium/core/tracing/__init__.py +37 -0
- noesium/core/tracing/langgraph_hooks.py +308 -0
- noesium/core/tracing/opik_tracing.py +144 -0
- noesium/core/tracing/token_tracker.py +166 -0
- noesium/core/utils/__init__.py +10 -0
- noesium/core/utils/logging.py +172 -0
- noesium/core/utils/statistics.py +12 -0
- noesium/core/utils/typing.py +17 -0
- noesium/core/vector_store/__init__.py +79 -0
- noesium/core/vector_store/base.py +94 -0
- noesium/core/vector_store/pgvector.py +304 -0
- noesium/core/vector_store/weaviate.py +383 -0
- noesium-0.1.0.dist-info/METADATA +525 -0
- noesium-0.1.0.dist-info/RECORD +86 -0
- noesium-0.1.0.dist-info/WHEEL +5 -0
- noesium-0.1.0.dist-info/licenses/LICENSE +21 -0
- noesium-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Graph storage and operations for the GoalithService DAG-based goal management system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
|
|
11
|
+
from noesium.core.goalith.errors import CycleDetectedError, NodeNotFoundError
|
|
12
|
+
from noesium.core.goalith.goalgraph.node import GoalNode, NodeStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GoalGraph:
|
|
16
|
+
"""
|
|
17
|
+
Wraps a NetworkX DiGraph to provide DAG storage and basic graph operations.
|
|
18
|
+
|
|
19
|
+
Handles CRUD operations on nodes and edges, queries for ready nodes,
|
|
20
|
+
and persistence/loading of graph snapshots.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
"""Initialize empty DAG."""
|
|
25
|
+
self._graph = nx.DiGraph()
|
|
26
|
+
self._nodes: Dict[str, GoalNode] = {}
|
|
27
|
+
|
|
28
|
+
# Core CRUD operations
|
|
29
|
+
|
|
30
|
+
def add_node(self, node: GoalNode) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Add a node to the graph.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
node: The GoalNode to add
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If node already exists
|
|
39
|
+
"""
|
|
40
|
+
if node.id in self._nodes:
|
|
41
|
+
raise ValueError(f"Node {node.id} already exists")
|
|
42
|
+
|
|
43
|
+
self._nodes[node.id] = node
|
|
44
|
+
self._graph.add_node(node.id)
|
|
45
|
+
|
|
46
|
+
def get_node(self, node_id: str) -> GoalNode:
|
|
47
|
+
"""
|
|
48
|
+
Get a node by ID.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
node_id: The node ID
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The GoalNode
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
NodeNotFoundError: If node doesn't exist
|
|
58
|
+
"""
|
|
59
|
+
if node_id not in self._nodes:
|
|
60
|
+
raise NodeNotFoundError(f"Node {node_id} not found")
|
|
61
|
+
return self._nodes[node_id]
|
|
62
|
+
|
|
63
|
+
def update_node(self, node: GoalNode) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Update an existing node.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
node: The updated GoalNode
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
NodeNotFoundError: If node doesn't exist
|
|
72
|
+
"""
|
|
73
|
+
if node.id not in self._nodes:
|
|
74
|
+
raise NodeNotFoundError(f"Node {node.id} not found")
|
|
75
|
+
self._nodes[node.id] = node
|
|
76
|
+
|
|
77
|
+
def remove_node(self, node_id: str) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Remove a node and all its edges.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
node_id: The node ID to remove
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
NodeNotFoundError: If node doesn't exist
|
|
86
|
+
"""
|
|
87
|
+
if node_id not in self._nodes:
|
|
88
|
+
raise NodeNotFoundError(f"Node {node_id} not found")
|
|
89
|
+
|
|
90
|
+
# Remove from graph (this also removes all edges)
|
|
91
|
+
self._graph.remove_node(node_id)
|
|
92
|
+
|
|
93
|
+
# Remove from nodes dict
|
|
94
|
+
del self._nodes[node_id]
|
|
95
|
+
|
|
96
|
+
# Update parent/child relationships in remaining nodes
|
|
97
|
+
for node in self._nodes.values():
|
|
98
|
+
node.dependencies.discard(node_id)
|
|
99
|
+
node.children.discard(node_id)
|
|
100
|
+
if node.parent == node_id:
|
|
101
|
+
node.parent = None
|
|
102
|
+
|
|
103
|
+
def add_dependency(self, dependent_id: str, dependency_id: str) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Add a dependency edge between two nodes.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
dependent_id: The node that depends on the prerequisite
|
|
109
|
+
dependency_id: The node that is depended upon (prerequisite)
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
NodeNotFoundError: If either node doesn't exist
|
|
113
|
+
CycleDetectedError: If adding this edge would create a cycle
|
|
114
|
+
"""
|
|
115
|
+
if dependent_id not in self._nodes:
|
|
116
|
+
raise NodeNotFoundError(f"Node {dependent_id} not found")
|
|
117
|
+
if dependency_id not in self._nodes:
|
|
118
|
+
raise NodeNotFoundError(f"Node {dependency_id} not found")
|
|
119
|
+
|
|
120
|
+
# Check if adding this edge would create a cycle before actually adding it
|
|
121
|
+
# Create a temporary graph to test for cycles
|
|
122
|
+
temp_graph = self._graph.copy()
|
|
123
|
+
temp_graph.add_edge(dependency_id, dependent_id)
|
|
124
|
+
|
|
125
|
+
if not nx.is_directed_acyclic_graph(temp_graph):
|
|
126
|
+
raise CycleDetectedError(f"Adding dependency {dependent_id} -> {dependency_id} would create a cycle")
|
|
127
|
+
|
|
128
|
+
# Add edge to graph (dependency -> dependent)
|
|
129
|
+
self._graph.add_edge(dependency_id, dependent_id)
|
|
130
|
+
|
|
131
|
+
# Update node relationships: dependent node gets the dependency
|
|
132
|
+
self._nodes[dependent_id].add_dependency(dependency_id)
|
|
133
|
+
# Update parent-child relationship: dependency becomes parent of dependent
|
|
134
|
+
self._nodes[dependency_id].add_child(dependent_id)
|
|
135
|
+
# Note: We don't set parent here as it conflicts with hierarchical parent-child relationships
|
|
136
|
+
|
|
137
|
+
def remove_dependency(self, dependent_id: str, dependency_id: str) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Remove a dependency edge between two nodes.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
dependent_id: The node that depends on the prerequisite
|
|
143
|
+
dependency_id: The node that is depended upon (prerequisite)
|
|
144
|
+
|
|
145
|
+
Note:
|
|
146
|
+
If the dependency relationship doesn't exist, this method does nothing.
|
|
147
|
+
"""
|
|
148
|
+
if self._graph.has_edge(dependency_id, dependent_id):
|
|
149
|
+
self._graph.remove_edge(dependency_id, dependent_id)
|
|
150
|
+
|
|
151
|
+
if dependent_id in self._nodes:
|
|
152
|
+
self._nodes[dependent_id].remove_dependency(dependency_id)
|
|
153
|
+
|
|
154
|
+
if dependency_id in self._nodes:
|
|
155
|
+
self._nodes[dependency_id].remove_child(dependent_id)
|
|
156
|
+
|
|
157
|
+
def add_parent_child(self, parent_id: str, child_id: str) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Add a parent-child relationship (hierarchical, not dependency).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
parent_id: The parent node ID
|
|
163
|
+
child_id: The child node ID
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
NodeNotFoundError: If either node doesn't exist
|
|
167
|
+
"""
|
|
168
|
+
if parent_id not in self._nodes:
|
|
169
|
+
raise NodeNotFoundError(f"Node {parent_id} not found")
|
|
170
|
+
if child_id not in self._nodes:
|
|
171
|
+
raise NodeNotFoundError(f"Node {child_id} not found")
|
|
172
|
+
|
|
173
|
+
# Update relationships
|
|
174
|
+
self._nodes[parent_id].add_child(child_id)
|
|
175
|
+
self._nodes[child_id].parent = parent_id
|
|
176
|
+
|
|
177
|
+
def remove_parent_child(self, parent_id: str, child_id: str) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Remove a parent-child relationship.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
parent_id: The parent node ID
|
|
183
|
+
child_id: The child node ID
|
|
184
|
+
"""
|
|
185
|
+
if parent_id in self._nodes:
|
|
186
|
+
self._nodes[parent_id].remove_child(child_id)
|
|
187
|
+
if child_id in self._nodes:
|
|
188
|
+
self._nodes[child_id].parent = None
|
|
189
|
+
|
|
190
|
+
# Query operations
|
|
191
|
+
|
|
192
|
+
def get_ready_nodes(self) -> List[GoalNode]:
|
|
193
|
+
"""
|
|
194
|
+
Get all nodes that are ready for execution (all dependencies completed).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of ready GoalNodes
|
|
198
|
+
"""
|
|
199
|
+
ready_nodes = []
|
|
200
|
+
|
|
201
|
+
# First, find all nodes that are dependencies of other nodes
|
|
202
|
+
dependency_nodes = set()
|
|
203
|
+
for node in self._nodes.values():
|
|
204
|
+
dependency_nodes.update(node.dependencies)
|
|
205
|
+
|
|
206
|
+
for node in self._nodes.values():
|
|
207
|
+
# Skip nodes that have children but are not dependencies of any other node
|
|
208
|
+
# These are organizational containers that are not meant to be executed directly
|
|
209
|
+
if node.children and node.id not in dependency_nodes:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
if node.status == NodeStatus.PENDING:
|
|
213
|
+
# Check if all dependencies are completed
|
|
214
|
+
all_deps_completed = True
|
|
215
|
+
for dep_id in node.dependencies:
|
|
216
|
+
if dep_id in self._nodes:
|
|
217
|
+
dep_node = self._nodes[dep_id]
|
|
218
|
+
if dep_node.status != NodeStatus.COMPLETED:
|
|
219
|
+
all_deps_completed = False
|
|
220
|
+
break
|
|
221
|
+
else:
|
|
222
|
+
# Dependency node doesn't exist, consider it completed
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
if all_deps_completed:
|
|
226
|
+
ready_nodes.append(node)
|
|
227
|
+
|
|
228
|
+
return ready_nodes
|
|
229
|
+
|
|
230
|
+
def get_parents(self, node_id: str) -> List[GoalNode]:
|
|
231
|
+
"""
|
|
232
|
+
Get all parent nodes of a given node.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
node_id: The node ID
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
List of parent GoalNode objects (including both hierarchical parents and dependencies)
|
|
239
|
+
"""
|
|
240
|
+
if node_id not in self._nodes:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
node = self._nodes[node_id]
|
|
244
|
+
parents = []
|
|
245
|
+
|
|
246
|
+
# Add hierarchical parent if it exists
|
|
247
|
+
if node.parent and node.parent in self._nodes:
|
|
248
|
+
parents.append(self._nodes[node.parent])
|
|
249
|
+
|
|
250
|
+
# Add dependency parents
|
|
251
|
+
for dep_id in node.dependencies:
|
|
252
|
+
if dep_id in self._nodes:
|
|
253
|
+
parents.append(self._nodes[dep_id])
|
|
254
|
+
|
|
255
|
+
return parents
|
|
256
|
+
|
|
257
|
+
def get_children(self, node_id: str) -> List[GoalNode]:
|
|
258
|
+
"""
|
|
259
|
+
Get all child nodes of a given node.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
node_id: The node ID
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
List of child GoalNode objects
|
|
266
|
+
"""
|
|
267
|
+
if node_id not in self._nodes:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
node = self._nodes[node_id]
|
|
271
|
+
return [self._nodes[child_id] for child_id in node.children if child_id in self._nodes]
|
|
272
|
+
|
|
273
|
+
def list_nodes(self) -> List[GoalNode]:
|
|
274
|
+
"""
|
|
275
|
+
Get list of all node objects.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
List of all GoalNode objects
|
|
279
|
+
"""
|
|
280
|
+
return list(self._nodes.values())
|
|
281
|
+
|
|
282
|
+
def get_nodes_by_status(self, status: NodeStatus) -> List[GoalNode]:
|
|
283
|
+
"""
|
|
284
|
+
Get all nodes with a specific status.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
status: The status to filter by
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
List of GoalNodes with the given status
|
|
291
|
+
"""
|
|
292
|
+
return [node for node in self._nodes.values() if node.status == status and not node.children]
|
|
293
|
+
|
|
294
|
+
def get_dependencies(self, node_id: str) -> List[GoalNode]:
|
|
295
|
+
"""
|
|
296
|
+
Get all dependency nodes of a given node.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
node_id: The dependent node ID
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of dependency GoalNodes
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
NodeNotFoundError: If node doesn't exist
|
|
306
|
+
"""
|
|
307
|
+
if node_id not in self._nodes:
|
|
308
|
+
raise NodeNotFoundError(f"Node {node_id} not found")
|
|
309
|
+
|
|
310
|
+
node = self._nodes[node_id]
|
|
311
|
+
return [self._nodes[dep_id] for dep_id in node.dependencies if dep_id in self._nodes]
|
|
312
|
+
|
|
313
|
+
def get_root_nodes(self) -> List[GoalNode]:
|
|
314
|
+
"""
|
|
315
|
+
Get all root nodes (nodes with no parents).
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
List of root GoalNodes
|
|
319
|
+
"""
|
|
320
|
+
return [node for node in self._nodes.values() if node.parent is None]
|
|
321
|
+
|
|
322
|
+
def get_descendants(self, node_id: str) -> List[GoalNode]:
|
|
323
|
+
"""
|
|
324
|
+
Get all descendant nodes of a given node.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
node_id: The node ID
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
List of descendant GoalNode objects
|
|
331
|
+
"""
|
|
332
|
+
if node_id not in self._nodes:
|
|
333
|
+
return []
|
|
334
|
+
|
|
335
|
+
descendants = []
|
|
336
|
+
visited = set()
|
|
337
|
+
to_process = [node_id]
|
|
338
|
+
|
|
339
|
+
while to_process:
|
|
340
|
+
current_id = to_process.pop(0)
|
|
341
|
+
if current_id in visited:
|
|
342
|
+
continue
|
|
343
|
+
visited.add(current_id)
|
|
344
|
+
|
|
345
|
+
current = self._nodes[current_id]
|
|
346
|
+
for child_id in current.children:
|
|
347
|
+
if child_id in self._nodes and child_id not in visited:
|
|
348
|
+
descendants.append(self._nodes[child_id])
|
|
349
|
+
to_process.append(child_id)
|
|
350
|
+
return descendants
|
|
351
|
+
|
|
352
|
+
def get_ancestors(self, node_id: str) -> List[GoalNode]:
|
|
353
|
+
"""
|
|
354
|
+
Get all ancestor nodes of a given node.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
node_id: The node ID
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
List of ancestor GoalNode objects
|
|
361
|
+
"""
|
|
362
|
+
if node_id not in self._nodes:
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
ancestors = []
|
|
366
|
+
visited = set()
|
|
367
|
+
current_id = node_id
|
|
368
|
+
|
|
369
|
+
# First, traverse parent hierarchy
|
|
370
|
+
while current_id in self._nodes:
|
|
371
|
+
node = self._nodes[current_id]
|
|
372
|
+
if node.parent and node.parent not in visited:
|
|
373
|
+
parent = self._nodes[node.parent]
|
|
374
|
+
ancestors.append(parent)
|
|
375
|
+
visited.add(node.parent)
|
|
376
|
+
current_id = node.parent
|
|
377
|
+
else:
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
# Then, traverse dependency hierarchy
|
|
381
|
+
def add_dependency_ancestors(dep_id: str):
|
|
382
|
+
if dep_id in visited or dep_id not in self._nodes:
|
|
383
|
+
return
|
|
384
|
+
visited.add(dep_id)
|
|
385
|
+
dep_node = self._nodes[dep_id]
|
|
386
|
+
ancestors.append(dep_node)
|
|
387
|
+
# Recursively add ancestors of this dependency
|
|
388
|
+
for parent_dep_id in dep_node.dependencies:
|
|
389
|
+
add_dependency_ancestors(parent_dep_id)
|
|
390
|
+
|
|
391
|
+
# Start recursive traversal from the original node's dependencies
|
|
392
|
+
original_node = self._nodes[node_id]
|
|
393
|
+
for dep_id in original_node.dependencies:
|
|
394
|
+
add_dependency_ancestors(dep_id)
|
|
395
|
+
|
|
396
|
+
return ancestors
|
|
397
|
+
|
|
398
|
+
def is_dag(self) -> bool:
|
|
399
|
+
"""
|
|
400
|
+
Check if the graph is a DAG.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
True if valid DAG, False otherwise
|
|
404
|
+
"""
|
|
405
|
+
return nx.is_directed_acyclic_graph(self._graph)
|
|
406
|
+
|
|
407
|
+
def get_leaf_nodes(self) -> List[GoalNode]:
|
|
408
|
+
"""
|
|
409
|
+
Get all leaf nodes (nodes with no children).
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
List of leaf GoalNodes
|
|
413
|
+
"""
|
|
414
|
+
return [node for node in self._nodes.values() if not node.children]
|
|
415
|
+
|
|
416
|
+
def get_all_nodes(self) -> List[GoalNode]:
|
|
417
|
+
"""
|
|
418
|
+
Get all nodes in the graph.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
List of all GoalNodes
|
|
422
|
+
"""
|
|
423
|
+
return list(self._nodes.values())
|
|
424
|
+
|
|
425
|
+
def has_node(self, node_id: str) -> bool:
|
|
426
|
+
"""
|
|
427
|
+
Check if a node exists in the graph.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
node_id: The node ID to check
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
True if node exists, False otherwise
|
|
434
|
+
"""
|
|
435
|
+
return node_id in self._nodes
|
|
436
|
+
|
|
437
|
+
# Persistence operations
|
|
438
|
+
|
|
439
|
+
def save_to_json(self, filepath: Path) -> None:
|
|
440
|
+
"""
|
|
441
|
+
Save the graph to a JSON file.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
filepath: Path to save the graph
|
|
445
|
+
"""
|
|
446
|
+
# Prepare data for serialization
|
|
447
|
+
data = {
|
|
448
|
+
"nodes": {node_id: node.model_dump(mode="json") for node_id, node in self._nodes.items()},
|
|
449
|
+
"edges": list(self._graph.edges()),
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
# Save to file
|
|
453
|
+
with open(filepath, "w") as f:
|
|
454
|
+
json.dump(data, f, indent=2, default=str)
|
|
455
|
+
|
|
456
|
+
def load_from_json(self, filepath: Path) -> None:
|
|
457
|
+
"""
|
|
458
|
+
Load the graph from a JSON file.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
filepath: Path to load the graph from
|
|
462
|
+
"""
|
|
463
|
+
with open(filepath, "r") as f:
|
|
464
|
+
data = json.load(f)
|
|
465
|
+
|
|
466
|
+
# Clear current graph
|
|
467
|
+
self._graph.clear()
|
|
468
|
+
self._nodes.clear()
|
|
469
|
+
|
|
470
|
+
# Load nodes
|
|
471
|
+
for node_id, node_data in data["nodes"].items():
|
|
472
|
+
# Convert sets back from lists
|
|
473
|
+
if "dependencies" in node_data:
|
|
474
|
+
node_data["dependencies"] = set(node_data["dependencies"])
|
|
475
|
+
if "children" in node_data:
|
|
476
|
+
node_data["children"] = set(node_data["children"])
|
|
477
|
+
if "tags" in node_data:
|
|
478
|
+
node_data["tags"] = set(node_data["tags"])
|
|
479
|
+
|
|
480
|
+
node = GoalNode(**node_data)
|
|
481
|
+
self._nodes[node_id] = node
|
|
482
|
+
self._graph.add_node(node_id)
|
|
483
|
+
|
|
484
|
+
# Load edges
|
|
485
|
+
for source, target in data["edges"]:
|
|
486
|
+
self._graph.add_edge(source, target)
|
|
487
|
+
|
|
488
|
+
def clear(self) -> None:
|
|
489
|
+
"""Clear all nodes and edges from the graph."""
|
|
490
|
+
self._graph.clear()
|
|
491
|
+
self._nodes.clear()
|
|
492
|
+
|
|
493
|
+
def get_graph_stats(self) -> Dict[str, int]:
|
|
494
|
+
"""
|
|
495
|
+
Get statistics about the graph.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Dictionary with graph statistics
|
|
499
|
+
"""
|
|
500
|
+
status_counts = {}
|
|
501
|
+
for status in NodeStatus:
|
|
502
|
+
status_counts[str(status)] = len(self.get_nodes_by_status(status))
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
"total_nodes": len(self._nodes),
|
|
506
|
+
"total_edges": len(self._graph.edges()),
|
|
507
|
+
"ready_nodes": len(self.get_ready_nodes()),
|
|
508
|
+
"root_nodes": len(self.get_root_nodes()),
|
|
509
|
+
"leaf_nodes": len(self.get_leaf_nodes()),
|
|
510
|
+
**status_counts,
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
# Test-compatible methods that return IDs instead of objects
|
|
514
|
+
def get_dependency_ids(self, node_id: str) -> set:
|
|
515
|
+
"""Get dependency IDs for a node (test-compatible version)."""
|
|
516
|
+
if node_id not in self._nodes:
|
|
517
|
+
raise NodeNotFoundError(f"Node {node_id} not found")
|
|
518
|
+
node = self._nodes[node_id]
|
|
519
|
+
return set(node.dependencies)
|
|
520
|
+
|
|
521
|
+
def get_child_ids(self, node_id: str) -> set:
|
|
522
|
+
"""Get child IDs for a node (test-compatible version)."""
|
|
523
|
+
if node_id not in self._nodes:
|
|
524
|
+
raise NodeNotFoundError(f"Node {node_id} not found")
|
|
525
|
+
node = self._nodes[node_id]
|
|
526
|
+
return set(node.children)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Goal Node data model for the GoalithService DAG-based goal management system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, List, Optional, Set
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NodeStatus(str, Enum):
|
|
14
|
+
"""Status of a node in the goal DAG."""
|
|
15
|
+
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
IN_PROGRESS = "in_progress"
|
|
18
|
+
COMPLETED = "completed"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
CANCELLED = "cancelled"
|
|
21
|
+
BLOCKED = "blocked"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GoalNode(BaseModel):
|
|
25
|
+
"""
|
|
26
|
+
Lightweight DTO representing a node in the goal DAG.
|
|
27
|
+
|
|
28
|
+
Contains all metadata needed for goal tracking, decomposition,
|
|
29
|
+
and dependency management.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Node metadata
|
|
33
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
34
|
+
description: str = Field(..., description="Human-readable description of the goal/task")
|
|
35
|
+
status: NodeStatus = Field(default=NodeStatus.PENDING)
|
|
36
|
+
priority: float = Field(default=1.0, description="Priority score (higher = more important)")
|
|
37
|
+
decomposer_name: Optional[str] = Field(default=None, description="Name of decomposer used")
|
|
38
|
+
context: Dict[str, Any] = Field(default_factory=dict, description="Additional context data")
|
|
39
|
+
tags: List[str] = Field(default_factory=list, description="Tags for categorization")
|
|
40
|
+
estimated_effort: Optional[str] = Field(default=None, description="Estimated effort for this task")
|
|
41
|
+
|
|
42
|
+
# Relationships (managed by GraphStore, but stored here for serialization)
|
|
43
|
+
dependencies: Set[str] = Field(default_factory=set, description="IDs of nodes this depends on")
|
|
44
|
+
children: Set[str] = Field(default_factory=set, description="IDs of child nodes")
|
|
45
|
+
parent: Optional[str] = Field(default=None, description="ID of parent node")
|
|
46
|
+
|
|
47
|
+
# Timing
|
|
48
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
49
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
50
|
+
started_at: Optional[datetime] = Field(default=None)
|
|
51
|
+
completed_at: Optional[datetime] = Field(default=None)
|
|
52
|
+
deadline: Optional[datetime] = Field(default=None)
|
|
53
|
+
|
|
54
|
+
# Execution tracking
|
|
55
|
+
assigned_to: Optional[str] = Field(default=None, description="Agent or user assigned to this node")
|
|
56
|
+
retry_count: int = Field(default=0)
|
|
57
|
+
max_retries: int = Field(default=3)
|
|
58
|
+
error_message: Optional[str] = Field(default=None)
|
|
59
|
+
execution_notes: Dict[str, Any] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
class Config:
|
|
62
|
+
"""Pydantic configuration."""
|
|
63
|
+
|
|
64
|
+
use_enum_values = True
|
|
65
|
+
|
|
66
|
+
def is_ready(self) -> bool:
|
|
67
|
+
"""Check if this node is ready for execution (all dependencies completed)."""
|
|
68
|
+
return self.status == NodeStatus.PENDING
|
|
69
|
+
|
|
70
|
+
def is_terminal(self) -> bool:
|
|
71
|
+
"""Check if this node is in a terminal state."""
|
|
72
|
+
return self.status in {
|
|
73
|
+
NodeStatus.COMPLETED,
|
|
74
|
+
NodeStatus.FAILED,
|
|
75
|
+
NodeStatus.CANCELLED,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def can_retry(self) -> bool:
|
|
79
|
+
"""Check if this node can be retried after failure."""
|
|
80
|
+
return self.status == NodeStatus.FAILED and self.retry_count < self.max_retries
|
|
81
|
+
|
|
82
|
+
def mark_started(self) -> None:
|
|
83
|
+
"""Mark this node as started."""
|
|
84
|
+
self.status = NodeStatus.IN_PROGRESS
|
|
85
|
+
self.started_at = datetime.now(timezone.utc)
|
|
86
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
87
|
+
|
|
88
|
+
def mark_completed(self) -> None:
|
|
89
|
+
"""Mark this node as completed."""
|
|
90
|
+
self.status = NodeStatus.COMPLETED
|
|
91
|
+
self.completed_at = datetime.now(timezone.utc)
|
|
92
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
93
|
+
|
|
94
|
+
def mark_failed(self, error_message: Optional[str] = None) -> None:
|
|
95
|
+
"""Mark this node as failed with optional error message."""
|
|
96
|
+
self.status = NodeStatus.FAILED
|
|
97
|
+
self.retry_count += 1
|
|
98
|
+
if error_message:
|
|
99
|
+
self.error_message = error_message
|
|
100
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
101
|
+
|
|
102
|
+
def mark_cancelled(self) -> None:
|
|
103
|
+
"""Mark this node as cancelled."""
|
|
104
|
+
self.status = NodeStatus.CANCELLED
|
|
105
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
106
|
+
|
|
107
|
+
def add_note(self, note: str) -> None:
|
|
108
|
+
"""Add an execution note."""
|
|
109
|
+
self.execution_notes[datetime.now(timezone.utc).isoformat()] = note
|
|
110
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
111
|
+
|
|
112
|
+
def update_context(self, key: str, value: Any) -> None:
|
|
113
|
+
"""Update context with a key-value pair."""
|
|
114
|
+
self.context[key] = value
|
|
115
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
116
|
+
|
|
117
|
+
def add_dependency(self, node_id: str) -> None:
|
|
118
|
+
"""Add a dependency to this node."""
|
|
119
|
+
self.dependencies.add(node_id)
|
|
120
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
121
|
+
|
|
122
|
+
def remove_dependency(self, node_id: str) -> None:
|
|
123
|
+
"""Remove a dependency from this node."""
|
|
124
|
+
self.dependencies.discard(node_id)
|
|
125
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
126
|
+
|
|
127
|
+
def add_child(self, node_id: str) -> None:
|
|
128
|
+
"""Add a child to this node."""
|
|
129
|
+
self.children.add(node_id)
|
|
130
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
131
|
+
|
|
132
|
+
def remove_child(self, node_id: str) -> None:
|
|
133
|
+
"""Remove a child from this node."""
|
|
134
|
+
self.children.discard(node_id)
|
|
135
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
136
|
+
|
|
137
|
+
def __hash__(self) -> int:
|
|
138
|
+
"""Make GoalNode hashable based on its ID."""
|
|
139
|
+
return hash(self.id)
|
|
140
|
+
|
|
141
|
+
def __eq__(self, other: object) -> bool:
|
|
142
|
+
"""
|
|
143
|
+
Compare two GoalNodes for equality based on meaningful content.
|
|
144
|
+
|
|
145
|
+
Excludes timestamp fields (created_at, updated_at, started_at, completed_at)
|
|
146
|
+
as these are automatically generated and not part of the logical state.
|
|
147
|
+
"""
|
|
148
|
+
if not isinstance(other, GoalNode):
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
# Compare all fields except timestamps
|
|
152
|
+
return (
|
|
153
|
+
self.id == other.id
|
|
154
|
+
and self.description == other.description
|
|
155
|
+
and self.status == other.status
|
|
156
|
+
and self.priority == other.priority
|
|
157
|
+
and self.estimated_effort == other.estimated_effort
|
|
158
|
+
and self.dependencies == other.dependencies
|
|
159
|
+
and self.children == other.children
|
|
160
|
+
and self.parent == other.parent
|
|
161
|
+
and self.context == other.context
|
|
162
|
+
and self.tags == other.tags
|
|
163
|
+
and self.decomposer_name == other.decomposer_name
|
|
164
|
+
and self.assigned_to == other.assigned_to
|
|
165
|
+
and self.deadline == other.deadline
|
|
166
|
+
and self.retry_count == other.retry_count
|
|
167
|
+
and self.max_retries == other.max_retries
|
|
168
|
+
and self.error_message == other.error_message
|
|
169
|
+
and self.execution_notes == other.execution_notes
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
173
|
+
"""Convert the GoalNode to a dictionary for serialization."""
|
|
174
|
+
return self.model_dump()
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def from_dict(cls, data: Dict[str, Any]) -> "GoalNode":
|
|
178
|
+
"""Create a GoalNode from a dictionary."""
|
|
179
|
+
return cls(**data)
|