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:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lembas"
|
name = "lembas"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "0.3.1"
|
__version__ = "0.4.0"
|
||||||
|
|||||||
@@ -24,15 +24,23 @@ router = APIRouter(tags=["admin-models"])
|
|||||||
# these are an administrator's assertion.
|
# these are an administrator's assertion.
|
||||||
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
|
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
|
||||||
|
|
||||||
# Which built-in tools this model is given. Distinct from the above: `tools` is
|
# Which tools this model is given. Distinct from the above: `tools` is whether a
|
||||||
# whether a tools array may be sent at all, these are what goes in it. Every one
|
# tools array may be sent at all, these are what goes in it. Every one of them is
|
||||||
# of them is meaningless unless `tools` is on.
|
# 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_CAPABILITIES = (
|
||||||
("tool_web_search", "Web search"),
|
("tool_web_search", "Web search"),
|
||||||
("tool_knowledge", "Knowledge"),
|
("tool_knowledge", "Knowledge"),
|
||||||
("tool_notes", "Notes"),
|
("tool_notes", "Notes"),
|
||||||
("tool_memory", "Memory"),
|
("tool_memory", "Memory"),
|
||||||
("tool_skills", "Skills"),
|
("tool_skills", "Skills"),
|
||||||
|
("tool_custom", "Custom tools"),
|
||||||
|
("tool_mcp", "MCP servers"),
|
||||||
)
|
)
|
||||||
|
|
||||||
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
|
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
|
||||||
|
|||||||
@@ -29,15 +29,20 @@ router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
|
|||||||
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
|
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
|
||||||
|
|
||||||
|
|
||||||
def _families_of(names: list[str]) -> list[str]:
|
def _families_of(db: Db, names: list[str]) -> list[str]:
|
||||||
"""Keep only real family names, in the registry's order."""
|
"""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)
|
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(
|
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(
|
values.update(
|
||||||
{
|
{
|
||||||
"model_name": model_name,
|
"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 "",
|
"memories": memories_service.block(db, user) if "memory" in families else "",
|
||||||
"skills": skills_service.index_block(db, user) if "skills" in families else "",
|
"skills": skills_service.index_block(db, user) if "skills" in families else "",
|
||||||
"knowledge_bases": bases if "knowledge" 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):
|
async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||||
stored = prompts_service.stored(db)
|
stored = prompts_service.stored(db)
|
||||||
models = chat_service.available_models(db, user)
|
models = chat_service.available_models(db, user)
|
||||||
families = list(tools_service.FAMILIES)
|
families = list(tools_service.families(db))
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -115,7 +120,7 @@ async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool =
|
|||||||
"models": models,
|
"models": models,
|
||||||
"families": families,
|
"families": families,
|
||||||
"registry": sorted(
|
"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(
|
"max_harness_chars": settings_store.get(
|
||||||
db, "max_harness_chars", key=settings_store.PROMPTS
|
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()
|
form = await request.form()
|
||||||
overrides = _submitted(db, 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 "")
|
model_name = str(form.get("preview_model") or "")
|
||||||
bases = str(form.get("preview_bases") or "").strip()
|
bases = str(form.get("preview_bases") or "").strip()
|
||||||
documents = str(form.get("preview_documents") or "").strip()
|
documents = str(form.get("preview_documents") or "").strip()
|
||||||
|
|||||||
@@ -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}.")
|
||||||
@@ -42,6 +42,21 @@ from lembas.db.models.library import (
|
|||||||
)
|
)
|
||||||
from lembas.db.models.setting import Setting
|
from lembas.db.models.setting import Setting
|
||||||
from lembas.db.models.suggestion import Suggestion
|
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 (
|
from lembas.db.models.user import (
|
||||||
ROLE_ADMIN,
|
ROLE_ADMIN,
|
||||||
ROLE_PENDING,
|
ROLE_PENDING,
|
||||||
@@ -63,20 +78,31 @@ __all__ = [
|
|||||||
"RESOURCE_BASE",
|
"RESOURCE_BASE",
|
||||||
"RESOURCE_NOTE",
|
"RESOURCE_NOTE",
|
||||||
"RESOURCE_SKILL",
|
"RESOURCE_SKILL",
|
||||||
|
"RESPONSE_JSON",
|
||||||
|
"RESPONSE_MODES",
|
||||||
|
"RESPONSE_RAW",
|
||||||
|
"RESPONSE_TEXT",
|
||||||
"ROLE_ADMIN",
|
"ROLE_ADMIN",
|
||||||
"ROLE_ASSISTANT",
|
"ROLE_ASSISTANT",
|
||||||
"ROLE_PENDING",
|
"ROLE_PENDING",
|
||||||
"ROLE_SYSTEM",
|
"ROLE_SYSTEM",
|
||||||
"ROLE_TOOL",
|
"ROLE_TOOL",
|
||||||
"ROLE_USER",
|
"ROLE_USER",
|
||||||
|
"SECRET_BEARER",
|
||||||
|
"SECRET_HEADER",
|
||||||
|
"SECRET_NONE",
|
||||||
|
"SECRET_PLACEMENTS",
|
||||||
|
"SECRET_QUERY",
|
||||||
"SOURCE_LINK",
|
"SOURCE_LINK",
|
||||||
"SOURCE_UPLOAD",
|
"SOURCE_UPLOAD",
|
||||||
"Chat",
|
"Chat",
|
||||||
"Connection",
|
"Connection",
|
||||||
|
"CustomTool",
|
||||||
"Document",
|
"Document",
|
||||||
"Folder",
|
"Folder",
|
||||||
"Group",
|
"Group",
|
||||||
"KnowledgeBase",
|
"KnowledgeBase",
|
||||||
|
"McpServer",
|
||||||
"Memory",
|
"Memory",
|
||||||
"Message",
|
"Message",
|
||||||
"Model",
|
"Model",
|
||||||
@@ -89,6 +115,8 @@ __all__ = [
|
|||||||
"Suggestion",
|
"Suggestion",
|
||||||
"User",
|
"User",
|
||||||
"chat_knowledge_bases",
|
"chat_knowledge_bases",
|
||||||
|
"custom_tool_groups",
|
||||||
|
"mcp_server_groups",
|
||||||
"model_groups",
|
"model_groups",
|
||||||
"user_groups",
|
"user_groups",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}>"
|
||||||
@@ -14,6 +14,7 @@ from lembas.db.types import JSONDict
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# Annotation only; SQLAlchemy resolves the real class from its registry.
|
# Annotation only; SQLAlchemy resolves the real class from its registry.
|
||||||
from lembas.db.models.connection import Model
|
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
|
# Roles are a simple ordered ladder rather than a permission matrix. Groups
|
||||||
# (below) carry finer-grained permissions once the users/groups UI lands.
|
# (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(
|
models: Mapped[list[Model]] = relationship(
|
||||||
"Model", secondary="model_groups", back_populates="groups"
|
"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):
|
class Session(UUIDPrimaryKey, Timestamps, Base):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from lembas.api import (
|
|||||||
admin_prompts,
|
admin_prompts,
|
||||||
admin_search,
|
admin_search,
|
||||||
admin_suggestions,
|
admin_suggestions,
|
||||||
|
admin_tools,
|
||||||
admin_users,
|
admin_users,
|
||||||
audio,
|
audio,
|
||||||
auth,
|
auth,
|
||||||
@@ -120,6 +121,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(admin_search.router)
|
app.include_router(admin_search.router)
|
||||||
app.include_router(admin_prompts.router)
|
app.include_router(admin_prompts.router)
|
||||||
app.include_router(admin_suggestions.router)
|
app.include_router(admin_suggestions.router)
|
||||||
|
app.include_router(admin_tools.router)
|
||||||
|
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -86,6 +86,22 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
|||||||
True,
|
True,
|
||||||
"Chat",
|
"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(
|
PermissionDef(
|
||||||
"audio.transcribe",
|
"audio.transcribe",
|
||||||
"Dictate messages",
|
"Dictate messages",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -239,7 +239,11 @@ async def _run(generation: Generation) -> None:
|
|||||||
owner = db.get(User, chat.user_id)
|
owner = db.get(User, chat.user_id)
|
||||||
|
|
||||||
# Read while the session is open: everything below outlives it.
|
# 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(
|
payload = chat_service.build_request(
|
||||||
db, chat, upto=message, tools=offered, user=owner
|
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
|
# Read here, with the rest, because titling happens after this
|
||||||
# session has closed and must not open another one.
|
# session has closed and must not open another one.
|
||||||
title_prompt = prompts_service.resolve(db, "task.title")
|
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)
|
model = chat_service.model_for(db, chat)
|
||||||
generation.context_limit = model.context_length if model is not None else 0
|
generation.context_limit = model.context_length if model is not None else 0
|
||||||
|
|||||||
@@ -57,16 +57,22 @@ MAX_HARNESS_CHARS = 8000
|
|||||||
MAX_NAMED_DOCUMENTS = 5
|
MAX_NAMED_DOCUMENTS = 5
|
||||||
|
|
||||||
|
|
||||||
def _families(tools: list[dict[str, Any]]) -> list[str]:
|
def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]:
|
||||||
"""Which families are represented in an offered tool list, in a fixed order."""
|
"""Which families are represented in an offered tool list, in a fixed order.
|
||||||
from lembas.services.tools import FAMILIES, REGISTRY
|
|
||||||
|
|
||||||
|
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 = {
|
offered = {
|
||||||
REGISTRY[name].family
|
book[name].family
|
||||||
for tool in tools
|
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:
|
def _tool_names(tools: list[dict[str, Any]]) -> str:
|
||||||
@@ -109,7 +115,7 @@ def context_variables(
|
|||||||
from lembas.services import tools as tools_service
|
from lembas.services import tools as tools_service
|
||||||
|
|
||||||
offered = tools or []
|
offered = tools or []
|
||||||
families = _families(offered)
|
families = _families(db, offered)
|
||||||
stamp = datetime.now().astimezone()
|
stamp = datetime.now().astimezone()
|
||||||
|
|
||||||
values: dict[str, str] = {
|
values: dict[str, str] = {
|
||||||
@@ -185,7 +191,7 @@ def compose(
|
|||||||
return compose_from(
|
return compose_from(
|
||||||
db,
|
db,
|
||||||
variables=context_variables(db, user, offered, chat),
|
variables=context_variables(db, user, offered, chat),
|
||||||
families=_families(offered),
|
families=_families(db, offered),
|
||||||
has_tools=bool(offered),
|
has_tools=bool(offered),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -29,10 +29,12 @@ from collections.abc import Awaitable, Callable
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from lembas.db.models import AUTHOR_MODEL, Chat, User
|
from lembas.db.models import AUTHOR_MODEL, Chat, User
|
||||||
from lembas.db.session import session_scope
|
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 search as search_service
|
||||||
from lembas.services import settings_store
|
from lembas.services import settings_store
|
||||||
from lembas.services.library import documents as documents_service
|
from lembas.services.library import documents as documents_service
|
||||||
@@ -58,8 +60,24 @@ FAMILY_NOTES = "notes"
|
|||||||
FAMILY_MEMORY = "memory"
|
FAMILY_MEMORY = "memory"
|
||||||
FAMILY_SKILLS = "skills"
|
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)
|
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
|
@dataclass
|
||||||
class ToolContext:
|
class ToolContext:
|
||||||
@@ -72,10 +90,14 @@ class ToolContext:
|
|||||||
|
|
||||||
owner_id: str
|
owner_id: str
|
||||||
search_config: dict[str, Any] = field(default_factory=dict)
|
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
|
# 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.
|
# owner can see", which is what a chat with none attached should do.
|
||||||
base_ids: list[str] = field(default_factory=list)
|
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
|
@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]:
|
def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
|
||||||
return {"type": "object", "properties": properties, "required": required}
|
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
|
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.
|
web search away from every model already set up for it.
|
||||||
"""
|
"""
|
||||||
|
gate = gate_of(family)
|
||||||
default = bool(capabilities.get("tools"))
|
default = bool(capabilities.get("tools"))
|
||||||
if not capabilities.get(f"tool_{family}", default):
|
if not capabilities.get(f"tool_{gate}", default):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if family == FAMILY_SEARCH:
|
if gate == FAMILY_SEARCH:
|
||||||
return bool(
|
return bool(
|
||||||
allowed.get("tools.web_search")
|
allowed.get("tools.web_search")
|
||||||
and config.get("enabled")
|
and config.get("enabled")
|
||||||
and not search_service.availability(str(config.get("provider") or "ddgs"))
|
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]]:
|
def _row_defs(db: DBSession, user: User | None, *, everything: bool = False) -> list[ToolDef]:
|
||||||
"""The tool schemas to offer for this chat."""
|
"""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.security import permissions
|
||||||
from lembas.services import chat as chat_service
|
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 {}
|
capabilities = model.capabilities_json or {}
|
||||||
|
|
||||||
if not capabilities.get("tools"):
|
if not capabilities.get("tools"):
|
||||||
return []
|
return ToolSet()
|
||||||
|
|
||||||
allowed = permissions.resolve(db, user)
|
allowed = permissions.resolve(db, user)
|
||||||
config = settings_store.search(db)
|
config = settings_store.search(db)
|
||||||
|
|
||||||
families = {
|
# Resolved against what this reader may see, not against everything that
|
||||||
family
|
# exists: a tool restricted to a group is not offered outside it.
|
||||||
for family in FAMILIES
|
book = _book(_row_defs(db, user))
|
||||||
if _family_allowed(family, config=config, capabilities=capabilities, allowed=allowed)
|
return ToolSet(
|
||||||
}
|
tuple(
|
||||||
return [tool.schema for tool in REGISTRY.values() if tool.family in families]
|
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."""
|
"""The snapshot a running tool needs, taken while the session is open."""
|
||||||
return ToolContext(
|
return ToolContext(
|
||||||
owner_id=user.id if user else "",
|
owner_id=user.id if user else "",
|
||||||
search_config=settings_store.search(db),
|
search_config=settings_store.search(db),
|
||||||
base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [],
|
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
|
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
|
carry on -- a failed lookup should produce "I could not find that" rather
|
||||||
than killing the whole reply.
|
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:
|
if tool is None:
|
||||||
return ToolOutcome(
|
return ToolOutcome(
|
||||||
f"There is no tool called {name!r}.",
|
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:
|
except json.JSONDecodeError:
|
||||||
# Small models emit malformed argument JSON often enough that this is a
|
# Small models emit malformed argument JSON often enough that this is a
|
||||||
# normal path, not an exceptional one. Treat the whole string as the
|
# normal path, not an exceptional one. Treat the whole string as the
|
||||||
# first required argument rather than giving up.
|
# tool's first argument rather than giving up: what it says is required,
|
||||||
required = tool.parameters.get("required") or ["query"]
|
# else the first thing it declares, and only then a guess -- a schema
|
||||||
parsed = {required[0]: arguments.strip()}
|
# 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):
|
if not isinstance(parsed, dict):
|
||||||
parsed = {"query": str(parsed)}
|
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__ = [
|
__all__ = [
|
||||||
"FAMILIES",
|
"FAMILIES",
|
||||||
"MAX_ROUNDS",
|
"MAX_ROUNDS",
|
||||||
@@ -816,9 +968,19 @@ __all__ = [
|
|||||||
"ToolContext",
|
"ToolContext",
|
||||||
"ToolDef",
|
"ToolDef",
|
||||||
"ToolOutcome",
|
"ToolOutcome",
|
||||||
|
"ToolSet",
|
||||||
"assistant_turn",
|
"assistant_turn",
|
||||||
"context_for",
|
"context_for",
|
||||||
"enabled_tools",
|
"enabled_tools",
|
||||||
|
"families",
|
||||||
|
"registry",
|
||||||
|
"resolve_tools",
|
||||||
"run_tool",
|
"run_tool",
|
||||||
"tool_turn",
|
"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)
|
||||||
|
|||||||
@@ -371,6 +371,23 @@
|
|||||||
line-height: var(--leading-relaxed);
|
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 ---------------------------------------------- */
|
/* --- Stop, notes and editing ---------------------------------------------- */
|
||||||
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
|
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
|
||||||
.msg__status:empty { display: none; }
|
.msg__status:empty { display: none; }
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
{{ icon("globe", "icon--sm") }}
|
{{ icon("globe", "icon--sm") }}
|
||||||
<span class="nav-item__label">Web search</span>
|
<span class="nav-item__label">Web search</span>
|
||||||
</a>
|
</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">
|
<a class="nav-item {{ 'is-active' if section == 'prompts' }}" href="/admin/prompts">
|
||||||
{{ icon("sparkle", "icon--sm") }}
|
{{ icon("sparkle", "icon--sm") }}
|
||||||
<span class="nav-item__label">Prompts</span>
|
<span class="nav-item__label">Prompts</span>
|
||||||
@@ -64,10 +68,6 @@
|
|||||||
|
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-group__label">Not yet built</div>
|
<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">
|
<span class="nav-item is-disabled">
|
||||||
{{ icon("server", "icon--sm") }}
|
{{ icon("server", "icon--sm") }}
|
||||||
<span class="nav-item__label">Agents</span>
|
<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 %}
|
||||||
@@ -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) and from the stored message afterwards, so the sources behind an
|
||||||
answer stay in the transcript rather than vanishing when the stream ends.
|
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
|
EVERYTHING in here is third-party text and is untrusted, exactly as much as
|
||||||
much as model output is. Jinja autoescaping covers the text; the URL is
|
model output is -- a search provider's results, an administrator's HTTP tool
|
||||||
checked separately, because `is_linkable` is the only thing standing between
|
relaying whatever it was pointed at, an MCP server's reply. Jinja autoescaping
|
||||||
a result carrying a javascript: URL and an anchor pointing at it.
|
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 %}
|
{% 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' }}">
|
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}">
|
||||||
<summary class="tool-activity__summary">
|
<summary class="tool-activity__summary">
|
||||||
|
{% if kind == 'search' %}
|
||||||
{{ icon("globe", "icon--sm tool-activity__icon") }}
|
{{ 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">
|
<span class="tool-activity__label">
|
||||||
|
{% if kind == 'search' %}
|
||||||
{% if event.status == "error" %}
|
{% if event.status == "error" %}
|
||||||
Web search failed
|
Web search failed
|
||||||
{% elif event.query %}
|
{% elif event.query %}
|
||||||
@@ -23,6 +43,18 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
Searched the web
|
Searched the web
|
||||||
{% endif %}
|
{% 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 %}
|
{% if event.results %}
|
||||||
<span class="tool-activity__count">
|
<span class="tool-activity__count">
|
||||||
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
|
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
|
||||||
@@ -33,21 +65,29 @@
|
|||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="tool-activity__body">
|
<div class="tool-activity__body">
|
||||||
|
{% if event.detail and kind != 'search' %}
|
||||||
|
<p class="tool-result__host">{{ event.detail }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if event.error %}
|
{% if event.error %}
|
||||||
<p class="tool-activity__error">{{ event.error }}</p>
|
<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>
|
<p class="tool-activity__error">Nothing was found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if event.text %}
|
||||||
|
<pre class="tool-result__text">{{ event.text }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for result in event.results %}
|
{% for result in event.results %}
|
||||||
<div class="tool-result">
|
<div class="tool-result">
|
||||||
{% set scheme = result.url.split(":")[0] | lower %}
|
{% set scheme = (result.url or "").split(":")[0] | lower %}
|
||||||
{% if scheme in ("http", "https") %}
|
{% if scheme in ("http", "https") %}
|
||||||
<a class="tool-result__title" href="{{ result.url }}"
|
<a class="tool-result__title" href="{{ result.url }}"
|
||||||
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
|
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Not a link. A search result is third-party text and its URL is not
|
{# Not a link. A result is third-party text and its URL is not trusted to
|
||||||
trusted to be safe to click. #}
|
be safe to click. #}
|
||||||
<span class="tool-result__title">{{ result.title }}</span>
|
<span class="tool-result__title">{{ result.title }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="tool-result__host">{{ result.host }}</span>
|
<span class="tool-result__host">{{ result.host }}</span>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -54,6 +54,27 @@ def test_only_the_guidance_for_offered_tools_appears(db, owner):
|
|||||||
assert "Skills are procedures" not in text
|
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):
|
def test_the_memory_block_is_included_when_memory_is_offered(db, owner):
|
||||||
memories_service.add(db, owner=owner, content="Prefers metric units.")
|
memories_service.add(db, owner=owner, content="Prefers metric units.")
|
||||||
text = harness.compose(db, owner, _tools("memory_add"))
|
text = harness.compose(db, owner, _tools("memory_add"))
|
||||||
|
|||||||
@@ -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 "<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
|
||||||
@@ -166,6 +166,57 @@ def _context(**kwargs):
|
|||||||
return tools_service.ToolContext(owner_id="someone", **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 ---------------------------------
|
# --- Knowledge is scoped to the chat's bases ---------------------------------
|
||||||
async def test_knowledge_search_is_limited_to_the_attached_bases(db, user_id):
|
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
|
""""Answer from the contracts folder" is a different question from "answer
|
||||||
|
|||||||
Reference in New Issue
Block a user