diff --git a/src/praisonai-agents/praisonaiagents/agent/agent.py b/src/praisonai-agents/praisonaiagents/agent/agent.py index 0a68194e0..bdb096aaf 100644 --- a/src/praisonai-agents/praisonaiagents/agent/agent.py +++ b/src/praisonai-agents/praisonaiagents/agent/agent.py @@ -3751,6 +3751,11 @@ def last_stop_reason(self) -> str: # the agent (see ``_extract_llm_response_content``); prefer it so a # provider block/refusal isn't masked by a backend default of "completed". own = getattr(self, '_last_stop_reason', None) + # Prefer a specific agent-owned provider block/refusal/truncation before + # any backend fallback so a stale backend reason (e.g. ``max_steps``) + # cannot mask the OpenAI-native classification recorded for this turn. + if own and own != "completed": + return own # Read from the already-instantiated backends only. ``__openai_client`` # is the raw (name-mangled) attribute, never the lazy ``_openai_client`` # property, so this never triggers OpenAI client creation for diff --git a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py index 27faa20e1..c83fabd28 100644 --- a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py @@ -5766,6 +5766,13 @@ def _model_fn(req): async def _achat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True): """Async retry/backoff core for chat completion (middleware-agnostic).""" + # Reset the agent-level finish-reason classification at the start of each + # async OpenAI-native turn, mirroring the sync ``_chat_completion`` reset, + # so a provider block/refusal recorded on a previous run (see + # ``_extract_llm_response_content``) never leaks into this one. The LiteLLM + # path resets its own backend flag independently. + self._last_stop_reason = "completed" + retry_config = getattr(self, '_retry_config', None) if not retry_config: return await self._execute_unified_achat_completion( diff --git a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py index 50c877088..cde0fd81a 100644 --- a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py @@ -312,9 +312,14 @@ def _outcome_for_result(self, result): """ from .run_outcome import RunOutcome, PROVIDER_BLOCK_REASONS output = str(result) if result is not None else None + # Read the recorded classification without letting a lookup problem mask a + # provider block as a successful ``completed``. Only an expected + # missing-state lookup (``AttributeError``) is treated as "no reason + # recorded"; anything else propagates rather than silently succeeding. + reason = None try: reason = getattr(self, "last_stop_reason", None) - except Exception: + except AttributeError: reason = None if reason in PROVIDER_BLOCK_REASONS: return RunOutcome(reason=reason, output=output) diff --git a/src/praisonai-agents/tests/test_run_outcome.py b/src/praisonai-agents/tests/test_run_outcome.py index 7074bfc0c..6ba3373fe 100644 --- a/src/praisonai-agents/tests/test_run_outcome.py +++ b/src/praisonai-agents/tests/test_run_outcome.py @@ -205,3 +205,33 @@ def test_astart_outcome_surfaces_refusal(): ) assert o.reason == "refused" assert o.succeeded is False + + +def test_agent_last_stop_reason_prefers_own_block_over_stale_backend(): + # Regression: an agent-owned provider block (OpenAI-native classification) + # must win over a stale backend reason like "max_steps"; otherwise the + # actionable "refused" is masked and _outcome_for_result reports success. + from praisonaiagents.agent.agent import Agent + + agent = Agent(instructions="test", llm="gpt-4o-mini") + agent._last_stop_reason = "refused" + + class _StaleBackend: + _last_stop_reason = "max_steps" + + agent.llm_instance = _StaleBackend() + assert agent.last_stop_reason == "refused" + + +def test_agent_last_stop_reason_backend_used_when_own_completed(): + # When the agent recorded nothing specific, the backend reason still wins. + from praisonaiagents.agent.agent import Agent + + agent = Agent(instructions="test", llm="gpt-4o-mini") + agent._last_stop_reason = "completed" + + class _Backend: + _last_stop_reason = "max_steps" + + agent.llm_instance = _Backend() + assert agent.last_stop_reason == "max_steps"