Skip to content

Commit 92c6690

Browse files
Merge pull request #1277 from MervinPraison/claude/issue-1276-20260404-1738
fix: resolve critical architecture gaps for multi-agent safety
2 parents d80bff2 + 8b05672 commit 92c6690

4 files changed

Lines changed: 158 additions & 64 deletions

File tree

src/praisonai-agents/praisonaiagents/escalation/observability.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -291,21 +291,6 @@ def get_summary(self) -> Dict[str, Any]:
291291
"final_stage": self._current_stage.name if self._current_stage else None,
292292
}
293293

294-
# Global hooks instance (opt-in)
295-
_global_hooks: Optional[ObservabilityHooks] = None
296-
297-
def get_hooks() -> Optional[ObservabilityHooks]:
298-
"""Get global observability hooks."""
299-
return _global_hooks
300-
301-
def enable_observability() -> ObservabilityHooks:
302-
"""Enable global observability."""
303-
global _global_hooks
304-
if _global_hooks is None:
305-
_global_hooks = ObservabilityHooks(enabled=True)
306-
return _global_hooks
307-
308-
def disable_observability():
309-
"""Disable global observability."""
310-
global _global_hooks
311-
_global_hooks = None
294+
# NOTE: Observability is now per-agent/per-pipeline to comply with multi-agent safety.
295+
# Each agent creates its own ObservabilityHooks instance when observe=True.
296+
# See agent.py:2236 for the correct per-instance pattern.

src/praisonai-agents/praisonaiagents/memory/core.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import json
99
import logging
1010
import threading
11+
import asyncio
1112
from typing import Any, Dict, List, Optional
1213
from datetime import datetime
1314

@@ -304,4 +305,86 @@ def learn(self):
304305
self._learn_manager = LearnManager(config=self._learn_config)
305306
except ImportError:
306307
logging.warning("Learn manager not available - install learn dependencies")
307-
return self._learn_manager
308+
return self._learn_manager
309+
310+
# Async variants to prevent event loop blocking
311+
async def store_short_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
312+
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
313+
"""
314+
Async version of store_short_term to prevent event loop blocking.
315+
316+
Args:
317+
content: The content to store
318+
metadata: Optional metadata dictionary
319+
quality_score: Optional pre-calculated quality score
320+
user_id: Optional user identifier
321+
auto_promote: Whether to automatically promote to LTM if quality is high
322+
323+
Returns:
324+
The memory ID of the stored content
325+
"""
326+
if not content.strip():
327+
return ""
328+
329+
# Calculate quality score if not provided
330+
if quality_score is None:
331+
quality_score = self.compute_quality_score(content, metadata)
332+
333+
# Prepare metadata (mirror sync version's metadata construction including sanitization
334+
# to ensure only JSON-serializable values are stored, preventing crashes across backends)
335+
raw_metadata = metadata.copy() if metadata else {}
336+
raw_metadata.update({
337+
"timestamp": datetime.now().isoformat(),
338+
"quality_score": quality_score,
339+
"memory_type": "short_term"
340+
})
341+
if user_id:
342+
raw_metadata["user_id"] = user_id
343+
clean_metadata = self._sanitize_metadata(raw_metadata)
344+
345+
# Store in SQLite STM
346+
memory_id = ""
347+
try:
348+
memory_id = await asyncio.to_thread(self._store_sqlite_stm, content, clean_metadata, quality_score)
349+
except Exception as e:
350+
logging.error(f"Failed to store in SQLite STM: {e}")
351+
return ""
352+
353+
# Auto-promote to long-term memory if quality is high (async)
354+
if auto_promote and quality_score >= 7.5: # High quality threshold
355+
try:
356+
await self.store_long_term_async(content, clean_metadata, quality_score, user_id)
357+
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
358+
except Exception as e:
359+
logging.warning(f"Failed to auto-promote to LTM: {e}")
360+
361+
# Emit memory event
362+
self._emit_memory_event("store", "short_term", content, clean_metadata)
363+
364+
self._log_verbose(f"Stored in STM: {content[:100]}... (quality: {quality_score:.2f})")
365+
366+
return memory_id or ""
367+
368+
async def store_long_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
369+
user_id: Optional[str] = None) -> str:
370+
"""
371+
Async version of store_long_term to prevent event loop blocking.
372+
373+
Args:
374+
content: The content to store
375+
metadata: Optional metadata dictionary
376+
quality_score: Optional pre-calculated quality score
377+
user_id: Optional user identifier
378+
379+
Returns:
380+
The memory ID of the stored content
381+
"""
382+
if not content.strip():
383+
return ""
384+
385+
# Calculate quality score if not provided
386+
if quality_score is None:
387+
quality_score = self.compute_quality_score(content, metadata)
388+
389+
# Use sync version in thread to avoid blocking event loop
390+
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id)

src/praisonai-agents/praisonaiagents/process/process.py

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import asyncio
3+
import time
34
import json
45
from typing import Dict, Optional, List, Any, AsyncGenerator
56
from pydantic import BaseModel, ConfigDict
@@ -25,6 +26,8 @@ def __init__(
2526
manager_llm: Optional[str] = None,
2627
verbose: bool = False,
2728
max_iter: int = 10,
29+
max_retries: int = 3,
30+
workflow_timeout: Optional[int] = None, # seconds, None = no timeout
2831
output: Optional[str] = None,
2932
):
3033
logging.debug(f"=== Initializing Process ===")
@@ -37,6 +40,8 @@ def __init__(
3740
self.agents = agents
3841
self.manager_llm = manager_llm
3942
self.max_iter = max_iter
43+
self.max_retries = max_retries
44+
self.workflow_timeout = workflow_timeout
4045
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
4146
self.workflow_finished = False # ADDED: Workflow finished flag
4247
self._state_lock = asyncio.Lock() # Async lock for shared state protection
@@ -233,7 +238,7 @@ def _find_next_not_started_task(self) -> Optional[Task]:
233238
if hasattr(task, 'description') and 'Input data from previous tasks:' in task.description:
234239
task.description = task.description.split('Input data from previous tasks:')[0].strip()
235240

236-
while fallback_attempts < Process.DEFAULT_RETRY_LIMIT and not temp_current_task:
241+
while fallback_attempts < self.max_retries and not temp_current_task:
237242
fallback_attempts += 1
238243
logging.debug(f"Fallback attempt {fallback_attempts}: Trying to find next 'not started' task.")
239244
for task_candidate in self.tasks.values():
@@ -248,13 +253,13 @@ def _find_next_not_started_task(self) -> Optional[Task]:
248253
if not leads_to_task and not task_candidate.next_tasks:
249254
continue # Skip if no valid path exists
250255

251-
if self.task_retry_counter.get(task_candidate.id, 0) < Process.DEFAULT_RETRY_LIMIT:
256+
if self.task_retry_counter.get(task_candidate.id, 0) < self.max_retries:
252257
self.task_retry_counter[task_candidate.id] = self.task_retry_counter.get(task_candidate.id, 0) + 1
253258
temp_current_task = task_candidate
254259
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
255260
return temp_current_task # Return the found task immediately
256261
else:
257-
logging.debug(f"Max retries reached for task {task_candidate.name} in fallback mode, marking as failed.")
262+
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
258263
task_candidate.status = "failed"
259264
if not temp_current_task:
260265
logging.debug(f"Fallback attempt {fallback_attempts}: No 'not started' task found within retry limit.")
@@ -383,6 +388,7 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
383388
"""Async version of workflow method"""
384389
logging.debug("=== Starting Async Workflow ===")
385390
current_iter = 0 # Track how many times we've looped
391+
workflow_start = time.monotonic() # For timeout enforcement
386392
# Build workflow relationships first
387393
logging.debug("Building workflow relationships...")
388394
for task in self.tasks.values():
@@ -418,6 +424,13 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
418424
logging.info(f"Max iteration limit {self.max_iter} reached, ending workflow.")
419425
break
420426

427+
# Enforce workflow timeout if set
428+
if self.workflow_timeout is not None:
429+
elapsed = time.monotonic() - workflow_start
430+
if elapsed > self.workflow_timeout:
431+
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, stopping.")
432+
break
433+
421434
# ADDED: Check workflow finished flag at the start of each cycle
422435
if self.workflow_finished:
423436
logging.info("Workflow finished early as all tasks are completed.")
@@ -455,11 +468,13 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
455468
Description length: {len(current_task.description)}
456469
""")
457470

458-
# Add context from previous tasks to description
471+
# Build context and set description for this execution pass only
459472
context = self._build_task_context(current_task)
460-
if context:
461-
# Update task description with context
462-
current_task.description = current_task.description + context
473+
# Store original description if not already stored
474+
if not hasattr(current_task, '_original_description'):
475+
current_task._original_description = current_task.description
476+
# Set description with context for execution; reset after yield to prevent accumulation
477+
current_task.description = current_task._original_description + (context if context else "")
463478

464479
# Skip execution for loop tasks, only process their subtasks
465480
if current_task.task_type == "loop":
@@ -566,6 +581,9 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
566581
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
567582
yield task_id
568583
visited_tasks.add(task_id)
584+
# Reset description to original after execution to prevent context accumulation
585+
if hasattr(current_task, '_original_description'):
586+
current_task.description = current_task._original_description
569587

570588
# Only end workflow if no next_tasks AND no conditions
571589
if not current_task.next_tasks and not current_task.condition and not any(
@@ -577,28 +595,29 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
577595
current_task = None
578596
break
579597

580-
# Reset completed task to "not started" so it can run again
581-
if self.tasks[task_id].status == "completed":
582-
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
583-
subtask_name = self.tasks[task_id].name
584-
task_to_check = self.tasks[task_id]
585-
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
586-
logging.debug(f"Task type: {task_to_check.task_type}")
587-
logging.debug(f"Task status before reset check: {task_to_check.status}")
588-
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
589-
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
590-
591-
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
592-
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
593-
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
594-
for t in self.tasks.values()) and
595-
not task_to_check.async_execution): # Don't reset async parallel tasks
596-
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
597-
self.tasks[task_id].status = "not started"
598-
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
599-
else:
600-
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
601-
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
598+
# Reset completed task to "not started" so it can run again (atomic operation)
599+
async with self._state_lock:
600+
if self.tasks[task_id].status == "completed":
601+
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
602+
subtask_name = self.tasks[task_id].name
603+
task_to_check = self.tasks[task_id]
604+
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
605+
logging.debug(f"Task type: {task_to_check.task_type}")
606+
logging.debug(f"Task status before reset check: {task_to_check.status}")
607+
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
608+
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
609+
610+
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
611+
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
612+
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
613+
for t in self.tasks.values()) and
614+
not task_to_check.async_execution): # Don't reset async parallel tasks
615+
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
616+
self.tasks[task_id].status = "not started"
617+
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
618+
else:
619+
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
620+
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
602621

603622
# Handle loop progression
604623
if current_task.task_type == "loop":
@@ -1116,11 +1135,13 @@ def workflow(self):
11161135
Description length: {len(current_task.description)}
11171136
""")
11181137

1119-
# Add context from previous tasks to description
1138+
# Build context and set description for this execution pass only
11201139
context = self._build_task_context(current_task)
1121-
if context:
1122-
# Update task description with context
1123-
current_task.description = current_task.description + context
1140+
# Store original description if not already stored
1141+
if not hasattr(current_task, '_original_description'):
1142+
current_task._original_description = current_task.description
1143+
# Set description with context for execution; reset after yield to prevent accumulation
1144+
current_task.description = current_task._original_description + (context if context else "")
11241145

11251146
# Skip execution for loop tasks, only process their subtasks
11261147
if current_task.task_type == "loop":
@@ -1227,6 +1248,9 @@ def workflow(self):
12271248
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
12281249
yield task_id
12291250
visited_tasks.add(task_id)
1251+
# Reset description to original after execution to prevent context accumulation
1252+
if hasattr(current_task, '_original_description'):
1253+
current_task.description = current_task._original_description
12301254

12311255
# Only end workflow if no next_tasks AND no conditions
12321256
if not current_task.next_tasks and not current_task.condition and not any(

src/praisonai-agents/praisonaiagents/tools/__init__.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,16 @@
200200
'email_tools': ('.email_tools', None),
201201
}
202202

203-
_instances = {} # Cache for class instances (protected by _tools_lock)
203+
# Tool factory functions - caches classes but creates fresh instances
204+
# This prevents state leakage between concurrent agents while optimizing import performance
205+
_loaded_classes = {} # Cache the Class, NOT the instance
206+
207+
def _create_tool_instance(class_name: str, module_path: str):
208+
"""Create a new tool instance. Caches the class but returns fresh instances to prevent state sharing."""
209+
if class_name not in _loaded_classes:
210+
module = import_module(module_path, __package__)
211+
_loaded_classes[class_name] = getattr(module, class_name)
212+
return _loaded_classes[class_name]() # Fresh instance safe for multi-agent
204213

205214
# Profile exports (lazy loaded)
206215
_PROFILE_EXPORTS = frozenset({
@@ -325,17 +334,10 @@ def __getattr__(name: str) -> Any:
325334
return module # Returns the callable module
326335
return getattr(module, name)
327336
else:
328-
# Class method import (thread-safe)
329-
if class_name not in _instances:
330-
with _tools_lock:
331-
# Double-check pattern to avoid race conditions
332-
if class_name not in _instances:
333-
module = import_module(module_path, __package__)
334-
class_ = getattr(module, class_name)
335-
_instances[class_name] = class_()
336-
337-
# Get the method and bind it to the instance
338-
method = getattr(_instances[class_name], name)
337+
# Create a fresh tool instance for each agent/session to prevent state leakage
338+
# This factory pattern ensures multi-agent safety by avoiding shared mutable state
339+
instance = _create_tool_instance(class_name, module_path)
340+
method = getattr(instance, name)
339341
return method
340342

341343
__all__ = list(TOOL_MAPPINGS.keys()) + [

0 commit comments

Comments
 (0)