Custom HTTP tools an administrator defines

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 16:26:47 +02:00
parent 4ee7d3db7d
commit d4cefb066a
26 changed files with 2771 additions and 60 deletions
+431
View File
@@ -0,0 +1,431 @@
"""Running the HTTP tools an administrator defined.
A row in `custom_tools` becomes a `ToolDef` like any built-in: same schema in
the same array, same `ToolOutcome` back. What is different is that the arguments
come from a model and the destination comes from a template, so two things have
to hold.
**An argument may fill a hole; it may not move the target.** The scheme and host
of the template are literal, checked when the row is saved and again here in
case a row predates the check, and every value is escaped for the position it
lands in -- percent-encoded with nothing safe in a URL, JSON-escaped in a body,
stripped of line breaks in a header. `quote(value, safe="")` is what stops a
value adding a path segment, a query parameter or a fragment; pinning the origin
afterwards is what catches anything that got past it.
**Every hop is checked.** This is the same request-forgery problem
`services/fetch.py` exists to solve, and the same answer: resolve and check the
address, follow redirects by hand, refuse private ranges unless this particular
row was allowed them. `fetch.fetch` itself cannot be reused -- it is GET-only,
has no body, and refuses any content type that is not HTML or text, which is
every JSON API there is -- so its redirect loop is deliberately copied rather
than the module bent into a general HTTP client.
The secret is decrypted into the snapshot and goes nowhere else: not into the
event, not into a log line, and not across a redirect that leaves the origin it
was issued for.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote, urlparse
import httpx
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
RESPONSE_JSON,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_BEARER,
SECRET_HEADER,
SECRET_QUERY,
CustomTool,
User,
)
from lembas.services import fetch as fetch_service
from lembas.services import tool_access
from lembas.services.crypto import decrypt
from lembas.services.prompts import VARIABLE_PATTERN
from lembas.services.tools import ToolContext, ToolDef, ToolOutcome
log = logging.getLogger(__name__)
# What a response may weigh before it is cut off. Well below the page fetcher's
# ceiling, because this is text that will be sent back to a model rather than
# stored for a person to read.
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
# How much of the response is kept on the message row for the transcript. Capped
# separately from `max_chars`: what the model reads is spent once, what the event
# holds is stored on every message forever.
MAX_EVENT_CHARS = 2000
# How much of the arguments the transcript summarises.
MAX_SUMMARY_CHARS = 200
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
# Bounds an administrator's number is clamped into. A tool that may return
# 400 000 characters is a tool that can fill the context window in one call.
MIN_CHARS, MAX_CHARS = 200, 40_000
MIN_TIMEOUT, MAX_TIMEOUT = 1, 120
@dataclass(frozen=True)
class HttpSpec:
"""Everything one custom tool needs, read while the session was open.
A frozen snapshot rather than the row, for the reason `Endpoint` is one: a
generation outlives the request that started it, and a detached SQLAlchemy
instance is a trap. The decrypted secret lives here and nowhere else.
"""
slug: str
label: str
method: str
url_template: str
body_template: str = ""
headers: dict[str, str] = field(default_factory=dict)
secret: str = ""
secret_placement: str = SECRET_BEARER
secret_name: str = "Authorization"
response_mode: str = RESPONSE_TEXT
response_path: str = ""
max_chars: int = 8000
timeout: int = 20
allow_private: bool = False
parameters: dict[str, Any] = field(default_factory=dict)
@property
def secret_header(self) -> str:
"""The header the secret rides in, if it rides in one."""
if not self.secret or self.secret_placement not in (SECRET_BEARER, SECRET_HEADER):
return ""
return self.secret_name or "Authorization"
def spec_from(row: CustomTool) -> HttpSpec:
"""Snapshot a row, decrypting its secret. Call this with a session open."""
return HttpSpec(
slug=row.slug,
label=row.name or row.slug,
method=(row.method or "GET").upper(),
url_template=row.url_template or "",
body_template=row.body_template or "",
headers=dict(row.headers_json or {}),
secret=decrypt(row.secret_encrypted),
secret_placement=row.secret_placement,
secret_name=row.secret_name or "Authorization",
response_mode=row.response_mode,
response_path=row.response_path or "",
max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS),
timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT),
allow_private=bool(row.allow_private),
parameters=dict(row.parameters_json or {}),
)
def tool_defs(
db: DBSession, user: User | None, *, everything: bool = False
) -> list[ToolDef]:
"""One `ToolDef` per custom tool this user may be offered."""
return [
ToolDef(
name=row.slug,
family=f"custom:{row.slug}",
description=row.description or f"Call the {row.name} tool.",
parameters=_schema_of(row),
run=_runner(spec_from(row)),
)
for row in tool_access.visible_custom_tools(db, user, everything=everything)
]
def _schema_of(row: CustomTool) -> dict[str, Any]:
schema = dict(row.parameters_json or {})
if schema.get("type") != "object":
# An endpoint expects an object here; anything else it will reject
# outright, which fails the whole request rather than the one tool.
return {"type": "object", "properties": {}}
return schema
def _runner(spec: HttpSpec):
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
return await call(spec, args)
return run
# --- Filling the template ----------------------------------------------------
def _scalar(value: Any) -> str:
"""One argument as text, before it is escaped for wherever it is going."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
return value
if isinstance(value, int | float):
return str(value)
return json.dumps(value, ensure_ascii=False)
def _for_url(value: str) -> str:
# safe="" is the whole point: an argument must not be able to introduce a
# path segment, a query separator or a fragment.
return quote(value, safe="")
def _for_body(value: str) -> str:
# The inside of a JSON string, so a quote or a backslash in an argument
# cannot end it early and add a field of its own.
return json.dumps(value, ensure_ascii=False)[1:-1]
def _for_header(value: str) -> str:
# A newline in a header value is header injection. Other control characters
# go with it; none of them mean anything in a header.
return "".join(character for character in value if character.isprintable())
def _substitute(template: str, spec: HttpSpec, args: dict[str, Any], escape) -> str:
"""Fill `{{name}}` from the call's arguments.
Not `prompts.substitute`, though the grammar is shared. The rules differ,
and the differences are the point: a name the tool does not declare never
substitutes, an unrecognised one becomes nothing rather than passing through
verbatim -- a literal `{{x}}` in a URL is not a feature -- and every value
is escaped for where it lands.
"""
declared = set(spec.parameters.get("properties") or {})
def swap(match) -> str:
name = match.group(1)
if name not in declared:
return ""
return escape(_scalar(args.get(name)))
return VARIABLE_PATTERN.sub(swap, template)
def _origin(url: str) -> tuple[str, str]:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise fetch_service.FetchError("A tool's URL must start with http:// or https://")
if not parsed.netloc:
raise fetch_service.FetchError("A tool's URL has no host.")
return parsed.scheme, parsed.netloc
def fill_url(spec: HttpSpec, args: dict[str, Any]) -> str:
"""Fill the URL template, refusing anything that moved the host.
Checked twice over: the template's own scheme and authority must be literal,
and the filled URL must still point at them. The first check is what stops
`https://{{host}}/x` from ever being saved; the second is what catches a row
that predates it, or an escaping mistake.
"""
template = spec.url_template.strip()
scheme, netloc = _origin(template)
if VARIABLE_PATTERN.search(f"{scheme}://{netloc}"):
raise fetch_service.FetchError(
"A tool's scheme and host must be literal, not filled from an argument."
)
filled = _substitute(template, spec, args, _for_url)
if _origin(filled) != (scheme, netloc):
raise fetch_service.FetchError("That call would have pointed somewhere else.")
return filled
def _prepare(spec: HttpSpec, args: dict[str, Any]) -> tuple[str, dict[str, str], bytes | None]:
"""The URL, headers and body for one call, secret included."""
url = fill_url(spec, args)
headers = {
"User-Agent": fetch_service.USER_AGENT,
"Accept": "application/json, text/*;q=0.9, */*;q=0.5",
}
for name, value in spec.headers.items():
clean = _for_header(str(name)).strip()
if clean:
headers[clean] = _substitute(str(value), spec, args, _for_header)
body: bytes | None = None
if spec.body_template.strip() and spec.method != "GET":
body = _substitute(spec.body_template, spec, args, _for_body).encode("utf-8")
headers.setdefault("Content-Type", "application/json")
if spec.secret:
if spec.secret_placement == SECRET_BEARER:
headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}"
elif spec.secret_placement == SECRET_HEADER:
headers[spec.secret_name or "Authorization"] = spec.secret
elif spec.secret_placement == SECRET_QUERY:
# Only on the URL this call starts at. A redirect's Location
# replaces the query, so the credential does not travel on by
# itself -- which is the behaviour wanted anyway.
joiner = "&" if urlparse(url).query else "?"
url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}"
return url, headers, body
# --- Reading the response ----------------------------------------------------
def _narrow(payload: Any, path: str) -> Any:
"""Walk a dotted path into a decoded JSON document.
Integer segments index a list, so "data.0.title" works. A path that does not
lead anywhere yields the whole document rather than nothing: an unhelpful
answer beats a silent empty one when the model has to explain itself.
"""
current = payload
for segment in [part for part in path.split(".") if part]:
if isinstance(current, dict) and segment in current:
current = current[segment]
elif isinstance(current, list) and segment.lstrip("-").isdigit():
try:
current = current[int(segment)]
except IndexError:
return payload
else:
return payload
return current
def _decode(payload: bytes, response: httpx.Response) -> str:
return payload.decode(response.encoding or "utf-8", "replace")
def _as_text(spec: HttpSpec, payload: bytes, response: httpx.Response) -> str:
content_type = response.headers.get("content-type", "")
if spec.response_mode == RESPONSE_JSON:
try:
document = json.loads(_decode(payload, response))
except (json.JSONDecodeError, UnicodeDecodeError):
# Falling back rather than failing: a JSON API answering with an
# HTML error page is a thing the model can report usefully.
return _decode(payload, response)
value = _narrow(document, spec.response_path)
if isinstance(value, str):
return value
return json.dumps(value, indent=2, ensure_ascii=False)
if spec.response_mode == RESPONSE_RAW:
return _decode(payload, response)
text = _decode(payload, response)
if "html" in content_type or text.lstrip()[:1] == "<":
_, text = fetch_service.html_to_text(text)
return text
def _clip(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n… (truncated)"
def _summary(args: dict[str, Any]) -> str:
"""What the transcript shows the tool was asked for."""
parts = [f"{name}={_scalar(value)!r}" for name, value in args.items()]
return _clip(", ".join(parts), MAX_SUMMARY_CHARS)
def _event(spec: HttpSpec, args: dict[str, Any], *, status: str, **extra: Any) -> dict[str, Any]:
return {
"name": spec.slug,
"kind": "custom",
"label": spec.label,
"query": _summary(args),
# The host, never the filled URL: a path or query segment can carry an
# argument, and the event is rendered and stored.
"detail": f"{spec.method} {urlparse(spec.url_template).netloc}",
"status": status,
"results": [],
**extra,
}
# --- Making the call ---------------------------------------------------------
async def call(spec: HttpSpec, args: dict[str, Any]) -> ToolOutcome:
"""Run one custom tool. Reports its own failures rather than raising."""
try:
url, headers, body = _prepare(spec, args)
current = fetch_service.check_url(url, allow_private=spec.allow_private)
origin = _origin(current)
response = await _send(spec, current, headers, body, origin)
except fetch_service.FetchError as exc:
return ToolOutcome(
f"The {spec.label} tool could not be called: {exc.message}",
_event(spec, args, status="error", error=exc.message),
)
except httpx.RequestError as exc:
message = f"Could not reach the {spec.label} tool: {exc}"
return ToolOutcome(message, _event(spec, args, status="error", error=str(exc)[:200]))
payload = response.content[:MAX_RESPONSE_BYTES]
text = _clip(_as_text(spec, payload, response).strip(), spec.max_chars)
if response.status_code >= 400:
note = f"{spec.label} returned HTTP {response.status_code}."
return ToolOutcome(
f"{note}\n\n{text}" if text else note,
_event(
spec,
args,
status="error",
error=f"HTTP {response.status_code}",
text=text[:MAX_EVENT_CHARS],
),
)
if not text:
return ToolOutcome(
f"{spec.label} returned nothing.",
_event(spec, args, status="ok", text=""),
)
return ToolOutcome(text, _event(spec, args, status="ok", text=text[:MAX_EVENT_CHARS]))
async def _send(
spec: HttpSpec,
url: str,
headers: dict[str, str],
body: bytes | None,
origin: tuple[str, str],
) -> httpx.Response:
"""Send the request, following redirects by hand so each hop is checked."""
current = url
async with httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False) as client:
for _ in range(fetch_service.MAX_REDIRECTS + 1):
response = await client.request(
spec.method, current, headers=headers, content=body
)
if not response.is_redirect:
return response
location = response.headers.get("location", "")
if not location:
raise fetch_service.FetchError("That tool redirected to nowhere.")
current = fetch_service.check_url(
str(response.url.join(location)), allow_private=spec.allow_private
)
if _origin(current) != origin:
# A server that can redirect us anywhere must not be able to
# redirect us at somebody else carrying the key.
if spec.secret_header:
headers.pop(spec.secret_header, None)
origin = _origin(current)
raise fetch_service.FetchError("That tool redirected too many times.")
__all__ = ["HttpSpec", "call", "fill_url", "spec_from", "tool_defs"]
+6 -2
View File
@@ -239,7 +239,11 @@ async def _run(generation: Generation) -> None:
owner = db.get(User, chat.user_id)
# Read while the session is open: everything below outlives it.
offered = tools_service.enabled_tools(db, chat, owner)
# Resolved once, so that what the loop is allowed to *run* is the
# same set the endpoint was *offered* -- not whatever happens to
# exist by the time a call comes back.
toolset = tools_service.resolve_tools(db, chat, owner)
offered = toolset.schemas
payload = chat_service.build_request(
db, chat, upto=message, tools=offered, user=owner
)
@@ -248,7 +252,7 @@ async def _run(generation: Generation) -> None:
# Read here, with the rest, because titling happens after this
# session has closed and must not open another one.
title_prompt = prompts_service.resolve(db, "task.title")
tool_context = tools_service.context_for(db, owner, chat)
tool_context = tools_service.context_for(db, owner, chat, tools=toolset)
model = chat_service.model_for(db, chat)
generation.context_limit = model.context_length if model is not None else 0
+14 -8
View File
@@ -57,16 +57,22 @@ MAX_HARNESS_CHARS = 8000
MAX_NAMED_DOCUMENTS = 5
def _families(tools: list[dict[str, Any]]) -> list[str]:
"""Which families are represented in an offered tool list, in a fixed order."""
from lembas.services.tools import FAMILIES, REGISTRY
def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]:
"""Which families are represented in an offered tool list, in a fixed order.
Resolved against the database rather than the import-time registry, because
an administrator-defined tool is a row and would otherwise contribute no
family at all -- which is to say its guidance would never be admitted.
"""
from lembas.services import tools as tools_service
book = tools_service.registry(db)
offered = {
REGISTRY[name].family
book[name].family
for tool in tools
if (name := (tool.get("function") or {}).get("name")) in REGISTRY
if (name := (tool.get("function") or {}).get("name")) in book
}
return [family for family in FAMILIES if family in offered]
return [family for family in tools_service.families(db) if family in offered]
def _tool_names(tools: list[dict[str, Any]]) -> str:
@@ -109,7 +115,7 @@ def context_variables(
from lembas.services import tools as tools_service
offered = tools or []
families = _families(offered)
families = _families(db, offered)
stamp = datetime.now().astimezone()
values: dict[str, str] = {
@@ -185,7 +191,7 @@ def compose(
return compose_from(
db,
variables=context_variables(db, user, offered, chat),
families=_families(offered),
families=_families(db, offered),
has_tools=bool(offered),
)
+63
View File
@@ -0,0 +1,63 @@
"""Who may be offered which administrator-defined tool.
One definition, used by the resolver that builds a chat's tool set and by the
admin screens that say what a row will reach. It mirrors
`permissions.models_visible_to` rather than `services/sharing.py`, and the
difference matters: sharing has no admin branch because reading someone's
private notes is not something a role should grant, while a tool is instance
configuration an administrator could give themselves in one click anyway.
`everything=True` is for callers that need to map a tool *name* back to what it
belongs to -- the harness, the prompt preview -- rather than to decide what to
offer. Gating on visibility there would mean an administrator's own preview
disagreeing with what a reader actually gets.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import CustomTool, McpServer, User
def _permitted(rows: list, user: User | None, *, everything: bool) -> list:
if everything or (user is not None and user.is_admin):
return rows
if user is None:
return []
member_of = {group.id for group in user.groups}
return [
row for row in rows if row.public or member_of.intersection({g.id for g in row.groups})
]
def visible_custom_tools(
db: DBSession, user: User | None, *, everything: bool = False
) -> list[CustomTool]:
"""Enabled custom tools this user may be offered, in position order."""
rows = list(
db.scalars(
select(CustomTool)
.where(CustomTool.enabled.is_(True))
.order_by(CustomTool.position, CustomTool.slug)
)
)
return _permitted(rows, user, everything=everything)
def visible_mcp_servers(
db: DBSession, user: User | None, *, everything: bool = False
) -> list[McpServer]:
"""Enabled MCP servers this user may be offered, in position order."""
rows = list(
db.scalars(
select(McpServer)
.where(McpServer.enabled.is_(True))
.order_by(McpServer.position, McpServer.slug)
)
)
return _permitted(rows, user, everything=everything)
__all__ = ["visible_custom_tools", "visible_mcp_servers"]
+180 -18
View File
@@ -29,10 +29,12 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, Chat, User
from lembas.db.session import session_scope
from lembas.services import prompts as prompts_service
from lembas.services import search as search_service
from lembas.services import settings_store
from lembas.services.library import documents as documents_service
@@ -58,8 +60,24 @@ FAMILY_NOTES = "notes"
FAMILY_MEMORY = "memory"
FAMILY_SKILLS = "skills"
# A tool that is a database row gets a family of its own, so that it can carry
# its own guidance -- "custom:weather", "mcp:github". Everything before the
# colon is the *gate*: the capability flag and the permission are per gate, not
# per row, because a server advertising forty tools must not mean forty
# checkboxes on every model.
FAMILY_CUSTOM = "custom"
FAMILY_MCP = "mcp"
# The built-in families, in the order they are offered.
FAMILIES = (FAMILY_SEARCH, FAMILY_KNOWLEDGE, FAMILY_NOTES, FAMILY_MEMORY, FAMILY_SKILLS)
GATES = (*FAMILIES, FAMILY_CUSTOM, FAMILY_MCP)
def gate_of(family: str) -> str:
"""The part a capability flag and a permission are named after."""
return family.split(":", 1)[0]
@dataclass
class ToolContext:
@@ -72,10 +90,14 @@ class ToolContext:
owner_id: str
search_config: dict[str, Any] = field(default_factory=dict)
allow_private_fetch: bool = False
# Which knowledge bases this chat is scoped to. Empty means "everything the
# owner can see", which is what a chat with none attached should do.
base_ids: list[str] = field(default_factory=list)
# Name -> definition for the tools actually offered on this request. None
# means nobody resolved a set, and only then does `run_tool` fall back to
# the import-time registry. A dict, *even an empty one*, is authoritative:
# a model naming a tool it was not offered must not get it run.
tools: dict[str, ToolDef] | None = None
@dataclass
@@ -114,6 +136,31 @@ class ToolDef:
}
@dataclass(frozen=True)
class ToolSet:
"""What one request may call: the schemas to send, and how to run them.
The two halves have to travel together. `enabled_tools` used to return
schemas alone, which worked only because every runner was reachable through
the import-time `REGISTRY`. A tool that is a database row is not, so the
resolution has to be carried from the session that made it to the loop that
uses it.
"""
defs: tuple[ToolDef, ...] = ()
@property
def schemas(self) -> list[dict[str, Any]]:
return [tool.schema for tool in self.defs]
@property
def by_name(self) -> dict[str, ToolDef]:
return {tool.name: tool for tool in self.defs}
def __bool__(self) -> bool:
return bool(self.defs)
def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
return {"type": "object", "properties": properties, "required": required}
@@ -645,21 +692,69 @@ def _family_allowed(
Absent counts as on when `tools` is on, so an upgrade does not silently take
web search away from every model already set up for it.
"""
gate = gate_of(family)
default = bool(capabilities.get("tools"))
if not capabilities.get(f"tool_{family}", default):
if not capabilities.get(f"tool_{gate}", default):
return False
if family == FAMILY_SEARCH:
if gate == FAMILY_SEARCH:
return bool(
allowed.get("tools.web_search")
and config.get("enabled")
and not search_service.availability(str(config.get("provider") or "ddgs"))
)
return bool(allowed.get(f"tools.{family}") and allowed.get("library.use"))
if gate in (FAMILY_CUSTOM, FAMILY_MCP):
# Deliberately without `library.use`: an HTTP endpoint an administrator
# wrote has nothing to do with this person's own documents and notes,
# and requiring the library permission for it would be a coincidence of
# naming rather than a rule.
return bool(allowed.get(f"tools.{gate}"))
return bool(allowed.get(f"tools.{gate}") and allowed.get("library.use"))
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
"""The tool schemas to offer for this chat."""
def _row_defs(db: DBSession, user: User | None, *, everything: bool = False) -> list[ToolDef]:
"""Tool definitions built from rows, in the order they claim names.
Imported here rather than at the top because `custom_tools` needs `ToolDef`
from this module.
"""
from lembas.services import custom_tools
return custom_tools.tool_defs(db, user, everything=everything)
def _book(defs: list[ToolDef]) -> dict[str, ToolDef]:
"""Keyed by name, first claim winning.
The built-ins are laid down first, so a row can never shadow one -- a tool
called `notes_delete` that turns out to be somebody's HTTP endpoint is the
kind of surprise that has no good failure mode.
"""
book = dict(REGISTRY)
for tool in defs:
book.setdefault(tool.name, tool)
return book
def registry(db: DBSession) -> dict[str, ToolDef]:
"""Every tool that exists on this instance, keyed by name, ungated.
`REGISTRY` holds the built-ins alone, because it is built at import time and
an administrator-defined tool is a row. Callers that only need to map a name
back to a family use this; callers deciding what to *offer* use
`resolve_tools`, which applies the gates as well.
"""
return _book(_row_defs(db, None, everything=True))
def families(db: DBSession) -> tuple[str, ...]:
"""Every family that exists, the built-ins in their fixed order first."""
rows = tuple(tool.family for tool in _row_defs(db, None, everything=True))
return (*FAMILIES, *rows)
def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
"""Every tool this chat may call right now, with its runner attached."""
from lembas.security import permissions
from lembas.services import chat as chat_service
@@ -669,25 +764,47 @@ def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str
capabilities = model.capabilities_json or {}
if not capabilities.get("tools"):
return []
return ToolSet()
allowed = permissions.resolve(db, user)
config = settings_store.search(db)
families = {
family
for family in FAMILIES
if _family_allowed(family, config=config, capabilities=capabilities, allowed=allowed)
}
return [tool.schema for tool in REGISTRY.values() if tool.family in families]
# Resolved against what this reader may see, not against everything that
# exists: a tool restricted to a group is not offered outside it.
book = _book(_row_defs(db, user))
return ToolSet(
tuple(
tool
for tool in book.values()
if _family_allowed(
tool.family, config=config, capabilities=capabilities, allowed=allowed
)
)
)
def context_for(db: DBSession, user: User | None, chat: Chat | None = None) -> ToolContext:
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
"""The tool schemas to offer for this chat.
The shape is unchanged on purpose: the inspector and `build_request` want
exactly this. Anything that will also *run* a tool wants `resolve_tools`.
"""
return resolve_tools(db, chat, user).schemas
def context_for(
db: DBSession,
user: User | None,
chat: Chat | None = None,
*,
tools: ToolSet | None = None,
) -> ToolContext:
"""The snapshot a running tool needs, taken while the session is open."""
return ToolContext(
owner_id=user.id if user else "",
search_config=settings_store.search(db),
base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [],
tools=tools.by_name if tools is not None else None,
)
@@ -697,8 +814,15 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco
Never raises. A tool that fails hands the model an explanation and lets it
carry on -- a failed lookup should produce "I could not find that" rather
than killing the whole reply.
The lookup is against what was *offered*, not against everything that
exists. Reaching for the registry directly meant a model naming a tool its
chat was gated out of -- a family switched off for the model, a permission
the reader does not have -- had it run anyway, because only the offer was
ever filtered.
"""
tool = REGISTRY.get(name)
book = REGISTRY if context.tools is None else context.tools
tool = book.get(name)
if tool is None:
return ToolOutcome(
f"There is no tool called {name!r}.",
@@ -710,9 +834,12 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco
except json.JSONDecodeError:
# Small models emit malformed argument JSON often enough that this is a
# normal path, not an exceptional one. Treat the whole string as the
# first required argument rather than giving up.
required = tool.parameters.get("required") or ["query"]
parsed = {required[0]: arguments.strip()}
# tool's first argument rather than giving up: what it says is required,
# else the first thing it declares, and only then a guess -- a schema
# somebody else wrote need not have either.
properties = tool.parameters.get("properties") or {}
names = tool.parameters.get("required") or list(properties) or ["query"]
parsed = {str(names[0]): arguments.strip()}
if not isinstance(parsed, dict):
parsed = {"query": str(parsed)}
@@ -808,6 +935,31 @@ def tool_turn(call: dict[str, Any], content: str) -> dict[str, Any]:
}
def _row_source(db: DBSession):
"""One harness fragment per administrator-defined tool.
The seam `prompts.register_source` exists for. The row supplies the default
text and the admin page supplies the override, which is why a tool deleted
and recreated under the same slug keeps whatever wording somebody chose for
it -- the override outlives the row.
Gated on the tool's own family, so the guidance appears exactly when the
tool it describes is offered and never otherwise.
"""
from lembas.db.models import CustomTool
for row in db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)):
yield prompts_service.Fragment(
key=f"tool.custom_{row.slug}",
label=row.name or row.slug,
group=prompts_service.GROUP_TOOLS,
order=500 + row.position,
families=(f"{FAMILY_CUSTOM}:{row.slug}",),
hint=f"Appears when the {row.slug} tool is offered.",
default=row.guidance or "",
)
__all__ = [
"FAMILIES",
"MAX_ROUNDS",
@@ -816,9 +968,19 @@ __all__ = [
"ToolContext",
"ToolDef",
"ToolOutcome",
"ToolSet",
"assistant_turn",
"context_for",
"enabled_tools",
"families",
"registry",
"resolve_tools",
"run_tool",
"tool_turn",
]
# Registered at import. `services.tools` is imported by the chat routes, the
# generation service and the prompts admin, so the source is in place before
# anything renders a catalogue.
prompts_service.register_source(_row_source)