Skip to content

Commit 387b81d

Browse files
Merge pull request #1297 from MervinPraison/claude/issue-1288-20260408-0723
fix: resolve three architectural gaps (DRY, error protocol, streaming parity)
2 parents ae1cf98 + 55287d4 commit 387b81d

8 files changed

Lines changed: 542 additions & 146 deletions

File tree

src/praisonai-agents/praisonaiagents/__init__.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,10 @@ def _get_lazy_cache():
177177
'HandoffResult': ('praisonaiagents.agent.handoff', 'HandoffResult'),
178178
'HandoffInputData': ('praisonaiagents.agent.handoff', 'HandoffInputData'),
179179
'ContextPolicy': ('praisonaiagents.agent.handoff', 'ContextPolicy'),
180-
'HandoffError': ('praisonaiagents.agent.handoff', 'HandoffError'),
181-
'HandoffCycleError': ('praisonaiagents.agent.handoff', 'HandoffCycleError'),
182-
'HandoffDepthError': ('praisonaiagents.agent.handoff', 'HandoffDepthError'),
183-
'HandoffTimeoutError': ('praisonaiagents.agent.handoff', 'HandoffTimeoutError'),
180+
'HandoffError': ('praisonaiagents.errors', 'HandoffError'),
181+
'HandoffCycleError': ('praisonaiagents.errors', 'HandoffCycleError'),
182+
'HandoffDepthError': ('praisonaiagents.errors', 'HandoffDepthError'),
183+
'HandoffTimeoutError': ('praisonaiagents.errors', 'HandoffTimeoutError'),
184184

185185
# Embedding API (Note: embedding/embeddings handled in custom_handler to override subpackage)
186186
'aembedding': ('praisonaiagents.embedding.embed', 'aembedding'),
@@ -204,7 +204,15 @@ def _get_lazy_cache():
204204

205205
# Agent classes
206206
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
207-
'BudgetExceededError': ('praisonaiagents.agent.agent', 'BudgetExceededError'),
207+
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
208+
209+
# Error hierarchy - structured exception handling
210+
'PraisonAIError': ('praisonaiagents.errors', 'PraisonAIError'),
211+
'ToolExecutionError': ('praisonaiagents.errors', 'ToolExecutionError'),
212+
'LLMError': ('praisonaiagents.errors', 'LLMError'),
213+
'ValidationError': ('praisonaiagents.errors', 'ValidationError'),
214+
'NetworkError': ('praisonaiagents.errors', 'NetworkError'),
215+
'ErrorContextProtocol': ('praisonaiagents.errors', 'ErrorContextProtocol'),
208216
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
209217
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
210218
'ImageAgent': ('praisonaiagents.agent.image_agent', 'ImageAgent'),

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

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -187,23 +187,8 @@ def _is_file_path(value: str) -> bool:
187187
from ..rag.models import RAGResult, ContextPack
188188
from ..eval.results import EvaluationLoopResult
189189

190-
class BudgetExceededError(Exception):
191-
"""Raised when an agent exceeds its max_budget.
192-
193-
Usage:
194-
try:
195-
agent.start("...")
196-
except BudgetExceededError as e:
197-
print(f"Agent '{e.agent_name}' spent ${e.total_cost:.4f} of ${e.max_budget:.4f}")
198-
"""
199-
def __init__(self, agent_name: str, total_cost: float, max_budget: float):
200-
self.agent_name = agent_name
201-
self.total_cost = total_cost
202-
self.max_budget = max_budget
203-
super().__init__(
204-
f"Agent '{agent_name}' exceeded budget: "
205-
f"${total_cost:.4f} >= ${max_budget:.4f}"
206-
)
190+
# Import structured error from central errors module
191+
from ..errors import BudgetExceededError
207192

208193
class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin):
209194
# Class-level counter for generating unique display names for nameless agents

src/praisonai-agents/praisonaiagents/agent/chat_mixin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from praisonaiagents._logging import get_logger
1414
import asyncio
1515
import threading
16+
from ..errors import BudgetExceededError
1617

1718
# Fallback helpers to avoid circular imports
1819
def _get_console():
@@ -666,7 +667,13 @@ def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, r
666667
self._llm_call_count += 1
667668
if self._max_budget and self._total_cost >= self._max_budget:
668669
if self._on_budget_exceeded == "stop":
669-
raise BudgetExceededError(self.name, self._total_cost, self._max_budget)
670+
raise BudgetExceededError(
671+
f"Agent '{self.name}' exceeded budget: ${self._total_cost:.4f} >= ${self._max_budget:.4f}",
672+
budget_type="cost",
673+
limit=self._max_budget,
674+
used=self._total_cost,
675+
agent_id=self.name
676+
)
670677
elif self._on_budget_exceeded == "warn":
671678
logging.warning(
672679
f"[budget] {self.name}: ${self._total_cost:.4f} exceeded "
@@ -692,6 +699,8 @@ def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, r
692699

693700
return final_response
694701

702+
except BudgetExceededError:
703+
raise
695704
except Exception as e:
696705
error_str = str(e).lower()
697706

src/praisonai-agents/praisonaiagents/agent/handoff.py

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -100,29 +100,13 @@ def from_dict(cls, data: Dict[str, Any]) -> 'HandoffConfig':
100100
data["context_policy"] = ContextPolicy(data["context_policy"])
101101
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
102102

103-
class HandoffError(Exception):
104-
"""Base exception for handoff errors."""
105-
pass
106-
107-
class HandoffCycleError(HandoffError):
108-
"""Raised when a cycle is detected in handoff chain."""
109-
def __init__(self, chain: List[str]):
110-
self.chain = chain
111-
super().__init__(f"Handoff cycle detected: {' -> '.join(chain)}")
112-
113-
class HandoffDepthError(HandoffError):
114-
"""Raised when max handoff depth is exceeded."""
115-
def __init__(self, depth: int, max_depth: int):
116-
self.depth = depth
117-
self.max_depth = max_depth
118-
super().__init__(f"Max handoff depth exceeded: {depth} > {max_depth}")
119-
120-
class HandoffTimeoutError(HandoffError):
121-
"""Raised when handoff times out."""
122-
def __init__(self, timeout: float, agent_name: str):
123-
self.timeout = timeout
124-
self.agent_name = agent_name
125-
super().__init__(f"Handoff to {agent_name} timed out after {timeout}s")
103+
# Import structured error hierarchy from central errors module
104+
from ..errors import (
105+
HandoffError,
106+
HandoffCycleError,
107+
HandoffDepthError,
108+
HandoffTimeoutError
109+
)
126110

127111
# Thread-local storage for tracking handoff chains
128112
_handoff_context = threading.local()
@@ -270,12 +254,26 @@ def _check_safety(self, source_agent: 'Agent') -> None:
270254
if self.config.detect_cycles:
271255
chain = _get_handoff_chain()
272256
if target_name in chain:
273-
raise HandoffCycleError(chain + [target_name])
257+
cycle_path = chain + [target_name]
258+
raise HandoffCycleError(
259+
f"Handoff cycle detected: {' -> '.join(cycle_path)}",
260+
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
261+
target_agent=target_name,
262+
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
263+
cycle_path=cycle_path
264+
)
274265

275266
# Check depth
276267
current_depth = _get_handoff_depth()
277268
if current_depth >= self.config.max_depth:
278-
raise HandoffDepthError(current_depth + 1, self.config.max_depth)
269+
raise HandoffDepthError(
270+
f"Max handoff depth exceeded: {current_depth + 1} > {self.config.max_depth}",
271+
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
272+
target_agent=target_name,
273+
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
274+
max_depth=self.config.max_depth,
275+
current_depth=current_depth + 1
276+
)
279277

280278
def _prepare_context(self, source_agent: 'Agent', kwargs: Dict[str, Any]) -> HandoffInputData:
281279
"""
@@ -541,7 +539,13 @@ async def _execute():
541539
handoff_depth=_get_handoff_depth(),
542540
)
543541
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
544-
raise HandoffTimeoutError(self.config.timeout_seconds, self.agent.name)
542+
raise HandoffTimeoutError(
543+
f"Handoff to {self.agent.name} timed out after {self.config.timeout_seconds}s",
544+
timeout_seconds=self.config.timeout_seconds,
545+
source_agent=source_agent.name if hasattr(source_agent, "name") else "unknown",
546+
target_agent=self.agent.name,
547+
agent_id=source_agent.name if hasattr(source_agent, "name") else "unknown"
548+
)
545549
except Exception as e:
546550
result = HandoffResult(
547551
success=False,

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

Lines changed: 99 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,93 @@ def get_multimodal_message(text_prompt: str, images: list) -> list:
108108
})
109109
return content
110110

111+
def _prepare_task_prompt(task, task_description, context_text=None):
112+
"""
113+
Prepare task prompt with context and memory (DRY helper).
114+
"""
115+
# Build task prompt - only use "User Input/Topic" format if there's actual content
116+
if context_text and context_text.strip():
117+
task_prompt = f"""
118+
User Input/Topic: {context_text}
119+
120+
Task: {task_description}
121+
Expected Output: {task.expected_output}
122+
123+
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
124+
else:
125+
task_prompt = f"""
126+
You need to do the following task: {task_description}.
127+
Expected Output: {task.expected_output}."""
128+
129+
# Add memory context if available
130+
if task.memory:
131+
try:
132+
memory_context = task.memory.build_context_for_task(task.description)
133+
if memory_context:
134+
# Log detailed memory context for debugging
135+
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
136+
# Include actual memory content without verbose headers (essential for AI agent functionality)
137+
task_prompt += f"\n\n{memory_context}"
138+
except Exception as e:
139+
logger.error(f"Error getting memory context: {e}")
140+
141+
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
142+
return task_prompt
143+
144+
def _execute_with_agent_sync(executor_agent, task_prompt, task, tools, stream=None):
145+
"""
146+
Execute task with agent synchronously (DRY helper).
147+
"""
148+
if task.images:
149+
return executor_agent.chat(
150+
get_multimodal_message(task_prompt, task.images),
151+
tools=tools,
152+
output_json=task.output_json,
153+
output_pydantic=task.output_pydantic,
154+
stream=stream,
155+
task_name=task.name,
156+
task_description=task.description,
157+
task_id=task.id
158+
)
159+
else:
160+
return executor_agent.chat(
161+
task_prompt,
162+
tools=tools,
163+
output_json=task.output_json,
164+
output_pydantic=task.output_pydantic,
165+
stream=stream,
166+
task_name=task.name,
167+
task_description=task.description,
168+
task_id=task.id
169+
)
170+
171+
async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, stream=None):
172+
"""
173+
Execute task with agent asynchronously (DRY helper).
174+
"""
175+
if task.images:
176+
return await executor_agent.achat(
177+
get_multimodal_message(task_prompt, task.images),
178+
tools=tools,
179+
output_json=task.output_json,
180+
output_pydantic=task.output_pydantic,
181+
stream=stream,
182+
task_name=task.name,
183+
task_description=task.description,
184+
task_id=task.id
185+
)
186+
else:
187+
return await executor_agent.achat(
188+
task_prompt,
189+
tools=tools,
190+
output_json=task.output_json,
191+
output_pydantic=task.output_pydantic,
192+
stream=stream,
193+
task_name=task.name,
194+
task_description=task.description,
195+
task_id=task.id
196+
)
197+
111198
def process_task_context(context_item, verbose=0, user_id=None):
112199
"""
113200
Process a single context item for task execution.
@@ -703,59 +790,17 @@ async def aexecute_task(self, task_id):
703790
context_separator = '\n\n'
704791
context_text = context_separator.join(unique_contexts)
705792

706-
# Build task prompt - only use "User Input/Topic" format if there's actual content
707-
if context_text and context_text.strip():
708-
task_prompt = f"""
709-
User Input/Topic: {context_text}
710-
711-
Task: {task_description}
712-
Expected Output: {task.expected_output}
713-
714-
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
715-
else:
716-
task_prompt = f"""
717-
You need to do the following task: {task_description}.
718-
Expected Output: {task.expected_output}."""
719-
720-
# Add memory context if available
721-
if task.memory:
722-
try:
723-
memory_context = task.memory.build_context_for_task(task.description)
724-
if memory_context:
725-
# Log detailed memory context for debugging
726-
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
727-
# Include actual memory content without verbose headers (essential for AI agent functionality)
728-
task_prompt += f"\n\n{memory_context}"
729-
except Exception as e:
730-
logger.error(f"Error getting memory context: {e}")
731-
732-
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
793+
# Build task prompt using DRY helper
794+
task_prompt = _prepare_task_prompt(task, task_description, context_text)
733795

734796
if self.verbose >= 2:
735797
logger.info(f"Executing task {task_id}: {task_description} using {executor_agent.display_name}")
736798
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
737799

738-
if task.images:
739-
# Use shared multimodal helper (DRY - defined at module level)
740-
agent_output = await executor_agent.achat(
741-
get_multimodal_message(task_prompt, task.images),
742-
tools=tools,
743-
output_json=task.output_json,
744-
output_pydantic=task.output_pydantic,
745-
task_name=task.name,
746-
task_description=task.description,
747-
task_id=task.id
748-
)
749-
else:
750-
agent_output = await executor_agent.achat(
751-
task_prompt,
752-
tools=tools,
753-
output_json=task.output_json,
754-
output_pydantic=task.output_pydantic,
755-
task_name=task.name,
756-
task_description=task.description,
757-
task_id=task.id
758-
)
800+
# Execute with agent using DRY helper (fixes missing stream parameter)
801+
agent_output = await _execute_with_agent_async(
802+
executor_agent, task_prompt, task, tools, stream=self.stream
803+
)
759804

760805
if agent_output:
761806
# Store the response in memory
@@ -1066,60 +1111,17 @@ def execute_task(self, task_id):
10661111
context_separator = '\n\n'
10671112
context_text = context_separator.join(unique_contexts)
10681113

1069-
# Build task prompt - only use "User Input/Topic" format if there's actual content
1070-
if context_text and context_text.strip():
1071-
task_prompt = f"""
1072-
User Input/Topic: {context_text}
1073-
1074-
Task: {task_description}
1075-
Expected Output: {task.expected_output}
1076-
1077-
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
1078-
else:
1079-
task_prompt = f"""
1080-
You need to do the following task: {task_description}.
1081-
Expected Output: {task.expected_output}."""
1082-
1083-
# Add memory context if available
1084-
if task.memory:
1085-
try:
1086-
memory_context = task.memory.build_context_for_task(task.description)
1087-
if memory_context:
1088-
# Log detailed memory context for debugging
1089-
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
1090-
# Include actual memory content without verbose headers (essential for AI agent functionality)
1091-
task_prompt += f"\n\n{memory_context}"
1092-
except Exception as e:
1093-
logger.error(f"Error getting memory context: {e}")
1094-
1095-
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
1114+
# Build task prompt using DRY helper
1115+
task_prompt = _prepare_task_prompt(task, task_description, context_text)
10961116

10971117
if self.verbose >= 2:
10981118
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.display_name}")
10991119
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
11001120

1101-
if task.images:
1102-
# Use shared multimodal helper (DRY - defined at module level)
1103-
agent_output = executor_agent.chat(
1104-
get_multimodal_message(task_prompt, task.images),
1105-
tools=tools,
1106-
output_json=task.output_json,
1107-
output_pydantic=task.output_pydantic,
1108-
task_name=task.name,
1109-
task_description=task.description,
1110-
task_id=task_id
1111-
)
1112-
else:
1113-
agent_output = executor_agent.chat(
1114-
task_prompt,
1115-
tools=tools,
1116-
output_json=task.output_json,
1117-
output_pydantic=task.output_pydantic,
1118-
stream=self.stream,
1119-
task_name=task.name,
1120-
task_description=task.description,
1121-
task_id=task_id
1122-
)
1121+
# Execute with agent using DRY helper
1122+
agent_output = _execute_with_agent_sync(
1123+
executor_agent, task_prompt, task, tools, stream=self.stream
1124+
)
11231125

11241126
if agent_output:
11251127
# Store the response in memory

0 commit comments

Comments
 (0)