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
co-authored by Claude Opus 5
parent 4ee7d3db7d
commit d4cefb066a
26 changed files with 2771 additions and 60 deletions
+14 -9
View File
@@ -29,15 +29,20 @@ router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
def _families_of(names: list[str]) -> list[str]:
"""Keep only real family names, in the registry's order."""
def _families_of(db: Db, names: list[str]) -> list[str]:
"""Keep only real family names, in the registry's order.
Read from the database rather than the constant: a family can belong to an
administrator-defined tool, and one the preview cannot name is one whose
guidance cannot be checked here.
"""
wanted = set(names)
return [family for family in tools_service.FAMILIES if family in wanted]
return [family for family in tools_service.families(db) if family in wanted]
def _tool_names(families: list[str]) -> str:
def _tool_names(db: Db, families: list[str]) -> str:
return ", ".join(
name for name, tool in tools_service.REGISTRY.items() if tool.family in families
name for name, tool in tools_service.registry(db).items() if tool.family in families
)
@@ -67,7 +72,7 @@ def _variables(
values.update(
{
"model_name": model_name,
"tool_names": _tool_names(families),
"tool_names": _tool_names(db, families),
"memories": memories_service.block(db, user) if "memory" in families else "",
"skills": skills_service.index_block(db, user) if "skills" in families else "",
"knowledge_bases": bases if "knowledge" in families else "",
@@ -89,7 +94,7 @@ def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict:
async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
stored = prompts_service.stored(db)
models = chat_service.available_models(db, user)
families = list(tools_service.FAMILIES)
families = list(tools_service.families(db))
return render(
request,
@@ -115,7 +120,7 @@ async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool =
"models": models,
"families": families,
"registry": sorted(
tools_service.REGISTRY.values(), key=lambda t: (t.family, t.name)
tools_service.registry(db).values(), key=lambda t: (t.family, t.name)
),
"max_harness_chars": settings_store.get(
db, "max_harness_chars", key=settings_store.PROMPTS
@@ -164,7 +169,7 @@ async def preview(request: Request, db: Db, user: AdminUser):
"""
form = await request.form()
overrides = _submitted(db, form)
families = _families_of([str(value) for value in form.getlist("preview_family")])
families = _families_of(db, [str(value) for value in form.getlist("preview_family")])
model_name = str(form.get("preview_model") or "")
bases = str(form.get("preview_bases") or "").strip()
documents = str(form.get("preview_documents") or "").strip()