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
+161
View File
@@ -0,0 +1,161 @@
"""Telling the model how to use what it has been given.
A model handed a `tools` array will often ignore it. It answers from recall
because that is what it was trained to do, and nothing in the request suggests
otherwise. The harness is the part of the prompt that says otherwise: one line
per tool about *when* to reach for it, the memories, and the list of skills
available.
**On the "system prompts are precedence, not concatenation" rule.** That rule
governs the three authored layers -- instance, model, chat -- and it is
untouched here: exactly one of them still wins, and
``chat.effective_system_prompt`` still decides which. This is a different axis.
It describes the machinery rather than the behaviour, nobody authored it, and
there is nothing for it to disagree with. So it is prepended to whichever
authored prompt won, inside one system message, under a heading that makes the
seam obvious.
One system message rather than two because several endpoints reject a second
one. The authored prompt goes last, where it is closest to the conversation.
Nothing is emitted for a model with no tools and no memories: an empty harness
is worse than none, being tokens that say only that there is nothing to say.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import User
from lembas.services.library import memories as memories_service
from lembas.services.library import skills as skills_service
log = logging.getLogger(__name__)
# Keyed by tool family, so a family that is off contributes nothing. Written as
# guidance rather than rules: a model told "you MUST search" searches for the
# capital of France.
GUIDANCE: dict[str, str] = {
"web_search": (
"- Look things up rather than trusting your recall, whenever the answer "
"depends on current facts, on details you are not certain of, or on "
"anything that may have changed. If the first results are thin or "
"beside the point, search again with different words instead of "
"answering from them — two or three searches are normal. Say where an "
"answer came from."
),
"knowledge": (
"- The user has a library of their own documents. When a question is "
"about their material — their files, their notes on paper, a page they "
"saved — search that before searching the web."
),
"notes": (
"- You keep notes across conversations. Search them when a task sounds "
"like one you have done before. Write one when you work something out "
"that would be tedious to work out again: a procedure, a decision and "
"its reasons, a summary of a long document."
),
# Two variants: the first refers to a heading that only exists when there
# is something under it, and telling a model to consult an absent section
# is a good way to make it invent one.
"memory": (
"- You can remember durable facts about this person — a preference, a "
"constraint, a name — but not the details of one task, and never "
"anything secret."
),
"memory_with_records": (
"- What is listed under “What you know about this person” below was "
"remembered earlier and still applies. Add to it only for durable facts "
"— a preference, a constraint, a name — never for the details of one "
"task, and never for anything secret."
),
"skills": (
"- Skills are procedures you have saved. The list below gives only each "
"one's name and when to use it; read the full instructions with "
"skill_get before following one. If you work out a repeatable way to do "
"something, save it as a new skill."
),
}
HEADING = "## How to work"
# A ceiling on the whole block, so that a large library cannot quietly eat the
# context window. Memory and skills have their own caps below this one.
MAX_HARNESS_CHARS = 8000
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
offered = {
REGISTRY[name].family
for tool in tools
if (name := (tool.get("function") or {}).get("name")) in REGISTRY
}
return [family for family in FAMILIES if family in offered]
def compose(
db: DBSession,
user: User | None,
tools: list[dict[str, Any]] | None,
) -> str:
"""The operational preamble for this request, or "" when there is nothing to say."""
families = _families(tools or [])
if not families:
return ""
parts: list[str] = [
HEADING,
"",
"You have tools. Use them rather than guessing; a wrong answer given "
"confidently is worse than a slower one that was checked.",
"",
]
# Read before the guidance is assembled, because whether there are any
# memories decides which wording the memory line gets.
block = memories_service.block(db, user) if "memory" in families else ""
for family in families:
if family == "memory" and block:
parts.append(GUIDANCE["memory_with_records"])
elif family in GUIDANCE:
parts.append(GUIDANCE[family])
if block:
parts += ["", "### What you know about this person", "", block]
if "skills" in families:
index = skills_service.index_block(db, user)
if index:
parts += [
"",
"### Skills available",
"",
index,
"",
"Read one with skill_get before following it.",
]
text = "\n".join(parts).strip()
if len(text) > MAX_HARNESS_CHARS:
text = text[:MAX_HARNESS_CHARS].rstrip() + "\n"
return text
def join(harness: str, authored: str) -> str:
"""Put the harness in front of whichever authored prompt won.
Separated from `compose` so the precedence between instance, model and chat
stays testable on its own -- this function is the only place the two axes
meet.
"""
if not harness:
return authored
if not authored:
return harness
return f"{harness}\n\n---\n\n{authored}"