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.
Files changed (86) hide show
  1. noesium/core/__init__.py +4 -0
  2. noesium/core/agent/__init__.py +14 -0
  3. noesium/core/agent/base.py +227 -0
  4. noesium/core/consts.py +6 -0
  5. noesium/core/goalith/conflict/conflict.py +104 -0
  6. noesium/core/goalith/conflict/detector.py +53 -0
  7. noesium/core/goalith/decomposer/__init__.py +6 -0
  8. noesium/core/goalith/decomposer/base.py +46 -0
  9. noesium/core/goalith/decomposer/callable_decomposer.py +65 -0
  10. noesium/core/goalith/decomposer/llm_decomposer.py +326 -0
  11. noesium/core/goalith/decomposer/prompts.py +140 -0
  12. noesium/core/goalith/decomposer/simple_decomposer.py +61 -0
  13. noesium/core/goalith/errors.py +22 -0
  14. noesium/core/goalith/goalgraph/graph.py +526 -0
  15. noesium/core/goalith/goalgraph/node.py +179 -0
  16. noesium/core/goalith/replanner/base.py +31 -0
  17. noesium/core/goalith/replanner/replanner.py +36 -0
  18. noesium/core/goalith/service.py +26 -0
  19. noesium/core/llm/__init__.py +154 -0
  20. noesium/core/llm/base.py +152 -0
  21. noesium/core/llm/litellm.py +528 -0
  22. noesium/core/llm/llamacpp.py +487 -0
  23. noesium/core/llm/message.py +184 -0
  24. noesium/core/llm/ollama.py +459 -0
  25. noesium/core/llm/openai.py +520 -0
  26. noesium/core/llm/openrouter.py +89 -0
  27. noesium/core/llm/prompt.py +551 -0
  28. noesium/core/memory/__init__.py +11 -0
  29. noesium/core/memory/base.py +464 -0
  30. noesium/core/memory/memu/__init__.py +24 -0
  31. noesium/core/memory/memu/config/__init__.py +26 -0
  32. noesium/core/memory/memu/config/activity/config.py +46 -0
  33. noesium/core/memory/memu/config/event/config.py +46 -0
  34. noesium/core/memory/memu/config/markdown_config.py +241 -0
  35. noesium/core/memory/memu/config/profile/config.py +48 -0
  36. noesium/core/memory/memu/llm_adapter.py +129 -0
  37. noesium/core/memory/memu/memory/__init__.py +31 -0
  38. noesium/core/memory/memu/memory/actions/__init__.py +40 -0
  39. noesium/core/memory/memu/memory/actions/add_activity_memory.py +299 -0
  40. noesium/core/memory/memu/memory/actions/base_action.py +342 -0
  41. noesium/core/memory/memu/memory/actions/cluster_memories.py +262 -0
  42. noesium/core/memory/memu/memory/actions/generate_suggestions.py +198 -0
  43. noesium/core/memory/memu/memory/actions/get_available_categories.py +66 -0
  44. noesium/core/memory/memu/memory/actions/link_related_memories.py +515 -0
  45. noesium/core/memory/memu/memory/actions/run_theory_of_mind.py +254 -0
  46. noesium/core/memory/memu/memory/actions/update_memory_with_suggestions.py +514 -0
  47. noesium/core/memory/memu/memory/embeddings.py +130 -0
  48. noesium/core/memory/memu/memory/file_manager.py +306 -0
  49. noesium/core/memory/memu/memory/memory_agent.py +578 -0
  50. noesium/core/memory/memu/memory/recall_agent.py +376 -0
  51. noesium/core/memory/memu/memory_store.py +628 -0
  52. noesium/core/memory/models.py +149 -0
  53. noesium/core/msgbus/__init__.py +12 -0
  54. noesium/core/msgbus/base.py +395 -0
  55. noesium/core/orchestrix/__init__.py +0 -0
  56. noesium/core/py.typed +0 -0
  57. noesium/core/routing/__init__.py +20 -0
  58. noesium/core/routing/base.py +66 -0
  59. noesium/core/routing/router.py +241 -0
  60. noesium/core/routing/strategies/__init__.py +9 -0
  61. noesium/core/routing/strategies/dynamic_complexity.py +361 -0
  62. noesium/core/routing/strategies/self_assessment.py +147 -0
  63. noesium/core/routing/types.py +38 -0
  64. noesium/core/toolify/__init__.py +39 -0
  65. noesium/core/toolify/base.py +360 -0
  66. noesium/core/toolify/config.py +138 -0
  67. noesium/core/toolify/mcp_integration.py +275 -0
  68. noesium/core/toolify/registry.py +214 -0
  69. noesium/core/toolify/toolkits/__init__.py +1 -0
  70. noesium/core/tracing/__init__.py +37 -0
  71. noesium/core/tracing/langgraph_hooks.py +308 -0
  72. noesium/core/tracing/opik_tracing.py +144 -0
  73. noesium/core/tracing/token_tracker.py +166 -0
  74. noesium/core/utils/__init__.py +10 -0
  75. noesium/core/utils/logging.py +172 -0
  76. noesium/core/utils/statistics.py +12 -0
  77. noesium/core/utils/typing.py +17 -0
  78. noesium/core/vector_store/__init__.py +79 -0
  79. noesium/core/vector_store/base.py +94 -0
  80. noesium/core/vector_store/pgvector.py +304 -0
  81. noesium/core/vector_store/weaviate.py +383 -0
  82. noesium-0.1.0.dist-info/METADATA +525 -0
  83. noesium-0.1.0.dist-info/RECORD +86 -0
  84. noesium-0.1.0.dist-info/WHEEL +5 -0
  85. noesium-0.1.0.dist-info/licenses/LICENSE +21 -0
  86. 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)