A ceiling that was a schedule, and a reply that ended in silence
Reported: an ordinary chat with a small local model researching a question well -- six searches, each one informed by the last -- stopped at the round limit and produced no answer at all. Two separate faults, and the second is the serious one. The limit was 5 and it should not have been a working number. It was 1 once, and the note beside it already said why that was wrong: a count low enough to be reached by ordinary work is a schedule, not a ceiling, and it overrides the model's judgement on every turn instead of catching a runaway. Five was the same mistake with a larger number. It is 0 now -- no ceiling, falling back to MAX_TOOL_ROUNDS as a runaway backstop, which is the shape `Limits.steps` already had for an agent chat. What bounds an ordinary chat is the context window, which is a real limit rather than a guess at how much looking-up a question deserves. An administrator who wants a ceiling can still set one. The worse fault: *every* budget ended the reply where it was noticed. That is survivable for a model that narrates as it works and produces nothing at all for one that goes straight to tool calls -- an empty bubble with a red line under it, and everything it had gathered thrown away. `_wrap_up` withdraws the tools and asks once more instead. What it found is in the transcript either way; one request turns it into an answer. Same move `plan_submit` makes, and the reason the loop now runs to `budget + 2`: the round at the budget notices, the one after it answers. The event stays, because an answer the model chose to give and one it gave because it ran out of room read identically otherwise. `_too_big` is the one exception and stays a hard stop. It *is* the finding that there is no room for another request, so a wrap-up round would be the same overflow with an upstream error in place of an explanation. `core.keep_working` was gated on the agent family and is now gated on `unbounded`, the exact complement of `round_budget` -- so an ordinary chat with no ceiling is told to work until the job is done rather than being told nothing, and is never told it has a budget of two hundred, which it would ration. The regression test asserts the reply is not empty, and fails with `'' == 'Here is what I found.'` against the old code -- which is exactly what was seen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -167,14 +167,106 @@ async def test_a_model_that_only_ever_calls_tools_is_stopped(db, user_id, monkey
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
# The ceiling, plus the round that has to answer with words.
|
||||
assert len(payloads) == 4
|
||||
# Three rounds that may call tools, the one that notices the ceiling, and
|
||||
# the one asked for an answer with the tools withdrawn.
|
||||
assert len(payloads) == 5
|
||||
# And the last request carried no tools at all, which is what makes it a
|
||||
# round the model can only answer.
|
||||
assert "tools" not in payloads[-1]
|
||||
# Recorded rather than silently dropped: an answer that stops here has to
|
||||
# be explicable.
|
||||
assert generation.tool_events[-1]["status"] == "error"
|
||||
assert "3 rounds" in generation.tool_events[-1]["error"]
|
||||
|
||||
|
||||
async def test_running_out_of_rounds_still_produces_an_answer(db, user_id, monkeypatch):
|
||||
"""The bug this exists for.
|
||||
|
||||
A model that goes straight to tool calls has written no prose at all by the
|
||||
time a budget runs out, so ending the reply there produced an empty bubble
|
||||
with an error line under it -- a good piece of research, six searches deep,
|
||||
thrown away. The tools are withdrawn and it is asked once more instead: what
|
||||
it gathered is in the transcript either way, and one request turns it into
|
||||
an answer.
|
||||
"""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
chat_id, message_id = _chat_with_tools(db, user_id)
|
||||
monkeypatch.setattr("lembas.services.search.run", _empty_search)
|
||||
settings_store.update(db, {"max_chat_rounds": 2})
|
||||
|
||||
payloads: list[dict] = []
|
||||
|
||||
async def stream_chat(_endpoint, payload):
|
||||
payloads.append(payload)
|
||||
# Exactly what a real model does: call tools while it has them, and
|
||||
# answer when it has none.
|
||||
if payload.get("tools"):
|
||||
yield _tool_call_chunk("web_search", '{"query": "x"}')
|
||||
else:
|
||||
yield {"choices": [{"delta": {"content": "Here is what I found."}}]}
|
||||
|
||||
monkeypatch.setattr(generation_service, "stream_chat", stream_chat)
|
||||
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
assert generation.text == "Here is what I found."
|
||||
assert not generation.error
|
||||
# And the reader can still tell this apart from an answer the model chose
|
||||
# to give, which is what the event is for.
|
||||
assert generation.tool_events[-1]["status"] == "error"
|
||||
assert "2 rounds" in generation.tool_events[-1]["error"]
|
||||
|
||||
|
||||
async def test_no_ceiling_by_default(db, user_id):
|
||||
"""A number low enough to be reached by ordinary work is a schedule, not a
|
||||
ceiling. What bounds an ordinary chat is the context window."""
|
||||
assert settings_store.chat_rounds(db) == 0
|
||||
|
||||
|
||||
async def test_without_a_ceiling_the_model_is_told_to_keep_working(db, user_id):
|
||||
"""Exactly one of the two fragments ever appears. With no budget the model
|
||||
must not be left with nothing said about when to stop -- and must certainly
|
||||
not be told it has a budget of two hundred, which it would ration."""
|
||||
from lembas.db.models import User
|
||||
from lembas.services import harness
|
||||
from lembas.services import tools as tools_service
|
||||
|
||||
chat_id, _message_id = _chat_with_tools(db, user_id)
|
||||
chat = db.get(Chat, chat_id)
|
||||
user = db.get(User, user_id)
|
||||
offered = tools_service.resolve_tools(db, chat, user).schemas
|
||||
|
||||
values = harness.context_variables(db, user, offered, chat)
|
||||
assert values["round_budget"] == ""
|
||||
assert values["unbounded"] == "yes"
|
||||
|
||||
text = harness.compose(db, user, offered, chat)
|
||||
assert "Keep working until the task is actually done" in text
|
||||
assert "rounds of tool calls before you have to" not in text
|
||||
|
||||
|
||||
async def test_with_a_ceiling_the_model_is_told_the_budget(db, user_id):
|
||||
from lembas.db.models import User
|
||||
from lembas.services import harness
|
||||
from lembas.services import tools as tools_service
|
||||
|
||||
settings_store.update(db, {"max_chat_rounds": 3})
|
||||
chat_id, _message_id = _chat_with_tools(db, user_id)
|
||||
chat = db.get(Chat, chat_id)
|
||||
user = db.get(User, user_id)
|
||||
offered = tools_service.resolve_tools(db, chat, user).schemas
|
||||
|
||||
values = harness.context_variables(db, user, offered, chat)
|
||||
assert values["round_budget"] == "3"
|
||||
assert values["unbounded"] == ""
|
||||
|
||||
text = harness.compose(db, user, offered, chat)
|
||||
assert "at most 3 rounds" in text
|
||||
assert "Keep working until the task is actually done" not in text
|
||||
|
||||
|
||||
async def test_a_prompt_queued_during_a_one_round_reply_waits_for_its_own(
|
||||
db, user_id, monkeypatch
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user