e16bede85b
subagent_run gives a self-contained piece of work to a helper carrying the parent's connection, directory, model and effort, and hands its answer back as the tool result. The mechanism is the one scheduled runs already use -- a hidden chat, one turn, wake_chat, and a poll -- so tools, rounds, budgets, metrics and steps all work with no second implementation. The two alternatives were rejected where they had already been rejected once: a nested Generation is two replies writing one transcript, and a one-shot complete() has no tools, which schedule/runner.py records as useless for exactly this case. Every restriction is a property of the child's row, applied by resolve_tools after the gates, because a rule that lives in a system message is one a page the model just read can argue with. No questions, no recursion, nothing that writes unless the call asked for it and the parent's own mode would not have stopped first, and commands only from a fixed read-only list -- in every mode including Auto, because the task text can have come from a page. Withdrawing ask_user turned out to be half of "nobody is watching". An approval still built a card nobody could see and parked the reply until approval_timeout, which from every screen is the feature not working. Chat.unattended is the question now, and not the kind: _authorise answers with a refusal instead. A scheduled task's chat had the same hole and is covered by the same flag. Three bounds, counted where each is knowable: per reply on the parent's Generation, instance-wide in a set a restart clears, and per helper in settings of its own so one runs out of room long before the reply that asked. Past the clock the helper is stopped rather than abandoned, so a partial answer comes back with a sentence saying so. Also: four gates had shipped into the scope menu with no name, taking the first tool's label instead -- the canvas switch read "Canvas written". There is a test that refuses a family without one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
269 lines
9.4 KiB
Python
269 lines
9.4 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",
|
|
"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",
|
|
"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",
|
|
"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",
|
|
}
|
|
|
|
|
|
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",
|
|
]
|