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:
@@ -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}"
|
||||
Reference in New Issue
Block a user