2f09d8363d
Nothing in LLeMbas ever happened on its own. Every reply was downstream of
somebody pressing Send, and the one exception -- jobs.wake, waking a chat when a
background job finishes -- was downstream of a command they had run. PLAN.md
never listed scheduling as unbuilt because services/chat.py:618 had recorded it
as a decision: "a scheduler is a whole new concern for a single-worker
application". This is that concern, taken on deliberately, plus the two places
its output goes.
Reports first, because it is useful with no scheduling at all. A report is not a
Chat with one Message in it: it has no turns and no reply, it is read top to
bottom, and it must be writable with no chat behind it -- being the fallback for
a run whose own chat has gone. As a Chat it would need a sidebar row per daily
report, a title that regenerates itself, a composer to suppress and a bubble with
a rewind button around something that is not a turn. The section's character is
enforced by absence: nothing under reports/ includes the composer or renders
chat/_message.html, so there is no sse-connect anywhere and nothing on those
pages *can* start a generation. The test reads that off the OpenAPI schema, not
by walking app.routes -- this FastAPI keeps an included router wrapped rather
than flattening it, so the walk finds nothing and the assertion passes for the
wrong reason.
rule.py is pure, total, and was finished before anything called it. No session,
no wall clock, nothing that raises: validate clamps what it recognises, drops
what it does not, and answers {} for prose -- at which point the caller shows the
manual form. It had to be that way because the compile step's output is model
output that becomes a *timer*, which is the sharpest case of hard rule 6 here.
The invariant, pinned: anything validate accepts has a computable next
occurrence. A schedule that can never fire looks exactly like a working one on
every screen it appears on.
Wall-clock and elapsed time are kept apart because they mean different things.
at.times are wall-clock in the owner's zone, so 15:00 stays 15:00 across a
daylight-saving change -- that is what "every Monday at 3PM" means. every is
elapsed real time, so six hours stays six hours across a 23- or 25-hour day --
that is what a timer means. Conflating them gets one of the two wrong twice a
year. A time inside the spring-forward gap fires at the first minute that exists;
left to zoneinfo's own resolution it lands an hour away wearing a wall-clock time
that did not happen, and a daily 02:30 report vanishing once a year on a machine
nobody watches is the failure this file is arranged around.
The ticker claims and commits *before* it fires. The other order is a hot loop: a
firing that raises is retried every tick for ever against whatever it was that
failed, and the only symptom is load. Its blanket except is copied from the
terminal reaper for a sharper reason -- a ticker that dies on one bad row stops
every schedule on the instance and says nothing at all. No request fails, no
reply errors, no dot appears. The reports simply stop.
Three rules that look like bugs from outside: a firing arriving while the chat is
still answering queues rather than starting a second reply, and past max_queued
is skipped with the reason on the row; Run now does not advance next_fire_at, or
testing a schedule silently consumes the run it was testing; resuming recomputes
from now, or a schedule paused for a month fires the instant it comes back, once
per occurrence it missed. Catching up lives in the sweep and not in a startup
hook, because a suspended host and a long stall reproduce "its time passed while
nothing was running" with no restart to hang one on.
services/wake.py is the lock discipline extracted rather than copied. A finished
job and a due schedule are the same problem, and both depend on there being no
await between the running_for check and the writes; two lock dictionaries for one
invariant is how one of them drifts. jobs.wake is now a caller that supplies
wording, and _completion_text stayed exactly where it was because tool.background
quotes its opening sentence.
A scheduled run has no reader, so ask_user is withdrawn from resolve_tools rather
than merely discouraged in core.unattended -- a rule living only in a system
message is one a page the model just read can argue with, and a parked question
holds the reply for the whole approval_timeout with nobody to answer it. For the
same reason a task chat may not be an agent chat in v1: Manual, Edit and Plan all
stop to ask on RISK_EXECUTE, so the only two outcomes would be unattended
execution and a reply that stalls. That deserves its own pass.
Messages is bounded in the request and unbounded on disk. Only the latest chunk
is sent; everything else stays exactly where it was written. Nothing is folded
into text and nothing is deleted -- the visible conversation is identical either
way, so destroying the older rows would buy only disk, against being irreversible
and losing every attachment and tool call in the range, and it would contradict
the rule compaction already holds. should_compact refuses this kind for the
matching reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary. The history route is the mirror of thread_tail and keeps
its four properties; the fifth is its own, that prepending moves the scroll
position, so app.js records scrollHeight before the swap and adds the difference
back after.
An empty Chat.kind meant "both sides of the switch" and had been read as "no
filter" since there were only two of them. The sidebar passes "" precisely when
agent chats are switched off -- so the moment a third kind existed, every task
chat and every Messages conversation appeared in somebody's ordinary chat list,
on exactly the instances whose owners would never think to look. KINDS stays the
two-sided fork, because set_sidebar_kind validates against it and a third entry
there makes the tree filterable to a side with no button to leave it; ALL_KINDS
is what a row may be. Both narrowings are pinned, because they are two
implementations of one rule and only one of them is SQL.
Per-user timezone had to exist for any of this: harness.py:179 was telling every
reader the *server's* idea of the date, which is survivable while the answer is
prose and stops being survivable the moment somebody says "every Monday at 3" and
something has to work out when that is.
Three things were caught by a test being wrong rather than by the code being
wrong. The task-chat "no composer" assertions were passing against a page
rendering its no-models-configured branch. A permission test asserted the same
thing twice because the administrator bypasses every permission. And every
Messages test passed with default_model never called, because none of them
configured a model -- so the pair it returns was being assigned straight to
model_id, and SQLite refuses a tuple in a String column. The fixtures now say why
they exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
8.3 KiB
Python
244 lines
8.3 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",
|
|
"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",
|
|
"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",
|
|
"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",
|
|
}
|
|
|
|
|
|
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",
|
|
]
|