Custom HTTP tools an administrator defines

A row in custom_tools becomes a ToolDef like any built-in, offered beside
the thirteen. The registry had to stop being an import-time constant for
that: `resolve_tools` now returns the schemas *and* the runners together,
carried to the loop on the ToolContext.

That closes a hole on the way. `run_tool` looked names up in the global
REGISTRY with no reference to what had been offered, so a model naming a
tool its chat was gated out of -- a family switched off, a permission the
reader lacks -- had it run anyway. The resolved set is now authoritative.

Arguments come from a model, so an argument may fill a hole but never move
the target: the scheme and host of a URL template are literal, values are
escaped for where they land, and the origin is pinned afterwards. Every
redirect hop is checked the way services/fetch.py checks one, and the
secret is dropped if a hop leaves the origin it was issued for.

Also fixes the tool-activity block claiming every library tool had
"searched the web", which it has done since the second family landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 16:26:47 +02:00
parent 4ee7d3db7d
commit d4cefb066a
26 changed files with 2771 additions and 60 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lembas"
version = "0.3.1"
version = "0.4.0"
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.3.1"
__version__ = "0.4.0"
+11 -3
View File
@@ -24,15 +24,23 @@ router = APIRouter(tags=["admin-models"])
# these are an administrator's assertion.
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
# Which built-in tools this model is given. Distinct from the above: `tools` is
# whether a tools array may be sent at all, these are what goes in it. Every one
# of them is meaningless unless `tools` is on.
# Which tools this model is given. Distinct from the above: `tools` is whether a
# tools array may be sent at all, these are what goes in it. Every one of them is
# meaningless unless `tools` is on.
#
# The last two are gates rather than single tools: one covers every custom HTTP
# tool an administrator has defined, the other every MCP server. Which of those
# a particular person gets is the tool's own group list, not a flag here -- a
# server can advertise forty tools, and a model page listing all of them is a
# page nobody can read.
TOOL_CAPABILITIES = (
("tool_web_search", "Web search"),
("tool_knowledge", "Knowledge"),
("tool_notes", "Notes"),
("tool_memory", "Memory"),
("tool_skills", "Skills"),
("tool_custom", "Custom tools"),
("tool_mcp", "MCP servers"),
)
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
+14 -9
View File
@@ -29,15 +29,20 @@ router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
def _families_of(names: list[str]) -> list[str]:
"""Keep only real family names, in the registry's order."""
def _families_of(db: Db, names: list[str]) -> list[str]:
"""Keep only real family names, in the registry's order.
Read from the database rather than the constant: a family can belong to an
administrator-defined tool, and one the preview cannot name is one whose
guidance cannot be checked here.
"""
wanted = set(names)
return [family for family in tools_service.FAMILIES if family in wanted]
return [family for family in tools_service.families(db) if family in wanted]
def _tool_names(families: list[str]) -> str:
def _tool_names(db: Db, families: list[str]) -> str:
return ", ".join(
name for name, tool in tools_service.REGISTRY.items() if tool.family in families
name for name, tool in tools_service.registry(db).items() if tool.family in families
)
@@ -67,7 +72,7 @@ def _variables(
values.update(
{
"model_name": model_name,
"tool_names": _tool_names(families),
"tool_names": _tool_names(db, families),
"memories": memories_service.block(db, user) if "memory" in families else "",
"skills": skills_service.index_block(db, user) if "skills" in families else "",
"knowledge_bases": bases if "knowledge" in families else "",
@@ -89,7 +94,7 @@ def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict:
async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
stored = prompts_service.stored(db)
models = chat_service.available_models(db, user)
families = list(tools_service.FAMILIES)
families = list(tools_service.families(db))
return render(
request,
@@ -115,7 +120,7 @@ async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool =
"models": models,
"families": families,
"registry": sorted(
tools_service.REGISTRY.values(), key=lambda t: (t.family, t.name)
tools_service.registry(db).values(), key=lambda t: (t.family, t.name)
),
"max_harness_chars": settings_store.get(
db, "max_harness_chars", key=settings_store.PROMPTS
@@ -164,7 +169,7 @@ async def preview(request: Request, db: Db, user: AdminUser):
"""
form = await request.form()
overrides = _submitted(db, form)
families = _families_of([str(value) for value in form.getlist("preview_family")])
families = _families_of(db, [str(value) for value in form.getlist("preview_family")])
model_name = str(form.get("preview_model") or "")
bases = str(form.get("preview_bases") or "").strip()
documents = str(form.get("preview_documents") or "").strip()
+415
View File
@@ -0,0 +1,415 @@
"""Administration for the tools an administrator defines.
List-plus-detail, like `/admin/models` and for the same reason: a tool has
fifteen fields and a page that renders fifteen fields per row is unusable. The
list is compact and searchable; the whole form lives at `/admin/tools/{id}/edit`.
Validation reports back into the form rather than raising a 422. The fields here
are a JSON schema, a URL template and a secret; getting one wrong is normal, and
losing the other fourteen because of it is not acceptable. So a rejected save
re-renders the form from what was submitted, with the reason.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from lembas.api.deps import AdminUser, Db
from lembas.db.models import (
RESPONSE_JSON,
RESPONSE_MODES,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_NONE,
SECRET_PLACEMENTS,
CustomTool,
Group,
)
from lembas.services import custom_tools
from lembas.services import prompts as prompts_service
from lembas.services import tools as tools_service
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(tags=["admin-tools"])
PAGE_SIZE = 40
# The slug is the function name sent to the endpoint, so it is bound by the
# charset those accept, and it is half of this tool's prompt-fragment key, so it
# is bound by that pattern too. The intersection is this.
SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
FILTERS: dict[str, tuple[str, object]] = {
"all": ("All", lambda t: True),
"enabled": ("Enabled", lambda t: t.enabled),
"disabled": ("Disabled", lambda t: not t.enabled),
"restricted": ("Restricted", lambda t: not t.public),
}
RESPONSE_LABELS = (
(RESPONSE_TEXT, "Text — HTML reduced to prose"),
(RESPONSE_JSON, "JSON — parsed, narrowed by the path below"),
(RESPONSE_RAW, "Raw — exactly as it arrived"),
)
SECRET_LABELS = (
(SECRET_NONE, "None — this endpoint needs no credential"),
("bearer", "Bearer token in a header"),
("header", "The header named below, verbatim"),
("query", "A query parameter named below"),
)
def _tool(db: Db, tool_id: str) -> CustomTool:
tool = db.get(CustomTool, tool_id)
if tool is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That tool no longer exists.")
return tool
def _ordered(db: Db) -> list[CustomTool]:
return list(db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)))
def _back(message: str = "") -> Response:
target = f"/admin/tools?saved={message}" if message else "/admin/tools"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
# --- Form <-> row ------------------------------------------------------------
def _headers_text(headers: dict) -> str:
return "\n".join(f"{name}: {value}" for name, value in (headers or {}).items())
def _parse_headers(text: str) -> dict[str, str]:
"""One `Name: value` per line. Blank lines and lines with no colon are dropped."""
out: dict[str, str] = {}
for line in (text or "").splitlines():
name, _, value = line.partition(":")
if name.strip() and _:
out[name.strip()] = value.strip()
return out
def _number(raw: str, *, default: int, low: int, high: int) -> int:
text = str(raw or "").strip()
if not text.lstrip("-").isdigit():
return default
return min(max(int(text), low), high)
def _populate(tool: CustomTool, form) -> None:
"""Copy a submitted form onto a row (or a draft of one).
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
`x`, and an absent one is exactly what an unticked box sends.
"""
tool.name = str(form.get("name") or "").strip()[:120]
tool.description = str(form.get("description") or "").strip()
tool.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
tool.method = str(form.get("method") or "GET").strip().upper()
tool.url_template = str(form.get("url_template") or "").strip()[:1000]
tool.body_template = str(form.get("body_template") or "").replace("\r\n", "\n")
tool.headers_json = _parse_headers(str(form.get("headers") or ""))
placement = str(form.get("secret_placement") or SECRET_NONE)
tool.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
tool.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
mode = str(form.get("response_mode") or RESPONSE_TEXT)
tool.response_mode = mode if mode in RESPONSE_MODES else RESPONSE_TEXT
tool.response_path = str(form.get("response_path") or "").strip()[:300]
tool.max_chars = _number(
form.get("max_chars"),
default=8000,
low=custom_tools.MIN_CHARS,
high=custom_tools.MAX_CHARS,
)
tool.timeout = _number(
form.get("timeout"),
default=20,
low=custom_tools.MIN_TIMEOUT,
high=custom_tools.MAX_TIMEOUT,
)
tool.position = _number(form.get("position"), default=tool.position or 0, low=0, high=999)
tool.allow_private = "allow_private" in form
tool.enabled = "enabled" in form
tool.public = "public" in form
def _problem(db: Db, tool: CustomTool, form, *, existing_id: str = "") -> str:
"""Why this cannot be saved, or an empty string."""
if not tool.name:
return "A tool needs a name."
slug = str(form.get("slug") or "").strip().lower()
if not SLUG_PATTERN.match(slug):
return (
"The identifier must be lowercase letters, digits, hyphens or "
"underscores, start with a letter or digit, and be at most 48 "
"characters. It is the name the model calls."
)
if slug in tools_service.REGISTRY:
return f"{slug}” is the name of a built-in tool. Choose another."
clash = db.scalar(select(CustomTool).where(CustomTool.slug == slug))
if clash is not None and clash.id != existing_id:
return f"There is already a tool called “{slug}”."
tool.slug = slug
if tool.method not in custom_tools.ALLOWED_METHODS:
return f"{tool.method} is not a method this can send."
raw = str(form.get("parameters") or "").strip() or '{"type": "object", "properties": {}}'
try:
parameters = json.loads(raw)
except json.JSONDecodeError as exc:
return f"The parameters are not valid JSON: {exc}"
if not isinstance(parameters, dict) or parameters.get("type") != "object":
return 'The parameters must be a JSON object whose "type" is "object".'
tool.parameters_json = parameters
# The same check the runner makes, so a template that could never be called
# is refused here rather than at the first call.
try:
custom_tools.fill_url(custom_tools.spec_from(tool), {})
except Exception as exc: # noqa: BLE001 - any refusal is a message for the form
return str(getattr(exc, "message", exc))
return ""
def _detail(request: Request, db: Db, tool: CustomTool, *, is_new: bool, error: str = "", **extra):
key = f"tool.custom_{tool.slug}" if tool.slug else ""
return render(
request,
"admin/tool_detail.html",
{
"tool": tool,
"is_new": is_new,
"error": error,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"selected_groups": extra.pop(
"selected_groups", {group.id for group in (tool.groups if tool.id else [])}
),
"headers_text": extra.pop("headers_text", _headers_text(tool.headers_json)),
"parameters_text": extra.pop(
"parameters_text", json.dumps(tool.parameters_json or {}, indent=2)
),
"masked": mask(decrypt(tool.secret_encrypted)) if tool.secret_encrypted else "",
"unchanged": UNCHANGED_SENTINEL,
"methods": custom_tools.ALLOWED_METHODS,
"response_modes": RESPONSE_LABELS,
"secret_placements": SECRET_LABELS,
"prompt_key": key,
"prompt_overridden": key in prompts_service.stored(db),
**extra,
},
)
# --- The list ----------------------------------------------------------------
@router.get("/admin/tools")
async def tools_page(
request: Request,
db: Db,
user: AdminUser,
saved: str = "",
q: str = "",
filter: str = "all",
page: int = 1,
):
everything = _ordered(db)
predicate = FILTERS.get(filter, FILTERS["all"])[1]
needle = q.strip().lower()
matching = [
tool
for tool in everything
if predicate(tool)
and (not needle or needle in tool.slug.lower() or needle in (tool.name or "").lower())
]
pages = max(1, -(-len(matching) // PAGE_SIZE))
page = max(1, min(page, pages))
start = (page - 1) * PAGE_SIZE
return render(
request,
"admin/tools.html",
{
"tools": matching[start : start + PAGE_SIZE],
"total": len(everything),
"matched": len(matching),
"page": page,
"pages": pages,
"page_start": start,
"counts": {
key: sum(1 for tool in everything if rule(tool))
for key, (_label, rule) in FILTERS.items()
},
"filters": {key: label for key, (label, _rule) in FILTERS.items()},
"active_filter": filter if filter in FILTERS else "all",
"q": q,
"saved": saved,
},
)
# Registered before /{tool_id}: FastAPI matches in registration order, so with
# the parameterised route first "new" is captured as an id and the handler 404s
# on a tool that does not exist. This has already been a bug once, in
# /admin/models.
@router.get("/admin/tools/new")
async def new_tool_page(request: Request, db: Db, user: AdminUser):
draft = CustomTool(
name="",
slug="",
method="GET",
url_template="https://",
parameters_json={"type": "object", "properties": {}, "required": []},
secret_placement=SECRET_NONE,
response_mode=RESPONSE_TEXT,
max_chars=8000,
timeout=20,
enabled=True,
public=True,
position=0,
)
return _detail(request, db, draft, is_new=True)
@router.post("/admin/tools")
async def create_tool(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
draft = CustomTool(headers_json={}, parameters_json={})
_populate(draft, form)
draft.position = db.scalar(select(func.coalesce(func.max(CustomTool.position), -1))) + 1
problem = _problem(db, draft, form)
if problem:
return _detail(
request,
db,
draft,
is_new=True,
error=problem,
headers_text=str(form.get("headers") or ""),
parameters_text=str(form.get("parameters") or ""),
selected_groups=set(form.getlist("group_ids")),
)
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
draft.groups = _chosen_groups(db, form, public=draft.public)
db.add(draft)
db.commit()
log.info("%s added custom tool %s", user.email, draft.slug)
return _back(f"Added {draft.name}.")
def _chosen_groups(db: Db, form, *, public: bool) -> list[Group]:
"""A public tool holds no groups, the way a public model holds none."""
if public:
return []
ids = set(form.getlist("group_ids"))
return list(db.scalars(select(Group).where(Group.id.in_(ids)))) if ids else []
@router.get("/admin/tools/{tool_id}/edit")
async def edit_tool_page(request: Request, db: Db, user: AdminUser, tool_id: str):
return _detail(request, db, _tool(db, tool_id), is_new=False)
@router.post("/admin/tools/{tool_id}/test")
async def test_tool(request: Request, db: Db, user: AdminUser, tool_id: str):
"""Call the stored row once, with arguments the administrator typed.
The stored row rather than the submitted form, so what is tested is what a
chat would actually do -- the same reason `/admin/search/test` reads the
saved provider settings.
"""
tool = _tool(db, tool_id)
form = await request.form()
raw = str(form.get("arguments") or "").strip() or "{}"
try:
arguments = json.loads(raw)
if not isinstance(arguments, dict):
raise ValueError("Arguments must be a JSON object.")
except (json.JSONDecodeError, ValueError) as exc:
return render(
request,
"admin/_tool_test.html",
{"tool": tool, "error": f"Those arguments are not a JSON object: {exc}"},
)
outcome = await custom_tools.call(custom_tools.spec_from(tool), arguments)
tool.last_error = str(outcome.event.get("error") or "")
tool.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request,
"admin/_tool_test.html",
{
"tool": tool,
"outcome": outcome,
"error": outcome.event.get("error") or "",
"detail": outcome.event.get("detail") or "",
},
)
@router.post("/admin/tools/{tool_id}/delete")
async def delete_tool(db: Db, user: AdminUser, tool_id: str) -> Response:
tool = _tool(db, tool_id)
name = tool.name
db.delete(tool)
db.commit()
log.info("%s deleted custom tool %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/admin/tools/{tool_id}")
async def update_tool(request: Request, db: Db, user: AdminUser, tool_id: str) -> Response:
tool = _tool(db, tool_id)
form = await request.form()
# Validated against a draft so that a rejected save leaves the stored row
# untouched and the form still holds what was typed.
draft = CustomTool(headers_json={}, parameters_json={}, position=tool.position)
_populate(draft, form)
problem = _problem(db, draft, form, existing_id=tool.id)
if problem:
draft.id = tool.id
draft.secret_encrypted = tool.secret_encrypted
return _detail(
request,
db,
draft,
is_new=False,
error=problem,
headers_text=str(form.get("headers") or ""),
parameters_text=str(form.get("parameters") or ""),
selected_groups=set(form.getlist("group_ids")),
)
_populate(tool, form)
tool.slug = draft.slug
tool.parameters_json = draft.parameters_json
tool.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), tool.secret_encrypted)
tool.groups = _chosen_groups(db, form, public=tool.public)
db.commit()
log.info("%s updated custom tool %s", user.email, tool.slug)
return _back(f"Saved {tool.name}.")
+28
View File
@@ -42,6 +42,21 @@ from lembas.db.models.library import (
)
from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.tool import (
RESPONSE_JSON,
RESPONSE_MODES,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_BEARER,
SECRET_HEADER,
SECRET_NONE,
SECRET_PLACEMENTS,
SECRET_QUERY,
CustomTool,
McpServer,
custom_tool_groups,
mcp_server_groups,
)
from lembas.db.models.user import (
ROLE_ADMIN,
ROLE_PENDING,
@@ -63,20 +78,31 @@ __all__ = [
"RESOURCE_BASE",
"RESOURCE_NOTE",
"RESOURCE_SKILL",
"RESPONSE_JSON",
"RESPONSE_MODES",
"RESPONSE_RAW",
"RESPONSE_TEXT",
"ROLE_ADMIN",
"ROLE_ASSISTANT",
"ROLE_PENDING",
"ROLE_SYSTEM",
"ROLE_TOOL",
"ROLE_USER",
"SECRET_BEARER",
"SECRET_HEADER",
"SECRET_NONE",
"SECRET_PLACEMENTS",
"SECRET_QUERY",
"SOURCE_LINK",
"SOURCE_UPLOAD",
"Chat",
"Connection",
"CustomTool",
"Document",
"Folder",
"Group",
"KnowledgeBase",
"McpServer",
"Memory",
"Message",
"Model",
@@ -89,6 +115,8 @@ __all__ = [
"Suggestion",
"User",
"chat_knowledge_bases",
"custom_tool_groups",
"mcp_server_groups",
"model_groups",
"user_groups",
]
+187
View File
@@ -0,0 +1,187 @@
"""Tools an administrator defined: HTTP endpoints and remote MCP servers.
Both are instance configuration rather than someone's content, so access is
shaped like `Model` and not like a note: a row is either public or reachable
through the groups it names, resolved the way `permissions.models_visible_to`
resolves a model. There is deliberately no per-user tool. A tool is a credential
pointed at a third party, and "anyone may define one" is a different feature
with a different threat model.
The two tables are near-twins on purpose -- name, slug, secret, group list,
last check -- because an administrator adding one should not have to learn a
second screen. What differs is what sits between the row and the model: a
custom tool *is* one call, described here in full, while an MCP server is a
conversation whose tools are discovered and cached.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict, JSONList
if TYPE_CHECKING:
# Annotation only; SQLAlchemy resolves the real class from its registry.
from lembas.db.models.user import Group
# How a row's secret is attached to a request. Stored values, so these are
# schema rather than presentation.
SECRET_NONE = "none"
SECRET_BEARER = "bearer"
SECRET_HEADER = "header"
SECRET_QUERY = "query"
SECRET_PLACEMENTS = (SECRET_NONE, SECRET_BEARER, SECRET_HEADER, SECRET_QUERY)
# How a response becomes text for the model.
RESPONSE_TEXT = "text" # prose; HTML reduced by fetch.html_to_text
RESPONSE_JSON = "json" # parsed, narrowed by response_path, pretty-printed
RESPONSE_RAW = "raw" # verbatim, truncated -- CSV, plain logs
RESPONSE_MODES = (RESPONSE_TEXT, RESPONSE_JSON, RESPONSE_RAW)
custom_tool_groups = Table(
"custom_tool_groups",
Base.metadata,
Column(
"tool_id", String(32), ForeignKey("custom_tools.id", ondelete="CASCADE"), primary_key=True
),
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
mcp_server_groups = Table(
"mcp_server_groups",
Base.metadata,
Column(
"server_id", String(32), ForeignKey("mcp_servers.id", ondelete="CASCADE"), primary_key=True
),
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
class CustomTool(UUIDPrimaryKey, Timestamps, Base):
"""One HTTP call, described well enough for a model to decide to make it."""
__tablename__ = "custom_tools"
# `slug` IS the function name sent to the endpoint, so it is bound by the
# charset those accept and is fixed once the row exists: it is also half of
# this tool's prompt-fragment key. `name` is the human label, shown in the
# admin list and in the transcript.
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Sent verbatim in the tools array. The only thing the model has to decide
# with, which is why the form insists on it.
description: Mapped[str] = mapped_column(Text, default="")
parameters_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# The *default* text of this tool's harness fragment. An administrator's
# edit on /admin/prompts is an override stored in the settings group like
# any other, so a tool deleted and recreated under the same slug keeps the
# wording somebody chose for it.
guidance: Mapped[str] = mapped_column(Text, default="")
method: Mapped[str] = mapped_column(String(8), default="GET", nullable=False)
# {{name}} placeholders, filled from the call's arguments. The scheme and
# the host must be literal -- see services/custom_tools.py for why.
url_template: Mapped[str] = mapped_column(String(1000), nullable=False)
body_template: Mapped[str] = mapped_column(Text, default="")
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
secret_placement: Mapped[str] = mapped_column(
String(16), default=SECRET_BEARER, nullable=False
)
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
response_mode: Mapped[str] = mapped_column(String(16), default=RESPONSE_TEXT, nullable=False)
# A dotted path into a JSON response: "data.items.0.title". Empty is the
# whole document. Not JSONPath -- that is a dependency and a syntax nobody
# would remember for the one field they want.
response_path: Mapped[str] = mapped_column(String(300), default="")
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
timeout: Mapped[int] = mapped_column(Integer, default=20, nullable=False)
# Whether this row may reach loopback, private or link-local addresses. Per
# row rather than the instance-wide search setting: an administrator naming
# http://127.0.0.1:11434 by hand is not the same act as a model handing the
# fetcher a URL it read on a page.
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
groups: Mapped[list[Group]] = relationship(
"Group", secondary=custom_tool_groups, back_populates="custom_tools"
)
def __repr__(self) -> str:
return f"<CustomTool {self.slug}>"
class McpServer(UUIDPrimaryKey, Timestamps, Base):
"""A remote MCP server, reached over streamable HTTP.
The tools it advertises are cached in `tools_json` rather than given a table
of their own. A discovered tool carries exactly one administrator decision
-- offered or not, which `tool_overrides_json` holds -- while credentials,
guidance and access are all per server; and the whole list is replaced on
every refresh, so a table would mean reconciling rows against a cache of
somebody else's document.
"""
__tablename__ = "mcp_servers"
# Prefixed onto every tool name this server advertises, so that two servers
# both exposing "search" do not collide and neither shadows a built-in.
slug: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
url: Mapped[str] = mapped_column(String(1000), nullable=False)
guidance: Mapped[str] = mapped_column(Text, default="")
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
secret_placement: Mapped[str] = mapped_column(
String(16), default=SECRET_BEARER, nullable=False
)
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
timeout: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# The last tools/list, cached. One entry per tool:
# {"name", "offer_name", "description", "schema"}.
tools_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
# Per-tool switch, keyed by the server's own name for it. Absent means on,
# the same rule the model capability flags follow, so a newly advertised
# tool works rather than silently doing nothing.
tool_overrides_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# What the server answered at initialize, for the admin list.
protocol_version: Mapped[str] = mapped_column(String(32), default="")
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
groups: Mapped[list[Group]] = relationship(
"Group", secondary=mcp_server_groups, back_populates="mcp_servers"
)
def __repr__(self) -> str:
return f"<McpServer {self.slug}>"
+7
View File
@@ -14,6 +14,7 @@ from lembas.db.types import JSONDict
if TYPE_CHECKING:
# Annotation only; SQLAlchemy resolves the real class from its registry.
from lembas.db.models.connection import Model
from lembas.db.models.tool import CustomTool, McpServer
# Roles are a simple ordered ladder rather than a permission matrix. Groups
# (below) carry finer-grained permissions once the users/groups UI lands.
@@ -72,6 +73,12 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
models: Mapped[list[Model]] = relationship(
"Model", secondary="model_groups", back_populates="groups"
)
custom_tools: Mapped[list[CustomTool]] = relationship(
"CustomTool", secondary="custom_tool_groups", back_populates="groups"
)
mcp_servers: Mapped[list[McpServer]] = relationship(
"McpServer", secondary="mcp_server_groups", back_populates="groups"
)
class Session(UUIDPrimaryKey, Timestamps, Base):
+2
View File
@@ -19,6 +19,7 @@ from lembas.api import (
admin_prompts,
admin_search,
admin_suggestions,
admin_tools,
admin_users,
audio,
auth,
@@ -120,6 +121,7 @@ def create_app() -> FastAPI:
app.include_router(admin_search.router)
app.include_router(admin_prompts.router)
app.include_router(admin_suggestions.router)
app.include_router(admin_tools.router)
register_error_handlers(app)
return app
+16
View File
@@ -86,6 +86,22 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
True,
"Chat",
),
PermissionDef(
"tools.custom",
"Use custom tools",
"Let a model call the HTTP tools an administrator has defined. Which "
"ones depends on the groups each tool is restricted to.",
True,
"Chat",
),
PermissionDef(
"tools.mcp",
"Use MCP servers",
"Let a model call tools from the MCP servers an administrator has "
"added. Which ones depends on the groups each server is restricted to.",
True,
"Chat",
),
PermissionDef(
"audio.transcribe",
"Dictate messages",
+431
View File
@@ -0,0 +1,431 @@
"""Running the HTTP tools an administrator defined.
A row in `custom_tools` becomes a `ToolDef` like any built-in: same schema in
the same array, same `ToolOutcome` back. What is different is that the arguments
come from a model and the destination comes from a template, so two things have
to hold.
**An argument may fill a hole; it may not move the target.** The scheme and host
of the template are literal, checked when the row is saved and again here in
case a row predates the check, and every value is escaped for the position it
lands in -- percent-encoded with nothing safe in a URL, JSON-escaped in a body,
stripped of line breaks in a header. `quote(value, safe="")` is what stops a
value adding a path segment, a query parameter or a fragment; pinning the origin
afterwards is what catches anything that got past it.
**Every hop is checked.** This is the same request-forgery problem
`services/fetch.py` exists to solve, and the same answer: resolve and check the
address, follow redirects by hand, refuse private ranges unless this particular
row was allowed them. `fetch.fetch` itself cannot be reused -- it is GET-only,
has no body, and refuses any content type that is not HTML or text, which is
every JSON API there is -- so its redirect loop is deliberately copied rather
than the module bent into a general HTTP client.
The secret is decrypted into the snapshot and goes nowhere else: not into the
event, not into a log line, and not across a redirect that leaves the origin it
was issued for.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote, urlparse
import httpx
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
RESPONSE_JSON,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_BEARER,
SECRET_HEADER,
SECRET_QUERY,
CustomTool,
User,
)
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
log = logging.getLogger(__name__)
# What a response may weigh before it is cut off. Well below the page fetcher's
# ceiling, because this is text that will be sent back to a model rather than
# stored for a person to read.
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
# How much of the response is kept on the message row for the transcript. Capped
# separately from `max_chars`: what the model reads is spent once, what the event
# holds is stored on every message forever.
MAX_EVENT_CHARS = 2000
# How much of the arguments the transcript summarises.
MAX_SUMMARY_CHARS = 200
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
# Bounds an administrator's number is clamped into. A tool that may return
# 400 000 characters is a tool that can fill the context window in one call.
MIN_CHARS, MAX_CHARS = 200, 40_000
MIN_TIMEOUT, MAX_TIMEOUT = 1, 120
@dataclass(frozen=True)
class HttpSpec:
"""Everything one custom tool needs, read while the session was open.
A frozen snapshot rather than the row, for the reason `Endpoint` is one: a
generation outlives the request that started it, and a detached SQLAlchemy
instance is a trap. The decrypted secret lives here and nowhere else.
"""
slug: str
label: str
method: str
url_template: str
body_template: str = ""
headers: dict[str, str] = field(default_factory=dict)
secret: str = ""
secret_placement: str = SECRET_BEARER
secret_name: str = "Authorization"
response_mode: str = RESPONSE_TEXT
response_path: str = ""
max_chars: int = 8000
timeout: int = 20
allow_private: bool = False
parameters: dict[str, Any] = field(default_factory=dict)
@property
def secret_header(self) -> str:
"""The header the secret rides in, if it rides in one."""
if not self.secret or self.secret_placement not in (SECRET_BEARER, SECRET_HEADER):
return ""
return self.secret_name or "Authorization"
def spec_from(row: CustomTool) -> HttpSpec:
"""Snapshot a row, decrypting its secret. Call this with a session open."""
return HttpSpec(
slug=row.slug,
label=row.name or row.slug,
method=(row.method or "GET").upper(),
url_template=row.url_template or "",
body_template=row.body_template or "",
headers=dict(row.headers_json or {}),
secret=decrypt(row.secret_encrypted),
secret_placement=row.secret_placement,
secret_name=row.secret_name or "Authorization",
response_mode=row.response_mode,
response_path=row.response_path or "",
max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS),
timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT),
allow_private=bool(row.allow_private),
parameters=dict(row.parameters_json or {}),
)
def tool_defs(
db: DBSession, user: User | None, *, everything: bool = False
) -> list[ToolDef]:
"""One `ToolDef` per custom tool this user may be offered."""
return [
ToolDef(
name=row.slug,
family=f"custom:{row.slug}",
description=row.description or f"Call the {row.name} tool.",
parameters=_schema_of(row),
run=_runner(spec_from(row)),
)
for row in tool_access.visible_custom_tools(db, user, everything=everything)
]
def _schema_of(row: CustomTool) -> dict[str, Any]:
schema = dict(row.parameters_json or {})
if schema.get("type") != "object":
# An endpoint expects an object here; anything else it will reject
# outright, which fails the whole request rather than the one tool.
return {"type": "object", "properties": {}}
return schema
def _runner(spec: HttpSpec):
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
return await call(spec, args)
return run
# --- Filling the template ----------------------------------------------------
def _scalar(value: Any) -> str:
"""One argument as text, before it is escaped for wherever it is going."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
return value
if isinstance(value, int | float):
return str(value)
return json.dumps(value, ensure_ascii=False)
def _for_url(value: str) -> str:
# safe="" is the whole point: an argument must not be able to introduce a
# path segment, a query separator or a fragment.
return quote(value, safe="")
def _for_body(value: str) -> str:
# The inside of a JSON string, so a quote or a backslash in an argument
# cannot end it early and add a field of its own.
return json.dumps(value, ensure_ascii=False)[1:-1]
def _for_header(value: str) -> str:
# A newline in a header value is header injection. Other control characters
# go with it; none of them mean anything in a header.
return "".join(character for character in value if character.isprintable())
def _substitute(template: str, spec: HttpSpec, args: dict[str, Any], escape) -> str:
"""Fill `{{name}}` from the call's arguments.
Not `prompts.substitute`, though the grammar is shared. The rules differ,
and the differences are the point: a name the tool does not declare never
substitutes, an unrecognised one becomes nothing rather than passing through
verbatim -- a literal `{{x}}` in a URL is not a feature -- and every value
is escaped for where it lands.
"""
declared = set(spec.parameters.get("properties") or {})
def swap(match) -> str:
name = match.group(1)
if name not in declared:
return ""
return escape(_scalar(args.get(name)))
return VARIABLE_PATTERN.sub(swap, template)
def _origin(url: str) -> tuple[str, str]:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise fetch_service.FetchError("A tool's URL must start with http:// or https://")
if not parsed.netloc:
raise fetch_service.FetchError("A tool's URL has no host.")
return parsed.scheme, parsed.netloc
def fill_url(spec: HttpSpec, args: dict[str, Any]) -> str:
"""Fill the URL template, refusing anything that moved the host.
Checked twice over: the template's own scheme and authority must be literal,
and the filled URL must still point at them. The first check is what stops
`https://{{host}}/x` from ever being saved; the second is what catches a row
that predates it, or an escaping mistake.
"""
template = spec.url_template.strip()
scheme, netloc = _origin(template)
if VARIABLE_PATTERN.search(f"{scheme}://{netloc}"):
raise fetch_service.FetchError(
"A tool's scheme and host must be literal, not filled from an argument."
)
filled = _substitute(template, spec, args, _for_url)
if _origin(filled) != (scheme, netloc):
raise fetch_service.FetchError("That call would have pointed somewhere else.")
return filled
def _prepare(spec: HttpSpec, args: dict[str, Any]) -> tuple[str, dict[str, str], bytes | None]:
"""The URL, headers and body for one call, secret included."""
url = fill_url(spec, args)
headers = {
"User-Agent": fetch_service.USER_AGENT,
"Accept": "application/json, text/*;q=0.9, */*;q=0.5",
}
for name, value in spec.headers.items():
clean = _for_header(str(name)).strip()
if clean:
headers[clean] = _substitute(str(value), spec, args, _for_header)
body: bytes | None = None
if spec.body_template.strip() and spec.method != "GET":
body = _substitute(spec.body_template, spec, args, _for_body).encode("utf-8")
headers.setdefault("Content-Type", "application/json")
if spec.secret:
if spec.secret_placement == SECRET_BEARER:
headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}"
elif spec.secret_placement == SECRET_HEADER:
headers[spec.secret_name or "Authorization"] = spec.secret
elif spec.secret_placement == SECRET_QUERY:
# Only on the URL this call starts at. A redirect's Location
# replaces the query, so the credential does not travel on by
# itself -- which is the behaviour wanted anyway.
joiner = "&" if urlparse(url).query else "?"
url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}"
return url, headers, body
# --- Reading the response ----------------------------------------------------
def _narrow(payload: Any, path: str) -> Any:
"""Walk a dotted path into a decoded JSON document.
Integer segments index a list, so "data.0.title" works. A path that does not
lead anywhere yields the whole document rather than nothing: an unhelpful
answer beats a silent empty one when the model has to explain itself.
"""
current = payload
for segment in [part for part in path.split(".") if part]:
if isinstance(current, dict) and segment in current:
current = current[segment]
elif isinstance(current, list) and segment.lstrip("-").isdigit():
try:
current = current[int(segment)]
except IndexError:
return payload
else:
return payload
return current
def _decode(payload: bytes, response: httpx.Response) -> str:
return payload.decode(response.encoding or "utf-8", "replace")
def _as_text(spec: HttpSpec, payload: bytes, response: httpx.Response) -> str:
content_type = response.headers.get("content-type", "")
if spec.response_mode == RESPONSE_JSON:
try:
document = json.loads(_decode(payload, response))
except (json.JSONDecodeError, UnicodeDecodeError):
# Falling back rather than failing: a JSON API answering with an
# HTML error page is a thing the model can report usefully.
return _decode(payload, response)
value = _narrow(document, spec.response_path)
if isinstance(value, str):
return value
return json.dumps(value, indent=2, ensure_ascii=False)
if spec.response_mode == RESPONSE_RAW:
return _decode(payload, response)
text = _decode(payload, response)
if "html" in content_type or text.lstrip()[:1] == "<":
_, text = fetch_service.html_to_text(text)
return text
def _clip(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n… (truncated)"
def _summary(args: dict[str, Any]) -> str:
"""What the transcript shows the tool was asked for."""
parts = [f"{name}={_scalar(value)!r}" for name, value in args.items()]
return _clip(", ".join(parts), MAX_SUMMARY_CHARS)
def _event(spec: HttpSpec, args: dict[str, Any], *, status: str, **extra: Any) -> dict[str, Any]:
return {
"name": spec.slug,
"kind": "custom",
"label": spec.label,
"query": _summary(args),
# The host, never the filled URL: a path or query segment can carry an
# argument, and the event is rendered and stored.
"detail": f"{spec.method} {urlparse(spec.url_template).netloc}",
"status": status,
"results": [],
**extra,
}
# --- Making the call ---------------------------------------------------------
async def call(spec: HttpSpec, args: dict[str, Any]) -> ToolOutcome:
"""Run one custom tool. Reports its own failures rather than raising."""
try:
url, headers, body = _prepare(spec, args)
current = fetch_service.check_url(url, allow_private=spec.allow_private)
origin = _origin(current)
response = await _send(spec, current, headers, body, origin)
except fetch_service.FetchError as exc:
return ToolOutcome(
f"The {spec.label} tool could not be called: {exc.message}",
_event(spec, args, status="error", error=exc.message),
)
except httpx.RequestError as exc:
message = f"Could not reach the {spec.label} tool: {exc}"
return ToolOutcome(message, _event(spec, args, status="error", error=str(exc)[:200]))
payload = response.content[:MAX_RESPONSE_BYTES]
text = _clip(_as_text(spec, payload, response).strip(), spec.max_chars)
if response.status_code >= 400:
note = f"{spec.label} returned HTTP {response.status_code}."
return ToolOutcome(
f"{note}\n\n{text}" if text else note,
_event(
spec,
args,
status="error",
error=f"HTTP {response.status_code}",
text=text[:MAX_EVENT_CHARS],
),
)
if not text:
return ToolOutcome(
f"{spec.label} returned nothing.",
_event(spec, args, status="ok", text=""),
)
return ToolOutcome(text, _event(spec, args, status="ok", text=text[:MAX_EVENT_CHARS]))
async def _send(
spec: HttpSpec,
url: str,
headers: dict[str, str],
body: bytes | None,
origin: tuple[str, str],
) -> httpx.Response:
"""Send the request, following redirects by hand so each hop is checked."""
current = url
async with httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False) as client:
for _ in range(fetch_service.MAX_REDIRECTS + 1):
response = await client.request(
spec.method, current, headers=headers, content=body
)
if not response.is_redirect:
return response
location = response.headers.get("location", "")
if not location:
raise fetch_service.FetchError("That tool redirected to nowhere.")
current = fetch_service.check_url(
str(response.url.join(location)), allow_private=spec.allow_private
)
if _origin(current) != origin:
# A server that can redirect us anywhere must not be able to
# redirect us at somebody else carrying the key.
if spec.secret_header:
headers.pop(spec.secret_header, None)
origin = _origin(current)
raise fetch_service.FetchError("That tool redirected too many times.")
__all__ = ["HttpSpec", "call", "fill_url", "spec_from", "tool_defs"]
+6 -2
View File
@@ -239,7 +239,11 @@ async def _run(generation: Generation) -> None:
owner = db.get(User, chat.user_id)
# Read while the session is open: everything below outlives it.
offered = tools_service.enabled_tools(db, chat, owner)
# Resolved once, so that what the loop is allowed to *run* is the
# same set the endpoint was *offered* -- not whatever happens to
# exist by the time a call comes back.
toolset = tools_service.resolve_tools(db, chat, owner)
offered = toolset.schemas
payload = chat_service.build_request(
db, chat, upto=message, tools=offered, user=owner
)
@@ -248,7 +252,7 @@ async def _run(generation: Generation) -> None:
# Read here, with the rest, because titling happens after this
# session has closed and must not open another one.
title_prompt = prompts_service.resolve(db, "task.title")
tool_context = tools_service.context_for(db, owner, chat)
tool_context = tools_service.context_for(db, owner, chat, tools=toolset)
model = chat_service.model_for(db, chat)
generation.context_limit = model.context_length if model is not None else 0
+14 -8
View File
@@ -57,16 +57,22 @@ MAX_HARNESS_CHARS = 8000
MAX_NAMED_DOCUMENTS = 5
def _families(tools: list[dict[str, Any]]) -> list[str]:
"""Which families are represented in an offered tool list, in a fixed order."""
from lembas.services.tools import FAMILIES, REGISTRY
def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]:
"""Which families are represented in an offered tool list, in a fixed order.
Resolved against the database rather than the import-time registry, because
an administrator-defined tool is a row and would otherwise contribute no
family at all -- which is to say its guidance would never be admitted.
"""
from lembas.services import tools as tools_service
book = tools_service.registry(db)
offered = {
REGISTRY[name].family
book[name].family
for tool in tools
if (name := (tool.get("function") or {}).get("name")) in REGISTRY
if (name := (tool.get("function") or {}).get("name")) in book
}
return [family for family in FAMILIES if family in offered]
return [family for family in tools_service.families(db) if family in offered]
def _tool_names(tools: list[dict[str, Any]]) -> str:
@@ -109,7 +115,7 @@ def context_variables(
from lembas.services import tools as tools_service
offered = tools or []
families = _families(offered)
families = _families(db, offered)
stamp = datetime.now().astimezone()
values: dict[str, str] = {
@@ -185,7 +191,7 @@ def compose(
return compose_from(
db,
variables=context_variables(db, user, offered, chat),
families=_families(offered),
families=_families(db, offered),
has_tools=bool(offered),
)
+63
View File
@@ -0,0 +1,63 @@
"""Who may be offered which administrator-defined tool.
One definition, used by the resolver that builds a chat's tool set and by the
admin screens that say what a row will reach. It mirrors
`permissions.models_visible_to` rather than `services/sharing.py`, and the
difference matters: sharing has no admin branch because reading someone's
private notes is not something a role should grant, while a tool is instance
configuration an administrator could give themselves in one click anyway.
`everything=True` is for callers that need to map a tool *name* back to what it
belongs to -- the harness, the prompt preview -- rather than to decide what to
offer. Gating on visibility there would mean an administrator's own preview
disagreeing with what a reader actually gets.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import CustomTool, McpServer, User
def _permitted(rows: list, user: User | None, *, everything: bool) -> list:
if everything or (user is not None and user.is_admin):
return rows
if user is None:
return []
member_of = {group.id for group in user.groups}
return [
row for row in rows if row.public or member_of.intersection({g.id for g in row.groups})
]
def visible_custom_tools(
db: DBSession, user: User | None, *, everything: bool = False
) -> list[CustomTool]:
"""Enabled custom tools this user may be offered, in position order."""
rows = list(
db.scalars(
select(CustomTool)
.where(CustomTool.enabled.is_(True))
.order_by(CustomTool.position, CustomTool.slug)
)
)
return _permitted(rows, user, everything=everything)
def visible_mcp_servers(
db: DBSession, user: User | None, *, everything: bool = False
) -> list[McpServer]:
"""Enabled MCP servers this user may be offered, in position order."""
rows = list(
db.scalars(
select(McpServer)
.where(McpServer.enabled.is_(True))
.order_by(McpServer.position, McpServer.slug)
)
)
return _permitted(rows, user, everything=everything)
__all__ = ["visible_custom_tools", "visible_mcp_servers"]
+180 -18
View File
@@ -29,10 +29,12 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, Chat, User
from lembas.db.session import session_scope
from lembas.services import prompts as prompts_service
from lembas.services import search as search_service
from lembas.services import settings_store
from lembas.services.library import documents as documents_service
@@ -58,8 +60,24 @@ FAMILY_NOTES = "notes"
FAMILY_MEMORY = "memory"
FAMILY_SKILLS = "skills"
# A tool that is a database row gets a family of its own, so that it can carry
# its own guidance -- "custom:weather", "mcp:github". Everything before the
# colon is the *gate*: the capability flag and the permission are per gate, not
# per row, because a server advertising forty tools must not mean forty
# checkboxes on every model.
FAMILY_CUSTOM = "custom"
FAMILY_MCP = "mcp"
# The built-in families, in the order they are offered.
FAMILIES = (FAMILY_SEARCH, FAMILY_KNOWLEDGE, FAMILY_NOTES, FAMILY_MEMORY, FAMILY_SKILLS)
GATES = (*FAMILIES, FAMILY_CUSTOM, FAMILY_MCP)
def gate_of(family: str) -> str:
"""The part a capability flag and a permission are named after."""
return family.split(":", 1)[0]
@dataclass
class ToolContext:
@@ -72,10 +90,14 @@ class ToolContext:
owner_id: str
search_config: dict[str, Any] = field(default_factory=dict)
allow_private_fetch: bool = False
# Which knowledge bases this chat is scoped to. Empty means "everything the
# owner can see", which is what a chat with none attached should do.
base_ids: list[str] = field(default_factory=list)
# Name -> definition for the tools actually offered on this request. None
# means nobody resolved a set, and only then does `run_tool` fall back to
# 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
@dataclass
@@ -114,6 +136,31 @@ class ToolDef:
}
@dataclass(frozen=True)
class ToolSet:
"""What one request may call: the schemas to send, and how to run them.
The two halves have to travel together. `enabled_tools` used to return
schemas alone, which worked only because every runner was reachable through
the import-time `REGISTRY`. A tool that is a database row is not, so the
resolution has to be carried from the session that made it to the loop that
uses it.
"""
defs: tuple[ToolDef, ...] = ()
@property
def schemas(self) -> list[dict[str, Any]]:
return [tool.schema for tool in self.defs]
@property
def by_name(self) -> dict[str, ToolDef]:
return {tool.name: tool for tool in self.defs}
def __bool__(self) -> bool:
return bool(self.defs)
def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
return {"type": "object", "properties": properties, "required": required}
@@ -645,21 +692,69 @@ def _family_allowed(
Absent counts as on when `tools` is on, so an upgrade does not silently take
web search away from every model already set up for it.
"""
gate = gate_of(family)
default = bool(capabilities.get("tools"))
if not capabilities.get(f"tool_{family}", default):
if not capabilities.get(f"tool_{gate}", default):
return False
if family == FAMILY_SEARCH:
if gate == FAMILY_SEARCH:
return bool(
allowed.get("tools.web_search")
and config.get("enabled")
and not search_service.availability(str(config.get("provider") or "ddgs"))
)
return bool(allowed.get(f"tools.{family}") and allowed.get("library.use"))
if gate in (FAMILY_CUSTOM, FAMILY_MCP):
# 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.
return bool(allowed.get(f"tools.{gate}"))
return bool(allowed.get(f"tools.{gate}") and allowed.get("library.use"))
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
"""The tool schemas to offer for this chat."""
def _row_defs(db: DBSession, user: User | None, *, everything: bool = False) -> list[ToolDef]:
"""Tool definitions built from rows, in the order they claim names.
Imported here rather than at the top because `custom_tools` needs `ToolDef`
from this module.
"""
from lembas.services import custom_tools
return custom_tools.tool_defs(db, user, everything=everything)
def _book(defs: list[ToolDef]) -> dict[str, ToolDef]:
"""Keyed by name, first claim winning.
The built-ins are laid down first, so a row can never shadow one -- a tool
called `notes_delete` that turns out to be somebody's HTTP endpoint is the
kind of surprise that has no good failure mode.
"""
book = dict(REGISTRY)
for tool in defs:
book.setdefault(tool.name, tool)
return book
def registry(db: DBSession) -> dict[str, ToolDef]:
"""Every tool that exists on this instance, keyed by name, ungated.
`REGISTRY` holds the built-ins alone, because it is built at import time and
an administrator-defined tool is a row. Callers that only need to map a name
back to a family use this; callers deciding what to *offer* use
`resolve_tools`, which applies the gates as well.
"""
return _book(_row_defs(db, None, everything=True))
def families(db: DBSession) -> tuple[str, ...]:
"""Every family that exists, the built-ins in their fixed order first."""
rows = tuple(tool.family for tool in _row_defs(db, None, everything=True))
return (*FAMILIES, *rows)
def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
"""Every tool this chat may call right now, with its runner attached."""
from lembas.security import permissions
from lembas.services import chat as chat_service
@@ -669,25 +764,47 @@ def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str
capabilities = model.capabilities_json or {}
if not capabilities.get("tools"):
return []
return ToolSet()
allowed = permissions.resolve(db, user)
config = settings_store.search(db)
families = {
family
for family in FAMILIES
if _family_allowed(family, config=config, capabilities=capabilities, allowed=allowed)
}
return [tool.schema for tool in REGISTRY.values() if tool.family in families]
# Resolved against what this reader may see, not against everything that
# exists: a tool restricted to a group is not offered outside it.
book = _book(_row_defs(db, user))
return ToolSet(
tuple(
tool
for tool in book.values()
if _family_allowed(
tool.family, config=config, capabilities=capabilities, allowed=allowed
)
)
)
def context_for(db: DBSession, user: User | None, chat: Chat | None = None) -> ToolContext:
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
"""The tool schemas to offer for this chat.
The shape is unchanged on purpose: the inspector and `build_request` want
exactly this. Anything that will also *run* a tool wants `resolve_tools`.
"""
return resolve_tools(db, chat, user).schemas
def context_for(
db: DBSession,
user: User | None,
chat: Chat | None = None,
*,
tools: ToolSet | None = None,
) -> ToolContext:
"""The snapshot a running tool needs, taken while the session is open."""
return ToolContext(
owner_id=user.id if user else "",
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,
)
@@ -697,8 +814,15 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco
Never raises. A tool that fails hands the model an explanation and lets it
carry on -- a failed lookup should produce "I could not find that" rather
than killing the whole reply.
The lookup is against what was *offered*, not against everything that
exists. Reaching for the registry directly meant a model naming a tool its
chat was gated out of -- a family switched off for the model, a permission
the reader does not have -- had it run anyway, because only the offer was
ever filtered.
"""
tool = REGISTRY.get(name)
book = REGISTRY if context.tools is None else context.tools
tool = book.get(name)
if tool is None:
return ToolOutcome(
f"There is no tool called {name!r}.",
@@ -710,9 +834,12 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco
except json.JSONDecodeError:
# Small models emit malformed argument JSON often enough that this is a
# normal path, not an exceptional one. Treat the whole string as the
# first required argument rather than giving up.
required = tool.parameters.get("required") or ["query"]
parsed = {required[0]: arguments.strip()}
# tool's first argument rather than giving up: what it says is required,
# else the first thing it declares, and only then a guess -- a schema
# somebody else wrote need not have either.
properties = tool.parameters.get("properties") or {}
names = tool.parameters.get("required") or list(properties) or ["query"]
parsed = {str(names[0]): arguments.strip()}
if not isinstance(parsed, dict):
parsed = {"query": str(parsed)}
@@ -808,6 +935,31 @@ def tool_turn(call: dict[str, Any], content: str) -> dict[str, Any]:
}
def _row_source(db: DBSession):
"""One harness fragment per administrator-defined tool.
The seam `prompts.register_source` exists for. The row supplies the default
text and the admin page supplies the override, which is why a tool deleted
and recreated under the same slug keeps whatever wording somebody chose for
it -- the override outlives the row.
Gated on the tool's own family, so the guidance appears exactly when the
tool it describes is offered and never otherwise.
"""
from lembas.db.models import CustomTool
for row in db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)):
yield prompts_service.Fragment(
key=f"tool.custom_{row.slug}",
label=row.name or row.slug,
group=prompts_service.GROUP_TOOLS,
order=500 + row.position,
families=(f"{FAMILY_CUSTOM}:{row.slug}",),
hint=f"Appears when the {row.slug} tool is offered.",
default=row.guidance or "",
)
__all__ = [
"FAMILIES",
"MAX_ROUNDS",
@@ -816,9 +968,19 @@ __all__ = [
"ToolContext",
"ToolDef",
"ToolOutcome",
"ToolSet",
"assistant_turn",
"context_for",
"enabled_tools",
"families",
"registry",
"resolve_tools",
"run_tool",
"tool_turn",
]
# Registered at import. `services.tools` is imported by the chat routes, the
# generation service and the prompts admin, so the source is in place before
# anything renders a catalogue.
prompts_service.register_source(_row_source)
+17
View File
@@ -371,6 +371,23 @@
line-height: var(--leading-relaxed);
}
/* What a tool returned, verbatim. Preformatted rather than rendered: this is
third-party text and markdown is the one path allowed to emit HTML. */
.tool-result__text {
margin: 0;
padding: var(--sp-3);
border-radius: var(--radius-sm);
background: var(--bg-sunken);
color: var(--ink-muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: var(--leading-relaxed);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 22em;
overflow-y: auto;
}
/* --- Stop, notes and editing ---------------------------------------------- */
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
.msg__status:empty { display: none; }
+4 -4
View File
@@ -43,6 +43,10 @@
{{ icon("globe", "icon--sm") }}
<span class="nav-item__label">Web search</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'tools' }}" href="/admin/tools">
{{ icon("link", "icon--sm") }}
<span class="nav-item__label">Tools</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'prompts' }}" href="/admin/prompts">
{{ icon("sparkle", "icon--sm") }}
<span class="nav-item__label">Prompts</span>
@@ -64,10 +68,6 @@
<div class="nav-group">
<div class="nav-group__label">Not yet built</div>
<span class="nav-item is-disabled">
{{ icon("gear", "icon--sm") }}
<span class="nav-item__label">Tools</span>
</span>
<span class="nav-item is-disabled">
{{ icon("server", "icon--sm") }}
<span class="nav-item__label">Agents</span>
@@ -0,0 +1,21 @@
{% from "_macros.html" import icon %}
{#
The result of calling one tool by hand.
What comes back is whatever the endpoint chose to send, which makes it exactly
as untrusted as a search result or model output. It is shown escaped, inside a
<pre>, and never rendered as Markdown.
#}
{% if error %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
{% else %}
<div class="alert alert--success">
{{ icon("check", "icon--sm") }}
<span>{{ detail or tool.slug }} answered.</span>
</div>
{% endif %}
{% if outcome %}
<p class="field__hint">This is what the model would read back:</p>
<pre class="tool-result__text">{{ outcome.content }}</pre>
{% endif %}
@@ -0,0 +1,308 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "tools" %}
{% block title %}{{ "New tool" if is_new else tool.name }} - LLeMbas{% endblock %}
{% block heading %}{{ "New tool" if is_new else tool.name }}{% endblock %}
{% block admin_content %}
<nav class="crumbs">
<a class="crumbs__back" href="/admin/tools">
{{ icon("chevron-right", "icon--sm crumbs__icon") }} All tools
</a>
</nav>
{% if error %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
{% endif %}
<form method="post" action="{{ '/admin/tools' if is_new else '/admin/tools/' ~ tool.id }}"
class="form-grid">
<section class="card">
<h2 class="card__title">What it is</h2>
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="input" id="name" name="name" value="{{ tool.name }}" required
maxlength="120" placeholder="Weather">
<p class="field__hint">Shown in the transcript when the model uses it.</p>
</div>
<div class="field">
<label class="field__label" for="slug">Identifier</label>
<input class="input input--mono" id="slug" name="slug" value="{{ tool.slug }}" required
maxlength="48" placeholder="weather" pattern="[a-z0-9][a-z0-9_\-]*">
<p class="field__hint">
The name the model calls, and the key its guidance is stored under.
Lowercase letters, digits, hyphens and underscores.
{% if not is_new %}Changing it starts its guidance afresh.{% endif %}
</p>
</div>
<div class="field">
<label class="field__label" for="description">Description</label>
<textarea class="textarea" id="description" name="description" rows="3"
placeholder="Look up the current weather for a city."
>{{ tool.description }}</textarea>
<p class="field__hint">
Sent to the model verbatim. This is the whole basis on which it decides
whether to call this tool, so say what it does and when it is the right
thing to use.
</p>
</div>
<div class="field">
<label class="field__label" for="parameters">Parameters</label>
<textarea class="textarea input--mono" id="parameters" name="parameters" rows="10"
spellcheck="false">{{ parameters_text }}</textarea>
<p class="field__hint">
A JSON Schema object. Each property becomes a
<code>{{ '{{name}}' }}</code> you can use below, and a
<code>description</code> on each one is worth writing — the model reads
those too.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">The call</h2>
<div class="field">
<label class="field__label" for="method">Method</label>
<select class="select" id="method" name="method">
{% for method in methods %}
<option value="{{ method }}" {{ 'selected' if method == tool.method }}>{{ method }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="url_template">URL</label>
<input class="input input--mono" id="url_template" name="url_template" required
value="{{ tool.url_template }}"
placeholder="https://api.example.com/weather/{{ '{{city}}' }}">
<p class="field__hint">
Placeholders are filled from the arguments and escaped, so a value
cannot add a path segment or a query of its own. The scheme and host
must be written out — they cannot come from an argument.
</p>
</div>
<div class="field">
<label class="field__label" for="headers">Headers</label>
<textarea class="textarea input--mono" id="headers" name="headers" rows="3"
spellcheck="false"
placeholder="Accept: application/json">{{ headers_text }}</textarea>
<p class="field__hint">One <code>Name: value</code> per line. Placeholders work here too.</p>
</div>
<div class="field">
<label class="field__label" for="body_template">Body</label>
<textarea class="textarea input--mono" id="body_template" name="body_template" rows="4"
spellcheck="false">{{ tool.body_template }}</textarea>
<p class="field__hint">
Ignored for GET. Placeholders are escaped for JSON, so a value cannot
end the string it sits in and add a field.
</p>
</div>
<div class="field">
<label class="field__label" for="timeout">Timeout (seconds)</label>
<input class="input" id="timeout" name="timeout" value="{{ tool.timeout }}" inputmode="numeric">
</div>
</section>
<section class="card">
<h2 class="card__title">Credential</h2>
<div class="field">
<label class="field__label" for="secret_placement">How it is sent</label>
<select class="select" id="secret_placement" name="secret_placement">
{% for value, label in secret_placements %}
<option value="{{ value }}" {{ 'selected' if value == tool.secret_placement }}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="secret_name">Header or parameter name</label>
<input class="input input--mono" id="secret_name" name="secret_name"
value="{{ tool.secret_name }}" maxlength="120">
</div>
<div class="field">
<label class="field__label" for="secret">Secret</label>
<input class="input input--mono" id="secret" name="secret" type="password"
autocomplete="off" placeholder="No secret set"
value="{{ unchanged if tool.secret_encrypted else '' }}">
<p class="field__hint">
{% if tool.secret_encrypted %}
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
or clear the field to remove it.
{% else %}
Encrypted at rest and never shown again. It is dropped if the endpoint
redirects to another host.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">The answer</h2>
<div class="field">
<label class="field__label" for="response_mode">Read the response as</label>
<select class="select" id="response_mode" name="response_mode">
{% for value, label in response_modes %}
<option value="{{ value }}" {{ 'selected' if value == tool.response_mode }}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="response_path">Path into the JSON</label>
<input class="input input--mono" id="response_path" name="response_path"
value="{{ tool.response_path }}" placeholder="data.items.0.title" maxlength="300">
<p class="field__hint">
Dotted; a number indexes a list. Leave empty for the whole document.
A path that leads nowhere gives the whole document rather than nothing.
</p>
</div>
<div class="field">
<label class="field__label" for="max_chars">Most characters to keep</label>
<input class="input" id="max_chars" name="max_chars" value="{{ tool.max_chars }}"
inputmode="numeric">
<p class="field__hint">
Spent out of the context window on every call. The rest is cut off, and
the model is told so.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Guidance</h2>
<div class="field">
<textarea class="textarea" name="guidance" rows="4"
placeholder="- Check the weather rather than guessing at it."
>{{ tool.guidance }}</textarea>
<p class="field__hint">
Added to the system message whenever this tool is offered, and nowhere
else. Start a line with <code>- </code> and it joins the list of tool
instructions. Optional: the description above is what makes the tool
usable, this is for habits.
{% if prompt_overridden %}
<br><strong>Someone has overridden this wording under
<a href="/admin/prompts">Prompts</a></strong> — that is what the model
sees, not this.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Availability</h2>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if tool.enabled }}>
<span>Enabled — offered in chats</span>
</label>
<label class="checkbox">
<input type="checkbox" name="allow_private" value="true"
{{ 'checked' if tool.allow_private }}>
<span>May reach private and loopback addresses</span>
</label>
</div>
<p class="field__hint">
Leave the second unticked unless this tool points at something on your
own network. It is what stops a tool being aimed at this server, a
router, or a cloud metadata endpoint.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if tool.public }}>
<span>Available to everyone</span>
</label>
<p class="field__hint">
Uncheck to restrict this tool to chosen groups. Administrators always
have access.
</p>
</div>
<div class="field">
<span class="field__label">Groups with access</span>
{% if groups %}
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group.id in selected_groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
<p class="field__hint">Ignored while the tool is available to everyone.</p>
{% else %}
<p class="field__hint">
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
</p>
{% endif %}
</div>
<div class="field">
<label class="field__label" for="position">Position</label>
<input class="input" id="position" name="position" value="{{ tool.position }}"
inputmode="numeric">
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">
{{ "Create tool" if is_new else "Save changes" }}
</button>
<a class="btn btn--ghost" href="/admin/tools">Back to all tools</a>
{% if not is_new %}
<button class="btn btn--danger" type="submit" formnovalidate
formaction="/admin/tools/{{ tool.id }}/delete"
data-confirm-button="Delete the tool “{{ tool.name }}”? Chats that used it keep their transcripts.">
Delete
</button>
{% endif %}
</div>
</form>
{% if not is_new %}
<section class="card">
<h2 class="card__title">Try it</h2>
<p class="field__hint">
Calls the <em>saved</em> tool once, so what you see here is what a chat would
get. Nothing is sent to a model.
</p>
<div class="field">
<label class="field__label" for="arguments">Arguments</label>
<textarea class="textarea input--mono" id="arguments" name="arguments" rows="3"
spellcheck="false">{"city": "Minas Tirith"}</textarea>
</div>
<div class="btn-row">
<button class="btn" type="button"
hx-post="/admin/tools/{{ tool.id }}/test"
hx-include="#arguments"
hx-target="#tool-test-result">
{{ icon("refresh", "icon--sm") }} Run once
</button>
</div>
<div id="tool-test-result"></div>
</section>
{% endif %}
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "tools" %}
{% block title %}Tools - LLeMbas{% endblock %}
{% block heading %}Tools{% endblock %}
{% block admin_content %}
<p class="admin-lede">
HTTP calls a model can make while it answers. Each one is offered to models
marked <strong>Custom tools</strong>, to people who have the permission, and —
if it is restricted — only to the groups you choose. The model decides
<em>when</em> to call it from the description you write, so write that as if
explaining to a colleague what the tool is for.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% if not total %}
<div class="empty" style="padding: var(--sp-10) 0">
{{ icon("link", "empty__mark") }}
<p class="empty__text">
No tools yet. <a href="/admin/tools/new">Define one</a> — a name, a
description, and a URL with <code>{{ '{{placeholders}}' }}</code> in it.
</p>
</div>
{% else %}
{# Filters are links, so a filtered view is a real URL you can keep or share. #}
<div class="filter-bar">
<div class="filter-tabs">
{% for key, label in filters.items() %}
<a class="filter-tab {{ 'is-active' if key == active_filter }}"
href="/admin/tools?filter={{ key }}{% if q %}&q={{ q|urlencode }}{% endif %}">
{{ label }} <span class="filter-tab__count">{{ counts[key] }}</span>
</a>
{% endfor %}
</div>
<form class="filter-form" method="get" action="/admin/tools">
<input type="hidden" name="filter" value="{{ active_filter }}">
<input class="input" type="search" name="q" value="{{ q }}"
placeholder="Search tools…" aria-label="Search tools">
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
{% if q or active_filter != "all" %}
<a class="btn btn--ghost" href="/admin/tools">Clear</a>
{% endif %}
<a class="btn btn--primary" href="/admin/tools/new">{{ icon("plus", "icon--sm") }} New tool</a>
</form>
</div>
{% if not tools %}
<div class="empty" style="padding: var(--sp-8) 0">
<p class="empty__text">Nothing matches that filter.</p>
</div>
{% else %}
<div class="model-rows">
{% for tool in tools %}
<div class="model-row {{ 'is-off' if not tool.enabled }}">
<span class="model-row__pos">{{ page_start + loop.index }}</span>
<div class="model-row__main">
<div class="model-row__title">
<a class="model-row__name" href="/admin/tools/{{ tool.id }}/edit">{{ tool.name }}</a>
{% if not tool.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
{% if not tool.public %}<span class="badge">restricted</span>{% endif %}
{% if tool.allow_private %}<span class="badge">private network</span>{% endif %}
{% if tool.last_error %}<span class="badge badge--danger">last call failed</span>{% endif %}
</div>
<code class="model-row__id">
{{ tool.slug }} · {{ tool.method }} {{ tool.url_template }}
</code>
</div>
<div class="model-row__actions">
<a class="btn btn--sm" href="/admin/tools/{{ tool.id }}/edit">Edit</a>
</div>
</div>
{% endfor %}
</div>
<div class="list-footer">
<span class="text-xs faint">
Showing {{ page_start + 1 }}{{ page_start + tools|length }} of {{ matched }}
{%- if matched != total %} (filtered from {{ total }}){% endif %}
</span>
{% if pages > 1 %}
{% set base = "/admin/tools?filter=" ~ active_filter ~ ("&q=" ~ q|urlencode if q else "") %}
<div class="btn-row">
{% if page > 1 %}
<a class="btn btn--sm" href="{{ base }}&page={{ page - 1 }}">Previous</a>
{% else %}
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Previous</span>
{% endif %}
<span class="text-xs faint">Page {{ page }} of {{ pages }}</span>
{% if page < pages %}
<a class="btn btn--sm" href="{{ base }}&page={{ page + 1 }}">Next</a>
{% else %}
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Next</span>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endblock %}
@@ -6,16 +6,36 @@
answer) and from the stored message afterwards, so the sources behind an
answer stay in the transcript rather than vanishing when the stream ends.
EVERYTHING in here comes from a search provider and is untrusted, exactly as
much as model output is. Jinja autoescaping covers the text; the URL is
checked separately, because `is_linkable` is the only thing standing between
a result carrying a javascript: URL and an anchor pointing at it.
EVERYTHING in here is third-party text and is untrusted, exactly as much as
model output is -- a search provider's results, an administrator's HTTP tool
relaying whatever it was pointed at, an MCP server's reply. Jinja autoescaping
covers it. Two things are handled separately: the URL, because `is_linkable`
is the only thing standing between a result carrying a javascript: URL and an
anchor pointing at it, and `event.text`, which is rendered as preformatted
text and deliberately NOT through services/markdown.py -- markdown is the one
path allowed to emit HTML, and this is the last content that should be given
it.
`kind` says how to label the event. Rows written before it existed have none,
so web_search reads as a search and everything else falls to the generic
branch: an old notes_search event used to claim it had searched the web.
#}
{% for event in tool_events %}
{% set kind = event.kind or ('search' if event.name == 'web_search' else 'tool') %}
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}">
<summary class="tool-activity__summary">
{% if kind == 'search' %}
{{ icon("globe", "icon--sm tool-activity__icon") }}
{% elif kind == 'custom' %}
{{ icon("link", "icon--sm tool-activity__icon") }}
{% elif kind == 'mcp' %}
{{ icon("server", "icon--sm tool-activity__icon") }}
{% else %}
{{ icon("sparkle", "icon--sm tool-activity__icon") }}
{% endif %}
<span class="tool-activity__label">
{% if kind == 'search' %}
{% if event.status == "error" %}
Web search failed
{% elif event.query %}
@@ -23,6 +43,18 @@
{% else %}
Searched the web
{% endif %}
{% else %}
{% set label = event.label or event.name %}
{% if event.status == "error" %}
{{ label }} failed
{% else %}
{{ label }}
{% endif %}
{% if event.query %}
<span class="tool-activity__count">· {{ event.query }}</span>
{% endif %}
{% endif %}
{% if event.results %}
<span class="tool-activity__count">
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
@@ -33,21 +65,29 @@
</summary>
<div class="tool-activity__body">
{% if event.detail and kind != 'search' %}
<p class="tool-result__host">{{ event.detail }}</p>
{% endif %}
{% if event.error %}
<p class="tool-activity__error">{{ event.error }}</p>
{% elif not event.results %}
{% elif not event.results and not event.text %}
<p class="tool-activity__error">Nothing was found.</p>
{% endif %}
{% if event.text %}
<pre class="tool-result__text">{{ event.text }}</pre>
{% endif %}
{% for result in event.results %}
<div class="tool-result">
{% set scheme = result.url.split(":")[0] | lower %}
{% set scheme = (result.url or "").split(":")[0] | lower %}
{% if scheme in ("http", "https") %}
<a class="tool-result__title" href="{{ result.url }}"
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
{% else %}
{# Not a link. A search result is third-party text and its URL is not
trusted to be safe to click. #}
{# Not a link. A result is third-party text and its URL is not trusted to
be safe to click. #}
<span class="tool-result__title">{{ result.title }}</span>
{% endif %}
<span class="tool-result__host">{{ result.host }}</span>
+303
View File
@@ -0,0 +1,303 @@
"""The custom-tools admin screen."""
from __future__ import annotations
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import CustomTool, Group, User
from lembas.security.passwords import hash_password
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt
@pytest.fixture
def plain_user(client: TestClient, db, registered):
"""A second account, which is never an administrator."""
client.post("/auth/logout")
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
user.role = "user"
user.active = True
db.commit()
client.post(
"/auth/login",
data={"email": "sam@shire.test", "password": "correct horse battery"},
follow_redirects=False,
)
return user
def _form(**overrides) -> dict:
base = {
"name": "Weather",
"slug": "weather",
"description": "Look up the weather for a city.",
"parameters": json.dumps(
{"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
),
"method": "GET",
"url_template": "https://api.test/v1/{{city}}",
"headers": "Accept: application/json",
"secret_placement": "bearer",
"secret_name": "Authorization",
"response_mode": "json",
"max_chars": "8000",
"timeout": "20",
"position": "0",
"enabled": "true",
"public": "true",
}
base.update(overrides)
return {key: value for key, value in base.items() if value is not None}
def _create(client: TestClient, **overrides):
return client.post("/admin/tools", data=_form(**overrides), follow_redirects=False)
# --- Guards ------------------------------------------------------------------
def test_the_pages_are_refused_to_a_plain_user(client: TestClient, plain_user):
assert client.get("/admin/tools").status_code == 403
assert client.get("/admin/tools/new").status_code == 403
assert client.post("/admin/tools", data=_form()).status_code == 403
def test_a_tool_that_does_not_exist_is_a_404(client: TestClient, registered):
assert client.get("/admin/tools/nope/edit").status_code == 404
def test_new_is_not_parsed_as_a_tool_id(client: TestClient, registered):
"""FastAPI matches in registration order; /admin/models has been bitten by
exactly this."""
response = client.get("/admin/tools/new")
assert response.status_code == 200
assert "New tool" in response.text
# --- Creating and editing ----------------------------------------------------
def test_creating_a_tool_then_editing_it(client: TestClient, db, registered):
_create(client, secret="s3cret")
tool = db.scalar(select(CustomTool))
assert tool.slug == "weather"
assert tool.headers_json == {"Accept": "application/json"}
assert tool.parameters_json["properties"]["city"] == {"type": "string"}
assert decrypt(tool.secret_encrypted) == "s3cret"
assert tool.enabled is True
client.post(
f"/admin/tools/{tool.id}",
data=_form(name="Forecast", enabled=None, secret=UNCHANGED_SENTINEL),
follow_redirects=False,
)
db.refresh(tool)
assert tool.name == "Forecast"
# An unticked checkbox is simply absent from the post, which is the signal.
assert tool.enabled is False
assert decrypt(tool.secret_encrypted) == "s3cret", "the dots must keep the secret"
def test_clearing_the_field_removes_the_secret(client: TestClient, db, registered):
_create(client, secret="s3cret")
tool = db.scalar(select(CustomTool))
client.post(f"/admin/tools/{tool.id}", data=_form(secret=""), follow_redirects=False)
db.refresh(tool)
assert tool.secret_encrypted == ""
def test_a_secret_is_never_rendered_in_full(client: TestClient, db, registered):
_create(client, secret="super-secret-value")
tool = db.scalar(select(CustomTool))
page = client.get(f"/admin/tools/{tool.id}/edit").text
assert "super-secret-value" not in page
assert UNCHANGED_SENTINEL in page
# --- Validation reports back into the form -----------------------------------
def test_bad_parameter_json_is_reported_not_a_422(client: TestClient, db, registered):
response = _create(client, parameters="{not json")
assert response.status_code == 200
assert "not valid JSON" in response.text
assert db.scalar(select(CustomTool)) is None
def test_parameters_that_are_not_an_object_are_refused(client: TestClient, db, registered):
response = _create(client, parameters='{"type": "string"}')
assert "must be a JSON object" in response.text
assert db.scalar(select(CustomTool)) is None
def test_a_slug_colliding_with_a_builtin_is_refused(client: TestClient, db, registered):
response = _create(client, slug="web_search")
assert "built-in tool" in response.text
assert db.scalar(select(CustomTool)) is None
def test_a_duplicate_slug_is_refused(client: TestClient, db, registered):
_create(client)
response = _create(client, name="Other")
assert "already a tool" in response.text
assert len(list(db.scalars(select(CustomTool)))) == 1
def test_a_url_whose_host_is_a_variable_is_refused(client: TestClient, db, registered):
response = _create(client, url_template="https://{{city}}.test/x")
assert "literal" in response.text
assert db.scalar(select(CustomTool)) is None
def test_a_rejected_save_keeps_what_was_typed(client: TestClient, db, registered):
_create(client)
tool = db.scalar(select(CustomTool))
response = client.post(
f"/admin/tools/{tool.id}",
data=_form(name="Renamed", parameters="{oops"),
follow_redirects=False,
)
assert "Renamed" in response.text, "the form still holds the submitted name"
db.refresh(tool)
assert tool.name == "Weather", "and the stored row is untouched"
# --- Access ------------------------------------------------------------------
def test_making_a_tool_public_clears_its_groups(client: TestClient, db, registered):
group = Group(name="Council")
db.add(group)
db.commit()
_create(client, public=None, group_ids=group.id)
tool = db.scalar(select(CustomTool))
assert [g.name for g in tool.groups] == ["Council"]
client.post(
f"/admin/tools/{tool.id}",
data={**_form(public="true"), "group_ids": group.id},
follow_redirects=False,
)
db.refresh(tool)
assert tool.groups == []
def test_a_restricted_tool_is_hidden_from_a_user_outside_its_groups(db, registered):
from lembas.services import tool_access
group = Group(name="Council")
outsider = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
db.add_all([group, outsider])
db.add(
CustomTool(
slug="weather", name="Weather", url_template="https://api.test/", public=False
)
)
db.commit()
tool = db.scalar(select(CustomTool))
tool.groups = [group]
db.commit()
assert tool_access.visible_custom_tools(db, outsider) == []
outsider.groups = [group]
db.commit()
assert len(tool_access.visible_custom_tools(db, outsider)) == 1
# --- Running one by hand -----------------------------------------------------
def test_the_test_button_shows_what_the_model_would_read(
client: TestClient, db, registered, mock_http, monkeypatch
):
monkeypatch.setattr(
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
)
mock_http(lambda _r: httpx.Response(200, json={"summary": "Sunny"}))
_create(client, response_mode="json", response_path="summary")
tool = db.scalar(select(CustomTool))
response = client.post(
f"/admin/tools/{tool.id}/test", data={"arguments": '{"city": "Minas Tirith"}'}
)
assert response.status_code == 200
assert "Sunny" in response.text
def test_a_failing_test_is_recorded_on_the_row(
client: TestClient, db, registered, mock_http, monkeypatch
):
monkeypatch.setattr(
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
)
mock_http(lambda _r: httpx.Response(503, text="down"))
_create(client)
tool = db.scalar(select(CustomTool))
client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "{}"})
db.refresh(tool)
assert "503" in tool.last_error
assert tool.last_checked_at is not None
def test_bad_test_arguments_are_reported(client: TestClient, db, registered):
_create(client)
tool = db.scalar(select(CustomTool))
response = client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "[1, 2]"})
assert "JSON object" in response.text
# --- The list ----------------------------------------------------------------
def test_the_list_searches_and_filters(client: TestClient, db, registered):
_create(client)
_create(client, name="Tickets", slug="tickets", url_template="https://jira.test/{{city}}")
tool = db.scalar(select(CustomTool).where(CustomTool.slug == "tickets"))
tool.enabled = False
db.commit()
page = client.get("/admin/tools").text
assert "Weather" in page and "Tickets" in page
assert "Tickets" not in client.get("/admin/tools?filter=enabled").text
assert "Weather" not in client.get("/admin/tools?q=tick").text
def test_deleting_a_tool_leaves_its_prompt_override_alone(client: TestClient, db, registered):
"""The override outlives the row, which is what lets a tool be recreated
under the same slug without losing the wording somebody chose."""
from lembas.services import prompts as prompts_service
_create(client, guidance="- Default wording.")
tool = db.scalar(select(CustomTool))
prompts_service.save(db, {"tool.custom_weather": "- Edited wording."})
client.post(f"/admin/tools/{tool.id}/delete", follow_redirects=False)
assert db.scalar(select(CustomTool)) is None
assert prompts_service.stored(db)["tool.custom_weather"] == "- Edited wording."
def test_the_secret_survives_a_round_trip_through_the_form(client: TestClient, db, registered):
"""A regression guard on the sentinel: the field is rendered with dots, and
submitting the page unchanged must not overwrite the key with them."""
db.add(
CustomTool(
slug="weather",
name="Weather",
url_template="https://api.test/",
secret_encrypted=encrypt("s3cret"),
)
)
db.commit()
tool = db.scalar(select(CustomTool))
client.post(
f"/admin/tools/{tool.id}", data=_form(secret=UNCHANGED_SENTINEL), follow_redirects=False
)
db.refresh(tool)
assert decrypt(tool.secret_encrypted) == "s3cret"
+382
View File
@@ -0,0 +1,382 @@
"""Custom HTTP tools: filling the template, and refusing to be pointed elsewhere.
Most of this file is about the second. The arguments come from a model, which
can be talked into things by whatever it just read, so an argument filling a
hole in a URL is the same kind of input as a URL typed by a stranger.
"""
from __future__ import annotations
import json
import httpx
import pytest
from lembas.db.models import (
RESPONSE_JSON,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_BEARER,
SECRET_HEADER,
SECRET_NONE,
CustomTool,
)
from lembas.services import custom_tools
from lembas.services.crypto import encrypt
from lembas.services.fetch import FetchError
@pytest.fixture(autouse=True)
def dns(monkeypatch):
"""Resolve invented hostnames to a public address.
`api.test` does not exist and `check_url` resolves for real, which is the
whole point of it. A literal IP is handed back as itself, so the tests that
check a private address are still checking one.
"""
import ipaddress
import socket
def resolve(host, *_args, **_kwargs):
try:
ipaddress.ip_address(host)
except ValueError:
return [(2, 1, 6, "", ("93.184.216.34", 80))]
return [(2, 1, 6, "", (host, 80))]
monkeypatch.setattr(socket, "getaddrinfo", resolve)
def _spec(**overrides) -> custom_tools.HttpSpec:
base = {
"slug": "weather",
"label": "Weather",
"method": "GET",
"url_template": "https://api.test/v1/city/{{city}}",
"secret_placement": SECRET_NONE,
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}, "days": {"type": "integer"}},
"required": ["city"],
},
}
return custom_tools.HttpSpec(**{**base, **overrides})
def _ok(body: str = "sunny", *, status: int = 200, content_type: str = "text/plain"):
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["request"] = request
seen.setdefault("urls", []).append(str(request.url))
return httpx.Response(status, text=body, headers={"content-type": content_type})
return handler, seen
# --- Filling the URL ---------------------------------------------------------
def test_an_argument_is_percent_encoded_into_the_url():
url = custom_tools.fill_url(_spec(), {"city": "Minas Tirith"})
assert url == "https://api.test/v1/city/Minas%20Tirith"
def test_an_argument_cannot_add_a_path_segment_or_a_query():
"""safe="" is the whole point. Everything structural encodes."""
url = custom_tools.fill_url(_spec(), {"city": "../../admin?token=x#y"})
assert "/admin" not in url
assert "?" not in url and "#" not in url
assert url.startswith("https://api.test/v1/city/")
def test_an_argument_cannot_reach_another_host():
url = custom_tools.fill_url(_spec(), {"city": "evil.test/steal"})
assert url.startswith("https://api.test/")
def test_an_undeclared_name_is_removed_rather_than_passed_through():
"""Unlike the prompt fragments, where an unrecognised {{x}} is left alone.
A literal {{x}} in a URL is not a feature."""
spec = _spec(url_template="https://api.test/{{city}}/{{unknown}}")
assert custom_tools.fill_url(spec, {"city": "a", "unknown": "b"}) == "https://api.test/a/"
def test_an_argument_the_model_did_not_send_becomes_nothing():
assert custom_tools.fill_url(_spec(), {}) == "https://api.test/v1/city/"
def test_a_template_whose_host_is_a_variable_is_refused():
with pytest.raises(FetchError, match="literal"):
custom_tools.fill_url(_spec(url_template="https://{{city}}.test/x"), {"city": "a"})
def test_a_template_that_is_not_http_is_refused():
with pytest.raises(FetchError):
custom_tools.fill_url(_spec(url_template="file:///etc/passwd"), {})
# --- Reaching the network ----------------------------------------------------
async def test_a_private_address_is_refused_unless_the_row_allows_it(mock_http):
handler, _seen = _ok()
mock_http(handler)
spec = _spec(url_template="http://127.0.0.1:11434/api/tags", parameters={})
refused = await custom_tools.call(spec, {})
assert refused.event["status"] == "error"
assert "private or local" in refused.event["error"]
allowed = await custom_tools.call(
_spec(
url_template="http://127.0.0.1:11434/api/tags", parameters={}, allow_private=True
),
{},
)
assert allowed.event["status"] == "ok"
async def test_a_hostname_resolving_to_loopback_is_refused(mock_http, monkeypatch):
"""A name pointing at 127.0.0.1 walks past any check that only reads the
text of the URL."""
handler, _seen = _ok()
mock_http(handler)
monkeypatch.setattr(
"socket.getaddrinfo",
lambda *a, **k: [(2, 1, 6, "", ("127.0.0.1", 80))],
)
outcome = await custom_tools.call(_spec(parameters={}), {})
assert outcome.event["status"] == "error"
assert "private or local" in outcome.event["error"]
async def test_every_redirect_hop_is_checked(mock_http, monkeypatch):
"""httpx's own following would validate the first address and then happily
land on localhost."""
hops = []
def handler(request: httpx.Request) -> httpx.Response:
hops.append(str(request.url))
if request.url.host == "api.test":
return httpx.Response(302, headers={"location": "http://inside.test/secrets"})
return httpx.Response(200, text="secrets")
mock_http(handler)
def resolve(host, *_args, **_kwargs):
if host == "inside.test":
return [(2, 1, 6, "", ("10.0.0.5", 80))]
return [(2, 1, 6, "", ("93.184.216.34", 80))]
monkeypatch.setattr("socket.getaddrinfo", resolve)
outcome = await custom_tools.call(_spec(parameters={}), {})
assert outcome.event["status"] == "error"
assert len(hops) == 1, "the second hop must never be requested"
async def test_the_secret_is_dropped_on_a_cross_host_redirect(mock_http):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.url.host, request.headers.get("authorization")))
if request.url.host == "api.test":
return httpx.Response(302, headers={"location": "https://elsewhere.test/x"})
return httpx.Response(200, text="ok")
mock_http(handler)
outcome = await custom_tools.call(
_spec(parameters={}, secret="s3cret", secret_placement=SECRET_BEARER), {}
)
assert outcome.event["status"] == "ok"
assert seen[0] == ("api.test", "Bearer s3cret")
assert seen[1] == ("elsewhere.test", None)
async def test_a_bearer_secret_reaches_the_request_and_never_the_event(mock_http):
handler, seen = _ok()
mock_http(handler)
outcome = await custom_tools.call(
_spec(parameters={}, secret="s3cret", secret_placement=SECRET_BEARER), {}
)
assert seen["request"].headers["authorization"] == "Bearer s3cret"
assert "s3cret" not in json.dumps(outcome.event)
async def test_a_header_secret_uses_the_name_it_was_given(mock_http):
handler, seen = _ok()
mock_http(handler)
await custom_tools.call(
_spec(
parameters={},
secret="k",
secret_placement=SECRET_HEADER,
secret_name="X-Api-Key",
),
{},
)
assert seen["request"].headers["x-api-key"] == "k"
async def test_a_header_value_cannot_carry_a_newline(mock_http):
handler, seen = _ok()
mock_http(handler)
await custom_tools.call(
_spec(headers={"X-Trace": "{{city}}"}), {"city": "a\r\nX-Admin: yes"}
)
assert "\n" not in seen["request"].headers["x-trace"]
async def test_a_body_argument_cannot_end_the_json_string(mock_http):
handler, seen = _ok()
mock_http(handler)
await custom_tools.call(
_spec(
method="POST",
url_template="https://api.test/v1/ask",
body_template='{"city": "{{city}}"}',
),
{"city": 'x", "admin": "yes'},
)
body = json.loads(seen["request"].content)
assert set(body) == {"city"}
# --- Reading the response ----------------------------------------------------
async def test_a_json_response_is_narrowed_by_the_path(mock_http):
def handler(_request):
return httpx.Response(
200,
json={"data": {"items": [{"title": "Mallorn"}, {"title": "Elanor"}]}},
)
mock_http(handler)
outcome = await custom_tools.call(
_spec(parameters={}, response_mode=RESPONSE_JSON, response_path="data.items.0.title"), {}
)
assert outcome.content == "Mallorn"
async def test_a_path_that_leads_nowhere_yields_the_whole_document(mock_http):
mock_http(lambda _r: httpx.Response(200, json={"a": 1}))
outcome = await custom_tools.call(
_spec(parameters={}, response_mode=RESPONSE_JSON, response_path="nope.nope"), {}
)
assert json.loads(outcome.content) == {"a": 1}
async def test_an_html_response_becomes_text(mock_http):
handler, _seen = _ok(
"<html><head><title>T</title></head><body><p>Hello</p></body></html>",
content_type="text/html",
)
mock_http(handler)
outcome = await custom_tools.call(_spec(parameters={}, response_mode=RESPONSE_TEXT), {})
assert outcome.content == "Hello"
async def test_a_raw_response_is_left_alone(mock_http):
handler, _seen = _ok("<p>kept</p>", content_type="text/html")
mock_http(handler)
outcome = await custom_tools.call(_spec(parameters={}, response_mode=RESPONSE_RAW), {})
assert outcome.content == "<p>kept</p>"
async def test_the_response_is_capped_and_says_so(mock_http):
handler, _seen = _ok("x" * 5000)
mock_http(handler)
outcome = await custom_tools.call(_spec(parameters={}, max_chars=500), {})
assert len(outcome.content) < 600
assert outcome.content.endswith("(truncated)")
async def test_the_event_preview_is_capped_independently(mock_http):
"""`max_chars` is spent once; the event is stored on the message row."""
handler, _seen = _ok("x" * 30_000)
mock_http(handler)
outcome = await custom_tools.call(_spec(parameters={}, max_chars=20_000), {})
assert len(outcome.event["text"]) <= custom_tools.MAX_EVENT_CHARS
assert len(outcome.content) > custom_tools.MAX_EVENT_CHARS
async def test_an_http_error_becomes_an_explanation_not_an_exception(mock_http):
handler, _seen = _ok("no such city", status=404)
mock_http(handler)
outcome = await custom_tools.call(_spec(parameters={}), {})
assert outcome.event["status"] == "error"
assert "404" in outcome.content
assert "no such city" in outcome.content
async def test_a_transport_failure_is_reported_to_the_model(mock_http):
def handler(request):
raise httpx.ConnectTimeout("too slow", request=request)
mock_http(handler)
outcome = await custom_tools.call(_spec(parameters={}), {})
assert outcome.event["status"] == "error"
assert "Could not reach" in outcome.content
async def test_the_event_names_the_host_and_not_the_filled_url(mock_http):
"""A path segment carries an argument, and the event is rendered and kept."""
handler, _seen = _ok()
mock_http(handler)
outcome = await custom_tools.call(_spec(), {"city": "Minas Tirith"})
assert outcome.event["detail"] == "GET api.test"
assert "Minas" not in outcome.event["detail"]
assert "Minas Tirith" in outcome.event["query"]
# --- Turning rows into tools -------------------------------------------------
def _row(db, **overrides) -> CustomTool:
row = CustomTool(
**{
"slug": "weather",
"name": "Weather",
"description": "Look up the weather.",
"url_template": "https://api.test/{{city}}",
"parameters_json": {"type": "object", "properties": {"city": {"type": "string"}}},
**overrides,
}
)
db.add(row)
db.commit()
return row
def test_a_row_becomes_a_tool_definition(db, user_id):
from lembas.db.models import User
_row(db)
defs = custom_tools.tool_defs(db, db.get(User, user_id))
assert [tool.name for tool in defs] == ["weather"]
assert defs[0].family == "custom:weather"
assert defs[0].schema["function"]["description"] == "Look up the weather."
def test_a_disabled_row_is_not_offered(db, user_id):
from lembas.db.models import User
_row(db, enabled=False)
assert custom_tools.tool_defs(db, db.get(User, user_id)) == []
def test_a_schema_that_is_not_an_object_is_replaced(db, user_id):
"""An endpoint rejects the whole request over a malformed tools array, so
one bad row must not take the reply with it."""
from lembas.db.models import User
_row(db, parameters_json={"type": "string"})
defs = custom_tools.tool_defs(db, db.get(User, user_id))
assert defs[0].parameters == {"type": "object", "properties": {}}
def test_the_secret_is_decrypted_into_the_snapshot_and_nowhere_else(db):
row = _row(db, secret_encrypted=encrypt("s3cret"))
spec = custom_tools.spec_from(row)
assert spec.secret == "s3cret"
assert "s3cret" not in row.secret_encrypted
+21
View File
@@ -54,6 +54,27 @@ def test_only_the_guidance_for_offered_tools_appears(db, owner):
assert "Skills are procedures" not in text
def test_a_custom_tools_guidance_appears_only_when_it_is_offered(db, owner):
"""The row supplies the default, and the fragment is gated on the tool's own
family -- which is why the registry had to stop being a module constant."""
from lembas.db.models import CustomTool
db.add(
CustomTool(
slug="weather",
name="Weather",
description="Look up the weather.",
url_template="https://api.test/{{city}}",
guidance="- Check the weather rather than guessing at it.",
)
)
db.commit()
offered = tools_service.registry(db)["weather"].schema
assert "Check the weather" in harness.compose(db, owner, [offered])
assert "Check the weather" not in harness.compose(db, owner, _tools("web_search"))
def test_the_memory_block_is_included_when_memory_is_offered(db, owner):
memories_service.add(db, owner=owner, content="Prefers metric units.")
text = harness.compose(db, owner, _tools("memory_add"))
+124
View File
@@ -0,0 +1,124 @@
"""Rendering what a tool did.
The block is written from four places and read from stored rows written by
earlier versions, so it has to render anything shaped roughly like an event --
and everything in it is third-party text.
"""
from __future__ import annotations
from lembas.web.templating import templates
def _render(*events, live: bool = False) -> str:
return templates.get_template("chat/_tool_activity.html").render(
{"tool_events": list(events), "live": live}
)
def test_a_search_still_says_it_searched_the_web():
html = _render(
{
"name": "web_search",
"kind": "search",
"query": "mallorn",
"status": "ok",
"results": [
{
"title": "Mallorn",
"url": "https://a.test/m",
"host": "a.test",
"snippet": "A tree.",
}
],
}
)
assert "Searched the web for “mallorn”" in html
assert '<a class="tool-result__title" href="https://a.test/m"' in html
assert "1 result" in html
def test_a_library_tool_no_longer_claims_to_have_searched_the_web():
"""Stored rows predate `kind`, and every one of them used to render a globe
and "Searched the web for <the note title>"."""
html = _render({"name": "notes_search", "query": "shopping", "status": "ok", "results": []})
assert "Searched the web" not in html
assert "notes_search" in html
def test_a_custom_tool_is_named_and_its_host_shown():
html = _render(
{
"name": "weather",
"kind": "custom",
"label": "Weather",
"query": "city='Minas Tirith'",
"detail": "GET api.test",
"status": "ok",
"results": [],
"text": "Sunny.",
}
)
assert "Weather" in html
assert "GET api.test" in html
assert "Sunny." in html
def test_a_tools_own_text_is_escaped_and_never_rendered_as_markdown():
"""Hard rule 6. A tool's reply is exactly as untrusted as a search result,
and markdown is the one path allowed to emit HTML."""
html = _render(
{
"name": "weather",
"kind": "custom",
"label": "Weather",
"status": "ok",
"results": [],
"text": "<img src=x onerror=alert(1)> [click](javascript:alert(1))",
}
)
assert "<img" not in html
assert "&lt;img" in html
# The markdown link is shown as the text it is, not turned into an anchor.
assert "<a " not in html
assert "[click](javascript:alert(1))" in html
def test_a_result_url_that_is_not_http_never_becomes_a_link():
html = _render(
{
"name": "web_search",
"kind": "search",
"status": "ok",
"results": [{"title": "Bad", "url": "javascript:alert(1)", "host": "", "snippet": ""}],
}
)
assert "<a " not in html
assert '<span class="tool-result__title">Bad</span>' in html
def test_a_result_with_no_url_at_all_does_not_explode():
html = _render(
{
"name": "notes_search",
"status": "ok",
"results": [{"title": "A note", "id": "abc"}],
}
)
assert "A note" in html
def test_a_failure_shows_its_reason():
html = _render(
{
"name": "weather",
"kind": "custom",
"label": "Weather",
"status": "error",
"error": "HTTP 503",
"results": [],
}
)
assert "tool-activity--error" in html
assert "Weather failed" in html
assert "HTTP 503" in html
+51
View File
@@ -166,6 +166,57 @@ def _context(**kwargs):
return tools_service.ToolContext(owner_id="someone", **kwargs)
# --- The offer and the runner travel together --------------------------------
def test_the_resolved_set_carries_the_runners_with_the_schemas(db, user_id):
"""A tool that is a database row is not reachable through the import-time
registry, so the resolution has to travel with the offer."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat = _chat_with(db, user_id, capabilities={"tools": True})
resolved = tools_service.resolve_tools(db, chat, _user(db, user_id))
assert _names(resolved.schemas) == set(resolved.by_name)
assert all(callable(tool.run) for tool in resolved.defs)
# The old accessor is the same set, so nothing that only wants schemas moved.
assert resolved.schemas == tools_service.enabled_tools(db, chat, _user(db, user_id))
async def test_a_tool_that_was_not_offered_is_refused(db, user_id):
"""The lookup is against what was offered, not against everything that
exists. A model naming a tool its chat was gated out of used to have it run,
because only the offer was ever filtered."""
from lembas.db.models import User
from lembas.services.library import notes as notes_service
owner = db.get(User, user_id)
note = notes_service.create(db, owner=owner, title="Keep me", body="...")
chat = _chat_with(db, user_id, capabilities={"tools": True, "tool_notes": False})
resolved = tools_service.resolve_tools(db, chat, owner)
context = tools_service.context_for(db, owner, chat, tools=resolved)
outcome = await tools_service.run_tool(
context, "notes_delete", json.dumps({"id": note.id})
)
assert outcome.event["status"] == "error"
assert notes_service.get(db, note.id, owner) is not None
async def test_a_context_with_no_toolset_still_finds_the_builtins(monkeypatch):
"""None means nobody resolved a set. An empty dict does not -- it means
nothing was offered, and is authoritative."""
async def fake_run(_config, query, *, limit=None):
return [SearchResult("A title", "https://a.test", "a snippet")]
monkeypatch.setattr("lembas.services.search.run", fake_run)
unresolved = await tools_service.run_tool(_context(), "web_search", '{"query": "x"}')
assert unresolved.event["status"] == "ok"
empty = await tools_service.run_tool(_context(tools={}), "web_search", '{"query": "x"}')
assert empty.event["status"] == "error"
# --- Knowledge is scoped to the chat's bases ---------------------------------
async def test_knowledge_search_is_limited_to_the_attached_bases(db, user_id):
""""Answer from the contracts folder" is a different question from "answer