Skip to content
Draft
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
24 changes: 24 additions & 0 deletions mellea/backends/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,30 @@ 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. `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. Fail fast instead.
if not 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
):
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."
)

# 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
24 changes: 24 additions & 0 deletions mellea/backends/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,30 @@ async def generate_from_chat_context(
)
case _:
messages.extend(self.formatter.to_chat_messages([action]))

# Issue #1597: refuse to send an empty user prompt. `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. Fail fast instead.
if not 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
):
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."
)
# 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
24 changes: 24 additions & 0 deletions mellea/backends/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,30 @@ 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. `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. Fail fast instead.
if not 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
):
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."
)

conversation: list[dict] = []

system_prompt = model_opts.get(ModelOption.SYSTEM_PROMPT, "")
Expand Down
24 changes: 22 additions & 2 deletions mellea/stdlib/context/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,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) -> SimpleContext:
"""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 @@ -28,7 +46,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