A ceiling for a chat, and a nudge for an agent that stops early

MAX_ROUNDS = 1 was wrong, and wrong in a way worth writing down. The loop
already ends the moment a round comes back with no tool calls -- that is the
model saying it has what it needs, and it is the termination condition every
agentic harness uses. A round limit was never a schedule; it exists to catch the
case where the model never says so. One is low enough to stop being a ceiling
and start being a schedule: it overrode the model's judgement on every single
turn.

And it broke something concrete. Several built-ins are two-step pairs --
knowledge_get and notes_get read a document "by the id a search returned" -- so
one round left the library searchable and not readable. That is not an edge
case, it is the library working at half depth, and I understated it as "cannot
search the web and then read a result" when the change went in.

It is a setting now, under General, default 5, with 0 meaning no ceiling. The
loop and the harness both read settings_store.chat_rounds, so the model is never
told a budget that is not its own; tools.MAX_ROUNDS is the fallback for callers
with no session and a test pins the two equal. core.rounds goes back to naming
the number, and vanishes entirely when there is no ceiling rather than promising
zero rounds.

The other half of "let it decide how long to go": an agent reply that ends while
its plan still has open tasks is asked once to carry on. Only against a plan,
because that is the one thing there is to be objectively wrong about -- a model
with no plan that says it has finished is believed, and arguing with it would be
guessing. At most twice in a row, with the count reset the moment it calls a
tool again, so the bound is on consecutive stops rather than on stops in total.
Never in Plan mode and never past plan_submit, which ends the turn on purpose.
Giving up is recorded as an event rather than left silent.

The model's own words go back with the nudge, which turned up a real bug on the
way: ReasoningSplitter holds back a few characters against a <think> tag split
across chunks, so round_text at the end of a round was missing its tail. That
text is echoed as an assistant turn for tool rounds too, so a model has been
occasionally asked to continue from a transcript where it trailed off
mid-sentence. Flushed per round now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-03 12:41:36 +02:00
parent 816f2ae957
commit a5fa982ae3
13 changed files with 530 additions and 55 deletions
+55 -13
View File
@@ -151,6 +151,10 @@ async def test_a_model_that_only_ever_calls_tools_is_stopped(db, user_id, monkey
chat_id, message_id = _chat_with_tools(db, user_id)
monkeypatch.setattr("lembas.services.search.run", _empty_search)
# Set explicitly rather than read from the constant: a test that reads the
# number under test passes whatever the number becomes, which is the
# assertion nobody wanted.
settings_store.update(db, {"max_chat_rounds": 3})
payloads = []
monkeypatch.setattr(
@@ -163,30 +167,27 @@ 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)
# One round that may call tools, then one that has to answer with words.
# Spelled out rather than derived from the constant: a test that reads
# MAX_ROUNDS passes whatever MAX_ROUNDS becomes, which is exactly the
# assertion nobody wanted.
assert tools_service.MAX_ROUNDS == 1
assert len(payloads) == 2
# The ceiling, plus the round that has to answer with words.
assert len(payloads) == 4
# Recorded rather than silently dropped: an answer that stops here has to
# be explicable.
assert generation.tool_events[-1]["status"] == "error"
assert "one round" in generation.tool_events[-1]["error"]
assert "3 rounds" in generation.tool_events[-1]["error"]
async def test_a_prompt_queued_during_a_one_round_reply_waits_for_its_own(
db, user_id, monkeypatch
):
"""`_inject` only takes a prompt in while there is a round left to answer in,
and with one round there never is -- so a queued message is not swallowed
into a reply that then has no chance to address it. It waits for `_drain`,
which always gives it a reply of its own.
"""`_inject` only takes a prompt in while there is a round left to answer
in. With a ceiling of one there never is, so a queued message is not
swallowed into a reply that then has no chance to address it -- it waits for
`_drain`, which always gives it a reply of its own.
No code change went with this; it falls out of the guard. The test is here
because "it happens to work" and "it is meant to work" look the same until
One is no longer the default, but it is still a setting somebody can choose,
and "it happens to work" and "it is meant to work" look the same until
somebody changes the guard.
"""
settings_store.update(db, {"max_chat_rounds": 1})
from lembas.db.models import Message
from lembas.services import chat as chat_service
@@ -381,3 +382,44 @@ async def test_a_custom_tool_runs_inside_the_loop(db, user_id, monkeypatch, mock
assert tool_turns[0]["content"] == "Sunny in Minas Tirith."
assert generation.tool_events[0]["kind"] == "custom"
assert generation.tool_events[0]["label"] == "Weather"
def test_the_default_and_the_fallback_cannot_drift(db):
"""`tools.MAX_ROUNDS` exists for callers with no session; the setting is
what the loop and the harness read. Two numbers meaning one thing is how
a model gets told a budget it does not have."""
assert tools_service.MAX_ROUNDS == settings_store.DEFAULT_CHAT_ROUNDS
assert settings_store.chat_rounds(db) == tools_service.MAX_ROUNDS
async def test_a_ceiling_of_zero_does_not_mean_zero_rounds(db, user_id, monkeypatch):
"""It means no ceiling. Read carelessly it would mean the model never gets
to call anything, which is the opposite."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
settings_store.update(db, {"max_chat_rounds": 0})
chat_id, message_id = _chat_with_tools(db, user_id)
monkeypatch.setattr("lembas.services.search.run", _empty_search)
payloads = []
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[[_tool_call_chunk("web_search", '{"query": "x"}')], [_text_chunk("Done.")]],
payloads,
),
)
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 == "Done."
assert len(payloads) == 2, "it called a tool and then answered, uninterrupted"
def test_the_ceiling_is_clamped(db):
settings_store.update(db, {"max_chat_rounds": 9999})
assert settings_store.chat_rounds(db) == 100
settings_store.update(db, {"max_chat_rounds": -5})
assert settings_store.chat_rounds(db) == 0