praisonaiagents 0.0.127__py3-none-any.whl → 0.0.129__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.
@@ -30,6 +30,16 @@ MODELS_SUPPORTING_STRUCTURED_OUTPUTS = {
30
30
  "gpt-4.1-mini",
31
31
  "o4-mini",
32
32
  "o3",
33
+
34
+ # Gemini models that support structured outputs
35
+ "gemini-2.0-flash",
36
+ "gemini-2.0-flash-exp",
37
+ "gemini-1.5-pro",
38
+ "gemini-1.5-pro-latest",
39
+ "gemini-1.5-flash",
40
+ "gemini-1.5-flash-latest",
41
+ "gemini-1.5-flash-8b",
42
+ "gemini-1.5-flash-8b-latest",
33
43
  }
34
44
 
35
45
  # Models that explicitly DON'T support structured outputs
@@ -57,16 +67,23 @@ def supports_structured_outputs(model_name: str) -> bool:
57
67
  if not model_name:
58
68
  return False
59
69
 
70
+ # Strip provider prefixes (e.g., 'google/', 'openai/', etc.)
71
+ model_without_provider = model_name
72
+ for prefix in ['google/', 'openai/', 'anthropic/', 'gemini/', 'mistral/', 'deepseek/', 'groq/']:
73
+ if model_name.startswith(prefix):
74
+ model_without_provider = model_name[len(prefix):]
75
+ break
76
+
60
77
  # First check if it's explicitly in the NOT supporting list
61
- if model_name in MODELS_NOT_SUPPORTING_STRUCTURED_OUTPUTS:
78
+ if model_without_provider in MODELS_NOT_SUPPORTING_STRUCTURED_OUTPUTS:
62
79
  return False
63
80
 
64
81
  # Then check if it's in the supporting list
65
- if model_name in MODELS_SUPPORTING_STRUCTURED_OUTPUTS:
82
+ if model_without_provider in MODELS_SUPPORTING_STRUCTURED_OUTPUTS:
66
83
  return True
67
84
 
68
85
  # For models with version suffixes, check the base model name
69
- base_model = model_name.split('-2024-')[0].split('-2025-')[0]
86
+ base_model = model_without_provider.split('-2024-')[0].split('-2025-')[0]
70
87
  if base_model in MODELS_SUPPORTING_STRUCTURED_OUTPUTS:
71
88
  return True
72
89
 
@@ -0,0 +1,348 @@
1
+ """
2
+ Model Router for intelligent model selection based on task characteristics.
3
+
4
+ This module provides functionality to automatically select the most appropriate
5
+ LLM model/provider based on task complexity, cost considerations, and model capabilities.
6
+ """
7
+
8
+ import os
9
+ import logging
10
+ from typing import Dict, List, Optional, Tuple
11
+ from dataclasses import dataclass
12
+ from enum import IntEnum
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class TaskComplexity(IntEnum):
18
+ """Enum for task complexity levels"""
19
+ SIMPLE = 1 # Basic queries, math, factual questions
20
+ MODERATE = 2 # Summarization, basic analysis
21
+ COMPLEX = 3 # Code generation, deep reasoning
22
+ VERY_COMPLEX = 4 # Multi-step reasoning, complex analysis
23
+
24
+
25
+ @dataclass
26
+ class ModelProfile:
27
+ """Profile for an LLM model with its characteristics"""
28
+ name: str
29
+ provider: str
30
+ complexity_range: Tuple[TaskComplexity, TaskComplexity]
31
+ cost_per_1k_tokens: float # Average of input/output costs
32
+ strengths: List[str]
33
+ capabilities: List[str]
34
+ context_window: int
35
+ supports_tools: bool = True
36
+ supports_streaming: bool = True
37
+
38
+
39
+ class ModelRouter:
40
+ """
41
+ Intelligent model router that selects the best model based on task requirements.
42
+
43
+ This router implements a strategy pattern for model selection, considering:
44
+ - Task complexity
45
+ - Cost optimization
46
+ - Model capabilities
47
+ - Specific strengths for different task types
48
+ """
49
+
50
+ # Default model profiles - can be customized via configuration
51
+ DEFAULT_MODELS = [
52
+ # Lightweight/cheap models for simple tasks
53
+ ModelProfile(
54
+ name="gpt-4o-mini",
55
+ provider="openai",
56
+ complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
57
+ cost_per_1k_tokens=0.00075, # Average of $0.00015 input, $0.0006 output
58
+ strengths=["speed", "cost-effective", "basic-reasoning"],
59
+ capabilities=["text", "function-calling"],
60
+ context_window=128000
61
+ ),
62
+ ModelProfile(
63
+ name="gemini/gemini-1.5-flash",
64
+ provider="google",
65
+ complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
66
+ cost_per_1k_tokens=0.000125, # Very cost-effective
67
+ strengths=["speed", "cost-effective", "multimodal"],
68
+ capabilities=["text", "vision", "function-calling"],
69
+ context_window=1048576
70
+ ),
71
+ ModelProfile(
72
+ name="claude-3-haiku-20240307",
73
+ provider="anthropic",
74
+ complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
75
+ cost_per_1k_tokens=0.0008, # Average of $0.00025 input, $0.00125 output
76
+ strengths=["speed", "instruction-following"],
77
+ capabilities=["text", "function-calling"],
78
+ context_window=200000
79
+ ),
80
+
81
+ # Mid-tier models for moderate complexity
82
+ ModelProfile(
83
+ name="gpt-4o",
84
+ provider="openai",
85
+ complexity_range=(TaskComplexity.MODERATE, TaskComplexity.COMPLEX),
86
+ cost_per_1k_tokens=0.0075, # Average of $0.0025 input, $0.01 output
87
+ strengths=["reasoning", "code-generation", "general-purpose"],
88
+ capabilities=["text", "vision", "function-calling"],
89
+ context_window=128000
90
+ ),
91
+ ModelProfile(
92
+ name="claude-3-5-sonnet-20241022",
93
+ provider="anthropic",
94
+ complexity_range=(TaskComplexity.MODERATE, TaskComplexity.VERY_COMPLEX),
95
+ cost_per_1k_tokens=0.009, # Average of $0.003 input, $0.015 output
96
+ strengths=["reasoning", "code-generation", "analysis", "writing"],
97
+ capabilities=["text", "vision", "function-calling"],
98
+ context_window=200000
99
+ ),
100
+
101
+ # High-end models for complex tasks
102
+ ModelProfile(
103
+ name="gemini/gemini-1.5-pro",
104
+ provider="google",
105
+ complexity_range=(TaskComplexity.COMPLEX, TaskComplexity.VERY_COMPLEX),
106
+ cost_per_1k_tokens=0.00625, # Average of $0.00125 input, $0.005 output
107
+ strengths=["reasoning", "long-context", "multimodal"],
108
+ capabilities=["text", "vision", "function-calling"],
109
+ context_window=2097152 # 2M context
110
+ ),
111
+ ModelProfile(
112
+ name="claude-3-opus-20240229",
113
+ provider="anthropic",
114
+ complexity_range=(TaskComplexity.COMPLEX, TaskComplexity.VERY_COMPLEX),
115
+ cost_per_1k_tokens=0.045, # Average of $0.015 input, $0.075 output
116
+ strengths=["deep-reasoning", "complex-analysis", "creative-writing"],
117
+ capabilities=["text", "vision", "function-calling"],
118
+ context_window=200000
119
+ ),
120
+ ModelProfile(
121
+ name="deepseek-chat",
122
+ provider="deepseek",
123
+ complexity_range=(TaskComplexity.COMPLEX, TaskComplexity.VERY_COMPLEX),
124
+ cost_per_1k_tokens=0.0014, # Very cost-effective for capability
125
+ strengths=["reasoning", "code-generation", "mathematics"],
126
+ capabilities=["text", "function-calling"],
127
+ context_window=128000
128
+ ),
129
+ ]
130
+
131
+ def __init__(
132
+ self,
133
+ models: Optional[List[ModelProfile]] = None,
134
+ default_model: Optional[str] = None,
135
+ cost_threshold: Optional[float] = None,
136
+ preferred_providers: Optional[List[str]] = None
137
+ ):
138
+ """
139
+ Initialize the ModelRouter.
140
+
141
+ Args:
142
+ models: Custom list of model profiles to use
143
+ default_model: Default model to use if no suitable model found
144
+ cost_threshold: Maximum cost per 1k tokens to consider
145
+ preferred_providers: List of preferred providers in order
146
+ """
147
+ self.models = models or self.DEFAULT_MODELS
148
+ self.default_model = default_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o')
149
+ self.cost_threshold = cost_threshold
150
+ self.preferred_providers = preferred_providers or []
151
+
152
+ # Build lookup indices for efficient access
153
+ self._model_by_name = {m.name: m for m in self.models}
154
+ self._models_by_complexity = self._build_complexity_index()
155
+
156
+ def _build_complexity_index(self) -> Dict[TaskComplexity, List[ModelProfile]]:
157
+ """Build an index of models by complexity level"""
158
+ index = {level: [] for level in TaskComplexity}
159
+
160
+ for model in self.models:
161
+ min_complexity, max_complexity = model.complexity_range
162
+ for level in TaskComplexity:
163
+ if min_complexity.value <= level.value <= max_complexity.value:
164
+ index[level].append(model)
165
+
166
+ return index
167
+
168
+ def analyze_task_complexity(
169
+ self,
170
+ task_description: str,
171
+ tools_required: Optional[List[str]] = None,
172
+ context_size: Optional[int] = None
173
+ ) -> TaskComplexity:
174
+ """
175
+ Analyze task description to determine complexity level.
176
+
177
+ This is a simple heuristic-based approach. In production, this could be
178
+ replaced with a more sophisticated ML-based classifier.
179
+ """
180
+ description_lower = task_description.lower()
181
+
182
+ # Keywords indicating different complexity levels
183
+ simple_keywords = [
184
+ "calculate", "compute", "what is", "define", "list", "count",
185
+ "simple", "basic", "check", "verify", "yes or no", "true or false"
186
+ ]
187
+
188
+ moderate_keywords = [
189
+ "summarize", "explain", "compare", "describe", "analyze briefly",
190
+ "find", "search", "extract", "classify", "categorize"
191
+ ]
192
+
193
+ complex_keywords = [
194
+ "implement", "code", "develop", "design", "create algorithm",
195
+ "optimize", "debug", "refactor", "architect", "solve"
196
+ ]
197
+
198
+ very_complex_keywords = [
199
+ "multi-step", "comprehensive analysis", "deep dive", "research",
200
+ "strategic", "framework", "system design", "proof", "theorem"
201
+ ]
202
+
203
+ # Check for keyword matches
204
+ if any(keyword in description_lower for keyword in very_complex_keywords):
205
+ return TaskComplexity.VERY_COMPLEX
206
+ elif any(keyword in description_lower for keyword in complex_keywords):
207
+ return TaskComplexity.COMPLEX
208
+ elif any(keyword in description_lower for keyword in moderate_keywords):
209
+ return TaskComplexity.MODERATE
210
+ elif any(keyword in description_lower for keyword in simple_keywords):
211
+ return TaskComplexity.SIMPLE
212
+
213
+ # Consider tool requirements
214
+ if tools_required and len(tools_required) > 3:
215
+ return TaskComplexity.COMPLEX
216
+
217
+ # Consider context size requirements
218
+ if context_size and context_size > 50000:
219
+ return TaskComplexity.COMPLEX
220
+
221
+ # Default to moderate
222
+ return TaskComplexity.MODERATE
223
+
224
+ def select_model(
225
+ self,
226
+ task_description: str,
227
+ required_capabilities: Optional[List[str]] = None,
228
+ tools_required: Optional[List[str]] = None,
229
+ context_size: Optional[int] = None,
230
+ budget_conscious: bool = True
231
+ ) -> str:
232
+ """
233
+ Select the most appropriate model for a given task.
234
+
235
+ Args:
236
+ task_description: Description of the task to perform
237
+ required_capabilities: List of required capabilities (e.g., ["vision", "function-calling"])
238
+ tools_required: List of tools that will be used
239
+ context_size: Estimated context size needed
240
+ budget_conscious: Whether to optimize for cost
241
+
242
+ Returns:
243
+ Model name string to use
244
+ """
245
+ # Analyze task complexity
246
+ complexity = self.analyze_task_complexity(task_description, tools_required, context_size)
247
+
248
+ # Get candidate models for this complexity level
249
+ candidates = self._models_by_complexity.get(complexity, [])
250
+
251
+ if not candidates:
252
+ logger.warning(f"No models found for complexity {complexity}, using default")
253
+ return self.default_model
254
+
255
+ # Filter by required capabilities
256
+ if required_capabilities:
257
+ candidates = [
258
+ m for m in candidates
259
+ if all(cap in m.capabilities for cap in required_capabilities)
260
+ ]
261
+
262
+ # Filter by tool support if needed
263
+ if tools_required:
264
+ candidates = [m for m in candidates if m.supports_tools]
265
+
266
+ # Filter by context window if specified
267
+ if context_size:
268
+ candidates = [m for m in candidates if m.context_window >= context_size]
269
+
270
+ # Filter by cost threshold if specified
271
+ if self.cost_threshold:
272
+ candidates = [m for m in candidates if m.cost_per_1k_tokens <= self.cost_threshold]
273
+
274
+ if not candidates:
275
+ logger.warning("No models meet all criteria, using default")
276
+ return self.default_model
277
+
278
+ # Sort by selection criteria
279
+ if budget_conscious:
280
+ # Sort by cost (ascending)
281
+ candidates.sort(key=lambda m: m.cost_per_1k_tokens)
282
+ else:
283
+ # Sort by capability (descending complexity)
284
+ candidates.sort(key=lambda m: m.complexity_range[1].value, reverse=True)
285
+
286
+ # Apply provider preferences
287
+ if self.preferred_providers:
288
+ for provider in self.preferred_providers:
289
+ for model in candidates:
290
+ if model.provider == provider:
291
+ logger.info(f"Selected model: {model.name} (complexity: {complexity}, cost: ${model.cost_per_1k_tokens}/1k tokens)")
292
+ return model.name
293
+
294
+ # Return the best candidate
295
+ selected = candidates[0]
296
+ logger.info(f"Selected model: {selected.name} (complexity: {complexity}, cost: ${selected.cost_per_1k_tokens}/1k tokens)")
297
+ return selected.name
298
+
299
+ def get_model_info(self, model_name: str) -> Optional[ModelProfile]:
300
+ """Get profile information for a specific model"""
301
+ return self._model_by_name.get(model_name)
302
+
303
+ def estimate_cost(self, model_name: str, estimated_tokens: int) -> float:
304
+ """Estimate the cost for a given model and token count"""
305
+ model = self._model_by_name.get(model_name)
306
+ if not model:
307
+ return 0.0
308
+ return (model.cost_per_1k_tokens * estimated_tokens) / 1000
309
+
310
+
311
+ def create_routing_agent(
312
+ models: Optional[List[str]] = None,
313
+ router: Optional[ModelRouter] = None,
314
+ **agent_kwargs
315
+ ) -> 'Agent':
316
+ """
317
+ Create a specialized routing agent that can select models dynamically.
318
+
319
+ Args:
320
+ models: List of model names to route between
321
+ router: Custom ModelRouter instance
322
+ **agent_kwargs: Additional arguments to pass to Agent constructor
323
+
324
+ Returns:
325
+ Agent configured for model routing
326
+ """
327
+ from ..agent import Agent
328
+
329
+ if not router:
330
+ router = ModelRouter()
331
+
332
+ routing_agent = Agent(
333
+ name=agent_kwargs.pop('name', 'ModelRouter'),
334
+ role=agent_kwargs.pop('role', 'Intelligent Model Router'),
335
+ goal=agent_kwargs.pop('goal', 'Select the most appropriate AI model based on task requirements'),
336
+ backstory=agent_kwargs.pop('backstory',
337
+ 'I analyze tasks and route them to the most suitable AI model, '
338
+ 'optimizing for performance, cost, and capability requirements.'
339
+ ),
340
+ **agent_kwargs
341
+ )
342
+
343
+ # TODO: Consider creating a proper RoutingAgent subclass instead of setting private attributes
344
+ # For now, store the router on the agent for use in execution
345
+ routing_agent._model_router = router
346
+ routing_agent._available_models = models or [m.name for m in router.models]
347
+
348
+ return routing_agent
@@ -6,9 +6,9 @@ from pydantic import BaseModel, ConfigDict
6
6
  from ..agent.agent import Agent
7
7
  from ..task.task import Task
8
8
  from ..main import display_error
9
+ from ..llm import LLM
9
10
  import csv
10
11
  import os
11
- from openai import AsyncOpenAI, OpenAI
12
12
 
13
13
  class LoopItems(BaseModel):
14
14
  model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -34,6 +34,29 @@ class Process:
34
34
  self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
35
35
  self.workflow_finished = False # ADDED: Workflow finished flag
36
36
 
37
+ def _create_llm_instance(self):
38
+ """Create and return a configured LLM instance for manager tasks."""
39
+ return LLM(model=self.manager_llm, temperature=0.7)
40
+
41
+ def _parse_manager_instructions(self, response, ManagerInstructions):
42
+ """Parse LLM response and return ManagerInstructions instance.
43
+
44
+ Args:
45
+ response: String response from LLM
46
+ ManagerInstructions: Pydantic model class for validation
47
+
48
+ Returns:
49
+ ManagerInstructions instance
50
+
51
+ Raises:
52
+ Exception: If parsing fails
53
+ """
54
+ try:
55
+ parsed_json = json.loads(response)
56
+ return ManagerInstructions(**parsed_json)
57
+ except (json.JSONDecodeError, ValueError, TypeError) as e:
58
+ raise Exception(f"Failed to parse response: {response}") from e
59
+
37
60
  def _create_loop_subtasks(self, loop_task: Task):
38
61
  """Create subtasks for a loop task from input file."""
39
62
  logging.warning(f"_create_loop_subtasks called for {loop_task.name} but method not fully implemented")
@@ -167,24 +190,24 @@ class Process:
167
190
 
168
191
  def _get_manager_instructions_with_fallback(self, manager_task, manager_prompt, ManagerInstructions):
169
192
  """Sync version of getting manager instructions with fallback"""
170
- # Create OpenAI client
171
- client = OpenAI()
193
+ # Create LLM instance with the manager_llm
194
+ llm = self._create_llm_instance()
195
+
172
196
  try:
173
- # First try structured output (OpenAI compatible)
197
+ # Use LLM with output_pydantic for structured output
174
198
  logging.info("Attempting structured output...")
175
- manager_response = client.beta.chat.completions.parse(
176
- model=self.manager_llm,
177
- messages=[
178
- {"role": "system", "content": manager_task.description},
179
- {"role": "user", "content": manager_prompt}
180
- ],
181
- temperature=0.7,
182
- response_format=ManagerInstructions
199
+ response = llm.get_response(
200
+ prompt=manager_prompt,
201
+ system_prompt=manager_task.description,
202
+ output_pydantic=ManagerInstructions
183
203
  )
184
- return manager_response.choices[0].message.parsed
204
+
205
+ # Parse the response and validate with Pydantic
206
+ return self._parse_manager_instructions(response, ManagerInstructions)
207
+
185
208
  except Exception as e:
186
209
  logging.info(f"Structured output failed: {e}, falling back to JSON mode...")
187
- # Fallback to regular JSON mode
210
+ # Fallback to regular JSON mode with explicit JSON instructions
188
211
  try:
189
212
  # Generate JSON structure description from Pydantic model
190
213
  try:
@@ -200,23 +223,14 @@ class Process:
200
223
  # Fallback to hardcoded prompt if schema generation fails
201
224
  enhanced_prompt = manager_prompt + "\n\nIMPORTANT: Respond with valid JSON only, using this exact structure: {\"task_id\": <int>, \"agent_name\": \"<string>\", \"action\": \"<execute or stop>\"}"
202
225
 
203
- manager_response = client.chat.completions.create(
204
- model=self.manager_llm,
205
- messages=[
206
- {"role": "system", "content": manager_task.description},
207
- {"role": "user", "content": enhanced_prompt}
208
- ],
209
- temperature=0.7,
210
- response_format={"type": "json_object"}
226
+ response = llm.get_response(
227
+ prompt=enhanced_prompt,
228
+ system_prompt=manager_task.description,
229
+ output_json=True
211
230
  )
212
231
 
213
232
  # Parse JSON and validate with Pydantic
214
- try:
215
- json_content = manager_response.choices[0].message.content
216
- parsed_json = json.loads(json_content)
217
- return ManagerInstructions(**parsed_json)
218
- except (json.JSONDecodeError, ValueError) as e:
219
- raise Exception(f"Failed to parse JSON response: {json_content}") from e
233
+ return self._parse_manager_instructions(response, ManagerInstructions)
220
234
  except Exception as fallback_error:
221
235
  error_msg = f"Both structured output and JSON fallback failed: {fallback_error}"
222
236
  logging.error(error_msg, exc_info=True)
@@ -224,40 +238,32 @@ class Process:
224
238
 
225
239
  async def _get_structured_response_async(self, manager_task, manager_prompt, ManagerInstructions):
226
240
  """Async version of structured response"""
227
- # Create an async client instance for this async method
228
- async_client = AsyncOpenAI()
229
- manager_response = await async_client.beta.chat.completions.parse(
230
- model=self.manager_llm,
231
- messages=[
232
- {"role": "system", "content": manager_task.description},
233
- {"role": "user", "content": manager_prompt}
234
- ],
235
- temperature=0.7,
236
- response_format=ManagerInstructions
241
+ # Create LLM instance with the manager_llm
242
+ llm = self._create_llm_instance()
243
+
244
+ # Use async get_response with output_pydantic
245
+ response = await llm.get_response_async(
246
+ prompt=manager_prompt,
247
+ system_prompt=manager_task.description,
248
+ output_pydantic=ManagerInstructions
237
249
  )
238
- return manager_response.choices[0].message.parsed
250
+
251
+ # Parse the response and validate with Pydantic
252
+ return self._parse_manager_instructions(response, ManagerInstructions)
239
253
 
240
254
  async def _get_json_response_async(self, manager_task, enhanced_prompt, ManagerInstructions):
241
255
  """Async version of JSON fallback response"""
242
- # Create an async client instance for this async method
243
- async_client = AsyncOpenAI()
244
- manager_response = await async_client.chat.completions.create(
245
- model=self.manager_llm,
246
- messages=[
247
- {"role": "system", "content": manager_task.description},
248
- {"role": "user", "content": enhanced_prompt}
249
- ],
250
- temperature=0.7,
251
- response_format={"type": "json_object"}
256
+ # Create LLM instance with the manager_llm
257
+ llm = self._create_llm_instance()
258
+
259
+ response = await llm.get_response_async(
260
+ prompt=enhanced_prompt,
261
+ system_prompt=manager_task.description,
262
+ output_json=True
252
263
  )
253
264
 
254
265
  # Parse JSON and validate with Pydantic
255
- try:
256
- json_content = manager_response.choices[0].message.content
257
- parsed_json = json.loads(json_content)
258
- return ManagerInstructions(**parsed_json)
259
- except (json.JSONDecodeError, ValueError) as e:
260
- raise Exception(f"Failed to parse JSON response: {json_content}") from e
266
+ return self._parse_manager_instructions(response, ManagerInstructions)
261
267
 
262
268
 
263
269
  async def aworkflow(self) -> AsyncGenerator[str, None]:
@@ -469,16 +475,18 @@ Subtask: {st.name}
469
475
  logging.debug(f"Task type: {task_to_check.task_type}")
470
476
  logging.debug(f"Task status before reset check: {task_to_check.status}")
471
477
  logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
478
+ logging.debug(f"Task async_execution: {task_to_check.async_execution}")
472
479
 
473
480
  if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
474
481
  task_to_check.task_type != "loop" and # Removed "decision" from exclusion
475
482
  not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
476
- for t in self.tasks.values())):
477
- logging.debug(f"=== Resetting non-loop, non-decision task {subtask_name} to 'not started' ===")
483
+ for t in self.tasks.values()) and
484
+ not task_to_check.async_execution): # Don't reset async parallel tasks
485
+ logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
478
486
  self.tasks[task_id].status = "not started"
479
487
  logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
480
488
  else:
481
- logging.debug(f"=== Skipping reset for loop/decision/subtask or rerun=False: {subtask_name} ===")
489
+ logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
482
490
  logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
483
491
 
484
492
  # Handle loop progression
@@ -1099,16 +1107,18 @@ Subtask: {st.name}
1099
1107
  logging.debug(f"Task type: {task_to_check.task_type}")
1100
1108
  logging.debug(f"Task status before reset check: {task_to_check.status}")
1101
1109
  logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
1110
+ logging.debug(f"Task async_execution: {task_to_check.async_execution}")
1102
1111
 
1103
1112
  if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
1104
1113
  task_to_check.task_type != "loop" and # Removed "decision" from exclusion
1105
1114
  not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
1106
- for t in self.tasks.values())):
1107
- logging.debug(f"=== Resetting non-loop, non-decision task {subtask_name} to 'not started' ===")
1115
+ for t in self.tasks.values()) and
1116
+ not task_to_check.async_execution): # Don't reset async parallel tasks
1117
+ logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
1108
1118
  self.tasks[task_id].status = "not started"
1109
1119
  logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
1110
1120
  else:
1111
- logging.debug(f"=== Skipping reset for loop/decision/subtask or rerun=False: {subtask_name} ===")
1121
+ logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
1112
1122
  logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
1113
1123
 
1114
1124
 
@@ -172,8 +172,15 @@ class Task:
172
172
  # Check return annotation if present
173
173
  return_annotation = sig.return_annotation
174
174
  if return_annotation != inspect.Signature.empty:
175
+ # Import GuardrailResult for checking
176
+ from ..guardrails import GuardrailResult
177
+
178
+ # Check if it's a GuardrailResult type
179
+ is_guardrail_result = return_annotation is GuardrailResult
180
+
181
+ # Check for tuple return type
175
182
  return_annotation_args = get_args(return_annotation)
176
- if not (
183
+ is_tuple = (
177
184
  get_origin(return_annotation) is tuple
178
185
  and len(return_annotation_args) == 2
179
186
  and return_annotation_args[0] is bool
@@ -183,9 +190,11 @@ class Task:
183
190
  or return_annotation_args[1] is TaskOutput
184
191
  or return_annotation_args[1] == Union[str, TaskOutput]
185
192
  )
186
- ):
193
+ )
194
+
195
+ if not (is_guardrail_result or is_tuple):
187
196
  raise ValueError(
188
- "If return type is annotated, it must be Tuple[bool, Any]"
197
+ "If return type is annotated, it must be GuardrailResult or Tuple[bool, Any]"
189
198
  )
190
199
 
191
200
  self._guardrail_fn = self.guardrail
@@ -447,7 +456,11 @@ Context:
447
456
  # Call the guardrail function
448
457
  result = self._guardrail_fn(task_output)
449
458
 
450
- # Convert the result to a GuardrailResult
459
+ # Check if result is already a GuardrailResult
460
+ if isinstance(result, GuardrailResult):
461
+ return result
462
+
463
+ # Otherwise, convert the tuple result to a GuardrailResult
451
464
  return GuardrailResult.from_tuple(result)
452
465
 
453
466
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: praisonaiagents
3
- Version: 0.0.127
3
+ Version: 0.0.129
4
4
  Summary: Praison AI agents for completing complex tasks with Self Reflection Agents
5
5
  Author: Mervin Praison
6
6
  Requires-Python: >=3.10