A reply can stop and ask you something
Three features turn out to be one mechanism: a command waiting to be approved, a question the model wants answered, and "this reply is waiting for you" are all — stop the generation, put an interactive block in the bubble, wait for a POST, carry on. So there is one primitive, and the only thing using it so far is `ask_user`: a model can offer you a few answers and a box to write your own. The shell executor is not here yet. This lands first on purpose, because it is the riskiest machinery in the feature and it is worth having working before any subprocess exists to complicate it. Two things about where the pause sits. It pauses a round, not a call: a round's calls run together under a semaphore, and parking four coroutines on four separate answers inside that gather would queue them behind each other invisibly. And Stop had to be taught about it — `cancel` is read between streamed chunks and there are no chunks while paused, so the button did nothing at all until `request_stop` learned to resolve the pause itself. Also here: a risk class on every tool (read, write, execute), which is what the four permission modes will be a table over, and the systemd unit loses ProtectKernelTunables. That last one is not tidying — it bind-mounts /proc/sys read-only, which stops bubblewrap mounting /proc at all, and the obvious workaround would expose this process's environment and with it the encryption key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ecadb66414
commit
1c659a5640
@@ -68,8 +68,20 @@ FAMILY_SKILLS = "skills"
|
||||
FAMILY_CUSTOM = "custom"
|
||||
FAMILY_MCP = "mcp"
|
||||
|
||||
# Stopping to ask the reader something. Its own family because it belongs to no
|
||||
# other one: it is offered in an ordinary chat as much as an agent chat, and it
|
||||
# is the only tool the model cannot resolve by itself.
|
||||
FAMILY_ASK = "ask"
|
||||
|
||||
# The built-in families, in the order they are offered.
|
||||
FAMILIES = (FAMILY_SEARCH, FAMILY_KNOWLEDGE, FAMILY_NOTES, FAMILY_MEMORY, FAMILY_SKILLS)
|
||||
FAMILIES = (
|
||||
FAMILY_SEARCH,
|
||||
FAMILY_KNOWLEDGE,
|
||||
FAMILY_NOTES,
|
||||
FAMILY_MEMORY,
|
||||
FAMILY_SKILLS,
|
||||
FAMILY_ASK,
|
||||
)
|
||||
|
||||
GATES = (*FAMILIES, FAMILY_CUSTOM, FAMILY_MCP)
|
||||
|
||||
@@ -79,6 +91,20 @@ def gate_of(family: str) -> str:
|
||||
return family.split(":", 1)[0]
|
||||
|
||||
|
||||
# What a tool does to the world. Only agent chats consult it -- an ordinary chat
|
||||
# behaves exactly as it always did -- but it is declared on every tool, because
|
||||
# the permission modes are a table indexed by it and a tool whose class is a
|
||||
# guess is a tool whose gate is a guess.
|
||||
RISK_READ = "read"
|
||||
RISK_WRITE = "write"
|
||||
RISK_EXECUTE = "execute"
|
||||
# Never resolves to "allowed", in any mode. `ask_user` is the only tool that
|
||||
# carries it: stopping to ask is the whole of what it does.
|
||||
RISK_ASK = "ask"
|
||||
|
||||
RISKS = (RISK_READ, RISK_WRITE, RISK_EXECUTE, RISK_ASK)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
"""What a tool needs to do its work, without holding a session open.
|
||||
@@ -98,6 +124,10 @@ class ToolContext:
|
||||
# the import-time registry. A dict, *even an empty one*, is authoritative:
|
||||
# a model naming a tool it was not offered must not get it run.
|
||||
tools: dict[str, ToolDef] | None = None
|
||||
# How long a reply waits for someone to answer a question or approve
|
||||
# something. Read from the instance settings while the session was open,
|
||||
# like everything else here.
|
||||
interaction_timeout: float = 900.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -123,6 +153,11 @@ class ToolDef:
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
run: Runner
|
||||
# Declared rather than derived from the name: `notes_edit` and
|
||||
# `knowledge_get` are not told apart by spelling, and the consequence of
|
||||
# guessing is that a mode silently permits something it meant to ask about.
|
||||
# Defaulted so that reading is what a tool has to be talked out of.
|
||||
risk: str = RISK_READ
|
||||
|
||||
@property
|
||||
def schema(self) -> dict[str, Any]:
|
||||
@@ -514,6 +549,31 @@ async def _run_skill_edit(context: ToolContext, args: dict[str, Any]) -> ToolOut
|
||||
|
||||
|
||||
# --- The registry ------------------------------------------------------------
|
||||
# --- Asking the reader -------------------------------------------------------
|
||||
async def _run_ask_user(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Never reached on the normal path.
|
||||
|
||||
`services.generation` intercepts every `ask` call before the runners are
|
||||
reached, because the answer comes from a person and `ToolContext` is a
|
||||
session-free snapshot that deliberately holds no way to reach one. Getting
|
||||
here means some other path called `run_tool` directly, and saying so is
|
||||
better than returning an empty answer the model would treat as a reply.
|
||||
"""
|
||||
question = str(args.get("question") or "").strip()
|
||||
return ToolOutcome(
|
||||
"That question could not be put to anyone, so it has gone unanswered. "
|
||||
"Carry on without it, or say what you need.",
|
||||
{
|
||||
"name": "ask_user",
|
||||
"kind": "ask",
|
||||
"query": question,
|
||||
"status": "error",
|
||||
"error": "No one was there to ask.",
|
||||
"results": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
REGISTRY: dict[str, ToolDef] = {
|
||||
tool.name: tool
|
||||
for tool in (
|
||||
@@ -592,6 +652,7 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
["title", "body"],
|
||||
),
|
||||
run=_run_notes_create,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="notes_edit",
|
||||
@@ -599,6 +660,7 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
description="Change a note you can write to. Omit a field to leave it alone.",
|
||||
parameters=_object({"id": _STRING, "title": _STRING, "body": _STRING}, ["id"]),
|
||||
run=_run_notes_edit,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="notes_delete",
|
||||
@@ -606,6 +668,7 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
description="Delete a note that is no longer true or useful.",
|
||||
parameters=_object({"id": _STRING}, ["id"]),
|
||||
run=_run_notes_delete,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="memory_add",
|
||||
@@ -621,6 +684,7 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
["content"],
|
||||
),
|
||||
run=_run_memory_add,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="memory_forget",
|
||||
@@ -631,6 +695,7 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
),
|
||||
parameters=_object({"content": _STRING}, ["content"]),
|
||||
run=_run_memory_forget,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="skill_get",
|
||||
@@ -660,6 +725,7 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
["name", "description", "body"],
|
||||
),
|
||||
run=_run_skill_create,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="skill_edit",
|
||||
@@ -678,6 +744,45 @@ REGISTRY: dict[str, ToolDef] = {
|
||||
["name"],
|
||||
),
|
||||
run=_run_skill_edit,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="ask_user",
|
||||
family=FAMILY_ASK,
|
||||
description=(
|
||||
"Ask the person you are talking to a question, and wait for their "
|
||||
"answer before going on. Use it when you genuinely need a decision "
|
||||
"only they can make — which of several approaches to take, a "
|
||||
"detail you cannot infer, permission for something consequential. "
|
||||
"Offer options when there is a small set of sensible answers; they "
|
||||
"can always type something else instead. Do not use it for "
|
||||
"anything you can work out yourself, and never ask for a password, "
|
||||
"a key or any other secret."
|
||||
),
|
||||
parameters=_object(
|
||||
{
|
||||
"question": {
|
||||
**_STRING,
|
||||
"description": "One clear question, in plain language.",
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": _STRING,
|
||||
"description": (
|
||||
"Up to six answers to offer as buttons. Optional; they "
|
||||
"can always write their own."
|
||||
),
|
||||
},
|
||||
},
|
||||
["question"],
|
||||
),
|
||||
# Never resolved by this runner. The reader answers it, in every
|
||||
# mode, and the loop turns their answer into the outcome -- see
|
||||
# services/interaction.py. The runner exists so that a call reaching
|
||||
# it by some path that skipped the loop fails loudly rather than
|
||||
# silently returning nothing.
|
||||
run=_run_ask_user,
|
||||
risk=RISK_ASK,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -703,11 +808,11 @@ def _family_allowed(
|
||||
and config.get("enabled")
|
||||
and not search_service.availability(str(config.get("provider") or "ddgs"))
|
||||
)
|
||||
if gate in (FAMILY_CUSTOM, FAMILY_MCP):
|
||||
if gate in (FAMILY_CUSTOM, FAMILY_MCP, FAMILY_ASK):
|
||||
# Deliberately without `library.use`: an HTTP endpoint an administrator
|
||||
# wrote has nothing to do with this person's own documents and notes,
|
||||
# and requiring the library permission for it would be a coincidence of
|
||||
# naming rather than a rule.
|
||||
# naming rather than a rule. The same goes for being asked a question.
|
||||
return bool(allowed.get(f"tools.{gate}"))
|
||||
return bool(allowed.get(f"tools.{gate}") and allowed.get("library.use"))
|
||||
|
||||
@@ -812,6 +917,7 @@ def context_for(
|
||||
search_config=settings_store.search(db),
|
||||
base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [],
|
||||
tools=tools.by_name if tools is not None else None,
|
||||
interaction_timeout=float(settings_store.agents(db)["approval_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user