From b5da5ffebae43bcdec85d92d9fbba4af6388dfd5 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:26:08 +0000 Subject: [PATCH 1/2] fix: surface provider content-filter/refusal/length-truncation as explicit terminal outcomes (fixes #4453) Core now inspects the provider finish_reason/refusal signal and records a distinct, additive terminal reason (content_filtered | refused | length_truncated) instead of collapsing provider-side blocks into a silent empty "completed" or a generic "failed". - run_outcome.py: extend TerminalReason + precedence additively; add a shared classify_finish_reason() helper and PROVIDER_BLOCK_REASONS. - llm.py: add _record_finish_reason() and call it at the non-streaming response capture points on both the sync and async LiteLLM paths (only overrides a "completed" reason so max_steps stays sticky). - chat_mixin.py: record the classification on the OpenAI-native path where the empty-content finish_reason/refusal is already detected; reset the agent-level reason per turn so a prior block never leaks. - agent.py: last_stop_reason surfaces the agent-recorded provider block reason. - execution_mixin.py: _outcome_for_result() threads the provider block reason into RunOutcome for return_outcome=True callers. - run.py (wrapper): map the new reasons to exit code 2 + a human-readable message + a machine-readable reason in --output json, winning over a generic empty-result failure. Backward-compatible: unknown/absent finish reasons behave exactly as today. Co-authored-by: Mervin Praison --- .../praisonaiagents/agent/agent.py | 16 +++- .../praisonaiagents/agent/chat_mixin.py | 13 +++ .../praisonaiagents/agent/execution_mixin.py | 25 ++++- .../praisonaiagents/agent/run_outcome.py | 50 +++++++++- .../praisonaiagents/llm/llm.py | 47 ++++++++++ .../tests/test_run_outcome.py | 63 ++++++++++++- .../praisonai_code/cli/commands/run.py | 92 ++++++++++++++++++- .../tests/unit/test_run_outcome_exit.py | 59 ++++++++++++ 8 files changed, 351 insertions(+), 14 deletions(-) diff --git a/src/praisonai-agents/praisonaiagents/agent/agent.py b/src/praisonai-agents/praisonaiagents/agent/agent.py index adf6e22805..796f26cc3c 100644 --- a/src/praisonai-agents/praisonaiagents/agent/agent.py +++ b/src/praisonai-agents/praisonaiagents/agent/agent.py @@ -3658,10 +3658,16 @@ def last_stop_reason(self) -> str: One of ``"completed"`` (task finished), ``"max_steps"`` (the unified step budget from ``ExecutionConfig.max_steps`` was reached and the run was - truncated) or ``"error"``. Lets CLI/CI callers branch on truncation - instead of parsing a magic string. Reads from whichever backend - (OpenAI-native or LiteLLM) executed the last turn. + truncated), a provider block/refusal/truncation + (``"content_filtered" | "refused" | "length_truncated"``) derived from the + LLM ``finish_reason``/refusal signal, or ``"error"``. Lets CLI/CI callers + branch on the terminal reason instead of parsing a magic string. Reads + from whichever backend (OpenAI-native or LiteLLM) executed the last turn. """ + # OpenAI-native path records the finish-reason classification directly on + # 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) # 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 @@ -3671,8 +3677,10 @@ def last_stop_reason(self) -> str: if backend is None: continue reason = getattr(backend, '_last_stop_reason', None) - if reason: + if reason and reason != "completed": return reason + if own: + return own return "completed" @property diff --git a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py index 5a5581b21c..e122c88d8e 100644 --- a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py @@ -1244,6 +1244,13 @@ def _extract_llm_response_content(self, response) -> Optional[str]: f"Agent {self.name}: model returned no content " f"(finish_reason={finish_reason!r}, refused={bool(refusal)})" ) + # Record a distinct terminal reason so the empty answer is + # actionable end-to-end (RunOutcome / CLI exit / --output + # json) instead of a silent "completed" with empty text. + from .run_outcome import classify_finish_reason + stop_reason = classify_finish_reason(finish_reason, refusal) + if stop_reason is not None: + self._last_stop_reason = stop_reason return "" except (AttributeError, IndexError, TypeError) as e: logging.warning( @@ -1747,6 +1754,12 @@ def _max_retry_depth(self) -> int: def _chat_completion(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0, cancel_token=None): start_time = time.time() + # Reset the agent-level finish-reason classification at the start of each + # OpenAI-native turn 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" + # --- Proactive Context Budget Management (default-on) --- # Analyzes token budget BEFORE LLM call and applies appropriate strategy try: diff --git a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py index 70ae3da19d..959ad60a0b 100644 --- a/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/execution_mixin.py @@ -297,7 +297,28 @@ async def _astart_with_outcome(self, prompt, timeout=None, **kwargs): raise except Exception as exc: # noqa: BLE001 - normalised into outcome return RunOutcome.from_exception(exc) - return RunOutcome.completed(output=str(result) if result is not None else None) + return self._outcome_for_result(result) + + def _outcome_for_result(self, result): + """Map a normally-returned run result into a canonical RunOutcome. + + A run that returned without raising is ``completed`` *unless* the core + recorded a provider block/refusal/truncation on ``last_stop_reason`` from + the LLM ``finish_reason``/refusal signal — in which case the specific, + actionable terminal reason (``content_filtered | refused | + length_truncated``) is surfaced instead of a silent empty ``completed``. + Any other stop reason (``completed``/``max_steps``/unknown) preserves the + existing ``completed`` semantics, so behaviour is unchanged on success. + """ + from .run_outcome import RunOutcome, PROVIDER_BLOCK_REASONS + output = str(result) if result is not None else None + try: + reason = getattr(self, "last_stop_reason", None) + except Exception: + reason = None + if reason in PROVIDER_BLOCK_REASONS: + return RunOutcome(reason=reason, output=output) + return RunOutcome.completed(output=output) def run(self, prompt: str, **kwargs: Any) -> Optional[str]: """Execute agent silently and return structured result. @@ -383,7 +404,7 @@ def _run_with_outcome(self, executor): result = executor() except BaseException as exc: # noqa: BLE001 - normalised into outcome return RunOutcome.from_exception(exc) - return RunOutcome.completed(output=str(result) if result is not None else None) + return self._outcome_for_result(result) def _delegate_to_backend(self, prompt: str, **kwargs) -> Optional[str]: """Delegate execution to external managed backend (e.g., ManagedAgentIntegration). diff --git a/src/praisonai-agents/praisonaiagents/agent/run_outcome.py b/src/praisonai-agents/praisonaiagents/agent/run_outcome.py index df03b7be87..f06eb51352 100644 --- a/src/praisonai-agents/praisonaiagents/agent/run_outcome.py +++ b/src/praisonai-agents/praisonaiagents/agent/run_outcome.py @@ -18,18 +18,36 @@ if Literal is not None: TerminalReason = Literal[ - "completed", "hard_timeout", "cancelled", "aborted", "failed" + "completed", + "hard_timeout", + "cancelled", + "aborted", + "failed", + "content_filtered", + "refused", + "length_truncated", ] else: # pragma: no cover TerminalReason = str # type: ignore +# Provider-side terminal reasons derived from the LLM ``finish_reason``/refusal +# signal. Additive and backward-compatible: absent/unknown finish reasons keep +# the existing ``completed|failed|...`` semantics unchanged. +PROVIDER_BLOCK_REASONS = ("content_filtered", "refused", "length_truncated") + # Precedence: higher wins and is sticky (a hard timeout is not downgraded). +# A specific provider block/refusal/truncation outranks a generic ``failed`` so +# the actionable reason is not masked, but stays below cancellation/timeout, +# which are host-level lifecycle signals. _REASON_PRECEDENCE = { "completed": 0, "failed": 1, - "aborted": 2, - "cancelled": 3, - "hard_timeout": 4, + "content_filtered": 2, + "refused": 2, + "length_truncated": 2, + "aborted": 3, + "cancelled": 4, + "hard_timeout": 5, } @@ -93,3 +111,27 @@ def from_exception( def _name_matches(exc: BaseException, needles: tuple) -> bool: name = type(exc).__name__.lower() return any(n in name for n in needles) + + +def classify_finish_reason(finish_reason, refusal=None): + """Map a provider ``finish_reason``/refusal signal to a terminal reason. + + Returns one of ``content_filtered | refused | length_truncated`` when the + provider blocked/refused/truncated the turn, or ``None`` for a normal stop + (``None``/``"stop"``) or any unrecognised value — so unknown finish reasons + behave exactly as today. Additive and zero-cost on the success path. + """ + if refusal: + return "refused" + if not finish_reason: + return None + fr = str(finish_reason).lower() + if fr in ("stop", "tool_calls", "function_call"): + return None + if "content_filter" in fr or fr == "content_filtered": + return "content_filtered" + if "refus" in fr: + return "refused" + if fr == "length" or "max_tokens" in fr or "truncat" in fr: + return "length_truncated" + return None diff --git a/src/praisonai-agents/praisonaiagents/llm/llm.py b/src/praisonai-agents/praisonaiagents/llm/llm.py index 66291a9da0..e4168b6043 100644 --- a/src/praisonai-agents/praisonaiagents/llm/llm.py +++ b/src/praisonai-agents/praisonaiagents/llm/llm.py @@ -2974,6 +2974,7 @@ def _prepare_return_value(text: str) -> Union[str, tuple]: response_text = resp["choices"][0]["message"]["content"] final_response = resp _final_llm_response = resp # Store for token usage extraction + self._record_finish_reason(resp) # Emit StreamEvent for reasoning content if callback provided if _emit and reasoning_content: @@ -3201,6 +3202,7 @@ def _prepare_return_value(text: str) -> Union[str, tuple]: ) ) _final_llm_response = final_response # Store for token usage extraction + self._record_finish_reason(final_response) # Handle None content from Gemini response_content = final_response["choices"][0]["message"].get("content") response_text = response_content if response_content is not None else "" @@ -3390,6 +3392,7 @@ def _prepare_return_value(text: str) -> Union[str, tuple]: ) ) _final_llm_response = final_response # Store for token usage extraction + self._record_finish_reason(final_response) # Handle None content from Gemini response_content = final_response["choices"][0]["message"].get("content") response_text = response_content if response_content is not None else "" @@ -4840,6 +4843,7 @@ def _inject_steering(msgs) -> None: **{k:v for k,v in kwargs.items() if k != 'reasoning_steps'} ) ) + self._record_finish_reason(resp) reasoning_content = resp["choices"][0]["message"].get("provider_specific_fields", {}).get("reasoning_content") response_text = resp["choices"][0]["message"]["content"] @@ -4948,6 +4952,7 @@ def _inject_steering(msgs) -> None: **{k:v for k,v in kwargs.items() if k != 'reasoning_steps'} ) ) + self._record_finish_reason(tool_response) # Handle None content from Gemini response_content = tool_response.choices[0].message.get("content") response_text = response_content if response_content is not None else "" @@ -5169,6 +5174,7 @@ def _inject_steering(msgs) -> None: **{k:v for k,v in kwargs.items() if k != 'reasoning_steps'} ) ) + self._record_finish_reason(resp) response_text = resp["choices"][0]["message"].get("content") or "" # If the response also contains new tool_calls, treat this as a # tool-calling round rather than a final answer (Anthropic pattern) @@ -5643,6 +5649,47 @@ def _detail_value(detail_names: tuple[str, ...], name: str) -> int: logging.warning(f"Failed to track token usage: {e}") return None + def _record_finish_reason(self, response: Union[Dict[str, Any], Any]) -> None: + """Classify the provider ``finish_reason``/refusal and record it. + + Sets ``self._last_stop_reason`` to a distinct provider block/refusal/ + truncation reason (``content_filtered | refused | length_truncated``) + when the last completion was blocked, so a blocked/refused/truncated turn + is surfaced as an explicit terminal reason instead of a silent empty + ``completed``. Only updates when the reason is still ``"completed"`` so a + prior ``max_steps`` (sticky truncation) is never downgraded. Absent or + unrecognised finish reasons are a no-op — zero overhead on success. + """ + try: + finish_reason = None + refusal = None + if isinstance(response, dict): + choices = response.get("choices") or [] + if choices: + choice = choices[0] + finish_reason = choice.get("finish_reason") + msg = choice.get("message") or {} + if isinstance(msg, dict): + refusal = msg.get("refusal") + else: + refusal = getattr(msg, "refusal", None) + else: + choices = getattr(response, "choices", None) or [] + if choices: + choice = choices[0] + finish_reason = getattr(choice, "finish_reason", None) + msg = getattr(choice, "message", None) + refusal = getattr(msg, "refusal", None) if msg is not None else None + if finish_reason is None and not refusal: + return + from ..agent.run_outcome import classify_finish_reason + reason = classify_finish_reason(finish_reason, refusal) + if reason is not None and self._last_stop_reason == "completed": + self._last_stop_reason = reason + except Exception: + # Never let outcome classification break the response path. + return + def _extract_token_usage(self, response: Union[Dict[str, Any], Any]) -> Optional[TokenUsage]: """Extract token usage from LiteLLM response for public API.""" try: diff --git a/src/praisonai-agents/tests/test_run_outcome.py b/src/praisonai-agents/tests/test_run_outcome.py index 3823c0e1d7..7074bfc0cc 100644 --- a/src/praisonai-agents/tests/test_run_outcome.py +++ b/src/praisonai-agents/tests/test_run_outcome.py @@ -2,7 +2,11 @@ import asyncio -from praisonaiagents.agent.run_outcome import RunOutcome +from praisonaiagents.agent.run_outcome import ( + RunOutcome, + classify_finish_reason, + PROVIDER_BLOCK_REASONS, +) from praisonaiagents.agent.execution_mixin import ExecutionMixin @@ -59,8 +63,9 @@ class _FakeAgent(ExecutionMixin): autonomy_enabled = False stream = None - def __init__(self, behavior): + def __init__(self, behavior, stop_reason="completed"): self.behavior = behavior + self.last_stop_reason = stop_reason def _load_history_context(self): pass @@ -71,6 +76,8 @@ def _auto_save_session(self): def chat(self, prompt, **kwargs): if self.behavior == "ok": return "answer" + if self.behavior == "empty": + return "" raise ValueError("kaboom") async def achat(self, prompt, **kwargs): @@ -146,3 +153,55 @@ async def scenario(): pass else: raise AssertionError("external cancellation should propagate") + + +# --- Provider finish_reason classification (content-filter/refusal/length) --- + + +def test_classify_finish_reason_normal_stops_are_none(): + assert classify_finish_reason(None) is None + assert classify_finish_reason("stop") is None + assert classify_finish_reason("tool_calls") is None + assert classify_finish_reason("function_call") is None + # Unknown/absent finish reasons behave exactly as today. + assert classify_finish_reason("some_new_reason") is None + + +def test_classify_finish_reason_blocks(): + assert classify_finish_reason("content_filter") == "content_filtered" + assert classify_finish_reason("CONTENT_FILTER") == "content_filtered" + assert classify_finish_reason("length") == "length_truncated" + assert classify_finish_reason("max_tokens") == "length_truncated" + # A safety refusal is carried independently of finish_reason. + assert classify_finish_reason("stop", refusal="I can't help with that") == "refused" + + +def test_provider_block_reasons_outrank_failed_but_not_cancel(): + from praisonaiagents.agent.run_outcome import _REASON_PRECEDENCE + for reason in PROVIDER_BLOCK_REASONS: + assert _REASON_PRECEDENCE[reason] > _REASON_PRECEDENCE["failed"] + assert _REASON_PRECEDENCE[reason] < _REASON_PRECEDENCE["cancelled"] + assert _REASON_PRECEDENCE[reason] < _REASON_PRECEDENCE["hard_timeout"] + + +def test_run_outcome_surfaces_provider_block_over_empty_completed(): + # An empty result from a content-filtered turn must surface the specific, + # actionable reason instead of a silent empty "completed". + o = _FakeAgent("empty", stop_reason="content_filtered").run( + "hi", return_outcome=True + ) + assert o.reason == "content_filtered" + assert o.succeeded is False + + +def test_run_outcome_completed_when_no_block(): + o = _FakeAgent("ok", stop_reason="completed").run("hi", return_outcome=True) + assert o.reason == "completed" and o.output == "answer" + + +def test_astart_outcome_surfaces_refusal(): + o = asyncio.run( + _FakeAgent("ok", stop_reason="refused").astart("hi", return_outcome=True) + ) + assert o.reason == "refused" + assert o.succeeded is False diff --git a/src/praisonai-code/praisonai_code/cli/commands/run.py b/src/praisonai-code/praisonai_code/cli/commands/run.py index daf57e424a..3b006fb2f2 100644 --- a/src/praisonai-code/praisonai_code/cli/commands/run.py +++ b/src/praisonai-code/praisonai_code/cli/commands/run.py @@ -59,6 +59,68 @@ def _run_was_truncated(agent: Any) -> bool: return False +# Provider-side terminal reasons the core records on ``agent.last_stop_reason`` +# from the LLM ``finish_reason``/refusal signal. Kept in lockstep with the core +# ``run_outcome.PROVIDER_BLOCK_REASONS`` taxonomy; unknown reasons are ignored so +# the completed/failed contract is preserved when the core reports nothing new. +_BLOCK_REASON_MESSAGES = { + "content_filtered": ( + "Run blocked: the provider's content filter blocked the response.", + "The model provider blocked this response with a content filter. " + "Rephrase the request or adjust provider safety settings.", + ), + "refused": ( + "Run refused: the model declined to answer (safety refusal).", + "The model refused to answer. Rephrase the request or try a different " + "model.", + ), + "length_truncated": ( + "Run truncated: the response hit the model's output length limit.", + "The response was cut off by the model's output length limit. " + "Re-run with a higher --max-tokens.", + ), +} + + +def _run_block_reason(agent: Any) -> Optional[str]: + """Return a provider block/refusal/truncation reason for the last run. + + The core records a distinct terminal reason on ``agent.last_stop_reason`` + when the provider ``finish_reason``/refusal signalled a content filter, + refusal, or length cutoff. Returns that reason when recognised, else None so + the existing completed/failed/max_steps contract is unchanged. + + Best-effort: any agent without the accessor (or a raising one) yields None. + """ + if agent is None: + return None + try: + reason = getattr(agent, "last_stop_reason", None) + except Exception: + return None + return reason if reason in _BLOCK_REASON_MESSAGES else None + + +def _report_run_blocked(output: Any, result: Any, reason: str) -> None: + """Report a provider-blocked/refused/truncated run distinctly and exit 2. + + Mirrors ``_report_run_truncated``: any partial text is preserved in the + emitted result, but the *status* is the specific reason so ``--output json`` + consumers and CI can branch on *why* nothing usable came back. Exits with + code 2 (an incomplete run), distinct from a hard failure (exit 1) and a + clean completion (exit 0). + """ + message, remediation = _BLOCK_REASON_MESSAGES[reason] + text = str(result) if result else None + output.emit_result( + message=message, + data={"status": reason, "result": text}, + ) + if not getattr(output, "is_json_mode", False): + output.print_warning(f"{message} {remediation}") + raise typer.Exit(2) + + def _report_run_failure(output: Any) -> None: """Report an agent-run failure and exit non-zero. @@ -1998,6 +2060,10 @@ def _run_prompt( detach_bridge(agent, bridge) succeeded = _run_succeeded(result) + # A provider content-filter/refusal/length cutoff is a distinct + # terminal reason recorded by the core; surface it (even for an empty + # result) so a blocked run isn't collapsed into a generic ``failed``. + block_reason = _run_block_reason(agent) # A non-empty finalisation summary from a step-limit-truncated run # is not a genuine completion: classify it *before* emitting the # terminal stream event so a `--output stream-json` consumer never @@ -2005,8 +2071,14 @@ def _run_prompt( # ``status: "truncated"`` outcome (exit 2) reported below. truncated = succeeded and _run_was_truncated(agent) if bridge is not None: - bridge.emit_run_result(result, ok=succeeded and not truncated) + bridge.emit_run_result( + result, ok=succeeded and not truncated and not block_reason + ) _record_session_usage(session_id or auto_save_name, model, output) + # A provider block/refusal/truncation wins over a generic empty-result + # failure so the specific, actionable reason is not masked. + if block_reason: + _report_run_blocked(output, result, block_reason) if not succeeded: _report_run_failure(output) # Report the truncated run distinctly (exit 2 + status "truncated") @@ -2580,14 +2652,24 @@ def _run_custom_agent( detach_bridge(agent, bridge) succeeded = _run_succeeded(result) + # A provider content-filter/refusal/length cutoff is a distinct terminal + # reason recorded by the core; surface it (even when the result is empty) + # so a blocked run isn't collapsed into a generic ``failed``. + block_reason = _run_block_reason(agent) # Classify a step-limit-truncated run *before* the terminal stream # event so a `--output stream-json` consumer never receives a # contradictory ``run.result {ok: true}`` ahead of the ``truncated`` # outcome (exit 2) reported below. truncated = succeeded and _run_was_truncated(agent) if bridge is not None: - bridge.emit_run_result(result, ok=succeeded and not truncated) + bridge.emit_run_result( + result, ok=succeeded and not truncated and not block_reason + ) _record_session_usage(session_id or auto_save_name, model, output) + # A provider block/refusal/truncation wins over a generic empty-result + # failure so the specific, actionable reason is not masked. + if block_reason: + _report_run_blocked(output, result, block_reason) if not succeeded: _report_run_failure(output) if result and not output.is_json_mode: @@ -2733,6 +2815,12 @@ def _run_prompt_profiled( # Print profiling report profiler.print_report() + # A provider block/refusal/truncation is a distinct terminal reason (exit 2) + # that wins over a generic empty-result failure, so the specific reason is + # not masked. Reported after the profile so the timing breakdown is shown. + _block_reason = _run_block_reason(agent) + if _block_reason: + _report_run_blocked(get_output_controller(), response, _block_reason) # Honour the same failure contract as the non-profiled paths: an empty # agent result is a run failure and must exit non-zero (the report is # emitted after the profiling output so the profile is still shown). diff --git a/src/praisonai-code/tests/unit/test_run_outcome_exit.py b/src/praisonai-code/tests/unit/test_run_outcome_exit.py index 0ecbae9220..dbc88fa6c5 100644 --- a/src/praisonai-code/tests/unit/test_run_outcome_exit.py +++ b/src/praisonai-code/tests/unit/test_run_outcome_exit.py @@ -137,6 +137,65 @@ def test_report_run_failure_exits_nonzero_and_emits_status(): assert remediation +@pytest.mark.parametrize( + "reason,expected", + [ + ("content_filtered", "content_filtered"), + ("refused", "refused"), + ("length_truncated", "length_truncated"), + ("completed", None), + ("max_steps", None), + ("error", None), + (None, None), + ], +) +def test_run_block_reason_classification(reason, expected): + assert run_cmd._run_block_reason(_StopReasonAgent(reason)) == expected + + +def test_run_block_reason_handles_missing_or_raising_agent(): + assert run_cmd._run_block_reason(None) is None + + class _Raising: + @property + def last_stop_reason(self): + raise RuntimeError("boom") + + assert run_cmd._run_block_reason(_Raising()) is None + + +@pytest.mark.parametrize( + "reason", ["content_filtered", "refused", "length_truncated"] +) +def test_report_run_blocked_exits_two_and_emits_reason_status(reason): + output = _RecordingOutput() + with pytest.raises(typer.Exit) as exc: + run_cmd._report_run_blocked(output, "partial text", reason) + + # A provider block/refusal/truncation is an incomplete run (exit 2), + # distinct from a hard failure (exit 1) and a clean completion (exit 0). + assert exc.value.exit_code == 2 + + # Machine-readable status carries the *specific* reason for --output json. + assert output.results, "expected a result event" + _, result_data = output.results[-1] + assert result_data.get("status") == reason + assert result_data.get("result") == "partial text" + + # Interactive users get a human-facing, actionable message. + assert output.warnings, "expected a human-facing block notice" + + +def test_report_run_blocked_warns_only_in_non_json_mode(): + output = _RecordingOutput() + output.is_json_mode = True + with pytest.raises(typer.Exit): + run_cmd._report_run_blocked(output, "", "refused") + _, result_data = output.results[-1] + assert result_data.get("status") == "refused" + assert not output.warnings, "JSON mode must not print a human warning" + + def test_actions_stream_reports_truncated_run_not_ok(monkeypatch): """A truncated run must emit ``run.result {ok: false}`` on the stream. From 12e70266c4694c01cd3eae37913b744d7b9c720b Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:20:36 +0000 Subject: [PATCH 2/2] fix: record provider finish_reason on async post-tool reasoning path The async reasoning_steps post-tool branch captured the completion but never called _record_finish_reason, so a content-filter/refusal/length cutoff returned after an async reasoning-enabled tool call was silently collapsed into "completed" instead of surfacing the explicit provider terminal reason. Mirrors the sync path which already records it. Co-authored-by: Mervin Praison --- src/praisonai-agents/praisonaiagents/llm/llm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/praisonai-agents/praisonaiagents/llm/llm.py b/src/praisonai-agents/praisonaiagents/llm/llm.py index e4168b6043..879b693d64 100644 --- a/src/praisonai-agents/praisonaiagents/llm/llm.py +++ b/src/praisonai-agents/praisonaiagents/llm/llm.py @@ -5123,6 +5123,7 @@ def _inject_steering(msgs) -> None: **{k:v for k,v in kwargs.items() if k != 'reasoning_steps'} ) ) + self._record_finish_reason(resp) reasoning_content = resp["choices"][0]["message"].get("provider_specific_fields", {}).get("reasoning_content") response_text = resp["choices"][0]["message"]["content"]