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:
@@ -21,6 +21,11 @@ from lembas.db.models import Setting
|
||||
|
||||
GENERAL = "general"
|
||||
AUDIO = "audio"
|
||||
|
||||
# Used when nothing is stored. `services/tools.py:MAX_ROUNDS` is the same number
|
||||
# and exists for callers with no session -- this module is where the setting is
|
||||
# read, and the two are asserted equal by a test so they cannot drift.
|
||||
DEFAULT_CHAT_ROUNDS = 5
|
||||
SEARCH = "search"
|
||||
PROMPTS = "prompts"
|
||||
AGENTS = "agents"
|
||||
@@ -42,6 +47,17 @@ def _general_defaults() -> dict[str, Any]:
|
||||
# Never fires for a model whose context_length is 0, since that is
|
||||
# "unknown" rather than "small". See services/compaction.py.
|
||||
"compact_threshold": 95,
|
||||
# How many rounds of tool calls an ordinary chat may take before it has
|
||||
# to answer with words. A **ceiling**, not a schedule: the loop already
|
||||
# ends the moment a round comes back with no tool calls, which is the
|
||||
# model saying it has what it needs. This only catches the case where it
|
||||
# never says so.
|
||||
#
|
||||
# Five rather than one because several built-in tools are two-step pairs
|
||||
# -- knowledge_get and notes_get read a document "by the id a search
|
||||
# returned" -- so a ceiling of one makes the second half unreachable and
|
||||
# the library searchable but not readable. Zero means no ceiling.
|
||||
"max_chat_rounds": 5,
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +132,11 @@ def _agents_defaults() -> dict[str, Any]:
|
||||
# untrusted, and the fragment carrying it is where that is dealt with.
|
||||
"instructions_enabled": True,
|
||||
"instructions_chars": 4000,
|
||||
# Whether a reply that ends while its plan still has open tasks is told
|
||||
# once to carry on. Only ever fires 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.
|
||||
"nudge_unfinished": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -214,6 +235,20 @@ def get(db: DBSession, name: str, *, key: str = GENERAL) -> Any:
|
||||
return get_group(db, key).get(name)
|
||||
|
||||
|
||||
def chat_rounds(db: DBSession) -> int:
|
||||
"""The ceiling on an ordinary chat's rounds of tool calls, clamped.
|
||||
|
||||
Zero is meaningful and is not clamped away: it means "no ceiling", the same
|
||||
convention `index_chars` and `max_completion_tokens` use. Read through here
|
||||
rather than from the group directly so the loop and the harness cannot
|
||||
disagree about the number the model is told.
|
||||
"""
|
||||
stored = get_group(db, GENERAL).get("max_chat_rounds")
|
||||
if stored is None:
|
||||
return DEFAULT_CHAT_ROUNDS
|
||||
return min(max(int(stored), 0), 100)
|
||||
|
||||
|
||||
def update(db: DBSession, changes: dict[str, Any], *, key: str = GENERAL) -> dict[str, Any]:
|
||||
"""Merge changes into a settings group and persist them."""
|
||||
row = db.get(Setting, key)
|
||||
|
||||
Reference in New Issue
Block a user