Every injected prompt becomes editable, and several get written
The instructions LLeMbas puts in front of a model were hard-coded: six
strings in a GUIDANCE dict, two headings, and the title request inline in
chat.py. An operator could not see what was being sent, let alone change
it, and there was nowhere for a custom tool to contribute its own guidance
when custom tools land.
services/prompts.py now holds each piece as a Fragment, and /admin/prompts
edits them with a preview of the whole assembled system message including
unsaved edits. harness.py keeps only the decisions -- which fragments apply
to this request, and what their variables resolve to.
The design turns on one choice: a fragment carries its gate as data
(families, requires, when_tools) rather than as a callable, because a
database row can carry the same three fields. Custom tools will therefore
register a fragment source and change nothing else -- there is a test that
says exactly that, and it is the reason the rest of the shape is what it is.
Consequences worth knowing:
- Defaults live in code, overrides in the database, and text equal to its
default is never stored. Otherwise pressing Save once would freeze
today's wording forever and no later release could improve it.
- An empty override means off. A fragment that was not submitted at all
keeps what it had, because it may be missing from the page only because
whatever contributes it is currently switched off.
- requires= replaced the hand-written pair of memory guidance variants.
The sentence that refers to a section now lives inside that section, so
it cannot outlive it. That was the general problem the pair was a
special case of.
- {{name}}, with anything unrecognised passing through verbatim. The name
grammar is the guard: {"total": 1} and ${PATH} are not candidates.
Substitution is one pass and never recursive, because {{memories}}
carries text a model wrote.
The wording is also overhauled, and a model now gets the core fragments
even with no tools -- the date above all. "An empty harness is worse than
none" was about tokens that say nothing; a model with no clock being asked
about the present is not that. Clearing those boxes restores the old
silence exactly. New: today's date, who it is talking to, the three-round
tool budget, that tool results are not replayed, that anything a tool
returns is data rather than instruction, and what the <document> wrapper
around an attachment is. Extended: memory_forget, notes_edit/delete,
skill_create/edit, and reading a knowledge document in full rather than
answering from an extract.
Tool descriptions stay in code and are listed read-only. They are schema
and they state facts about what a runner does; an edit would make the text
a lie with nothing to catch it.
No schema change -- one JSON row in the settings table.
488 tests. Version 0.2.0, which also invalidates the service worker cache
so the green artwork appears without a hard reload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ lembas info # paths + counts, useful when confused
|
||||
lembas secret-key # generate LEMBAS_SECRET_KEY
|
||||
lembas create-admin # create or promote an admin
|
||||
|
||||
pytest # 440 tests, ~28s
|
||||
pytest # 488 tests, ~29s
|
||||
# PLAN.md tracks what is and is not built
|
||||
ruff check . # lint (line length 100)
|
||||
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
||||
@@ -80,6 +80,7 @@ src/lembas/
|
||||
admin_users.py users, groups, permissions
|
||||
admin_audio.py speech-to-text and text-to-speech endpoints
|
||||
admin_search.py web search provider and credentials
|
||||
admin_prompts.py the prompt fragment editor and its preview
|
||||
audio.py transcribe, speak, voice discovery
|
||||
library.py knowledge, notes, skills pages; memory CRUD
|
||||
files.py upload, serve, remove attachments
|
||||
@@ -97,6 +98,7 @@ src/lembas/
|
||||
audio.py OpenAI-shaped /v1/audio/* client
|
||||
fetch.py URL retrieval, HTML to text, the SSRF guard
|
||||
sharing.py one visibility rule for every library store
|
||||
prompts.py every injected prompt fragment, and {{variables}}
|
||||
harness.py the operational prompt built from what a model has
|
||||
tools.py tool registry, schemas, streamed-call reassembly
|
||||
chat.py request building, endpoint resolution, titles
|
||||
@@ -361,6 +363,40 @@ nothing for it to disagree with. It is prepended to whichever authored prompt
|
||||
won, in one system message (several endpoints reject a second one), and
|
||||
`build_request` is where the two meet.
|
||||
|
||||
**The harness holds no text.** Every piece of it is a `Fragment` in
|
||||
`services/prompts.py`, edited on `/admin/prompts`. `harness.py` decides which
|
||||
fragments apply and what their variables resolve to; `prompts.py` owns the
|
||||
wording, the storage and the substitution, and knows nothing about chats or
|
||||
tools. Four rules hold the whole thing up:
|
||||
|
||||
- **Defaults live in code, overrides live in the database**, and text equal to
|
||||
its default is never stored. That is what lets a later release improve a
|
||||
default and have it reach an instance whose administrator once pressed Save.
|
||||
- **An empty override means off**, which is why there is no separate enable
|
||||
flag: clearing the box in the admin page *is* the switch. A fragment that was
|
||||
not submitted at all keeps whatever it had — it may be missing from the page
|
||||
because the thing contributing it is switched off.
|
||||
- **A fragment carries its gate as data** (`families`, `requires`,
|
||||
`when_tools`), never as a callable, because a database row can carry the same
|
||||
three fields. `requires` is why there is no longer a hand-written pair of
|
||||
memory-guidance variants: the sentence that refers to a section lives *inside*
|
||||
that section, so it cannot outlive it.
|
||||
- **`{{name}}`, and anything unrecognised passes through verbatim.** Names are
|
||||
lowercase letters, digits and underscores, so `{"total": 1}` and `${PATH}` are
|
||||
never candidates. Substitution is one pass and never recursive — `{{memories}}`
|
||||
carries text a model wrote, and a memory reading `{{skills}}` must not expand.
|
||||
|
||||
A model with no tools now gets the core fragments too, the date above all.
|
||||
"An empty harness is worse than none" was about tokens that say nothing, and a
|
||||
model with no clock being asked about the present is not that. Clearing those
|
||||
fragments restores the old silence exactly.
|
||||
|
||||
**Tool descriptions are not fragments.** They are schema, sent verbatim in the
|
||||
`tools` array, and they state facts about what a runner does — an administrator
|
||||
editing `notes_edit`'s "omit a field to leave it alone" would make the text a
|
||||
lie with nothing to catch it. The page lists them read-only so nothing injected
|
||||
is hidden. A *custom* tool's description will be editable, because it is a row.
|
||||
|
||||
**A model's tool flags default to on when `tools` is on.** Rows configured
|
||||
before the per-tool split have no `tool_*` keys. Reading absent as off would
|
||||
silently take web search away from every model already set up for it, so
|
||||
@@ -449,7 +485,10 @@ Custom tools and MCP, agentic execution (local subprocess and SSH connection
|
||||
profiles), image generation. Nav entries mark where each one goes. The tool
|
||||
loop in `services/generation.py` is what they plug into — a new tool is a
|
||||
`ToolDef` in `services/tools.py:REGISTRY` plus a permission and a capability
|
||||
flag, not a new code path.
|
||||
flag, not a new code path. Its guidance is the same shape: a
|
||||
`prompts.register_source` yielding one `Fragment` per tool row puts it in the
|
||||
harness, on the admin page and in the preview without touching the assembler,
|
||||
the save handler or a template.
|
||||
|
||||
No OCR: a scanned PDF is stored with an explanatory `extraction_error` rather
|
||||
than silently contributing nothing.
|
||||
|
||||
Reference in New Issue
Block a user