versionhq 1.1.13.0__py3-none-any.whl → 1.2.0.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.
@@ -0,0 +1,500 @@
1
+ import enum
2
+ import uuid
3
+ from abc import ABC
4
+ from typing import List, Any, Optional, Callable, Dict, Type, Tuple
5
+
6
+ from pydantic import BaseModel, InstanceOf, Field, UUID4, PrivateAttr, field_validator, model_validator
7
+ from pydantic_core import PydanticCustomError
8
+
9
+ from versionhq.task.model import Task, TaskOutput
10
+ from versionhq.agent.model import Agent
11
+ from versionhq._utils.logger import Logger
12
+
13
+ try:
14
+ import networkx as ntx
15
+ except ImportError:
16
+ try:
17
+ import os
18
+ os.system("uv add networkx --optional networkx")
19
+ import networkx as ntx
20
+ except:
21
+ raise ImportError("networkx is not installed. Please install it with: uv add networkx --optional networkx")
22
+
23
+
24
+ try:
25
+ import matplotlib.pyplot as plt
26
+ except ImportError:
27
+ try:
28
+ import os
29
+ os.system("uv add matplotlib --optional matplotlib")
30
+ import matplotlib.pyplot as plt
31
+ except:
32
+ raise ImportError("matplotlib is not installed. Please install it with: uv add matplotlib --optional matplotlib")
33
+
34
+ import networkx as nx
35
+ import matplotlib.pyplot as plt
36
+
37
+
38
+
39
+ class TaskStatus(enum.Enum):
40
+ """
41
+ Enum to track the task execution status
42
+ """
43
+ NOT_STARTED = 1
44
+ IN_PROGRESS = 2
45
+ WAITING = 3 # waiting for its dependant tasks to complete. resumption set as AUTO.
46
+ COMPLETED = 4
47
+ DELAYED = 5 # task in progress - but taking longer than expected duration
48
+ ON_HOLD = 6 # intentionally paused due to external factors and decisions. resumption set as DECISION.
49
+ ERROR = 7 # tried task execute but returned error. resupmtion follows edge weights and agent settings
50
+
51
+
52
+ class DependencyType(enum.Enum):
53
+ """
54
+ Concise enumeration of the edge type.
55
+ """
56
+ FINISH_TO_START = "FS" # Task B starts after Task A finishes
57
+ START_TO_START = "SS" # Task B starts when Task A starts
58
+ FINISH_TO_FINISH = "FF" # Task B finishes when Task A finishes
59
+ START_TO_FINISH = "SF" # Task B finishes when Task A starts
60
+
61
+
62
+
63
+ class TriggerEvent(enum.Enum):
64
+ """
65
+ Concise enumeration of key trigger events for task execution.
66
+ """
67
+ IMMEDIATE = 0 # execute immediately
68
+ DEPENDENCIES_MET = 1 # All/required dependencies are satisfied
69
+ RESOURCES_AVAILABLE = 2 # Necessary resources are available
70
+ SCHEDULED_TIME = 3 # Scheduled start time or time window reached
71
+ EXTERNAL_EVENT = 4 # Triggered by an external event/message
72
+ DATA_AVAILABLE = 5 # Required data is available both internal/external
73
+ APPROVAL_RECEIVED = 6 # Necessary approvals have been granted
74
+ STATUS_CHANGED = 7 # Relevant task/system status has changed
75
+ RULE_MET = 8 # A predefined rule or condition has been met
76
+ MANUAL_TRIGGER = 9 # Manually initiated by a user
77
+ ERROR_HANDLED = 10 # A previous error/exception has been handled
78
+
79
+
80
+
81
+ class Node(BaseModel):
82
+ """
83
+ A class to store a node object.
84
+ """
85
+ id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
86
+ task: InstanceOf[Task] = Field(default=None)
87
+ # trigger_event: TriggerEvent = Field(default=TriggerEvent.IMMEDIATE, description="store trigger event for starting the task execution")
88
+ in_degree_nodes: List[Any] = Field(default=None, description="list of Node objects")
89
+ out_degree_nodes: List[Any] = Field(default=None, description="list of Node objects")
90
+ assigned_to: InstanceOf[Agent] = Field(default=None)
91
+ status: TaskStatus = Field(default=TaskStatus.NOT_STARTED)
92
+
93
+
94
+ @field_validator("id", mode="before")
95
+ @classmethod
96
+ def _deny_id(cls, v: Optional[UUID4]) -> None:
97
+ if v:
98
+ raise PydanticCustomError("may_not_set_field", "This field is not to be set by the user.", {})
99
+
100
+ def is_independent(self) -> bool:
101
+ return not self.in_degree_nodes and not self.out_degree_nodes
102
+
103
+ @property
104
+ def in_degrees(self) -> int:
105
+ return len(self.in_degree_nodes) if self.in_degree_nodes else 0
106
+
107
+ @property
108
+ def out_degrees(self) -> int:
109
+ return len(self.out_degree_nodes) if self.out_degree_nodes else 0
110
+
111
+ @property
112
+ def degrees(self) -> int:
113
+ return self.in_degrees + self.out_degrees
114
+
115
+ @property
116
+ def identifier(self) -> str:
117
+ """Unique identifier for the node"""
118
+ return f"{str(self.id)}"
119
+
120
+ def handle_task_execution(self, agent: Agent = None, context: str = None) -> TaskOutput | None:
121
+ """
122
+ Start task execution and update status accordingly.
123
+ """
124
+
125
+ self.status = TaskStatus.IN_PROGRESS
126
+
127
+ if not self.task:
128
+ Logger(verbose=True).log(level="error", message="Missing a task to execute. We'll return None.", color="red")
129
+ self.status = TaskStatus.ERROR
130
+ return None
131
+
132
+ res = self.task.execute(agent=agent, context=context)
133
+ self.status = TaskStatus.COMPLETED if res else TaskStatus.ERROR
134
+ return res
135
+
136
+ def __str__(self):
137
+ return self.identifier
138
+
139
+
140
+
141
+ class Edge(BaseModel):
142
+ """
143
+ A class to store an edge object that connects source and target nodes.
144
+ """
145
+ source: Node = Field(default=None)
146
+ target: Node = Field(default=None)
147
+
148
+ description: Optional[str] = Field(default=None)
149
+ weight: Optional[float | int] = Field(default=1, description="est. duration for the task execution or respective weight of the target node (1 low - 10 high priority)")
150
+
151
+ dependency_type: DependencyType = Field(default=DependencyType.FINISH_TO_START)
152
+ required: bool = Field(default=True, description="whether to consider the source's status")
153
+ condition: Optional[Callable] = Field(default=None, description="conditional function to start executing the dependency")
154
+ condition_kwargs: Optional[Dict[str, Any]] = Field(default_factory=dict)
155
+
156
+ lag: Optional[float | int] = Field(default=None, description="lag time (sec) from the dependency met to the task execution")
157
+ data_transfer: bool = Field(default=True, description="whether the data transfer is required. by default transfer plane text output from in-degree nodes as context")
158
+
159
+
160
+ def dependency_met(self) -> bool:
161
+ """
162
+ Defines if the dependency is ready to execute:
163
+
164
+ required - condition - Dependency Met? - Dependent Task Can Start?
165
+ True Not Given Predecessor task finished Yes
166
+ True Given Predecessor task finished and condition True Yes
167
+ False Not Given Always (regardless of predecessor status) Yes
168
+ False Given Condition True (predecessor status irrelevant) Yes
169
+ """
170
+
171
+ if not self.required:
172
+ return self.condition(**self.condition_kwargs) if self.condition else True
173
+
174
+ match self.dependency_type:
175
+ case DependencyType.FINISH_TO_START:
176
+ """target starts after source finishes"""
177
+ if self.source.status == TaskStatus.COMPLETED:
178
+ return self.condition(**self.conditon_kwargs) if self.condtion else True
179
+ else:
180
+ return False
181
+
182
+ case DependencyType.START_TO_START:
183
+ """target starts when source starts"""
184
+ if self.source.status != TaskStatus.NOT_STARTED:
185
+ return self.condition(**self.conditon_kwargs) if self.condtion else True
186
+ else:
187
+ return False
188
+
189
+ case DependencyType.FINISH_TO_FINISH:
190
+ """target finish when source start"""
191
+ if self.source.status != TaskStatus.COMPLETED:
192
+ return self.condition(**self.conditon_kwargs) if self.condtion else True
193
+ else:
194
+ return False
195
+
196
+ case DependencyType.START_TO_FINISH:
197
+ """target finishes when source start"""
198
+ if self.source.status == TaskStatus.IN_PROGRESS:
199
+ return self.condition(**self.conditon_kwargs) if self.condtion else True
200
+ else:
201
+ return False
202
+
203
+
204
+ def activate(self) -> TaskOutput | None:
205
+ """
206
+ Activates the edge to initiate task execution of the target node.
207
+ """
208
+
209
+ if not self.dependency_met():
210
+ Logger(verbose=True).log(level="warning", message="Dependencies not met. We'll return None.", color="yellow")
211
+ return None
212
+
213
+ if not self.source or not self.target:
214
+ Logger(verbose=True).log(level="warning", message="Cannot find source or target nodes. We'll return None.", color="yellow")
215
+ return None
216
+
217
+ if self.lag:
218
+ import time
219
+ time.sleep(self.lag)
220
+
221
+ context = self.source.task.task_output.raw if self.data_transfer else None
222
+ res = self.target.handle_task_execution(context=context)
223
+ return res
224
+
225
+
226
+ class Graph(ABC, BaseModel):
227
+ """
228
+ An abstract class to store G using NetworkX library.
229
+ """
230
+
231
+ _logger: Logger = PrivateAttr(default_factory=lambda: Logger(verbose=True))
232
+ directed: bool = Field(default=False, description="Whether the graph is directed")
233
+ graph: Type[nx.Graph] = Field(default=None)
234
+ nodes: Dict[str, InstanceOf[Node]] = Field(default_factory=dict, description="identifier: Node - for the sake of ")
235
+ edges: Dict[str, InstanceOf[Edge]] = Field(default_factory=dict)
236
+
237
+ def __init__(self, directed: bool = False, **kwargs):
238
+ super().__init__(directed=directed, **kwargs)
239
+ self.graph = nx.DiGraph() if self.directed else nx.Graph()
240
+
241
+ def _return_node_object(self, node_identifier) -> Node | None:
242
+ return [v for k, v in self.nodes.items() if k == node_identifier][0] if [v for k, v in self.nodes.items() if k == node_identifier] else None
243
+
244
+ def add_node(self, node: Node) -> None:
245
+ self.graph.add_node(node.identifier, **node.model_dump())
246
+ self.nodes[node.identifier] = node
247
+
248
+ def add_edge(self, source: str, target: str, edge: Edge) -> None:
249
+ self.graph.add_edge(source, target, **edge.model_dump())
250
+ edge.source = self._return_node_object(source)
251
+ edge.target = self._return_node_object(target)
252
+ self.edges[(source, target)] = edge
253
+
254
+ def add_weighted_edges_from(self, edges):
255
+ self.graph.add_weighted_edges_from(edges)
256
+
257
+ def get_neighbors(self, node: Node) -> List[Node]:
258
+ return list(self.graph.neighbors(node))
259
+
260
+ def get_in_degree(self, node: Node) -> int:
261
+ return self.graph.in_degree(node)
262
+
263
+ def get_out_degree(self, node: Node) -> int:
264
+ return self.graph.out_degree(node)
265
+
266
+ def find_path(self, source: Optional[str] | None, target: str, weight: Optional[Any] | None) -> Any:
267
+ try:
268
+ return nx.shortest_path(self.graph, source=source, target=target, weight=weight)
269
+ except nx.NetworkXNoPath:
270
+ return None
271
+
272
+ def find_all_paths(self, source: str, target: str) -> List[Any]:
273
+ return list(nx.all_simple_paths(self.graph, source=source, target=target))
274
+
275
+ def find_critical_path(self) -> tuple[List[Any], int, Dict[str, int]]:
276
+ """
277
+ Finds the critical path in the graph.
278
+ Returns:
279
+ A tuple containing:
280
+ - The critical path (a list of task names).
281
+ - The duration of the critical path.
282
+ - A dictionary of all paths and their durations.
283
+ """
284
+
285
+ all_paths = {}
286
+ for start_node in (v for k, v in self.nodes.items() if v.in_degrees == 0): # Start from nodes with 0 in-degree
287
+ for end_node in (v for k, v in self.nodes.items() if v.out_degrees == 0): # End at nodes with 0 out-degree
288
+ for edge in nx.all_simple_paths(self.graph, source=start_node.identifier, target=end_node.identifier):
289
+ edge_weight = sum(self.edges.get(item).weight if self.edges.get(item) else 0 for item in edge)
290
+ all_paths[tuple(edge)] = edge_weight
291
+
292
+ if not all_paths:
293
+ return [], 0, all_paths
294
+
295
+ critical_path = max(all_paths, key=all_paths.get)
296
+ critical_duration = all_paths[critical_path]
297
+
298
+ return list(critical_path), critical_duration, all_paths
299
+
300
+
301
+ def is_circled(self, node: Node) -> bool:
302
+ """Check if there's a path from the node to itself and return bool."""
303
+ try:
304
+ path = nx.shortest_path(self.graph, source=node, target=node)
305
+ return True if path else False
306
+ except nx.NetworkXNoPath:
307
+ return False
308
+
309
+
310
+ def visualize(self, title: str = "Task Graph", pos: Any = None, **graph_config):
311
+ pos = pos if pos else nx.spring_layout(self.graph, seed=42)
312
+ nx.draw(
313
+ self.graph,
314
+ pos,
315
+ with_labels=True, node_size=700, node_color="skyblue", font_size=10, font_color="black", arrowstyle='-|>', arrowsize=20, arrows=True,
316
+ **graph_config
317
+ )
318
+ edge_labels = {}
319
+ for u, v, data in self.graph.edges(data=True):
320
+ edge = self.edges.get((u,v))
321
+ if edge:
322
+ label_parts = []
323
+ if edge.type:
324
+ label_parts.append(f"Type: {edge.type}")
325
+ if edge.duration is not None:
326
+ label_parts.append(f"Duration: {edge.duration}")
327
+ if edge.lag is not None:
328
+ label_parts.append(f"Lag: {edge.lag}")
329
+ edge_labels[(u, v)] = "\n".join(label_parts) # Combine labels with newlines
330
+ nx.draw_networkx_edge_labels(self.graph, pos, edge_labels=edge_labels)
331
+ plt.title(title)
332
+ plt.show()
333
+
334
+
335
+ class TaskGraph(Graph):
336
+ id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
337
+ should_reform: bool = Field(default=False)
338
+ status: Dict[str, TaskStatus] = Field(default_factory=dict, description="store identifier (str) and TaskStatus of all task_nodes")
339
+ outputs: Dict[str, TaskOutput] = Field(default_factory=dict, description="store identifire and TaskOutput")
340
+
341
+ def add_task(self, task: Node | Task) -> Node:
342
+ """Convert `task` to a Node object and add it to G"""
343
+ task_node = task if isinstance(task, Node) else Node(task=task)
344
+ self.add_node(task_node)
345
+ self.status[task_node.identifier] = TaskStatus.NOT_STARTED
346
+ return task_node
347
+
348
+
349
+ def add_dependency(
350
+ self, source_task_node_identifier: str, target_task_node_identifier: str, **edge_attributes
351
+ ) -> None:
352
+ """
353
+ Add an edge that connect task 1 (source) and task 2 (target) using task_node.name as an identifier
354
+ """
355
+
356
+ if not edge_attributes:
357
+ self._logger.log(level="error", message="Edge attributes are missing.", color="red")
358
+
359
+ edge = Edge()
360
+ for k in Edge.model_fields.keys():
361
+ v = edge_attributes.get(k, None)
362
+ if v:
363
+ setattr(edge, k, v)
364
+ else:
365
+ pass
366
+
367
+ self.add_edge(source_task_node_identifier, target_task_node_identifier, edge)
368
+
369
+
370
+ def set_task_status(self, identifier: str, status: TaskStatus) -> None:
371
+ if identifier in self.status:
372
+ self.status[identifier] = status
373
+ else:
374
+ self._logger.log(level="warning", message=f"Task '{identifier}' not found in the graph.", color="yellow")
375
+ pass
376
+
377
+ def get_task_status(self, identifier):
378
+ if identifier in self.status:
379
+ return self.status[identifier]
380
+ else:
381
+ self._logger.log(level="warning", message=f"Task '{identifier}' not found in the graph.", color="yellow")
382
+ return None
383
+
384
+
385
+ def visualize(self, layout: str = None):
386
+ try:
387
+ pos = nx.drawing.nx_agraph.graphviz_layout(self.graph, prog='dot') # 'dot', 'neato', 'fdp', 'sfdp'
388
+ except ImportError:
389
+ pos = nx.spring_layout(self.graph, seed=42) # REFINEME - layout
390
+
391
+ node_colors = list()
392
+ for k, v in self.graph.nodes.items():
393
+ status = self.get_task_status(identifier=k)
394
+ if status == TaskStatus.NOT_STARTED:
395
+ node_colors.append("skyblue")
396
+ elif status == TaskStatus.IN_PROGRESS:
397
+ node_colors.append("lightgreen")
398
+ elif status == TaskStatus.BLOCKED:
399
+ node_colors.append("lightcoral")
400
+ elif status == TaskStatus.COMPLETED:
401
+ node_colors.append("black")
402
+ elif status == TaskStatus.DELAYED:
403
+ node_colors.append("orange")
404
+ elif status == TaskStatus.ON_HOLD:
405
+ node_colors.append("yellow")
406
+ else:
407
+ node_colors.append("grey")
408
+
409
+ critical_paths, duration, paths = self.find_critical_path()
410
+ edge_colors = ['red' if (u, v) in zip(critical_paths, critical_paths[1:]) else 'black' for u, v in self.graph.edges()]
411
+ edge_widths = []
412
+
413
+ for k, v in self.edges.items():
414
+ # edge_weights = nx.get_edge_attributes(self.graph, 'weight')
415
+ # edge_colors.append(plt.cm.viridis(v.weight / max(edge_weights.values())))
416
+ edge_widths.append(v.weight * 0.5)
417
+
418
+ nx.draw(
419
+ self.graph, pos,
420
+ with_labels=True,
421
+ node_size=700,
422
+ node_color=node_colors,
423
+ font_size=10,
424
+ font_color="black",
425
+ edge_color=edge_colors,
426
+ width=edge_widths,
427
+ arrows=True,
428
+ arrowsize=20,
429
+ arrowstyle='-|>'
430
+ )
431
+
432
+ edge_labels = nx.get_edge_attributes(G=self.graph, name="edges")
433
+ nx.draw_networkx_edge_labels(self.graph, pos, edge_labels=edge_labels)
434
+
435
+ plt.title("Project Network Diagram")
436
+ self._save()
437
+ plt.show()
438
+
439
+
440
+ def _save(self, abs_file_path: str = None) -> None:
441
+ """
442
+ Save the graph image in the local directory.
443
+ """
444
+
445
+ try:
446
+ import os
447
+ project_root = os.path.abspath(os.getcwd())
448
+ abs_file_path = abs_file_path if abs_file_path else f"{project_root}/uploads"
449
+
450
+ os.makedirs(abs_file_path, exist_ok=True)
451
+
452
+ plt.savefig(f"{abs_file_path}/{str(self.id)}.png")
453
+
454
+ except Exception as e:
455
+ self._logger.log(level="error", message=f"Failed to save the graph {str(self.id)}: {str(e)}", color="red")
456
+
457
+
458
+ def activate(self, target_node_identifier: Optional[str] = None) -> Tuple[TaskOutput | None, Dict[str, TaskOutput]]:
459
+ """
460
+ Starts to execute all nodes in the graph or a specific node if the target is given, following the given conditons of the edge obeject.
461
+ Then returns tuple of the last task output and all task outputs (self.outputs)
462
+ """
463
+ if target_node_identifier:
464
+ if not [k for k in self.nodes.keys() if k == target_node_identifier]:
465
+ self._logger.log(level="error", message=f"The node {str(target_node_identifier)} is not in the graph.", color="red")
466
+ return None
467
+
468
+ # find a shortest path to each in-degree node of the node and see if dependency met.
469
+ node = self._return_node_object(target_node_identifier)
470
+ sources = node.in_degrees
471
+ edge_status = []
472
+ res = None
473
+
474
+ for item in sources:
475
+ edge = self.find_path(source=item, target=target_node_identifier)
476
+ edge_status.append(dict(edge=edge if edge else None, dep_met=edge.dependency_met() if edge else False))
477
+
478
+ if len([item for item in edge_status if item["dep_met"] == True]) == len(sources):
479
+ res = node.handle_task_execution()
480
+ self.outputs.update({ target_node_identifier: res })
481
+ self.status.update({ target_node_identifier: edge.target.status })
482
+
483
+ return res, self.outputs
484
+
485
+ else:
486
+ if not self.edges or self.nodes:
487
+ self._logger.log(level="error", message="Needs at least 2 nodes and 1 edge to activate the graph. We'll return None.", color="red")
488
+ return None
489
+
490
+ for edge in self.edges:
491
+ res = edge.activate()
492
+ if not res:
493
+ break
494
+
495
+ node_identifier = edge.target.identifier
496
+ self.outputs.update({ node_identifier: res })
497
+ self.status.update({ node_identifier: edge.target.status })
498
+
499
+ last_task_output = [v for v in self.outputs.values()][len([v for v in self.outputs.values()]) - 1] if [v for v in self.outputs.values()] else None
500
+ return last_task_output, self.outputs
@@ -72,7 +72,7 @@ class EvaluationItem(BaseModel):
72
72
 
73
73
  class Evaluation(BaseModel):
74
74
  items: List[EvaluationItem] = []
75
- latency: int = Field(default=None, description="job execution latency in seconds")
75
+ latency: float = Field(default=None, description="job execution latency in seconds")
76
76
  tokens: int = Field(default=None, description="tokens consumed")
77
77
  eval_by: Any = Field(default=None, description="stores agent object that evaluates the outcome")
78
78
 
@@ -79,7 +79,7 @@ def form_agent_network(
79
79
  if agents:
80
80
  vhq_task.description += "Consider adding following agents in the formation: " + ", ".join([agent.role for agent in agents if isinstance(agent, Agent)])
81
81
 
82
- res = vhq_task.execute_sync(agent=vhq_formation_planner, context=context)
82
+ res = vhq_task.execute(agent=vhq_formation_planner, context=context)
83
83
  _formation = Formation.SUPERVISING
84
84
 
85
85
  if res.pydantic: