Knowledge, notes, memory and skills, and a harness to make them used

Four places a model can reach for, differing in who writes a record and how it
gets in front of the model.

**Knowledge** is uploaded by a person and searched by the model. It goes through
`services/files.py:prepare` — the same pipeline as a chat attachment — so the
same PDF produces the same text whichever way it arrived, and `Document` carries
the same content columns as `Attachment` for the same reason.

**Notes** are written by the model and edited by you. Too long to inject, so
they are searched.

**Memory** is short facts, and every one of them goes into every request. That
single decision is where the rest of its design comes from: records are capped
short, the block has a budget, there is no search tool because the model is
already looking at them, and they are not shareable — a record about a person is
not content to hand round.

**Skills** are saved procedures. Only the name and description are injected; the
body is fetched when the model decides one applies, which is what makes a
hundred skills affordable. A model may write and revise its own — the safety
story is not a gate but a record: every revision is kept, attributed and
revertible. A model that has just read a hostile page can save a skill that
outlives the conversation, and the honest mitigation is that it is visible and
undoable rather than that it was prevented.

**The harness** is why any of it gets used. A model handed a tools array
ignores it and answers from recall, because nothing in the request suggests
otherwise. `services/harness.py` assembles a preamble from what this chat
actually has: when to reach for each tool, the memories, the skill index.

This is an exception to "system prompts are precedence, not concatenation", and
a deliberate one. That rule governs the three *authored* layers and is
untouched — exactly one still wins. The harness is a different axis: it
describes the machinery rather than the behaviour, nobody authored it, and there
is nothing for it to disagree with. It is prepended to whichever authored prompt
won, in one system message, since several endpoints reject a second.

Supporting changes:

- **Sharing**, in one helper. `visible_to()` is the only definition of who can
  see a library item and every listing and tool goes through it. Sharing grants
  *reading*; two people editing one note with no history and no merge is worse
  than copying it. **Administrators do not bypass this** — they bypass
  permissions elsewhere because an admin can grant themselves those anyway, but
  reading somebody's private notes is a different act.
- **FTS5**, created by `db/migrations.py:ensure_fts` with the triggers an
  external-content index needs. Idempotent, like the column sync beside it.
  Terms are ANDed and then ORed: the caller is usually a model writing a whole
  question, and requiring every word loses the match on one absent term.
- **The attach button is a menu** — file, image, a web page, or a document from
  the library. Attaching a document copies it, because history must not change
  when a document is edited later.
- **A URL fetcher with an SSRF guard.** This server can reach the router, the
  other services on the box and LLeMbas itself, and the address can come from a
  model. Private ranges are refused *after resolution* and redirects are followed
  by hand so every hop is checked. An admin can open it deliberately.
- **Model capabilities split** into protocol support and a toggle per built-in
  tool. Rows predating the split have no `tool_*` keys, and absent counts as on
  when `tools` is on — otherwise an upgrade silently takes web search away from
  every model already configured for it.

Also fixes the test fixture, which built the schema with `create_all` and so ran
against a database without the FTS tables production has; it now runs
`sync_schema`, the same path startup takes.

430 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 19:43:57 +02:00
parent 3ad4c82b86
commit 1eba860d39
49 changed files with 5028 additions and 148 deletions
+647 -90
View File
@@ -1,19 +1,24 @@
"""Tools a model may call while it answers.
One tool so far -- web search -- but the shape is the point: a registry of
named callables with a JSON schema each, offered to the endpoint and executed
here when it asks. Built-in tools, MCP servers and agentic execution all plug
in at the same place.
A registry of named callables with a JSON schema each: offered to the endpoint,
executed here when it asks. MCP servers and agentic execution plug in at the
same place, which is why the registry is keyed and grouped rather than being a
handful of if-statements.
Two things gate whether a tool is offered at all:
Three things gate whether a tool is offered:
* the administrator has configured and enabled it, and
* the chat's model is marked as supporting tools.
* the instance is configured for it (web search has a provider, and so on),
* the reader has the permission, and
* the chat's model is marked as having that tool.
The second is not optional politeness. Sending a ``tools`` array to an endpoint
The last is not optional politeness. Sending a ``tools`` array to an endpoint
that does not implement tool calling fails the entire request, exactly the way
sending image parts to a model without vision does -- and for the same reason,
the capability flag on the model is what decides.
sending image parts to a model without vision does.
Tools that *write* -- notes, memories, skills -- need a database session and a
user, and they run inside a background generation that outlives the request. So
they are handed a `ToolContext` carrying an owner id rather than a live session,
and open their own scope, the same way `services.generation` does.
"""
from __future__ import annotations
@@ -26,9 +31,14 @@ from typing import Any
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Chat, User
from lembas.db.models import AUTHOR_MODEL, Chat, User
from lembas.db.session import session_scope
from lembas.services import search as search_service
from lembas.services import settings_store
from lembas.services.library import documents as documents_service
from lembas.services.library import memories as memories_service
from lembas.services.library import notes as notes_service
from lembas.services.library import skills as skills_service
from lembas.services.search.base import SearchError
log = logging.getLogger(__name__)
@@ -39,34 +49,30 @@ log = logging.getLogger(__name__)
# out, and each round costs a full request.
MAX_ROUNDS = 3
WEB_SEARCH = "web_search"
# Tool families, matching the per-model capability flags and the permission
# keys. The three names differ by prefix only, which is deliberate: adding a
# family means adding one entry here and one permission.
FAMILY_SEARCH = "web_search"
FAMILY_KNOWLEDGE = "knowledge"
FAMILY_NOTES = "notes"
FAMILY_MEMORY = "memory"
FAMILY_SKILLS = "skills"
WEB_SEARCH_SCHEMA: dict[str, Any] = {
"type": "function",
"function": {
"name": WEB_SEARCH,
"description": (
"Search the web for current information. Use this when the answer "
"depends on recent events, on facts you are unsure of, or on "
"anything that may have changed since your training data. Returns "
"a numbered list of results with titles, URLs and short extracts."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search terms. Keep them short and specific.",
},
"max_results": {
"type": "integer",
"description": "How many results to return. Defaults to the site setting.",
},
},
"required": ["query"],
},
},
}
FAMILIES = (FAMILY_SEARCH, FAMILY_KNOWLEDGE, FAMILY_NOTES, FAMILY_MEMORY, FAMILY_SKILLS)
@dataclass
class ToolContext:
"""What a tool needs to do its work, without holding a session open.
`owner_id` rather than a User for the same reason `Endpoint` is a frozen
snapshot rather than a Connection: a generation outlives the request that
started it, and a detached SQLAlchemy instance is a trap.
"""
owner_id: str
search_config: dict[str, Any] = field(default_factory=dict)
allow_private_fetch: bool = False
@dataclass
@@ -74,7 +80,7 @@ class ToolOutcome:
"""What running a tool produced, for the model and for the reader.
The two are deliberately different. `content` is the flat text the model
reads back; `event` is what the transcript shows, and keeps the results
reads back; `event` is what the transcript shows, and keeps results
structured so they can be rendered as links rather than as a wall of URLs.
"""
@@ -82,72 +88,62 @@ class ToolOutcome:
event: dict[str, Any] = field(default_factory=dict)
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
"""The tool schemas to offer for this chat, which is usually none."""
from lembas.security import permissions
from lembas.services import chat as chat_service
config = settings_store.search(db)
if not config.get("enabled"):
return []
if not permissions.has(db, user, "tools.web_search"):
return []
if not chat_service.model_supports(db, chat, "tools"):
return []
if search_service.availability(str(config.get("provider") or "ddgs")):
# Configured but unusable -- offering a tool that will fail on every
# call is worse than not offering it.
return []
return [WEB_SEARCH_SCHEMA]
Runner = Callable[[ToolContext, dict[str, Any]], Awaitable[ToolOutcome]]
async def run_tool(config: dict[str, Any], name: str, arguments: str) -> ToolOutcome:
"""Execute one tool call.
@dataclass(frozen=True)
class ToolDef:
name: str
family: str
description: str
parameters: dict[str, Any]
run: Runner
Never raises. A tool that fails hands the model an explanation and lets it
carry on -- a failed search should produce "I could not look that up"
rather than killing the whole reply.
"""
if name != WEB_SEARCH:
return ToolOutcome(
content=f"There is no tool called {name!r}.",
event={"name": name, "status": "error", "error": "Unknown tool."},
)
@property
def schema(self) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
try:
parsed = json.loads(arguments) if arguments.strip() else {}
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
# query rather than giving up.
parsed = {"query": arguments.strip()}
if not isinstance(parsed, dict):
parsed = {"query": str(parsed)}
query = str(parsed.get("query") or "").strip()
def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
return {"type": "object", "properties": properties, "required": required}
_STRING = {"type": "string"}
# --- Web search --------------------------------------------------------------
async def _run_web_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
query = str(args.get("query") or "").strip()
if not query:
return ToolOutcome(
content="No search query was given.",
event={"name": name, "status": "error", "error": "No query was given."},
"No search query was given.",
{"name": "web_search", "status": "error", "error": "No query was given."},
)
limit = parsed.get("max_results")
limit = args.get("max_results")
try:
limit = int(limit) if limit is not None else None
except (TypeError, ValueError):
limit = None
try:
results = await search_service.run(config, query, limit=limit)
results = await search_service.run(context.search_config, query, limit=limit)
except SearchError as exc:
log.info("web search failed for %r: %s", query[:60], exc.message)
return ToolOutcome(
content=f"The search failed: {exc.message}",
event={"name": name, "query": query, "status": "error", "error": exc.message},
f"The search failed: {exc.message}",
{"name": "web_search", "query": query, "status": "error", "error": exc.message},
)
event = {
"name": name,
"name": "web_search",
"query": query,
"status": "ok",
"results": [
@@ -155,14 +151,573 @@ async def run_tool(config: dict[str, Any], name: str, arguments: str) -> ToolOut
for r in results
],
}
if not results:
return ToolOutcome(content=f"No results were found for {query!r}.", event=event)
return ToolOutcome(f"No results were found for {query!r}.", event)
lines = [f"Search results for {query!r}:"]
for index, result in enumerate(results, start=1):
lines.append(f"\n[{index}] {result.title}\n{result.url}\n{result.snippet}")
return ToolOutcome(content="\n".join(lines), event=event)
return ToolOutcome("\n".join(lines), event)
# --- Knowledge ---------------------------------------------------------------
async def _run_knowledge_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
query = str(args.get("query") or "").strip()
if not query:
return ToolOutcome(
"No search terms were given.",
{"name": "knowledge_search", "status": "error", "error": "No query."},
)
with session_scope() as db:
user = db.get(User, context.owner_id)
found = documents_service.search(db, user, query, limit=6)
event = {
"name": "knowledge_search",
"query": query,
"status": "ok",
"results": [
{"title": d.title, "id": d.id, "kind": d.kind, "host": d.source_url}
for d in found
],
}
if not found:
return ToolOutcome(
f"Nothing in the knowledge library matches {query!r}.", event
)
lines = [f"Knowledge library matches for {query!r}:"]
for document in found:
lines.append(
f"\n[{document.id}] {document.title}\n"
f"{documents_service.snippet(document)}"
)
lines.append(
"\nUse knowledge_get with an id in brackets to read a document in full."
)
return ToolOutcome("\n".join(lines), event)
async def _run_knowledge_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
document_id = str(args.get("id") or "").strip()
with session_scope() as db:
user = db.get(User, context.owner_id)
document = documents_service.get(db, document_id, user)
if document is None:
return ToolOutcome(
"There is no such document, or it is not available to you.",
{"name": "knowledge_get", "status": "error", "error": "Not found."},
)
event = {
"name": "knowledge_get",
"query": document.title,
"status": "ok",
"results": [{"title": document.title, "id": document.id}],
}
body = document.extracted_text or document.extraction_error or "(no text)"
return ToolOutcome(f"{document.title}\n\n{body}", event)
# --- Notes -------------------------------------------------------------------
async def _run_notes_search(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
query = str(args.get("query") or "").strip()
with session_scope() as db:
user = db.get(User, context.owner_id)
found = (
notes_service.search(db, user, query, limit=8)
if query
else notes_service.recent(db, user, limit=8)
)
event = {
"name": "notes_search",
"query": query,
"status": "ok",
"results": [{"title": n.title, "id": n.id} for n in found],
}
if not found:
return ToolOutcome("There are no notes matching that.", event)
lines = ["Notes:"]
for note in found:
lines.append(f"\n[{note.id}] {note.title}\n{notes_service.snippet(note)}")
lines.append("\nUse notes_get with an id to read one in full.")
return ToolOutcome("\n".join(lines), event)
async def _run_notes_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.get(db, str(args.get("id") or ""), user)
if note is None:
return ToolOutcome(
"There is no such note, or it is not available to you.",
{"name": "notes_get", "status": "error", "error": "Not found."},
)
return ToolOutcome(
f"{note.title}\n\n{note.body}",
{
"name": "notes_get",
"query": note.title,
"status": "ok",
"results": [{"title": note.title, "id": note.id}],
},
)
async def _run_notes_create(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
title = str(args.get("title") or "").strip()
body = str(args.get("body") or "").strip()
if not body:
return ToolOutcome(
"A note needs a body.",
{"name": "notes_create", "status": "error", "error": "Empty body."},
)
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.create(
db, owner=user, title=title, body=body, author=AUTHOR_MODEL
)
return ToolOutcome(
f"Saved note {note.id}{note.title!r}.",
{
"name": "notes_create",
"query": note.title,
"status": "ok",
"results": [{"title": note.title, "id": note.id}],
},
)
async def _run_notes_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.get(db, str(args.get("id") or ""), user)
if note is None or note.owner_id != context.owner_id:
return ToolOutcome(
"There is no such note, or it belongs to someone else. A note "
"shared with you can be read but not changed.",
{"name": "notes_edit", "status": "error", "error": "Not writable."},
)
notes_service.update(
db,
note,
title=args.get("title"),
body=args.get("body"),
)
return ToolOutcome(
f"Updated note {note.id}.",
{
"name": "notes_edit",
"query": note.title,
"status": "ok",
"results": [{"title": note.title, "id": note.id}],
},
)
async def _run_notes_delete(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
note = notes_service.get(db, str(args.get("id") or ""), user)
if note is None or note.owner_id != context.owner_id:
return ToolOutcome(
"There is no such note, or it belongs to someone else.",
{"name": "notes_delete", "status": "error", "error": "Not writable."},
)
title = note.title
notes_service.delete(db, note)
return ToolOutcome(
f"Deleted note {title!r}.",
{"name": "notes_delete", "query": title, "status": "ok", "results": []},
)
# --- Memory ------------------------------------------------------------------
async def _run_memory_add(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
content = str(args.get("content") or "").strip()
with session_scope() as db:
user = db.get(User, context.owner_id)
try:
memory = memories_service.add(
db, owner=user, content=content, author=AUTHOR_MODEL
)
except ValueError as exc:
return ToolOutcome(
str(exc), {"name": "memory_add", "status": "error", "error": str(exc)}
)
note = ""
if len(content) > memories_service.MAX_MEMORY_CHARS:
# Trimmed rather than refused, with the model told so -- it can then
# decide to put the long version in a note.
note = (
f" It was shortened to {memories_service.MAX_MEMORY_CHARS} characters; "
f"use notes for anything longer."
)
return ToolOutcome(
f"Remembered: {memory.content}{note}",
{
"name": "memory_add",
"query": memory.content,
"status": "ok",
"results": [],
},
)
async def _run_memory_forget(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
wanted = str(args.get("content") or "").strip().lower()
with session_scope() as db:
user = db.get(User, context.owner_id)
records = memories_service.all_for(db, user)
match = next((m for m in records if wanted and wanted in m.content.lower()), None)
if match is None:
return ToolOutcome(
"No memory matches that. The full list is in the prompt already.",
{"name": "memory_forget", "status": "error", "error": "No match."},
)
content = match.content
memories_service.delete(db, match)
return ToolOutcome(
f"Forgotten: {content}",
{"name": "memory_forget", "query": content, "status": "ok", "results": []},
)
# --- Skills ------------------------------------------------------------------
async def _run_skill_get(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
name = str(args.get("name") or "").strip()
with session_scope() as db:
user = db.get(User, context.owner_id)
skill = skills_service.by_name(db, name, user)
if skill is None:
return ToolOutcome(
f"There is no skill called {name!r}.",
{"name": "skill_get", "status": "error", "error": "Not found."},
)
return ToolOutcome(
f"Skill {skill.name}: {skill.description}\n\n{skill.body}",
{
"name": "skill_get",
"query": skill.name,
"status": "ok",
"results": [{"title": skill.name, "id": skill.id}],
},
)
async def _run_skill_create(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
try:
skill = skills_service.create(
db,
owner=user,
name=str(args.get("name") or ""),
description=str(args.get("description") or ""),
body=str(args.get("body") or ""),
author=AUTHOR_MODEL,
)
except skills_service.SkillError as exc:
return ToolOutcome(
str(exc), {"name": "skill_create", "status": "error", "error": str(exc)}
)
return ToolOutcome(
f"Created skill {skill.name!r}.",
{
"name": "skill_create",
"query": skill.name,
"status": "ok",
"results": [{"title": skill.name, "id": skill.id}],
},
)
async def _run_skill_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
with session_scope() as db:
user = db.get(User, context.owner_id)
skill = skills_service.by_name(db, str(args.get("name") or ""), user)
if skill is None or skill.owner_id != context.owner_id:
return ToolOutcome(
"There is no such skill, or it belongs to someone else.",
{"name": "skill_edit", "status": "error", "error": "Not writable."},
)
skills_service.update(
db,
skill,
description=args.get("description"),
body=args.get("body"),
author=AUTHOR_MODEL,
note=str(args.get("reason") or "")[:200],
)
return ToolOutcome(
f"Updated skill {skill.name!r}. The previous version was kept and can "
f"be restored.",
{
"name": "skill_edit",
"query": skill.name,
"status": "ok",
"results": [{"title": skill.name, "id": skill.id}],
},
)
# --- The registry ------------------------------------------------------------
REGISTRY: dict[str, ToolDef] = {
tool.name: tool
for tool in (
ToolDef(
name="web_search",
family=FAMILY_SEARCH,
description=(
"Search the web for current information. Use this when the answer "
"depends on recent events, on facts you are unsure of, or on "
"anything that may have changed since your training data. Returns "
"a numbered list of results with titles, URLs and short extracts."
),
parameters=_object(
{
"query": {
"type": "string",
"description": "The search terms. Keep them short and specific.",
},
"max_results": {
"type": "integer",
"description": "How many results to return.",
},
},
["query"],
),
run=_run_web_search,
),
ToolDef(
name="knowledge_search",
family=FAMILY_KNOWLEDGE,
description=(
"Search the user's own collected documents, files and saved web "
"pages. Use this before searching the web when the question is "
"about their material rather than about the world."
),
parameters=_object(
{"query": {**_STRING, "description": "Words likely to appear in the document."}},
["query"],
),
run=_run_knowledge_search,
),
ToolDef(
name="knowledge_get",
family=FAMILY_KNOWLEDGE,
description="Read one knowledge document in full, by the id a search returned.",
parameters=_object({"id": _STRING}, ["id"]),
run=_run_knowledge_get,
),
ToolDef(
name="notes_search",
family=FAMILY_NOTES,
description=(
"Search your notes. These are things you or the user wrote down in "
"earlier conversations. With no query, returns the most recent."
),
parameters=_object({"query": _STRING}, []),
run=_run_notes_search,
),
ToolDef(
name="notes_get",
family=FAMILY_NOTES,
description="Read one note in full, by the id a search returned.",
parameters=_object({"id": _STRING}, ["id"]),
run=_run_notes_get,
),
ToolDef(
name="notes_create",
family=FAMILY_NOTES,
description=(
"Write a note. Use this for something worth having in a later "
"conversation that is too long or too detailed for a memory: a "
"procedure, a summary, a set of preferences with reasons."
),
parameters=_object(
{"title": _STRING, "body": {**_STRING, "description": "Markdown."}},
["title", "body"],
),
run=_run_notes_create,
),
ToolDef(
name="notes_edit",
family=FAMILY_NOTES,
description="Change a note you can write to. Omit a field to leave it alone.",
parameters=_object({"id": _STRING, "title": _STRING, "body": _STRING}, ["id"]),
run=_run_notes_edit,
),
ToolDef(
name="notes_delete",
family=FAMILY_NOTES,
description="Delete a note that is no longer true or useful.",
parameters=_object({"id": _STRING}, ["id"]),
run=_run_notes_delete,
),
ToolDef(
name="memory_add",
family=FAMILY_MEMORY,
description=(
"Remember one short, durable fact about the user — a preference, a "
"constraint, how they like to be addressed. You are shown every "
"memory on every turn, so keep them few and short, and never store "
"passwords, keys or anything else secret."
),
parameters=_object(
{"content": {**_STRING, "description": "One fact, in one sentence."}},
["content"],
),
run=_run_memory_add,
),
ToolDef(
name="memory_forget",
family=FAMILY_MEMORY,
description=(
"Remove a memory that has become wrong. Give enough of its text to "
"identify it."
),
parameters=_object({"content": _STRING}, ["content"]),
run=_run_memory_forget,
),
ToolDef(
name="skill_get",
family=FAMILY_SKILLS,
description=(
"Read the full instructions for one of the skills listed in your "
"prompt. Do this before following a skill — the list gives only its "
"name and what it is for."
),
parameters=_object({"name": _STRING}, ["name"]),
run=_run_skill_get,
),
ToolDef(
name="skill_create",
family=FAMILY_SKILLS,
description=(
"Write a new skill: a reusable procedure for a task you expect to be "
"asked again. The description must say when to use it, since that is "
"all you will see next time."
),
parameters=_object(
{
"name": {**_STRING, "description": "Short slug, e.g. 'weekly-report'."},
"description": {**_STRING, "description": "When to use this skill."},
"body": {**_STRING, "description": "The instructions, in Markdown."},
},
["name", "description", "body"],
),
run=_run_skill_create,
),
ToolDef(
name="skill_edit",
family=FAMILY_SKILLS,
description=(
"Improve one of your skills. The previous version is kept and can be "
"restored, so say why you changed it."
),
parameters=_object(
{
"name": _STRING,
"description": _STRING,
"body": _STRING,
"reason": {**_STRING, "description": "Why the change was made."},
},
["name"],
),
run=_run_skill_edit,
),
)
}
def _family_allowed(
family: str, *, config: dict, capabilities: dict, allowed: dict
) -> bool:
"""Whether one family is on for this chat.
A model configured before the per-tool flags existed has no `tool_*` keys.
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.
"""
default = bool(capabilities.get("tools"))
if not capabilities.get(f"tool_{family}", default):
return False
if family == 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"))
def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]:
"""The tool schemas to offer for this chat."""
from lembas.security import permissions
from lembas.services import chat as chat_service
capabilities = {}
model = chat_service.model_for(db, chat)
if model is not None:
capabilities = model.capabilities_json or {}
if not capabilities.get("tools"):
return []
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]
def context_for(db: DBSession, user: User | 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),
)
async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutcome:
"""Execute one tool call.
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.
"""
tool = REGISTRY.get(name)
if tool is None:
return ToolOutcome(
f"There is no tool called {name!r}.",
{"name": name, "status": "error", "error": "Unknown tool."},
)
try:
parsed = json.loads(arguments) if arguments.strip() else {}
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()}
if not isinstance(parsed, dict):
parsed = {"query": str(parsed)}
try:
return await tool.run(context, parsed)
except Exception as exc: # noqa: BLE001 - a tool must never kill the reply
log.exception("tool %s failed", name)
return ToolOutcome(
f"The {name} tool failed: {exc}",
{"name": name, "status": "error", "error": str(exc)[:200]},
)
class ToolCallAccumulator:
@@ -247,14 +802,16 @@ def tool_turn(call: dict[str, Any], content: str) -> dict[str, Any]:
}
ToolRunner = Callable[..., Awaitable[ToolOutcome]]
__all__ = [
"FAMILIES",
"MAX_ROUNDS",
"WEB_SEARCH",
"REGISTRY",
"ToolCallAccumulator",
"ToolContext",
"ToolDef",
"ToolOutcome",
"assistant_turn",
"context_for",
"enabled_tools",
"run_tool",
"tool_turn",