Files
LLeMbas/src/lembas/services/tool_labels.py
T
HomerandClaude Opus 5 da0797ccad A crowd in one chat
The chat's own model answers, then each other member in order, then the order runs
backwards asking each whether it disagrees, ending at the main model, which either
closes or sends them round again. Design and reasoning: LLeMbas.wiki/Crowd-chats.

THE SPEAKER SEAM, WHICH IS ALSO A BUG FIX

`chat_service.speaker_for` makes the *message* name the answering model and the
chat only the default. That closes a live half-wired feature -- `wake_chat` takes a
model override and `schedule/runner` passes one, and it reached the row and never
the request, so a schedule naming another model got the chat's model wearing the
other one's name.

The seam is wider than `build_request`: `{{model_name}}`, the authored prompt's
model layer, `vision` (where a wrong answer makes the endpoint reject the whole
request), the effort vocabulary (which raises inside the model's own chat template,
and whose refusal narrows every Model row sharing the id), `resolve_tools`,
`context_length` -> `_too_big`, and `ToolContext.model_id`. `resolve_endpoint` may
now only write back `chat.connection_id` when the speaker *is* the chat's model.

WHY N CHAINED REPLIES

`Generation` is one reply's state and `_follow` streams per message, so one
generation cannot stream into nine bubbles and `ensure` would not know which of the
nine it was after a restart. A subagent per speaker cannot work either: its answer
comes back as a tool result and tool results are never replayed, so speaker 3 could
not see speaker 2 -- which is the whole point. Chained, exactly one incomplete row
exists at a time, and `tests/test_crowd_chain.py` asserts that at every
observation.

The round lives on `Message.crowd_json`, not on the chat: the row is the authority,
and chat-level state would describe turns a rewind or a restart had removed.
`crowd.next_turn` is pure, so all eight refusals are tested with no endpoint.

THREE RULES, EACH A BUG WRITTEN THE OTHER WAY ROUND

- `if not _advance_crowd(g): _drain(g)` -- advancing must *suppress* draining, or a
  queued human turn puts a second incomplete row beside the next speaker's.
- `_advance_crowd` refuses unless the finishing row is the newest, or regenerating
  member 2 creates a second member 3 and two chains race down one turn.
- an error skips one speaker and two in a row end the round: the usual failure is a
  small member's window overflowing, and `_drain`'s stop-on-error would kill every
  crowd at whichever member is smallest.

Each other speaker's turn is relabelled as attributed user content, which is both
how a model can disagree with words it did not write and how the history keeps
alternating. The per-speaker instruction is payload-only -- as a row it could be
dropped from the request by a `created_at` tie, and every later speaker would answer
it. Compaction, titling and the notification are gated to once per turn; `_inject`
is off during a round; the way back gets no tools and a member is treated as
unattended.

Membership stores the model as text with no foreign key: "Test & refresh" deletes
and recreates Model rows, and a cascade would empty the crowd out of every chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 13:38:52 +00:00

295 lines
11 KiB
Python

"""What a tool is called in the interface, and what it looks like.
Four places have to agree about one tool, and for the whole life of the feature
they did not:
- the transcript (`chat/_tool_activity.html`) showed the SSH profile's name for
an agent tool -- "homeserver · ls -la", naming the machine rather than the
thing that was done -- and the raw function name for everything else, so a
saved memory read `memory_add`;
- the status line while a round runs said "Running shell_run…";
- the approval card had its own hand-written if-chain;
- and nothing checked that any of the three matched.
So the table lives here and each of them reads it.
`LABELS` and `ACTIONS` are deliberately different words for the same tool, the
same way `policy.MODE_HINTS` and `policy.MODE_GUIDANCE` are. A label is a noun
phrase in a list of things that happened; an approval card is a sentence
somebody is agreeing to, and "Bash" is not one.
**The precedence is inverted on purpose, and that is the whole design.**
Tool events are persisted in `Message.tool_calls_json`, so every row written
before today already carries `label: "homeserver"`. A resolver that preferred
the stored label would fix nothing for any transcript that already exists. So
a name this module knows about resolves from the static table and the stored
label is ignored; a name it does not know -- a custom HTTP tool, an MCP tool,
whose labels are per row and cannot be tabulated -- keeps its own. One rule,
both cases correct.
Resolved **without a database**. It is called once per rendered event, and
reaching for `tools.registry(db)` from a Jinja global would be two table scans
per bubble.
"""
from __future__ import annotations
from typing import Any
# What the transcript calls each tool. Kept in the same order as the families
# in `services/tools.py` so that adding one has an obvious home.
LABELS: dict[str, str] = {
# Acting on the machine an agent chat is pointed at.
"shell_run": "Bash",
"file_read": "Read",
"file_write": "Write",
"file_edit": "Update",
"file_list": "List",
"plan_submit": "Plan",
"plan_update": "Plan updated",
"job_output": "Job output",
"job_list": "Jobs",
"job_stop": "Job stopped",
# The web.
"web_search": "Web search",
"fetch": "Fetch",
# The library.
"knowledge_search": "Knowledge searched",
"knowledge_get": "Document read",
"notes_search": "Notes searched",
"notes_get": "Note read",
"notes_create": "Note written",
"notes_edit": "Note updated",
"notes_delete": "Note deleted",
"scratch_write": "Canvas written",
# Reports.
"report_write": "Report filed",
"report_search": "Reports searched",
"report_get": "Report read",
"image_generate": "Image",
# Work set up to happen later.
"schedule_create": "Scheduled",
"schedule_list": "Schedules read",
"schedule_update": "Schedule changed",
"schedule_cancel": "Schedule stopped",
# Work handed to a second model.
"subagent_run": "Helper",
# A question put to one of the other models here.
"ask_friend": "Asked another model",
# The main model sending a crowd round again.
"crowd_again": "Another round",
# What a model keeps about itself and about the person it is talking to.
"persona_write": "Personality rewritten",
"impression_write": "Impression updated",
"memory_add": "Memory saved",
"memory_forget": "Memory removed",
"skill_get": "Skill read",
"skill_create": "Skill written",
"skill_edit": "Skill updated",
# Stopping to ask.
"ask_user": "Asked you",
}
# A symbol id from templates/partials/icons.html. Everything used to be the
# sparkle, which said only "a model did something".
ICONS: dict[str, str] = {
"shell_run": "terminal",
"file_read": "file-text",
"file_write": "pencil",
"file_edit": "diff",
"file_list": "folder",
"plan_submit": "check",
"plan_update": "check",
"job_output": "clock",
"job_list": "dots",
"job_stop": "stop-circle",
"web_search": "globe",
"fetch": "link",
"knowledge_search": "archive",
"knowledge_get": "file-text",
"notes_search": "search",
"notes_get": "file-text",
"notes_create": "pencil",
"notes_edit": "pencil",
"notes_delete": "trash",
"scratch_write": "file-text",
"report_write": "pencil",
"report_search": "search",
"report_get": "file-text",
"image_generate": "image",
"schedule_create": "clock",
"schedule_list": "clock",
"schedule_update": "clock",
"schedule_cancel": "stop-circle",
"subagent_run": "sparkle",
"ask_friend": "users",
"crowd_again": "refresh",
"persona_write": "user",
"impression_write": "user",
"memory_add": "star",
"memory_forget": "trash",
"skill_get": "sparkle",
"skill_create": "sparkle",
"skill_edit": "sparkle",
"ask_user": "chat",
}
# The icon for an event whose tool is not in the table -- a custom HTTP tool, an
# MCP tool, or a row written before `kind` existed.
KIND_ICONS: dict[str, str] = {
"search": "globe",
"fetch": "link",
"custom": "link",
"mcp": "server",
"image": "image",
}
FALLBACK_ICON = "sparkle"
# What an approval card is headed. A sentence somebody agrees to, in the
# imperative, because that is what pressing the button does.
ACTIONS: dict[str, str] = {
"shell_run": "Run a command",
"file_read": "Read a file",
"file_write": "Write a file",
"file_edit": "Update a file",
"file_list": "List a directory",
"web_search": "Search the web",
"fetch": "Fetch a page",
"knowledge_search": "Search the library",
"knowledge_get": "Read a document",
"notes_search": "Search notes",
"notes_get": "Read a note",
"notes_create": "Write a note",
"notes_edit": "Change a note",
"notes_delete": "Delete a note",
"scratch_write": "Write in the canvas",
"report_write": "File a report",
"report_search": "Search reports",
"report_get": "Read a report",
"image_generate": "Generate an image",
"schedule_create": "Set up a schedule",
"schedule_update": "Change a schedule",
"schedule_cancel": "Stop a schedule",
"subagent_run": "Send a helper",
"ask_friend": "Ask another model",
"crowd_again": "Send the crowd round again",
"persona_write": "Rewrite its own personality",
"impression_write": "Update what it makes of you",
"memory_add": "Remember something",
"memory_forget": "Forget something",
"skill_get": "Read a skill",
"skill_create": "Write a skill",
"skill_edit": "Change a skill",
"job_stop": "Stop a background job",
}
# Which argument is the thing being agreed to. Shown verbatim and escaped on the
# card: a summary that paraphrased it would be a card approving something other
# than what runs.
DETAIL_KEYS: dict[str, str] = {
"shell_run": "command",
"file_read": "path",
"file_write": "path",
"file_edit": "path",
"file_list": "path",
"fetch": "url",
"web_search": "query",
"knowledge_search": "query",
"notes_search": "query",
"report_search": "query",
# The title, not the body: a card has room for a line and the body is the
# report. Editable for the same reason the image prompt is -- correcting
# what a report will be called before it is filed is cheap, and renaming
# one afterwards means finding it first.
"report_write": "title",
"job_stop": "id",
# The thing being agreed to is what will be drawn, not which sampler draws
# it. Also what makes the box on the card editable: a prompt corrected
# before it runs is the commonest useful edit this feature will see.
"image_generate": "prompt",
# The instruction, not the timing. An approval card has room for one line,
# and the instruction is the part a person can read and correct -- the
# timing is an object, and the tool answers with it in words afterwards,
# which is where it is actually checkable.
"schedule_create": "instruction",
# The task, not the title. It is the thing a helper is actually sent, and
# the one field worth correcting before it goes -- a task with a wrong path
# in it comes back as a confident answer about the wrong thing.
"subagent_run": "task",
# The question, not the model asked. It is what actually goes, and a
# question carrying a wrong assumption comes back as a confident answer
# about the wrong thing -- the same reason `subagent_run` names the task.
"ask_friend": "question",
# What the next round is for. The only field it has, and the one thing worth
# correcting before several models spend a reply each on it.
"crowd_again": "focus",
# The whole text, because for these two the text *is* the thing being agreed
# to: there is no shorter field that says what the model would become.
"persona_write": "content",
"impression_write": "content",
}
def _name_of(event: dict[str, Any] | str) -> str:
if isinstance(event, str):
return event
return str(event.get("name") or "")
def label_for(event: dict[str, Any] | str) -> str:
"""What to call this tool in the transcript.
The static table wins over anything stored on the event. See the module
docstring: rows already on disk carry the wrong label, and deferring to them
would leave every existing transcript naming a machine.
"""
name = _name_of(event)
if name in LABELS:
return LABELS[name]
if isinstance(event, dict):
stored = str(event.get("label") or "").strip()
if stored:
return stored
return name
def icon_for(event: dict[str, Any] | str) -> str:
"""A symbol id for this event, never empty.
Falls through the tool's own icon, then the event's `kind`, then the
generic one -- so a custom tool still gets a link and an MCP tool a server,
which is what the template used to decide for itself.
"""
name = _name_of(event)
if name in ICONS:
return ICONS[name]
kind = str(event.get("kind") or "") if isinstance(event, dict) else ""
if not kind and name == "web_search":
# Rows written before `kind` existed. The template made the same
# allowance for the same reason.
kind = "search"
return KIND_ICONS.get(kind, FALLBACK_ICON)
def describe(name: str, args: dict[str, Any]) -> tuple[str, str]:
"""What an approval card says about one call: a title, and the detail."""
title = ACTIONS.get(name)
key = DETAIL_KEYS.get(name)
if title is not None:
return title, str(args.get(key) or "") if key else ""
detail = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:4])
return f"Use {label_for(name)}", detail[:400]
__all__ = [
"ACTIONS",
"DETAIL_KEYS",
"FALLBACK_ICON",
"ICONS",
"KIND_ICONS",
"LABELS",
"describe",
"icon_for",
"label_for",
]