2727from ....agents .invocation_context import InvocationContext
2828from ....agents .readonly_context import ReadonlyContext
2929from ....events .event import Event
30+ from ....features import FeatureName
31+ from ....features import is_feature_enabled
3032from ....live .live_request_queue import LiveRequestQueue
3133from ....models .llm_request import LlmRequest
3234from ....models .llm_response import LlmResponse
3335from ....telemetry .tracing import trace_call_llm
3436from ....telemetry .tracing import tracer
3537from ....utils ._runner_utils import _with_caller_context
3638from ....utils .context_utils import Aclosing
39+ from ._finalizer import has_meaningful_content
3740from ._utils import as_llm_agent as _as_llm_agent
3841from ._utils import require_run_config as _require_run_config
3942
4750NO_CONTENT_ERROR_MESSAGE = (
4851 'The model returned no content (finish_reason=STOP with empty parts).'
4952)
53+ NO_MEANINGFUL_CONTENT_ERROR_MESSAGE = (
54+ 'The model returned no actionable content (finish_reason=STOP with'
55+ ' thought-only or whitespace-only parts).'
56+ )
5057
5158
5259def apply_empty_response_policy (
5360 invocation_context : InvocationContext ,
5461 llm_response : LlmResponse ,
5562) -> None :
56- """Marks non-streaming empty STOP responses with NO_CONTENT_ERROR_CODE.
57-
58- A non-streaming turn that finishes with STOP but has no content parts would
59- otherwise be skipped and become a silent empty final response; surface it as
60- an actionable error instead. Streaming is excluded because a terminal
61- finish-only chunk legitimately follows content already streamed in earlier
62- chunks.
63+ """Marks terminal STOP responses that lack meaningful content as errors.
64+
65+ A turn that finishes with STOP but has no meaningful content (empty parts,
66+ thought-only parts, or whitespace-only text) would otherwise be skipped or
67+ treated as a complete answer; surface it as an actionable error instead.
68+ In SSE streaming, progressive SSE aggregates the entire turn into a single
69+ non-partial response, so a non-partial response carrying only thought or
70+ whitespace parts represents a completed turn with no answer, whereas empty
71+ parts (a terminal finish-only chunk) and non-progressive SSE (where the
72+ aggregator emits a non-partial thought-only chunk before a function call)
73+ are excluded.
6374
6475 This must run before the response processors. Emptiness is a property of
6576 what the model returned, so it can only be judged before local processing
@@ -68,17 +79,27 @@ def apply_empty_response_policy(
6879 once it has run the code and emitted its result.
6980 """
7081 run_config = _require_run_config (invocation_context )
82+ has_parts = bool (llm_response .content and llm_response .content .parts )
7183 if (
7284 not llm_response .partial
7385 and llm_response .error_code is None
7486 and llm_response .finish_reason == types .FinishReason .STOP
75- and (not llm_response .content or not llm_response .content .parts )
76- and run_config .streaming_mode != StreamingMode .SSE
87+ and not has_meaningful_content (llm_response )
88+ and (
89+ run_config .streaming_mode != StreamingMode .SSE
90+ or (
91+ has_parts
92+ and is_feature_enabled (FeatureName .PROGRESSIVE_SSE_STREAMING )
93+ )
94+ )
7795 ):
7896 llm_response .error_code = NO_CONTENT_ERROR_CODE
79- llm_response .error_message = (
80- llm_response .error_message or NO_CONTENT_ERROR_MESSAGE
97+ default_message = (
98+ NO_MEANINGFUL_CONTENT_ERROR_MESSAGE
99+ if has_parts
100+ else NO_CONTENT_ERROR_MESSAGE
81101 )
102+ llm_response .error_message = llm_response .error_message or default_message
82103
83104
84105async def resolve_llm (invocation_context : InvocationContext ) -> BaseLlm :
0 commit comments