Skip to content

fix(adk): handle empty choices and zero-content streaming chunks from guardrail-blocked responses#1993

Open
combrink wants to merge 4 commits into
kagent-dev:mainfrom
combrink:fix/openai-empty-choices-guardrail
Open

fix(adk): handle empty choices and zero-content streaming chunks from guardrail-blocked responses#1993
combrink wants to merge 4 commits into
kagent-dev:mainfrom
combrink:fix/openai-empty-choices-guardrail

Conversation

@combrink

Copy link
Copy Markdown

Summary

  • Fixes an IndexError when an OpenAI-compatible response has an empty choices list (e.g. an AWS Bedrock guardrail blocks the response entirely).
  • Fixes a streaming case where every chunk is filtered out, leaving an empty parts list which causes downstream IndexErrors.
  • Both cases now return LlmResponse with finish_reason=FinishReason.SAFETY and a placeholder text part, so callers get a recoverable signal instead of a crash.

Motivation / Context

This was triggered by Bedrock guardrail activations on eu.anthropic.claude-sonnet-4-6 via the agentgateway integration. The guardrail correctly blocked content, but the unhandled empty response caused the agent runtime to crash rather than surface a SAFETY finish reason.

Changes

  • _convert_openai_response_to_llm_response (non-streaming): guard against response.choices being empty before indexing into it.
  • BaseOpenAI streaming loop: after consuming all chunks, if final_parts is empty, append a placeholder part and set final_reason = FinishReason.SAFETY.

Test plan

  • Added test_empty_choices_returns_safety_finish_reason covering the non-streaming empty-choices case.
  • Added test_streaming_with_no_content_chunks_returns_safety_finish_reason covering the streaming all-filtered-chunks case.
  • uv run pytest tests/unittests/models/test_openai.py — all 28 tests pass.
  • ruff check passes on changed files.

Copilot AI review requested due to automatic review settings June 10, 2026 14:48
@github-actions github-actions Bot added the bug Something isn't working label Jun 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds guardrail-safe handling for OpenAI responses/streams that contain no content (e.g., fully filtered), preventing IndexError and returning a SAFETY finish reason with a placeholder message.

Changes:

  • Return a SAFETY LlmResponse when ChatCompletion.choices is empty.
  • Ensure streaming always emits a final response even when no content/tool parts were produced.
  • Add unit tests covering both empty-choices and “no content chunks” streaming cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
python/packages/kagent-adk/src/kagent/adk/models/_openai.py Adds fallback logic for empty choices and empty streaming parts to avoid IndexError and mark responses as SAFETY.
python/packages/kagent-adk/tests/unittests/models/test_openai.py Adds tests to ensure empty/filtered responses don’t crash and produce expected SAFETY output.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +319 to +326
if not response.choices:
return LlmResponse(
content=types.Content(
role="model",
parts=[types.Part.from_text(text="Response blocked by content policy.")],
),
finish_reason=types.FinishReason.SAFETY,
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f49c71d — usage extraction is now hoisted above the empty-choices guard and included in the early return.

Comment on lines +593 to +597
# Guardrail or content filter can produce zero content/tool chunks.
# An empty parts list causes downstream IndexError; emit a placeholder.
if not final_parts:
final_parts.append(types.Part.from_text(text="Response blocked by content policy."))
final_reason = types.FinishReason.SAFETY

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f49c71d — an empty stream that finished with length now keeps MAX_TOKENS (with an empty text part to avoid the IndexError) instead of being relabeled SAFETY. Note we intentionally don't narrow the SAFETY fallback to only finish_reason == "content_filter": the motivating case is Bedrock guardrails via agentgateway, which can block without setting that finish reason.

return LlmResponse(
content=types.Content(
role="model",
parts=[types.Part.from_text(text="Response blocked by content policy.")],

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f49c71d — extracted to a module-level _CONTENT_BLOCKED_PLACEHOLDER constant used by both paths.

# Guardrail or content filter can produce zero content/tool chunks.
# An empty parts list causes downstream IndexError; emit a placeholder.
if not final_parts:
final_parts.append(types.Part.from_text(text="Response blocked by content policy."))
Comment on lines +533 to +552
class MockDelta:
role = "assistant"
tool_calls = None
content = None

class MockChunkChoice:
def __init__(self, finish_reason=None):
self.delta = MockDelta()
self.finish_reason = finish_reason
self.index = 0

class MockChunk:
id = "chatcmpl-test"
created = 1234567890
model = "gpt-3.5-turbo"
object = "chat.completion.chunk"
usage = None

def __init__(self, finish_reason=None):
self.choices = [MockChunkChoice(finish_reason)]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving as-is: the existing streaming tests in this module (e.g. test_streaming_usage_metadata_propagation) already define per-test mock chunks, so this follows the established local style. Happy to extract a shared fixture in a follow-up if maintainers prefer.

@combrink combrink requested a review from Copilot June 10, 2026 15:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +597 to +605
# Guardrail or content filter can produce zero content/tool chunks.
# An empty parts list causes downstream IndexError; emit a placeholder.
if not final_parts:
if final_reason == types.FinishReason.MAX_TOKENS:
# Truncated by length before any content; not a safety block.
final_parts.append(types.Part.from_text(text=""))
else:
final_parts.append(types.Part.from_text(text=_CONTENT_BLOCKED_PLACEHOLDER))
final_reason = types.FinishReason.SAFETY

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the broad fallback intentionally: the motivating case is AWS Bedrock guardrails (via agentgateway), which block responses without setting finish_reason == "content_filter" — narrowing to an explicit filter signal would reintroduce the original crash for exactly the case this PR fixes. A stop finish with literally zero content and zero tool calls does not occur in normal operation, so treating it as a filtered response is the safer default. MAX_TOKENS is preserved since that one has a legitimate empty-content interpretation.

Comment on lines +533 to +536
class MockDelta:
role = "assistant"
tool_calls = None
content = None

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 77a0fa0 — renamed to EmptyContentDelta/EmptyContentChunkChoice/EmptyContentChunk.

combrink added 2 commits June 10, 2026 16:56
… guardrail-blocked responses

When an OpenAI-compatible content filter (e.g. an AWS Bedrock guardrail)
blocks a response, it can return a ChatCompletion with an empty choices
list, or a stream where every chunk is filtered out. Both previously
caused an IndexError or an empty-parts response. Return a SAFETY
finish reason with a placeholder part instead.

Signed-off-by: Combrink van der Vyver <combrink@gmail.com>
- Preserve usage_metadata when choices is empty
- Keep MAX_TOKENS finish reason when an empty stream was truncated by
  length rather than blocked by a content filter
- Centralize the blocked-content placeholder in a module constant

Signed-off-by: Combrink van der Vyver <combrink@gmail.com>
@combrink combrink force-pushed the fix/openai-empty-choices-guardrail branch from f49c71d to 834673e Compare June 10, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants