Skip to content

fix(backends): reject empty user content before sending chat request - #1632

Open
simonyang08 wants to merge 4 commits into
generative-computing:mainfrom
simonyang08:codex/mellea-1597-empty-user-content-guard
Open

simonyang08 wants to merge 4 commits into
generative-computing:mainfrom
simonyang08:codex/mellea-1597-empty-user-content-guard

Conversation

@simonyang08

Copy link
Copy Markdown

Fixes #1597

Root cause

SimpleContext.view_for_generation() returns [] by design — it is a stateless context. But .add() still exists and returns a chainable context, which reads like it records the turn for the next call. A caller who .add()s a user message and then passes an empty or whitespace-only action to generate_from_context ends up sending the model a conversation with no user-role content at all, and nothing errors. This is exactly what happened in #1587's live Ollama telemetry tests, where Granite 4.2 rambled for 2000+ tokens on the resulting empty prompt and stalled CI.

Fix

Add a shared invariant guard in the chat-message assembly path of the three backends that build user-role conversations for chat APIs — OpenAIBackend._generate_from_chat_context_standard, LiteLLMBackend._generate_from_chat_context_standard, and OllamaModelBackend.generate_from_chat_context:

  • if the fully assembled messages list has no user-role message with non-whitespace content, raise ValueError before any HTTP/SDK call is issued;
  • whitespace-only content counts as empty; user messages that carry images, audio, or documents (_docs) still pass, so vision/RAG paths keep working.

ValueError matches the existing repo convention for "call cannot proceed" (cf. context_lengths.py, intrinsic/_util.py). SimpleContext.add/view_for_generation docstrings now state explicitly that recorded turns are never forwarded, so the .add()-then-empty-action trap is documented at the API surface too.

Note on scope: WatsonxAIBackend and LocalHFBackend also assemble user-role conversations and would benefit from the same guard, but are intentionally left untouched to keep this change focused on the backends named in the issue. Happy to mirror the guard in a follow-up if preferred.

Verification

New regression suite test/backends/test_simple_context_guard.py (mocked providers, no network):

  • empty / whitespace-only action raises on OpenAI, LiteLLM, and Ollama backends — verified RED on unpatched main (DID NOT RAISE ValueError), green after the fix;
  • non-empty user content still sends the request on all three backends;
  • a user message with images passes the guard (multimodal path);
  • the guardian.py call pattern (non-empty context + empty assistant action) is not affected.

Focused backend/context suites: 170 passed. test/stdlib/ regression run shows no new failures relative to unpatched main.

DCO: Signed-off-by: simonyang08 <ppt5928@gmail.com>. AI-assisted contribution per CONTRIBUTING.md (Assisted-by: ZCode (GLM) trailer on the commit).

@github-actions github-actions Bot added the bug Something isn't working label Sep 5, 2026
SimpleContext.view_for_generation() returns [] by design (stateless
context), so a caller who chains .add(...) and then passes an empty or
whitespace-only action to generate_from_context used to ship an
empty user-role conversation to the model. Some chat models — Granite
4.2 in particular, see issue generative-computing#1587 — spin on an empty prompt and burn
tokens silently, which is hard to diagnose in CI.

Raise ValueError in three concrete chat assembly paths:
  * OpenAIBackend._generate_from_chat_context_standard
  * LiteLLMBackend._generate_from_chat_context_standard
  * OllamaModelBackend.generate_from_chat_context

The check ignores whitespace-only strings but still accepts user
messages that carry images, audio, or documents (vision / RAG paths
must continue to work). Each guard raises a ValueError before any
HTTP or SDK call is issued.

WatsonxAIBackend (mellea/backends/watsonx.py) and LocalHFBackend
(mellea/backends/huggingface.py via mellea/backends/utils.py:to_chat)
also assemble user-role conversations and would benefit from the same
guard, but they are intentionally left untouched to keep this change
focused on the backends named in the issue. Happy to mirror the same
guard block in those two backends in a follow-up if preferred.

SimpleContext.add's docstring is updated to make the stateless
semantics explicit (recorded turns are never forwarded; the action
argument is the only thing that reaches the model) and to call out
which backends now enforce the invariant.

Closes generative-computing#1597

Assisted-by: ZCode (GLM)
Signed-off-by: simonyang08 <ppt5928@gmail.com>
Add a TYPE_CHECKING import for ollama so the deferred return annotation
resolves at module level (MyPy name-defined error), and apply ruff format
to satisfy the CI pre-commit style gate. No behavioral change; all 10
guard tests pass.

Assisted-by: ZCode (GLM)
Signed-off-by: simonyang08 <ppt5928@gmail.com>
@simonyang08
simonyang08 force-pushed the codex/mellea-1597-empty-user-content-guard branch from 65c1177 to d93723f Compare September 30, 2026 16:02
@simonyang08
simonyang08 marked this pull request as ready for review October 1, 2026 01:18
@simonyang08
simonyang08 requested a review from a team as a code owner October 1, 2026 01:18

@planetf1 planetf1 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.

Thanks for tracking this down — the root cause write-up (SimpleContext.add() not forwarding to generation) is clear, and the regression test deliberately targeting the guardian false-positive case shows good instinct for where this guard could misfire. Ran the new suite plus test/stdlib/components/test_chat.py: all green, no regressions.

Two things I'd like addressed before merge, both about maintainability rather than correctness of the guard itself:

1. The guard is copy-pasted three times, not shared. The identical ~17-line check (predicate, comment, and raised message) is duplicated verbatim across openai.py, litellm.py, and ollama.py. All three already import from mellea/helpers/openai_compatible_helpers.py, which already hosts message_to_openai_message and messages_to_docs on the same list[Message] input. Could you pull this into a has_user_content(messages: list[Message]) -> bool there and call it from all three sites? Otherwise the Watsonx/HF follow-up you mentioned turns this into five copies, and a future tweak (new attachment type, wording change) has to land in lockstep everywhere.

2. Attachment pass-through is barely tested. The guard treats images, audio, and _docs as "has content" so multimodal/RAG messages don't trip it — but only the images branch is actually tested, and only on OpenAI. audio and documents have no coverage on any backend, and LiteLLM is also missing the whitespace-only case the other two cover. Not asking for nine near-duplicate tests — one audio=-only and one documents=-only case (even just on OpenAI, since the logic is identical across backends) would close the gap.

One scoping note on the PR description: deferring WatsonxAIBackend makes sense, it's already marked deprecated in favour of LiteLLM/OpenAI. LocalHFBackend isn't deprecated and builds its chat messages the same way (view_for_generation() → to_chat_messages()), so the same empty-prompt trap likely reproduces there. Happy for that to be a fast follow-up rather than blocking this PR, but could you open an issue for it so it doesn't fall through?

A couple of small docstring/message nits inline below and noted at the end. Nothing here blocks the fix itself, which is correct and well-placed — once the dedup and the attachment tests are in, this looks good to merge.

On the docstrings (these fall outside the diff's hunks so I can't anchor inline suggestions to them, but worth doing while you're in these files):

  • openai.py — _generate_from_context (the method the Backend ABC actually dispatches to, ~line 904) already has a Raises: section for the ALoraRequirement case. Add: ValueError: If the assembled conversation has no user-role message with non-whitespace content, images, audio, or documents.
  • ollama.py — generate_from_chat_context (~line 1073) already has a Raises: section for the image/audio cases. Same addition there.
  • litellm.py — _generate_from_context (~line 191) has no formal Raises: section yet, just prose ("raises NotImplementedError otherwise"). Worth adding one with both NotImplementedError: If ctx is not a chat context. and the new ValueError line.

Comment thread mellea/backends/openai.py
Comment on lines +1420 to +1426
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model. See "
"issue #1597."
)

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.

Minor: the raised message cites "issue #1597" directly. Worth dropping that from the user-facing string — a library consumer hitting this at runtime has no use for an internal tracking number. Keeping it in the comment above is fine.

Suggested change
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model. See "
"issue #1597."
)
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model."
)

Comment on lines +389 to +395
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model. See "
"issue #1597."
)

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.

Same note as the OpenAI backend — drop the issue number from the user-facing message.

Suggested change
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model. See "
"issue #1597."
)
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model."
)

Comment thread mellea/backends/ollama.py
Comment on lines +1110 to +1116
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model. See "
"issue #1597."
)

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.

Same note as the other two backends — drop the issue number from the user-facing message.

Suggested change
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model. See "
"issue #1597."
)
raise ValueError(
"Refusing to call the model: no user-role content in the assembled "
"conversation. This usually means a stateless context (e.g. "
"SimpleContext) was combined with an empty or whitespace-only "
"action; recorded turns are not forwarded to the model."
)

@planetf1 planetf1 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.

Requesting changes per the two warnings in my review above — the triplicated guard logic should be factored into a shared helper, and the untested attachment pass-through branches (audio, documents) need coverage before this merges. Everything else is non-blocking.

…ontent

Factor the triplicated ~17-line check into
mellea/helpers/openai_compatible_helpers.has_user_content and import it
from all three backends. Add regression coverage for the remaining
attachment pass-through branches: a user message with empty text but
audio, and one with documents, must both reach the model.

Assisted-by: ZCode (GLM)
Signed-off-by: simonyang08 <ppt5928@gmail.com>
@simonyang08

Copy link
Copy Markdown
Author

Both points addressed in 6142775:

1. Shared guard. The triplicated check now lives in mellea/helpers/openai_compatible_helpers.py as has_user_content(messages) (docstring carries the #1597/#1587 rationale, including why whitespace-only text counts as empty and why images/audio/documents count as content), re-exported through mellea.helpers and used by all three backends. The raised message is unchanged.

2. Attachment pass-through coverage. Two new tests pin the previously untested branches: a user message with empty text but an AudioBlock reaches chat.completions.create, and one with documents=[...] (coerced via Message's documents parameter) does too — both assert the guard does not fire and the request is issued.

Verification: guard suite 12/12 (10 existing + 2 new) and the test/stdlib/components/test_chat.py suite you ran both green locally (92 tests total across the two), ruff format + lint clean on the touched files.

@planetf1

planetf1 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Thanks, both blocking points are fixed in 6142775:

  1. Shared guard. has_user_content() in openai_compatible_helpers.py is now the single copy, called from the OpenAI, LiteLLM, and Ollama backends.
  2. Attachment coverage. The new audio and documents tests pass, and each one fails if its branch is removed from the helper, so they pin the right thing.

At 6142775 the guard suite passes 12/12 with all extras installed (LiteLLM tests included) and test_chat.py passes 80/80. I've dismissed my changes-requested review. The inline comments are non-blocking and I'll follow those up separately.

@planetf1
planetf1 dismissed their stale review October 2, 2026 08:29

Both blocking points addressed in 6142775 (shared has_user_content helper, audio and documents test coverage). Details in the comment above.

Comment on lines +624 to +632
"""Whether the assembled conversation carries real user-role content.

Issue #1597: ``SimpleContext`` intentionally discards recorded turns from
``view_for_generation()``, so a caller who chains ``.add(...)`` and then
passes an empty action would otherwise hit the model with no user-role
content at all. Some chat models (e.g. Granite 4.2, see #1587) spin on
empty prompts and burn tokens silently. Whitespace-only text counts as
empty; images, audio, and documents count as content.
"""

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.

The new has_user_content() docstring fails the docs quality gate (audit_coverage.py --quality --fail-on-quality): there's no Args: entry for messages and no Returns: for the bool. That job blocks, so CI will go red once the workflow runs are approved. It also uses double backticks, which AGENTS.md rules out (CI doesn't check those yet, see #1226). This suggestion fixes both, and passes the quality gate and the RST check locally:

Suggested change
"""Whether the assembled conversation carries real user-role content.
Issue #1597: ``SimpleContext`` intentionally discards recorded turns from
``view_for_generation()``, so a caller who chains ``.add(...)`` and then
passes an empty action would otherwise hit the model with no user-role
content at all. Some chat models (e.g. Granite 4.2, see #1587) spin on
empty prompts and burn tokens silently. Whitespace-only text counts as
empty; images, audio, and documents count as content.
"""
"""Whether the assembled conversation carries real user-role content.
Issue #1597: `SimpleContext` intentionally discards recorded turns from
`view_for_generation()`, so a caller who chains `.add(...)` and then
passes an empty action would otherwise hit the model with no user-role
content at all. Some chat models (e.g. Granite 4.2, see #1587) spin on
empty prompts and burn tokens silently. Whitespace-only text counts as
empty; images, audio, and documents count as content.
Args:
messages: The chat messages about to be sent to the model.
Returns:
True if any user-role message has non-whitespace text, images,
audio, or documents; False otherwise.
"""

Comment thread mellea/backends/ollama.py

# Issue #1597: refuse to send an empty user prompt; see
# `has_user_content` for why whitespace-only text still counts as empty.
if not has_user_content(messages):

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.

generate_from_chat_context lists its other ValueError cases under Raises: (line 1074) but not this one. Could you add:

            ValueError: If no user-role message has non-whitespace text,
                images, audio, or documents.

Comment thread mellea/backends/openai.py

# Issue #1597: refuse to send an empty user prompt; see
# `has_user_content` for why whitespace-only text still counts as empty.
if not has_user_content(messages):

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.

This error reaches callers through the public generate_from_chat_context (line 1343), whose docstring has no Raises: section yet. Could you add one:

        Raises:
            ValueError: If no user-role message has non-whitespace text,
                images, audio, or documents.

@planetf1

planetf1 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

For info: I've filed #1705 for the same gap in LocalHFBackend, which builds messages through to_chat() and has no guard. It's out of scope here, so no change needed in this PR.

… ValueError

Per review: add Args/Returns sections to has_user_content, switch its
double backticks to single (AGENTS.md rules out double), and document
the empty-user-content ValueError in generate_from_chat_context on
both the ollama and openai backends. Passes audit_coverage.py
--quality --fail-on-quality (0 issues).

Assisted-by: ZCode (GLM)
Signed-off-by: simonyang08 <ppt5928@gmail.com>
@simonyang08

Copy link
Copy Markdown
Author

All three docstring points addressed in cb6a106:

  1. has_user_content now carries Args: / Returns: exactly as your suggestion (and the double backticks are gone), applied verbatim from the suggestion block.
  2. ollama.py generate_from_chat_context gained the ValueError case in its existing Raises: section.
  3. openai.py generate_from_chat_context gained a Raises: section with the same entry.

Verified locally: audit_coverage.py --quality --fail-on-quality reports 0 issues, ruff format + lint clean on the touched files, guard suite 12/12 green.

This branch has not been deployed

No deployments
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.

SimpleContext.add() content is silently discarded from generation — add a safety net

3 participants