Say what a tool did, not where it ran

An agent event set its label to the SSH profile's name, so the transcript read
"homeserver · ls -la" -- naming the machine rather than the thing that was done.
Built-in tools set no label at all and fell back to the function name, so a
saved memory read "memory_add". The status line said "Running shell_run…" and
the approval card had its own hand-written wording. Four places, four answers,
nothing checking that any of them agreed.

services/tool_labels.py is the one table all of them read now. Bash, Read,
Write, List, Web search, Memory saved; an icon each, instead of everything
being the sparkle.

The precedence is inverted on purpose. Tool events are persisted in
Message.tool_calls_json, so every agent row already on disk carries the profile
name -- a resolver that preferred the stored value would fix nothing for any
transcript that already exists. So a name the table knows resolves from the
table, and a name it does not -- a custom HTTP tool, an MCP tool, whose labels
are per row and cannot be tabulated -- keeps its own. One rule, both cases
correct. The machine moves to `detail`, where "where this ran" belongs.

tool_label and tool_icon are Jinja globals because a message bubble is rendered
from four handlers, and a fifth thing each of them must remember to pass is a
fifth thing one of them will forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-03 10:58:02 +02:00
parent 52770d7ab1
commit a58e48fce5
8 changed files with 352 additions and 26 deletions
+3 -1
View File
@@ -235,7 +235,9 @@ async def test_the_status_names_the_running_tool_and_is_cleared(db, user_id, mon
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
await generation_service._run(generation)
assert seen == ["Running web_search…"]
# 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"
+71 -1
View File
@@ -7,6 +7,12 @@ and everything in it is third-party text.
from __future__ import annotations
import re
from pathlib import Path
from lembas.services import tool_labels
from lembas.services import tools as tools_service
from lembas.services.agent import tools as agent_tools
from lembas.web.templating import templates
@@ -43,7 +49,7 @@ def test_a_library_tool_no_longer_claims_to_have_searched_the_web():
and "Searched the web for <the note title>"."""
html = _render({"name": "notes_search", "query": "shopping", "status": "ok", "results": []})
assert "Searched the web" not in html
assert "notes_search" in html
assert "Notes searched" in html
def test_a_custom_tool_is_named_and_its_host_shown():
@@ -122,3 +128,67 @@ def test_a_failure_shows_its_reason():
assert "tool-activity--error" in html
assert "Weather failed" in html
assert "HTTP 503" in html
# --- What a tool is called -----------------------------------------------------
def test_a_stored_profile_name_no_longer_becomes_the_label():
"""The whole point of the inversion.
Every agent event written before today carries `label` set to the SSH
profile's name, so the transcript said "homeserver · ls -la" and named the
machine rather than the thing that was done. Those rows are on disk and are
re-rendered on every page load, so the fix has to reach them -- which means
the static table wins over the stored value, not the other way round.
"""
html = _render(
{
"name": "shell_run",
"kind": "agent",
"label": "homeserver",
"query": "ls -la",
"detail": "homeserver:/srv/app",
"status": "ok",
"results": [],
}
)
summary = html.split("</summary>")[0]
assert "Bash" in summary
assert "homeserver" not in summary
# It is still shown, in the body, where "where this ran" belongs.
assert "homeserver:/srv/app" in html
def test_a_custom_tools_own_label_still_wins():
"""The other half of the same rule. A row-backed tool's name is per row and
cannot be tabulated, so nothing in the table shadows it."""
html = _render({"name": "weather", "kind": "custom", "label": "Weather", "results": []})
assert "Weather" in html
def test_every_builtin_and_agent_tool_has_a_label_and_an_icon():
"""A property, not markup. A tool added without an entry renders its own
function name at somebody, which is the state this replaced."""
names = [tool.name for tool in tools_service.REGISTRY.values()]
names += [tool.name for tool in agent_tools.tool_defs()]
# plan_submit is filtered out of tool_defs() outside Plan mode.
names.append("plan_submit")
missing = [name for name in names if name not in tool_labels.LABELS]
assert not missing, f"no label for {missing}"
missing = [name for name in names if name not in tool_labels.ICONS]
assert not missing, f"no icon for {missing}"
def test_every_icon_named_exists_in_the_sprite():
"""A typo'd symbol id renders an empty box and says nothing. This is the
only thing that catches it."""
sprite = Path(tools_service.__file__).parents[1] / "web/templates/partials/icons.html"
available = set(re.findall(r'id="i-([a-z-]+)"', sprite.read_text()))
wanted = set(tool_labels.ICONS.values()) | set(tool_labels.KIND_ICONS.values())
wanted.add(tool_labels.FALLBACK_ICON)
assert wanted <= available, f"not in the sprite: {sorted(wanted - available)}"
def test_an_unknown_tool_falls_back_to_its_name():
assert tool_labels.label_for({"name": "mcp_thing"}) == "mcp_thing"
assert tool_labels.icon_for({"name": "mcp_thing", "kind": "mcp"}) == "server"
assert tool_labels.icon_for({"name": "whatever"}) == tool_labels.FALLBACK_ICON