From 783262a1ff7dcee5439d93b4afcecdd0bc25a8c8 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 21 Oct 2026 22:10:07 +0800 Subject: [PATCH 1/8] fix: return fixed guardrail message for rejected inputs --- .../praisonaiagents/agent/chat_mixin.py | 6 ++-- .../test_input_guardrail_blocked_response.py | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py diff --git a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py index b378ba2fc6..e16c370ed2 100644 --- a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py @@ -3384,7 +3384,7 @@ def chat(self, prompt: str, temperature: Optional[float] = None, tools: Optional _in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt) if not _in_ok: logging.warning(f"Agent {self.name}: input blocked by guardrail: {_in_err}") - return None + return self._guardrail_blocked_message(_in_err) prompt = _in_prompt # Check if external managed backend is configured @@ -4223,7 +4223,7 @@ async def achat(self, prompt: str, temperature: Optional[float] = None, tools: O _in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt) if not _in_ok: logging.warning(f"Agent {self.name}: input blocked by guardrail: {_in_err}") - return None + return self._guardrail_blocked_message(_in_err) prompt = _in_prompt # Check if external managed backend is configured (async parity with the @@ -5308,7 +5308,7 @@ def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]: _in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt) if not _in_ok: logging.warning(f"Agent {getattr(self, 'name', '')}: input blocked by guardrail: {_in_err}") - yield f"[Input blocked by guardrail: {_in_err}]" + yield self._guardrail_blocked_message(_in_err) return prompt = _in_prompt diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py new file mode 100644 index 0000000000..ab086b8040 --- /dev/null +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -0,0 +1,31 @@ +"""Rejected input has the same fixed string contract as rejected output.""" +import asyncio + +import pytest + +from praisonaiagents import Agent + + +class RejectInput: + """Reject input before model dispatch with a diagnostic-only reason.""" + + def validate_input(self, content, **kwargs): + return False, "synthetic validator diagnostic" + + def validate_output(self, content, **kwargs): + return True, content + + +@pytest.mark.parametrize("entry", ["chat", "achat", "start", "stream"]) +def test_rejected_input_returns_fixed_guardrail_message(entry): + """All public entry points return usable text, not None or diagnostics.""" + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) + expected = agent._guardrail_blocked_message() + if entry == "achat": + result = asyncio.run(agent.achat("blocked prompt")) + elif entry == "stream": + result = "".join(agent.start("blocked prompt", stream=True)) + else: + result = getattr(agent, entry)("blocked prompt") + assert result == expected + assert result.strip() From 41f16a10f0f36cb59a02a15a7e0dcb95f142ed88 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 21 Oct 2026 22:26:34 +0800 Subject: [PATCH 2/8] fix: retain guardrail rejection status through team result processing --- .../praisonaiagents/agent/agent.py | 5 ++- .../praisonaiagents/agents/agents.py | 5 +++ .../guardrails/guardrail_result.py | 4 +++ .../test_input_guardrail_blocked_response.py | 35 ++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/praisonai-agents/praisonaiagents/agent/agent.py b/src/praisonai-agents/praisonaiagents/agent/agent.py index 122dad4c61..6225e1879d 100644 --- a/src/praisonai-agents/praisonaiagents/agent/agent.py +++ b/src/praisonai-agents/praisonaiagents/agent/agent.py @@ -7173,7 +7173,10 @@ def _guardrail_blocked_message(self, error=None): reason = str(error) if error else getattr(self, "_last_guardrail_error", None) if reason: logging.info(f"Agent {self.name}: guardrail block reason: {reason}") - return "I'm sorry, but I can't share that response because it did not pass the safety guardrail." + from ..guardrails.guardrail_result import _GuardrailBlockedResponse + return _GuardrailBlockedResponse( + "I'm sorry, but I can't share that response because it did not pass the safety guardrail." + ) def _process_guardrail(self, task_output): """Process the guardrail validation for a task output. diff --git a/src/praisonai-agents/praisonaiagents/agents/agents.py b/src/praisonai-agents/praisonaiagents/agents/agents.py index f785f08288..0c7b84d817 100644 --- a/src/praisonai-agents/praisonaiagents/agents/agents.py +++ b/src/praisonai-agents/praisonaiagents/agents/agents.py @@ -538,11 +538,16 @@ def _process_task_result(agents_instance, context, agent_output): Eliminates duplication in result processing between sync/async paths. """ from .protocols import TaskResult + from ..guardrails.guardrail_result import _GuardrailBlockedResponse task = context.task task_id = context.task_id executor_agent = context.executor_agent llm = context.llm + + if isinstance(agent_output, _GuardrailBlockedResponse): + task.status = "failed" + return TaskResult(task_output=None, success=False, error="Agent input or output was blocked by a guardrail.") if agent_output: # Store the response in memory diff --git a/src/praisonai-agents/praisonaiagents/guardrails/guardrail_result.py b/src/praisonai-agents/praisonaiagents/guardrails/guardrail_result.py index 58b3eafb43..1534769d33 100644 --- a/src/praisonai-agents/praisonaiagents/guardrails/guardrail_result.py +++ b/src/praisonai-agents/praisonaiagents/guardrails/guardrail_result.py @@ -10,6 +10,10 @@ from ..output.models import TaskOutput +class _GuardrailBlockedResponse(str): + """User-facing text carrying a rejection marker for internal task callers.""" + + class GuardrailResult(BaseModel): """Result of a guardrail validation.""" diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py index ab086b8040..5e8ff2f6b7 100644 --- a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -3,7 +3,7 @@ import pytest -from praisonaiagents import Agent +from praisonaiagents import Agent, AgentTeam, Task class RejectInput: @@ -27,5 +27,38 @@ def test_rejected_input_returns_fixed_guardrail_message(entry): result = "".join(agent.start("blocked prompt", stream=True)) else: result = getattr(agent, entry)("blocked prompt") + assert isinstance(result, str) assert result == expected assert result.strip() + + +@pytest.mark.parametrize("is_async", [False, True]) +def test_team_does_not_complete_or_save_rejected_input(tmp_path, is_async): + """A text apology must not become a successful task or persisted output.""" + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) + path = tmp_path / "output.txt" + task = Task(name="blocked", description="blocked prompt", agent=agent, + output_file=str(path), max_retries=1) + team = AgentTeam(agents=[agent], tasks=[task]) + completed = [] + team.on_task_complete = lambda task, output: completed.append(task) + if is_async: + asyncio.run(team.arun_task(0)) + else: + team.run_task(0) + assert task.status == "failed" + assert task.result is None + assert not path.exists() + assert completed == [] + + +def test_plain_text_is_not_classified_by_its_message(monkeypatch): + """Identical ordinary text remains a successful task, without string matching.""" + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") + text = str(agent._guardrail_blocked_message()) + monkeypatch.setattr(agent, "chat", lambda *args, **kwargs: text) + task = Task(name="ordinary", description="test", agent=agent, max_retries=1) + team = AgentTeam(agents=[agent], tasks=[task]) + team.run_task(0) + assert task.status == "completed" + assert task.result.raw == text From cf7ac667ed7d2db30c2e61ec010ced353a865556 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sun, 25 Oct 2026 19:31:07 +0800 Subject: [PATCH 3/8] test: cover output guardrail blocks through team dispatch --- .../unit/test_input_guardrail_blocked_response.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py index 5e8ff2f6b7..86c8c042b9 100644 --- a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -1,5 +1,6 @@ """Rejected input has the same fixed string contract as rejected output.""" import asyncio +from unittest.mock import AsyncMock, MagicMock import pytest @@ -33,9 +34,17 @@ def test_rejected_input_returns_fixed_guardrail_message(entry): @pytest.mark.parametrize("is_async", [False, True]) -def test_team_does_not_complete_or_save_rejected_input(tmp_path, is_async): - """A text apology must not become a successful task or persisted output.""" - agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) +@pytest.mark.parametrize("blocked_stage", ["input", "output"]) +def test_team_does_not_complete_or_save_rejected_turn(tmp_path, is_async, blocked_stage): + """Input and output blocks must not become successful tasks or saved output.""" + guardrails = RejectInput() if blocked_stage == "input" else lambda output: (False, "rejected") + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=guardrails) + if blocked_stage == "output": + agent.max_guardrail_retries = 0 + agent._using_custom_llm = True + agent.llm_instance = MagicMock() + agent.llm_instance.get_response.return_value = "rejected model output" + agent.llm_instance.get_response_async = AsyncMock(return_value="rejected model output") path = tmp_path / "output.txt" task = Task(name="blocked", description="blocked prompt", agent=agent, output_file=str(path), max_retries=1) From 3d1d00f6657893f6446f7575730fa793a1c27c29 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Mon, 26 Oct 2026 02:11:36 +0800 Subject: [PATCH 4/8] fix: preserve guardrail rejection in alternate start modes --- .../praisonaiagents/agent/execution_mixin.py | 23 +++++- .../test_input_guardrail_blocked_response.py | 77 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py index 9e799007f5..d9f52cd333 100644 --- a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py @@ -197,7 +197,9 @@ async def astart(self, prompt: str, **kwargs): start_time = time_module.time() started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + from ..guardrails.guardrail_result import _GuardrailBlockedResponse response = await self.achat(prompt, **kwargs) + blocked = isinstance(response, _GuardrailBlockedResponse) response_str = str(response) if response else "" # Auto-save session after chat. Offloaded to a worker thread so @@ -210,7 +212,8 @@ async def astart(self, prompt: str, **kwargs): # Wrap in AutonomyResult for consistent API from .autonomy import AutonomyResult return AutonomyResult( - success=True, + success=not blocked, + error=response_str if blocked else None, output=response_str, completion_reason="caller_mode", iterations=1, @@ -672,7 +675,12 @@ def _start_with_planning(self, prompt: str, **kwargs): from rich.console import Console from rich.panel import Panel from rich.markdown import Markdown - + from ..guardrails.guardrail_result import _GuardrailBlockedResponse + + _in_ok, prompt, _in_err = self._validate_input_with_guardrail(prompt) + if not _in_ok: + return self._guardrail_blocked_message(_in_err) + console = _get_console()() # Step 1: Create the plan @@ -715,6 +723,8 @@ def _start_with_planning(self, prompt: str, **kwargs): # Execute the step result = self.chat(step_prompt, **kwargs) + if isinstance(result, _GuardrailBlockedResponse): + return result results.append({"step": i + 1, "description": step.description, "result": result}) # Update context for next step (use full result, not truncated) @@ -853,6 +863,10 @@ def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Gener kwargs['stream'] = self.stream else: kwargs['stream'] = sys.stdout.isatty() + _in_ok, prompt, _in_err = self._validate_input_with_guardrail(prompt) + if not _in_ok: + blocked = self._guardrail_blocked_message(_in_err) + return iter((blocked,)) if kwargs.get('stream') else blocked return self._delegate_to_backend(prompt, **kwargs) # ───────────────────────────────────────────────────────────────────── @@ -890,7 +904,9 @@ def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Gener self._load_history_context() # Use chat() — all tools, approval, doom-loop per-tool work + from ..guardrails.guardrail_result import _GuardrailBlockedResponse response = self.chat(prompt, **kwargs) + blocked = isinstance(response, _GuardrailBlockedResponse) response_str = str(response) if response else "" # Auto-save session after chat @@ -899,7 +915,8 @@ def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Gener # Wrap in AutonomyResult for consistent API from .autonomy import AutonomyResult return AutonomyResult( - success=True, + success=not blocked, + error=response_str if blocked else None, output=response_str, completion_reason="caller_mode", iterations=1, diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py index 86c8c042b9..0af660c2e7 100644 --- a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -61,6 +61,83 @@ def test_team_does_not_complete_or_save_rejected_turn(tmp_path, is_async, blocke assert completed == [] +@pytest.mark.parametrize("stream", [False, True]) +def test_start_backend_rejection_never_dispatches(monkeypatch, stream): + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) + agent.backend = object() + delegate = MagicMock(return_value="backend output") + monkeypatch.setattr(agent, "_delegate_to_backend", delegate) + result = agent.start("blocked prompt", stream=stream) + if stream: + result = "".join(result) + assert result == agent._guardrail_blocked_message() + delegate.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +def test_backend_receives_validated_prompt(monkeypatch, stream): + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") + agent.backend = object() + monkeypatch.setattr(agent, "_validate_input_with_guardrail", + lambda prompt: (True, "validated prompt", None)) + response = iter(("backend output",)) if stream else "backend output" + delegate = MagicMock(return_value=response) + monkeypatch.setattr(agent, "_delegate_to_backend", delegate) + assert agent.start("original prompt", stream=stream) is response + delegate.assert_called_once_with("validated prompt", stream=stream) + + +def test_planning_rejects_before_creating_plan(monkeypatch): + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) + planner = MagicMock() + planner.create_plan_sync.return_value = None + monkeypatch.setattr(agent, "_get_planning_agent", lambda: planner) + result = agent._start_with_planning("blocked prompt") + assert result == agent._guardrail_blocked_message() + planner.create_plan_sync.assert_not_called() + + +@pytest.mark.parametrize("blocked", [False, True]) +def test_planning_stops_after_blocked_step(monkeypatch, blocked): + from types import SimpleNamespace + + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") + plan = MagicMock() + plan.steps = [SimpleNamespace(description="first"), SimpleNamespace(description="second")] + plan.to_markdown.return_value = "two steps" + planner = MagicMock() + planner.create_plan_sync.return_value = plan + monkeypatch.setattr(agent, "_get_planning_agent", lambda: planner) + first = agent._guardrail_blocked_message() if blocked else "first result" + chat = MagicMock(side_effect=[first, "second result", "compiled result"]) + monkeypatch.setattr(agent, "chat", chat) + monkeypatch.setattr(agent, "_validate_input_with_guardrail", + lambda prompt: (True, "validated prompt", None)) + result = agent._start_with_planning("accepted prompt") + planner.create_plan_sync.assert_called_once_with(request="validated prompt", agents=[agent]) + if blocked: + assert result is first + chat.assert_called_once_with("first") + else: + assert result == "compiled result" + assert chat.call_count == 3 + + +@pytest.mark.parametrize("blocked", [False, True]) +@pytest.mark.parametrize("is_async", [False, True]) +def test_caller_mode_preserves_rejection_status(monkeypatch, blocked, is_async): + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", autonomy=True) + response = agent._guardrail_blocked_message() if blocked else "normal response" + monkeypatch.setattr(agent, "chat", lambda *a, **k: response) + monkeypatch.setattr(agent, "achat", AsyncMock(return_value=response)) + monkeypatch.setattr(agent, "_load_history_context", lambda: None) + monkeypatch.setattr(agent, "_auto_save_session", lambda: None) + result = asyncio.run(agent.astart("prompt")) if is_async else agent.start("prompt") + assert result.success is (not blocked) + assert result.output == response + assert result.error == (response if blocked else None) + + def test_plain_text_is_not_classified_by_its_message(monkeypatch): """Identical ordinary text remains a successful task, without string matching.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") From 883504f75428d5e344b1b8e45c9da2509710de66 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 27 Oct 2026 01:12:55 +0800 Subject: [PATCH 5/8] fix: retain rejected results in canonical run outcomes --- .../praisonaiagents/agent/execution_mixin.py | 7 +++++ .../test_input_guardrail_blocked_response.py | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py index d9f52cd333..de261fb23c 100644 --- a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py @@ -297,6 +297,13 @@ def _outcome_for_result(self, result): existing ``completed`` semantics, so behaviour is unchanged on success. """ from .run_outcome import RunOutcome, PROVIDER_BLOCK_REASONS + from .autonomy import AutonomyResult + from ..guardrails.guardrail_result import _GuardrailBlockedResponse + + if isinstance(result, _GuardrailBlockedResponse): + return RunOutcome(reason="failed", output=str(result), error=str(result)) + if isinstance(result, AutonomyResult) and not result.success: + return RunOutcome(reason="failed", output=result.output, error=result.error) 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 diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py index 0af660c2e7..6f44eb3cb4 100644 --- a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -63,6 +63,7 @@ def test_team_does_not_complete_or_save_rejected_turn(tmp_path, is_async, blocke @pytest.mark.parametrize("stream", [False, True]) def test_start_backend_rejection_never_dispatches(monkeypatch, stream): + """Reject backend prompts before delegation in either streaming mode.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) agent.backend = object() delegate = MagicMock(return_value="backend output") @@ -76,6 +77,7 @@ def test_start_backend_rejection_never_dispatches(monkeypatch, stream): @pytest.mark.parametrize("stream", [False, True]) def test_backend_receives_validated_prompt(monkeypatch, stream): + """Pass the once-validated prompt and original return shape to the backend.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") agent.backend = object() monkeypatch.setattr(agent, "_validate_input_with_guardrail", @@ -88,6 +90,7 @@ def test_backend_receives_validated_prompt(monkeypatch, stream): def test_planning_rejects_before_creating_plan(monkeypatch): + """No planner runs for rejected input.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) planner = MagicMock() planner.create_plan_sync.return_value = None @@ -99,6 +102,7 @@ def test_planning_rejects_before_creating_plan(monkeypatch): @pytest.mark.parametrize("blocked", [False, True]) def test_planning_stops_after_blocked_step(monkeypatch, blocked): + """Only accepted steps proceed to later steps and compilation.""" from types import SimpleNamespace agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") @@ -126,6 +130,7 @@ def test_planning_stops_after_blocked_step(monkeypatch, blocked): @pytest.mark.parametrize("blocked", [False, True]) @pytest.mark.parametrize("is_async", [False, True]) def test_caller_mode_preserves_rejection_status(monkeypatch, blocked, is_async): + """Sync and async caller results distinguish safe rejection from success.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", autonomy=True) response = agent._guardrail_blocked_message() if blocked else "normal response" monkeypatch.setattr(agent, "chat", lambda *a, **k: response) @@ -138,6 +143,31 @@ def test_caller_mode_preserves_rejection_status(monkeypatch, blocked, is_async): assert result.error == (response if blocked else None) +@pytest.mark.parametrize("autonomy", [False, True]) +@pytest.mark.parametrize("blocked", [False, True]) +def test_async_outcome_preserves_guardrail_failure(monkeypatch, autonomy, blocked): + """Canonical outcomes must not turn rejected caller results into success.""" + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", autonomy=autonomy) + response = agent._guardrail_blocked_message() if blocked else "normal response" + monkeypatch.setattr(agent, "achat", AsyncMock(return_value=response)) + monkeypatch.setattr(agent, "_auto_save_session", lambda: None) + outcome = asyncio.run(agent.astart("prompt", return_outcome=True)) + assert outcome.reason == ("failed" if blocked else "completed") + assert outcome.error == (str(response) if blocked else None) + + +def test_planning_outcome_preserves_guardrail_failure(monkeypatch): + """A rejected planning request is failed even before a provider stop reason exists.""" + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput()) + agent.planning = True + monkeypatch.setattr(agent, "_get_planning_agent", MagicMock()) + outcome = agent.run("blocked prompt", return_outcome=True) + assert outcome.reason == "failed" + assert outcome.output == agent._guardrail_blocked_message() + assert outcome.error == outcome.output + agent._get_planning_agent.assert_not_called() + + def test_plain_text_is_not_classified_by_its_message(monkeypatch): """Identical ordinary text remains a successful task, without string matching.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") From 6f89765425c2bf660bd6b6a16c4987758ac3fd05 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 27 Oct 2026 13:59:24 +0800 Subject: [PATCH 6/8] fix: keep provider outcomes ahead of generic autonomy failure --- .../praisonaiagents/agent/execution_mixin.py | 4 ++-- .../unit/test_input_guardrail_blocked_response.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py index de261fb23c..7d31ab7947 100644 --- a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py @@ -302,8 +302,6 @@ def _outcome_for_result(self, result): if isinstance(result, _GuardrailBlockedResponse): return RunOutcome(reason="failed", output=str(result), error=str(result)) - if isinstance(result, AutonomyResult) and not result.success: - return RunOutcome(reason="failed", output=result.output, error=result.error) 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 @@ -316,6 +314,8 @@ def _outcome_for_result(self, result): reason = None if reason in PROVIDER_BLOCK_REASONS: return RunOutcome(reason=reason, output=output) + if isinstance(result, AutonomyResult) and not result.success: + return RunOutcome(reason="failed", output=result.output, error=result.error) return RunOutcome.completed(output=output) def run(self, prompt: str, **kwargs: Any) -> Optional[str]: diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py index 6f44eb3cb4..a2ebd00eb3 100644 --- a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -168,6 +168,20 @@ def test_planning_outcome_preserves_guardrail_failure(monkeypatch): agent._get_planning_agent.assert_not_called() +@pytest.mark.parametrize("reason", ["refused", "content_filtered", "length_truncated"]) +def test_provider_reason_precedes_generic_autonomy_failure(reason): + """Iterative autonomy must not hide a recorded provider reason.""" + from praisonaiagents.agent.autonomy import AutonomyResult + + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") + agent._last_stop_reason = reason + result = AutonomyResult(success=False, output="provider output", error="max iterations") + outcome = agent._outcome_for_result(result) + assert outcome.reason == reason + blocked = agent._guardrail_blocked_message() + assert agent._outcome_for_result(blocked).reason == "failed" + + def test_plain_text_is_not_classified_by_its_message(monkeypatch): """Identical ordinary text remains a successful task, without string matching.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") From bf1518e322dd9337cba0464df857654a6303f243 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 27 Oct 2026 22:00:18 +0800 Subject: [PATCH 7/8] fix: preserve caller rejection markers and stop failed task retries --- .../praisonaiagents/agent/execution_mixin.py | 8 ++++---- .../praisonaiagents/agents/agents.py | 6 ++++++ .../test_input_guardrail_blocked_response.py | 18 ++++++++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py index 7d31ab7947..fed8d70420 100644 --- a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py @@ -200,7 +200,7 @@ async def astart(self, prompt: str, **kwargs): from ..guardrails.guardrail_result import _GuardrailBlockedResponse response = await self.achat(prompt, **kwargs) blocked = isinstance(response, _GuardrailBlockedResponse) - response_str = str(response) if response else "" + response_str = response if blocked else (str(response) if response else "") # Auto-save session after chat. Offloaded to a worker thread so # the synchronous FileLock/JSON read-modify-write never stalls @@ -302,6 +302,8 @@ def _outcome_for_result(self, result): if isinstance(result, _GuardrailBlockedResponse): return RunOutcome(reason="failed", output=str(result), error=str(result)) + if isinstance(result, AutonomyResult) and isinstance(result.output, _GuardrailBlockedResponse): + return RunOutcome(reason="failed", output=str(result.output), error=result.error) 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 @@ -314,8 +316,6 @@ def _outcome_for_result(self, result): reason = None if reason in PROVIDER_BLOCK_REASONS: return RunOutcome(reason=reason, output=output) - if isinstance(result, AutonomyResult) and not result.success: - return RunOutcome(reason="failed", output=result.output, error=result.error) return RunOutcome.completed(output=output) def run(self, prompt: str, **kwargs: Any) -> Optional[str]: @@ -914,7 +914,7 @@ def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Gener from ..guardrails.guardrail_result import _GuardrailBlockedResponse response = self.chat(prompt, **kwargs) blocked = isinstance(response, _GuardrailBlockedResponse) - response_str = str(response) if response else "" + response_str = response if blocked else (str(response) if response else "") # Auto-save session after chat self._auto_save_session() diff --git a/src/praisonai-agents/praisonaiagents/agents/agents.py b/src/praisonai-agents/praisonaiagents/agents/agents.py index 0c7b84d817..7c22edda52 100644 --- a/src/praisonai-agents/praisonaiagents/agents/agents.py +++ b/src/praisonai-agents/praisonaiagents/agents/agents.py @@ -1761,6 +1761,9 @@ async def arun_task(self, task_id): logger.debug(f"Attempt {retries+1} for task {task_id}") if task.status in ["not started", "in progress"]: task_output = await self.aexecute_task(task_id) + # Result processing marks guardrail rejection terminally failed. + if task.status == "failed": + return if task_output and self.completion_checker(task, task_output.raw): # Apply guardrail validation using shared helper (offloaded to # a thread so a blocking LLM guardrail does not stall the loop) @@ -2339,6 +2342,9 @@ def run_task(self, task_id): logger.debug(f"Attempt {retries+1} for task {task_id}") if task.status in ["not started", "in progress"]: task_output = self.execute_task(task_id) + # Result processing marks guardrail rejection terminally failed. + if task.status == "failed": + return if task_output and self.completion_checker(task, task_output.raw): # Apply guardrail validation using shared helper task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output) diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py index a2ebd00eb3..f6a1e25c56 100644 --- a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -35,7 +35,7 @@ def test_rejected_input_returns_fixed_guardrail_message(entry): @pytest.mark.parametrize("is_async", [False, True]) @pytest.mark.parametrize("blocked_stage", ["input", "output"]) -def test_team_does_not_complete_or_save_rejected_turn(tmp_path, is_async, blocked_stage): +def test_team_does_not_complete_or_save_rejected_turn(tmp_path, monkeypatch, is_async, blocked_stage): """Input and output blocks must not become successful tasks or saved output.""" guardrails = RejectInput() if blocked_stage == "input" else lambda output: (False, "rejected") agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=guardrails) @@ -45,9 +45,11 @@ def test_team_does_not_complete_or_save_rejected_turn(tmp_path, is_async, blocke agent.llm_instance = MagicMock() agent.llm_instance.get_response.return_value = "rejected model output" agent.llm_instance.get_response_async = AsyncMock(return_value="rejected model output") + dispatch = AsyncMock(wraps=agent.achat) if is_async else MagicMock(wraps=agent.chat) + monkeypatch.setattr(agent, "achat" if is_async else "chat", dispatch) path = tmp_path / "output.txt" task = Task(name="blocked", description="blocked prompt", agent=agent, - output_file=str(path), max_retries=1) + output_file=str(path), max_retries=3) team = AgentTeam(agents=[agent], tasks=[task]) completed = [] team.on_task_complete = lambda task, output: completed.append(task) @@ -59,6 +61,7 @@ def test_team_does_not_complete_or_save_rejected_turn(tmp_path, is_async, blocke assert task.result is None assert not path.exists() assert completed == [] + assert dispatch.call_count == 1 @pytest.mark.parametrize("stream", [False, True]) @@ -149,6 +152,8 @@ def test_async_outcome_preserves_guardrail_failure(monkeypatch, autonomy, blocke """Canonical outcomes must not turn rejected caller results into success.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini", autonomy=autonomy) response = agent._guardrail_blocked_message() if blocked else "normal response" + if blocked: + agent._last_stop_reason = "refused" # A previous turn must not override this block. monkeypatch.setattr(agent, "achat", AsyncMock(return_value=response)) monkeypatch.setattr(agent, "_auto_save_session", lambda: None) outcome = asyncio.run(agent.astart("prompt", return_outcome=True)) @@ -182,6 +187,15 @@ def test_provider_reason_precedes_generic_autonomy_failure(reason): assert agent._outcome_for_result(blocked).reason == "failed" +def test_generic_autonomy_failure_keeps_existing_outcome_contract(): + """Do not broaden a guardrail fix to redefine all autonomy outcomes.""" + from praisonaiagents.agent.autonomy import AutonomyResult + + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") + result = AutonomyResult(success=False, output="partial", completion_reason="max_iterations") + assert agent._outcome_for_result(result).reason == "completed" + + def test_plain_text_is_not_classified_by_its_message(monkeypatch): """Identical ordinary text remains a successful task, without string matching.""" agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") From 6b94786aca88fc381518588ebcbcc2bbd2dd15ed Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 28 Oct 2026 03:20:40 +0800 Subject: [PATCH 8/8] fix: retain retries for empty agent output --- .../praisonaiagents/agents/agents.py | 11 ++++++---- .../test_input_guardrail_blocked_response.py | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/praisonai-agents/praisonaiagents/agents/agents.py b/src/praisonai-agents/praisonaiagents/agents/agents.py index 7c22edda52..7992587bdc 100644 --- a/src/praisonai-agents/praisonaiagents/agents/agents.py +++ b/src/praisonai-agents/praisonaiagents/agents/agents.py @@ -546,6 +546,7 @@ def _process_task_result(agents_instance, context, agent_output): llm = context.llm if isinstance(agent_output, _GuardrailBlockedResponse): + object.__setattr__(task, "_guardrail_rejection_terminal", True) task.status = "failed" return TaskResult(task_output=None, success=False, error="Agent input or output was blocked by a guardrail.") @@ -1760,9 +1761,10 @@ async def arun_task(self, task_id): while task.status != "completed" and retries < task_max: logger.debug(f"Attempt {retries+1} for task {task_id}") if task.status in ["not started", "in progress"]: + object.__setattr__(task, "_guardrail_rejection_terminal", False) task_output = await self.aexecute_task(task_id) - # Result processing marks guardrail rejection terminally failed. - if task.status == "failed": + # Empty output can also mark failure, but remains retryable. + if getattr(task, "_guardrail_rejection_terminal", False): return if task_output and self.completion_checker(task, task_output.raw): # Apply guardrail validation using shared helper (offloaded to @@ -2341,9 +2343,10 @@ def run_task(self, task_id): while task.status != "completed" and retries < task_max: logger.debug(f"Attempt {retries+1} for task {task_id}") if task.status in ["not started", "in progress"]: + object.__setattr__(task, "_guardrail_rejection_terminal", False) task_output = self.execute_task(task_id) - # Result processing marks guardrail rejection terminally failed. - if task.status == "failed": + # Empty output can also mark failure, but remains retryable. + if getattr(task, "_guardrail_rejection_terminal", False): return if task_output and self.completion_checker(task, task_output.raw): # Apply guardrail validation using shared helper diff --git a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py index f6a1e25c56..6d0e793004 100644 --- a/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py +++ b/src/praisonai-agents/tests/unit/test_input_guardrail_blocked_response.py @@ -64,6 +64,27 @@ def test_team_does_not_complete_or_save_rejected_turn(tmp_path, monkeypatch, is_ assert dispatch.call_count == 1 +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("empty", [None, ""]) +def test_team_retries_ordinary_empty_output(monkeypatch, is_async, empty): + agent = Agent(name="T", instructions="test", llm="gpt-4o-mini") + dispatch = AsyncMock(side_effect=[empty, "success"]) if is_async else MagicMock(side_effect=[empty, "success"]) + monkeypatch.setattr(agent, "achat" if is_async else "chat", dispatch) + monkeypatch.setattr("time.sleep", lambda _: None) + async def no_sleep(_): + pass + monkeypatch.setattr("asyncio.sleep", no_sleep) + task = Task(name="empty", description="synthetic", agent=agent, max_retries=3) + team = AgentTeam(agents=[agent], tasks=[task]) + if is_async: + asyncio.run(team.arun_task(0)) + else: + team.run_task(0) + assert dispatch.call_count == 2 + assert task.status == "completed" + assert task.result.raw == "success" + + @pytest.mark.parametrize("stream", [False, True]) def test_start_backend_rejection_never_dispatches(monkeypatch, stream): """Reject backend prompts before delegation in either streaming mode."""