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:
@@ -30,7 +30,7 @@ from lembas.db.models import KIND_AGENT, ROLE_ASSISTANT, ROLE_USER, Chat, Messag
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import interaction, tokens, tool_labels
|
||||
from lembas.services import interaction, settings_store, tokens, tool_labels
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import tools as tools_service
|
||||
@@ -58,6 +58,17 @@ RENDER_INTERVAL = 0.1
|
||||
# gets the final frames, then are pruned.
|
||||
KEEP_FINISHED = timedelta(minutes=5)
|
||||
|
||||
# What "no ceiling" resolves to. A setting of 0 means an administrator does not
|
||||
# want a round limit, but a loop needs *some* stop or a model stuck calling one
|
||||
# cheap tool runs until the process does. This is high enough never to be
|
||||
# reached by anything but that.
|
||||
MAX_TOOL_ROUNDS = 200
|
||||
|
||||
# How many times in a row a reply that stopped with plan tasks outstanding may
|
||||
# be told to carry on. Two, so a model that genuinely has nothing left to do can
|
||||
# say so and be believed rather than argued with indefinitely.
|
||||
MAX_NUDGES = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class Generation:
|
||||
@@ -142,6 +153,10 @@ class Generation:
|
||||
# -- the one frame that reaches a browser after a reply is over.
|
||||
drained: bool = False
|
||||
injected_ids: list[str] = field(default_factory=list)
|
||||
# How many times *in a row* this reply has ended with plan tasks still open
|
||||
# and been told to carry on. Reset the moment it calls a tool again, so the
|
||||
# count is of consecutive stops rather than of stops in total.
|
||||
nudges: int = 0
|
||||
|
||||
def touch(self) -> None:
|
||||
self.version += 1
|
||||
@@ -363,11 +378,18 @@ async def _run(generation: Generation) -> None:
|
||||
# vision model, a plain string to anything else, or the endpoint
|
||||
# rejects the whole request.
|
||||
vision = chat_service.model_supports(db, chat, "vision")
|
||||
chat_rounds = settings_store.chat_rounds(db)
|
||||
nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
|
||||
|
||||
generation.prompt_estimate = tokens.estimate_request(payload)
|
||||
|
||||
limits = tool_context.agent.limits if tool_context.agent else None
|
||||
budget = limits.steps if limits else tools_service.MAX_ROUNDS
|
||||
# A ceiling, not a schedule -- the loop below ends the moment a round
|
||||
# produces no tool calls, which is the model saying it is done. Zero
|
||||
# means an ordinary chat has no ceiling either; `steps` is already a
|
||||
# runaway backstop rather than a budget, so an agent chat is bounded by
|
||||
# tokens and the clock instead.
|
||||
budget = limits.steps if limits else (chat_rounds or MAX_TOOL_ROUNDS)
|
||||
|
||||
for round_number in range(budget + 1):
|
||||
generation.rounds = round_number + 1
|
||||
@@ -441,9 +463,47 @@ async def _run(generation: Generation) -> None:
|
||||
# Let followers and other tasks run between chunks.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# The round is over, so anything the splitter is still holding back
|
||||
# against a `<think>` tag split across chunks is not a tag. Flushed
|
||||
# here rather than only after the loop, because `round_text` is
|
||||
# echoed back as an assistant turn -- for a tool round and for a
|
||||
# nudge alike -- and a turn missing its last few words is a turn the
|
||||
# model is asked to continue from having apparently trailed off.
|
||||
for kind, piece in splitter.flush():
|
||||
if kind == REASONING:
|
||||
generation.reasoning.append(piece)
|
||||
else:
|
||||
generation.content.append(piece)
|
||||
round_text.append(piece)
|
||||
|
||||
calls = accumulator.calls
|
||||
if generation.stopped or not calls:
|
||||
break
|
||||
# The model says it is done. Believe it -- unless this is an
|
||||
# agent chat whose plan still has work in it, in which case ask
|
||||
# once. `_nudge` returns the turn to send, or None.
|
||||
added = _nudge(
|
||||
generation,
|
||||
tool_context,
|
||||
enabled=nudge_enabled,
|
||||
stopped=generation.stopped,
|
||||
round_number=round_number,
|
||||
budget=budget,
|
||||
)
|
||||
if added is None:
|
||||
break
|
||||
# Its own words go back with the nudge. Without the assistant
|
||||
# turn the model is asked to carry on from a transcript in which
|
||||
# it never spoke, and repeats itself.
|
||||
said = "".join(round_text).strip()
|
||||
messages = [*payload["messages"]]
|
||||
if said:
|
||||
messages.append({"role": "assistant", "content": said})
|
||||
payload = {**payload, "messages": [*messages, added]}
|
||||
continue
|
||||
|
||||
# Something was called, so whatever it said it had finished, it had
|
||||
# not. The count is of *consecutive* stops.
|
||||
generation.nudges = 0
|
||||
|
||||
if round_number == budget:
|
||||
# Out of rounds with the model still asking for tools. Recorded
|
||||
@@ -631,7 +691,6 @@ async def _warm_project(generation: Generation) -> None:
|
||||
the new one was silently never warmed on any chat that had a listing --
|
||||
which is to say, on every chat after the first reply.
|
||||
"""
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import index as index_service
|
||||
from lembas.services.agent import instructions as instructions_service
|
||||
from lembas.services.agent import session as agent_session
|
||||
@@ -758,6 +817,89 @@ def _gave_up(generation, why: str) -> None:
|
||||
generation.touch()
|
||||
|
||||
|
||||
def _nudge(
|
||||
generation: Generation,
|
||||
context,
|
||||
*,
|
||||
enabled: bool,
|
||||
stopped: bool,
|
||||
round_number: int,
|
||||
budget: int,
|
||||
) -> dict | None:
|
||||
"""The turn telling an agent to carry on, or None to let the reply end.
|
||||
|
||||
A model that stops with work outstanding is the failure `core.keep_working`
|
||||
is worded against, and prompting is the cheaper half of the fix. This is the
|
||||
other half, and it only fires where there is something objective to check
|
||||
against: an open task on the chat's own plan. Without a plan there is
|
||||
nothing to be wrong about, so nothing happens -- a model that has genuinely
|
||||
finished must be able to say so and be believed.
|
||||
|
||||
Every "no" is a plain None:
|
||||
|
||||
* the setting is off, or the reply was stopped, or it errored;
|
||||
* this is not an agent chat, or is one in Plan mode -- `plan_submit` ends
|
||||
the turn deliberately and nudging past it would be arguing with the whole
|
||||
point of the mode;
|
||||
* there is no plan, or every task on it is done or dropped;
|
||||
* there is no round left to carry on in, or it has already been asked
|
||||
MAX_NUDGES times in a row.
|
||||
|
||||
The last one is recorded rather than silent. A reply that stopped twice with
|
||||
work outstanding is worth being able to see afterwards.
|
||||
"""
|
||||
agent = getattr(context, "agent", None)
|
||||
if not enabled or stopped or generation.error or agent is None:
|
||||
return None
|
||||
if agent.mode == agent_policy.MODE_PLAN or generation.plan_final:
|
||||
return None
|
||||
|
||||
plan = generation.plan if generation.plan is not None else agent.plan
|
||||
open_tasks = [
|
||||
task
|
||||
for phase in (plan or {}).get("phases", [])
|
||||
for task in phase.get("tasks", [])
|
||||
if task.get("status") not in ("done", "dropped")
|
||||
]
|
||||
if not open_tasks:
|
||||
return None
|
||||
if round_number >= budget:
|
||||
return None
|
||||
|
||||
if generation.nudges >= MAX_NUDGES:
|
||||
generation.tool_events.append(
|
||||
{
|
||||
"name": "plan_update",
|
||||
"kind": "plan",
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"Stopped with {len(open_tasks)} task(s) still open on the "
|
||||
f"plan, after being asked twice to carry on."
|
||||
),
|
||||
"results": [],
|
||||
}
|
||||
)
|
||||
generation.touch()
|
||||
return None
|
||||
|
||||
generation.nudges += 1
|
||||
remaining = "\n".join(f"- {task['id']} {task['text']}" for task in open_tasks[:8])
|
||||
# A user turn, and phrased as the reader would phrase it. Everything else
|
||||
# this codebase injects is quoted and attributed because it came out of a
|
||||
# file or a machine; this is the application speaking on the reader's behalf
|
||||
# about the reader's own plan, which is the one case where that is honest.
|
||||
return {
|
||||
"role": "user",
|
||||
"content": (
|
||||
"The plan still has work in it:\n"
|
||||
f"{remaining}\n\n"
|
||||
"Carry on with the next one. If something here cannot be done, or is "
|
||||
"no longer worth doing, mark it dropped with plan_update and say why "
|
||||
"— do not leave it open and stop."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _written(generation: Generation) -> int:
|
||||
"""How much this reply has written so far, in tokens, reported or estimated.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user