Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions mellea/backends/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
chat_completion_delta_merge,
extract_model_tool_requests,
get_current_event_loop,
has_user_content,
message_to_openai_message,
prefetch_audio_urls,
send_to_queue,
Expand Down Expand Up @@ -370,6 +371,17 @@ async def _generate_from_chat_context_standard(
case _:
messages.extend(self.formatter.to_chat_messages([action]))

# 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):
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."
)
Comment on lines +377 to +383

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."
)


# TODO: the supports_vision function is not reliably predicting if models support vision. E.g., ollama/llava is not a vision model?
# if any(m.images is not None for m in messages):
# # check if model can handle images
Expand Down
14 changes: 14 additions & 0 deletions mellea/backends/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
DEFAULT_CHUNK_TIMEOUT,
ClientCache,
get_current_event_loop,
has_user_content,
merge_provider_fields,
message_to_openai_message,
messages_to_docs,
Expand Down Expand Up @@ -1076,6 +1077,8 @@ async def generate_from_chat_context(
cannot be downloaded or decoded.
ValueError: If a message contains an `AudioBlock` or `AudioUrlBlock`;
Ollama does not support audio input.
ValueError: If no user-role message has non-whitespace text,
images, audio, or documents.
"""
# Start by awaiting any necessary computation.
await self.do_generate_walk(action)
Expand All @@ -1090,6 +1093,17 @@ async def generate_from_chat_context(
messages: list[Message] = self.formatter.to_chat_messages(linearized_context)
# Add the final message.
messages.extend(self.formatter.to_chat_messages([action]))

# 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.

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."
)
Comment on lines +1100 to +1106

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."
)

# construct the conversation from our messages, adding a system prompt at the first message if one was provided.
conversation: list[dict] = []
# We use system prompt None/empty-string semantics in a way that is consistent with Hugging Face and other libraries.
Expand Down
16 changes: 16 additions & 0 deletions mellea/backends/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
chat_completion_delta_merge,
extract_model_tool_requests,
get_current_event_loop,
has_user_content,
is_vllm_server_with_structured_output,
message_to_openai_message,
messages_to_docs,
Expand Down Expand Up @@ -1366,6 +1367,10 @@ async def generate_from_chat_context(
Returns:
tuple[ModelOutputThunk[C], Context]: A thunk holding the (lazy) model output
and an updated context that includes `action` and the new output.

Raises:
ValueError: If no user-role message has non-whitespace text,
images, audio, or documents.
"""
await self.do_generate_walk(action)

Expand Down Expand Up @@ -1401,6 +1406,17 @@ async def _generate_from_chat_context_standard(
# ALoraRequirement may arrive here when no adapter is registered;
# _generate is responsible for logging a warning in that case.

# 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.

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."
)
Comment on lines +1412 to +1418

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."
)


conversation: list[dict] = []

# Resolve any audio URLs off-thread so the sync serializer below hits the cache
Expand Down
1 change: 1 addition & 0 deletions mellea/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
OPENAI_COMPATIBLE_WIRE_PROVIDERS,
chat_completion_delta_merge,
extract_model_tool_requests,
has_user_content,
merge_provider_fields,
message_to_openai_message,
messages_to_docs,
Expand Down
29 changes: 29 additions & 0 deletions mellea/helpers/openai_compatible_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,3 +618,32 @@ def build_tool_calls(output: ModelOutputThunk) -> list[ToolCallDict] | None:
tool_calls.append(tool_call)

return tool_calls


def has_user_content(messages: list[Message]) -> bool:
"""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 on lines +624 to +639

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.
"""

return any(
m.role == "user"
and (
(m.content and m.content.strip())
or m.images
or m.audio
or getattr(m, "_docs", None)
)
for m in messages
)
24 changes: 22 additions & 2 deletions mellea/stdlib/context/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,29 @@


class SimpleContext(Context):
"""A `SimpleContext` is a context in which each interaction is a separate and independent turn. The history of all previous turns is NOT saved.."""
"""A `SimpleContext` is a context in which each interaction is a separate and independent turn. The history of all previous turns is NOT saved..

Note:
Because `view_for_generation` always returns an empty list, anything
passed to `SimpleContext.add` is **never forwarded to the model** — it
is recorded only on the in-memory context chain. The action passed to
`generate_from_context` (or `MelleaSession.chat`) is the *only* thing
that reaches the model. Combining `.add(...)` with an empty/whitespace
action therefore produces an empty user prompt; the OpenAI, LiteLLM,
and Ollama backends now reject such calls with a `ValueError` (see
issue #1597) rather than sending an empty conversation to the model.
"""

def add(self, c: Span) -> Self:
"""Add a new component or CBlock to the context and return the updated context.

The added span is stored on the context chain but is **not forwarded
to the model on subsequent generations** — `SimpleContext.view_for_generation`
always returns an empty list, so each generation is treated as a
stateless, independent turn. To actually talk to the model, pass the
prompt as the `action` argument to `MelleaSession.chat` /
`Backend.generate_from_context`, not via `add`.

Args:
c (Span): The component, content
block, or model output to record.
Expand All @@ -33,7 +51,9 @@ def view_for_generation(self) -> list[Span] | None:
"""Return an empty list, since `SimpleContext` does not pass history to the model.

Each call to the model is treated as a stateless, independent exchange.
No prior turns are forwarded.
No prior turns are forwarded. Spans recorded via `add` are kept on
the in-memory chain (`as_list`) for inspection but discarded for
generation.

Returns:
list[Span] | None: Always an empty list.
Expand Down
Loading
Loading