-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix: surface provider content-filter/refusal/length-truncation as explicit terminal outcomes #4472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Comment on lines
+21
to
+28
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Update the public outcome documentation.
🤖 Prompt for AI Agents |
||
| ] | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 "" | ||
|
|
@@ -5118,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"] | ||
|
|
||
|
|
@@ -5169,6 +5175,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 +5650,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 | ||
|
Comment on lines
+5653
to
+5689
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- repository conventions ---'
head -5 /tmp/coderabbit-repo-knowledge/mervinpraison-praisonai-dcd467e7/*/*.md 2>/dev/null || true
printf '%s\n' '--- target symbols and nearby code ---'
rg -n -C 8 "_record_finish_reason|Responses API|response\.output|stream" src/praisonai-agents/praisonaiagents/llm/llm.py | head -420
printf '%s\n' '--- outcome classifier ---'
rg -n -C 12 "def classify_finish_reason|class RunOutcome|_last_stop_reason" src/praisonai-agents/praisonaiagents/agent/run_outcome.py src/praisonai-agents/praisonaiagents/llm/llm.py
printf '%s\n' '--- direct callers ---'
rg -n -C 5 "_record_finish_reason" srcRepository: MervinPraison/PraisonAI Length of output: 50379 🏁 Script executed: #!/bin/bash
set -e
file=src/praisonai-agents/praisonaiagents/llm/llm.py
printf '%s\n' '--- synchronous response paths ---'
sed -n '2728,2815p' "$file"
sed -n '3098,3255p' "$file"
printf '%s\n' '--- asynchronous response paths ---'
rg -n "^ async def |_supports_responses_api|_call_responses_api|_stream_responses_api|_extract_from_responses_output|_record_finish_reason" "$file" | tail -100
printf '%s\n' '--- finish-reason method and classifier ---'
sed -n '5635,5705p' "$file"
sed -n '116,145p' src/praisonai-agents/praisonaiagents/agent/run_outcome.py
printf '%s\n' '--- outcome consumers ---'
rg -n -C 8 "_last_stop_reason|RunOutcome" src/praisonai-agents/praisonaiagents | tail -220Repository: MervinPraison/PraisonAI Length of output: 47914 🏁 Script executed: #!/bin/bash
set -e
file=src/praisonai-agents/praisonaiagents/llm/llm.py
printf '%s\n' '--- async Responses API and Chat Completions paths ---'
sed -n '4688,4740p' "$file"
sed -n '4800,4870p' "$file"
sed -n '5090,5200p' "$file"
printf '%s\n' '--- remaining synchronous stream/final-response handling ---'
sed -n '3180,3420p' "$file"
printf '%s\n' '--- Responses API helpers ---'
sed -n '6180,6515p' "$file"Repository: MervinPraison/PraisonAI Length of output: 45190 🏁 Script executed: #!/bin/bash
set -e
file=src/praisonai-agents/praisonaiagents/llm/llm.py
printf '%s\n' '--- synchronous return and final-response flow ---'
sed -n '3380,3475p' "$file"
printf '%s\n' '--- asynchronous streaming completion flow ---'
sed -n '5170,5285p' "$file"
printf '%s\n' '--- LLM stop-reason exposure ---'
rg -n -C 10 "def last_stop_reason|last_stop_reason =|_last_stop_reason" src/praisonai-agents/praisonaiagents/llm/llm.py src/praisonai-agents/praisonaiagents/agent/agent.py
printf '%s\n' '--- relevant tests and dependency declarations ---'
rg -n -g '*.py' "_record_finish_reason|finish_reason|Responses API|responses_api|last_stop_reason" src/praisonai-agents/tests tests 2>/dev/null | head -240
rg -n -i "litellm|openai" src/praisonai-agents/pyproject.toml src/praisonai-agents/requirements*.txt pyproject.toml requirements*.txt 2>/dev/null | head -120Repository: MervinPraison/PraisonAI Length of output: 50379 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- dependency versions ---'
rg -n -i "litellm|openai" --glob 'pyproject.toml' --glob 'requirements*.txt' --glob 'setup.cfg' --glob 'setup.py' --glob 'poetry.lock' --glob 'uv.lock' .
printf '%s\n' '--- focused response API tests ---'
sed -n '1,260p' src/praisonai-agents/tests/integration/test_responses_api.py
printf '%s\n' '--- focused outcome tests ---'
sed -n '145,215p' src/praisonai-agents/tests/test_run_outcome.pyRepository: MervinPraison/PraisonAI Length of output: 47729 🌐 Web query:
💡 Result: In the OpenAI Responses API, the response status indicates the generation state, and incomplete status is explicitly handled through specific event types and details objects [1][2][3]. 1. Response Status and Incomplete Details: The Citations:
Record terminal reasons for Responses API and streaming completions. The Responses API paths discard 🤖 Prompt for AI Agents |
||
| 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: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: MervinPraison/PraisonAI
Length of output: 42007
🏁 Script executed:
Repository: MervinPraison/PraisonAI
Length of output: 50381
🏁 Script executed:
Repository: MervinPraison/PraisonAI
Length of output: 50379
Reset
_last_stop_reasonfor async native turns_achat_impldoes not resetself._last_stop_reasonbefore calling_extract_llm_response_content. That method records blocked or refused responses but does not clear the flag for normal responses. A successful async turn can therefore leaveAgent.last_stop_reasonreporting the previous turn's reason. Add the reset at the start of_achat_impl;agent.pyonly consumes the value.📍 Affects 2 files
src/praisonai-agents/praisonaiagents/agent/chat_mixin.py#L1754-L1762(this comment)src/praisonai-agents/praisonaiagents/agent/agent.py#L3656-L3684🤖 Prompt for AI Agents