Files
LLeMbas/src/lembas/api/admin_prompts.py
T
HomerandClaude Opus 5 e970f10cca A time in no particular zone, and a preview missing what it previews
The first audit pass: everything from 0.8.1 to 0.9.8 read as a whole rather
than one feature at a time, starting with what a model is actually told.

Four of these had shipped as correct. The date line carried a timezone
variable that resolves to nothing until somebody chooses one -- so every
default account was told times were "in  unless they say otherwise", while
two comments asserted the line disappeared instead. The prompt preview
built its variables without a chat, which is what eleven fragments are
gated on, so the whole agent surface was absent from it whatever was
ticked. Plan mode was instructed to keep its plan current with a tool that
mode withdraws. And knowledge_get returned a document whole where every
sibling reader caps and says so, its description promising exactly that.

The subagent guidance was wrong in both directions at once: it denied a
documented parameter and named seven of twenty-three allowed commands.
Both halves are pinned by tests against the real list and the real schema
now, because prose and a constant drift the moment one is edited alone.

docs/notes/audit-0.9.md carries the findings that are not fixed here, with
why -- the ones whose fix would change what a feature does are the user's
call, not this pass's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:13:54 +02:00

326 lines
12 KiB
Python

"""Prompt administration: every piece of text LLeMbas injects into a model.
The fragments themselves live in `services/prompts.py`; this is the screen that
edits them, and the preview that shows what they assemble into before anything
is saved.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import chat as chat_service
from lembas.services import harness as harness_service
from lembas.services import prompts as prompts_service
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.services.agent import policy
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
# What the preview pretends is attached, so the attachment fragment can be read
# in place rather than imagined. An administrator can clear the field.
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
# The rest of what a preview has to pretend, and the reason it must.
#
# `harness.context_variables` fills most `requires` gates only when it is handed
# a real `Chat` -- the machine, the directory, the plan, the project listing, a
# scheduled task's instruction, the flag saying this is a helper. The preview
# passes `chat=None`, so every one of those stayed empty and **eleven gated
# fragments could never appear in it at all**: the whole agent surface, both
# scheduling fragments, and the helper warning. An administrator editing
# `tool.agent` previewed a system message with `tool.agent` missing from it, and
# nothing said so.
#
# Samples rather than a transient Chat. `compose_from` takes plain variables
# precisely so this screen never has to build one, and a constructed row would
# need a connection, a profile and a directory that exist -- inventing an SSH
# host to render a paragraph is a worse trade than inventing the paragraph's
# values. This is what `SAMPLE_DOCUMENTS` has always done, extended to the rest.
SAMPLE_AGENT = {
"agent_target": "buildbox",
"agent_dir": "/srv/www/example",
"agent_rewound": "on 3 August at 14:20",
"background": "on",
"project_files": "src/\n app.py\n models.py\nREADME.md\npyproject.toml",
"agent_instructions": "Run the tests with `make check` before proposing a change.",
"agent_instructions_file": "AGENTS.md",
"plan": "1. [done] Read the failing test\n2. [doing] Fix the parser\n3. [todo] Add a case",
}
SAMPLE_SCHEDULE = {
"schedule_instruction": "Summarise what changed in the repository since yesterday.",
"schedule_summary": "every weekday at 08:00",
}
# Situations a chat can be in that are not a tool family, so nothing on the
# "Tools offered" row can reach them. `kind` and `parent_chat_id` in the model.
SITUATION_ORDINARY = ""
SITUATION_TASK = "task"
SITUATION_HELPER = "helper"
SITUATIONS = (
(SITUATION_ORDINARY, "An ordinary chat"),
(SITUATION_TASK, "A scheduled task, running unattended"),
(SITUATION_HELPER, "A helper sent by another model"),
)
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(db) if family in wanted]
def _tool_names(db: Db, families: list[str]) -> str:
return ", ".join(
name for name, tool in tools_service.registry(db).items() if tool.family in families
)
def _variables(
db: Db,
user: AdminUser,
*,
families: list[str],
model_name: str = "",
bases: str = "",
documents: str = "",
situation: str = SITUATION_ORDINARY,
mode: str = "",
) -> dict[str, str]:
"""The preview's variable values.
Built from the administrator's *own* memories and skills rather than from
invented ones: a preview against synthetic data cannot tell you whether your
memory section reads well against what is actually in there. `AdminUser`
means this is the operator looking at their own library.
No Chat row is made. `harness.compose_from` takes plain variables precisely
so that this screen never has to build a transient one.
The samples are gated exactly as `context_variables` gates the real values --
the agent block on the `agent` family, the schedule and helper blocks on the
situation rather than on any family, because neither is a tool. A preview
that admitted a fragment the real request would not is worse than one that
omitted it, so the gating is mirrored rather than approximated.
"""
from lembas.services.agent import policy
from lembas.services.library import memories as memories_service
from lembas.services.library import skills as skills_service
values = harness_service.context_variables(db, user, [], None)
values.update(
{
"model_name": model_name,
"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 "",
"document_names": documents,
}
)
if "agent" in families:
values.update(SAMPLE_AGENT)
# A real one out of the table, not invented prose: this bullet *is* the
# mode guidance, so a made-up sentence here would preview wording that
# no request ever carries.
values["agent_mode"] = policy.MODE_GUIDANCE.get(mode, "") or policy.MODE_GUIDANCE[
policy.MODE_EDIT
]
if situation == SITUATION_TASK:
values.update(SAMPLE_SCHEDULE)
if situation == SITUATION_HELPER:
values["subagent"] = "yes"
return values
def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict:
return {
"fragment": prompts_service.catalogue(db)[key],
"value": value,
"overridden": overridden,
}
@router.get("")
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(db))
return render(
request,
"admin/prompts.html",
{
"groups": prompts_service.grouped(db),
"values": {
fragment.key: stored.get(fragment.key, fragment.default)
for fragment in prompts_service.catalogue(db).values()
},
"overridden": set(stored),
"variables": prompts_service.VARIABLES,
# The legend shows what each name resolves to right now, with every
# family on -- a legend nobody can check is just a list of words.
# Every situation at once, unlike the preview: a chat is either a
# scheduled task or a helper and never both, but a legend is a
# reference rather than a rendering, and a name shown as empty
# because of the situation it was built in reads as a name that
# resolves to nothing.
"resolved": {
**_variables(
db,
user,
families=families,
model_name=models[0].label if models else "",
bases="Contracts, Recipes",
documents=SAMPLE_DOCUMENTS,
situation=SITUATION_TASK,
),
"subagent": "yes",
},
"models": models,
"families": families,
"situations": SITUATIONS,
"modes": policy.MODE_LABELS,
"registry": sorted(
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
),
"default_harness_chars": harness_service.MAX_HARNESS_CHARS,
"sample_documents": SAMPLE_DOCUMENTS,
"saved": saved,
},
)
# Registered before anything that could take a path parameter. There is no such
# route today, but /admin/models has already been bitten once by adding one.
@router.post("/default")
async def use_default(request: Request, db: Db, user: AdminUser, key: str = Form("")):
"""Fill one field with its built-in text, without saving anything.
Deliberately not a write. The administrator may be halfway through editing
something else, and a button that silently persisted would take that with
it. Saving afterwards is what makes it stick -- and because the text then
equals the default, `prompts.save` stores nothing and the override is gone.
"""
fragment = prompts_service.catalogue(db).get(key)
if fragment is None:
return Response(status_code=status.HTTP_404_NOT_FOUND)
return render(
request,
"admin/_prompt_field.html",
_field_context(db, key, value=fragment.default, overridden=False),
)
@router.post("/reset")
async def reset_prompts(db: Db, user: AdminUser) -> Response:
prompts_service.clear(db)
log.info("prompt fragments reset to defaults by %s", user.email)
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/preview")
async def preview(request: Request, db: Db, user: AdminUser):
"""The whole system message, assembled from what is in the form right now.
Unsaved text is what an administrator wants to see, so the submitted values
are passed as overrides rather than read back from the database.
"""
form = await request.form()
overrides = _submitted(db, form)
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()
situation = str(form.get("preview_situation") or "")
mode = str(form.get("preview_mode") or "")
variables = _variables(
db,
user,
families=families,
model_name=model_name,
bases=bases,
documents=documents,
situation=situation,
mode=mode,
)
body = harness_service.compose_from(
db,
variables=variables,
families=families,
has_tools=bool(families),
overrides=overrides,
)
authored = (settings_store.get(db, "system_prompt") or "").strip()
lead = prompts_service.substitute(
overrides.get("seam.authored_lead", prompts_service.resolve(db, "seam.authored_lead")),
variables,
).strip()
return render(
request,
"admin/_prompt_preview.html",
{
"system": harness_service.join(body, authored, lead=lead),
"harness_chars": len(body),
"limit": harness_service.limit_for(db),
"authored": authored,
"title_prompt": prompts_service.substitute(
overrides.get("task.title", prompts_service.resolve(db, "task.title")),
{"question": "What is lembas?", "answer": "Elvish waybread."},
).strip(),
},
)
def _submitted(db: Db, form) -> dict[str, str]:
"""The fragment texts present in a form post, normalised.
Key presence is what is read, never a falsy value: an empty textarea is how
a fragment is turned off, and FastAPI's `Form(...)` cannot tell `x=` from an
absent `x`. Same reason `api/chats.py:update_chat` reads the raw form.
"""
out: dict[str, str] = {}
for key in prompts_service.catalogue(db):
field = f"prompt.{key}"
if field in form:
out[key] = str(form.get(field) or "").replace("\r\n", "\n")
return out
@router.post("")
async def save_prompts(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
stored = prompts_service.save(db, _submitted(db, form))
try:
cap = int(str(form.get("max_harness_chars") or 0))
except ValueError:
cap = 0
settings_store.update(
db,
{"max_harness_chars": min(max(cap, 0), 100_000)},
key=settings_store.PROMPTS,
)
log.info("prompt fragments saved by %s (%d edited)", user.email, len(stored))
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)