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 8a3a225fea
commit 374982174f
8 changed files with 352 additions and 26 deletions
+27 -12
View File
@@ -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
from lembas.services import interaction, 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
@@ -721,6 +721,26 @@ def _gave_up(generation, why: str) -> None:
generation.touch()
def _written(generation: Generation) -> int:
"""How much this reply has written so far, in tokens, reported or estimated.
Both, because neither alone is enough. `completion_tokens` is only populated
when the endpoint sends a usage block, and a good half of the ones this
talks to -- llama.cpp, Ollama and friends -- never do; the fallback estimate
is otherwise computed once, in `_run`'s `finally:`, long after the loop that
needs it. A ceiling reading only the reported figure would work on OpenAI
and silently do nothing everywhere else, which is the worst kind of limit:
one that looks configured.
Reasoning counts. It was generated and it was paid for, even though it is
deliberately never replayed as context.
"""
return max(
generation.completion_tokens,
tokens.estimate(generation.text + generation.thinking),
)
def _tool_status(calls: list[dict]) -> str:
"""What to show while tools run.
@@ -728,7 +748,7 @@ def _tool_status(calls: list[dict]) -> str:
nothing streaming, and a silent pause is exactly what a hang looks like.
"""
if len(calls) == 1:
return f"Running {calls[0]['name']}"
return f"Running {tool_labels.label_for(calls[0]['name'])}"
return f"Running {len(calls)} tools…"
@@ -746,17 +766,12 @@ def _describe(name: str, args: dict) -> tuple[str, str]:
The detail is the thing being agreed to -- the command line, the path -- and
is shown verbatim and escaped. A summary that paraphrased it would be a card
approving something other than what runs.
Delegated to services/tool_labels.py, which the transcript and the status
line read too. This used to be a hand-written if-chain and was the fourth
place with its own wording for the same tool.
"""
if name == "shell_run":
return "Run a command", str(args.get("command") or "")
if name == "file_write":
return "Write a file", str(args.get("path") or "")
if name == "file_read":
return "Read a file", str(args.get("path") or "")
if name == "file_list":
return "List a directory", str(args.get("path") or "")
detail = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:4])
return f"Use {name}", detail[:400]
return tool_labels.describe(name, args)
def _approvals(context, calls: list[dict]) -> list[interaction.Item]: