7977d4ef25
Two different jobs were sharing one number. A plain conversation asking a question is one round of looking things up and then an answer; the rounds after that were a small model that had decided searching was the answer searching until the context ran out, at a full request each. MAX_ROUNDS is 1 now. Several tools can still be called within that round, which is the thing worth telling the model. The trade is real and worth naming: a plain chat can no longer search and then read one of the results, because reading is a second round. That is what an agent chat is for. An agent chat is sized by Limits instead, where steps is now a runaway backstop and not a working budget. It was 40 and it was reached -- a step count low enough to be the thing that ends a reply is a count that ends it halfway. What bounds one now is the wall clock and a new completion-token ceiling, with zero meaning no ceiling, the same convention index_chars already uses. That ceiling would have been decorative. generation.completion_tokens is only populated when the endpoint sends a usage block, and llama.cpp, Ollama and friends never do; the fallback estimate is computed once, in _run's finally, long after the loop that needs it. So _written takes the larger of reported and estimated, and there is a test that runs the whole thing against a stream reporting no usage at all. A limit that works on OpenAI and silently does nothing everywhere else is the worst kind: one that looks configured. core.rounds could not stay one fragment. "You get at most N rounds" is not the same sentence with a different number in it -- a model told it has a budget rations it and stops early to report progress, which is exactly the behaviour that strands a long piece of work. So it splits: core.rounds keeps the one-round case and gates on a new round_budget variable that _agent_values blanks, and core.keep_working says the other thing to an agent chat. A queued message during a one-round reply is now never taken mid-reply -- there is no work under way to steer -- and falls through to _drain, which gives it a reply of its own. No code change went with that; it falls out of the guard, and there is a test so that "it happens to work" and "it is meant to work" stop looking the same. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
384 lines
14 KiB
Python
384 lines
14 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 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)
|
|
|
|
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)
|
|
|
|
# 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
|
|
# 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"]
|
|
|
|
|
|
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.
|
|
|
|
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
|
|
somebody changes the guard.
|
|
"""
|
|
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"
|