Skip to content

Commit 9ab9f17

Browse files
authored
Merge pull request #50 from nickroci/perf/cache-curator-prompts
perf(curator): cache the curator system prompt (split static instructions from per-scan data)
2 parents a7f284c + f3bcc00 commit 9ab9f17

7 files changed

Lines changed: 404 additions & 249 deletions

File tree

daemon/agent_mem_daemon/librarian.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,13 @@
6969
# precision).
7070
OUTPUT_RETRIES = 3
7171

72-
_SYSTEM_PROMPT = (
73-
"You are the Librarian, the recall tier of a two-tier memory curator. "
74-
"Use the read-only research tools (read_entry, grep_library, bm25_search, "
75-
"embedding_search) to inspect the existing library before proposing "
76-
"actions. You write nothing to disk and you only PROPOSE — the Scholar "
77-
"approves or vetoes. When ready, call submit_result EXACTLY ONCE with a "
78-
"LibrarianProposal object. Follow the detailed instructions in the user "
79-
"message."
80-
)
72+
# The big static instruction prefix that the SDK/engine prompt-caches across
73+
# scans. Built once and reused — it is byte-identical every call (the
74+
# per-scan dynamic data lives in the user message, not here), so the engine's
75+
# prefix cache (system_prompt + tool defs) hits. The short role preamble that
76+
# used to live here is now folded into the TOP of this prompt by
77+
# ``librarian_prompt.librarian_system_prompt``.
78+
_SYSTEM_PROMPT = lp.librarian_system_prompt()
8179

8280

8381
class EvidencePacket(TypedDict):
@@ -405,7 +403,10 @@ def _scan_for_packet(
405403
bucket = lp.derive_project_bucket(buffer_snapshot)
406404

407405
# ── Step 3: assemble + invoke ──────────────────────────────
408-
prompt = lp.assemble_prompt(
406+
# The big static instructions live in the SDK-cached system prompt
407+
# (_SYSTEM_PROMPT, byte-stable). Only the per-scan dynamic blocks go
408+
# in the user message, so the engine's prefix cache hits across scans.
409+
prompt = lp.build_librarian_user_message(
409410
project_slug=bucket,
410411
rolling_buffer=formatted_buffer,
411412
library_snapshot=library_snapshot,
@@ -415,12 +416,14 @@ def _scan_for_packet(
415416
record.input_prompt = prompt
416417

417418
log.debug(
418-
"librarian.scan: session=%s bucket=%s turns=%d repair_tasks=%d prompt_chars=%d",
419+
"librarian.scan: session=%s bucket=%s turns=%d repair_tasks=%d "
420+
"prompt_chars=%d system_prompt_chars=%d",
419421
session_id,
420422
bucket,
421423
len(flat),
422424
len(repair_tasks),
423425
len(prompt),
426+
len(_SYSTEM_PROMPT),
424427
)
425428

426429
# The Librarian needs the knowledge dir so its read/grep/bm25/

daemon/agent_mem_daemon/librarian_prompt.py

Lines changed: 105 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -887,47 +887,39 @@ def _slugify(s: str) -> str:
887887
git-tracked — assume the worst.
888888
889889
═══════════════════════════════════════════════════════════════════
890-
INPUTS
890+
INPUTS (provided as tagged blocks in the user message)
891891
═══════════════════════════════════════════════════════════════════
892892
893-
<active_project>
894-
slug: {{PROJECT_SLUG}}
895-
(use this when picking paths; lessons clearly tied to {{PROJECT_SLUG}} → \
896-
``projects/{{PROJECT_SLUG}}/...``; lessons that apply across repos → \
897-
``global/...``)
898-
</active_project>
899-
900-
<rolling_buffer>
901-
{{ROLLING_BUFFER}}
902-
</rolling_buffer>
903-
904-
Each turn is ``[turn_id] (turn_seq=S) [role] <text>``. ``[turn_id]`` is a \
905-
scan-local label you may quote in ``reasoning``; ``(turn_seq=S)`` is a \
906-
STABLE id you MUST echo in ``cited_turn_seq`` when emitting a \
907-
``used_helpfully`` signal (it is how the daemon avoids double-counting a \
908-
re-seen turn). ``[USER-ASSERTED]`` marks turns that arrived via the \
909-
user's /ultan command — file these by default unless they are \
910-
nonsensical.
911-
912-
<library_snapshot>
913-
{{LIBRARY_SNAPSHOT}}
914-
</library_snapshot>
915-
916-
This is your read-only view of the library's current state. If you need \
917-
to verify an entry's contents before proposing an action against it, use \
918-
``read_entry`` with the relative path of an entry you see in the snapshot \
919-
above. Use ``grep_library`` / ``bm25_search`` / ``embedding_search`` to \
920-
find anything you suspect exists but don't see in the snapshot.
893+
The user message for this scan supplies five tagged blocks. Read them \
894+
there; this section explains how to use each.
895+
896+
``<project_context>`` carries the active project's ``slug``. Use it when \
897+
picking paths: lessons clearly tied to that slug → \
898+
``projects/<slug>/...``; lessons that apply across repos → ``global/...``.
899+
900+
``<rolling_buffer>`` is the conversation buffer. Each turn is \
901+
``[turn_id] (turn_seq=S) [role] <text>``. ``[turn_id]`` is a scan-local \
902+
label you may quote in ``reasoning``; ``(turn_seq=S)`` is a STABLE id you \
903+
MUST echo in ``cited_turn_seq`` when emitting a ``used_helpfully`` signal \
904+
(it is how the daemon avoids double-counting a re-seen turn). \
905+
``[USER-ASSERTED]`` marks turns that arrived via the user's /ultan \
906+
command — file these by default unless they are nonsensical.
907+
908+
``<library_snapshot>`` is your read-only view of the library's current \
909+
state. If you need to verify an entry's contents before proposing an \
910+
action against it, use ``read_entry`` with the relative path of an entry \
911+
you see in that snapshot. Use ``grep_library`` / ``bm25_search`` / \
912+
``embedding_search`` to find anything you suspect exists but don't see in \
913+
the snapshot.
921914
922915
═══════════════════════════════════════════════════════════════════
923916
INTEGRITY-REPAIR TASKS (HIGHEST PRIORITY — fix these first)
924917
═══════════════════════════════════════════════════════════════════
925918
926-
<repair_tasks>
927-
{{REPAIR_TASKS}}
928-
</repair_tasks>
919+
The ``<repair_tasks>`` block in the user message lists any integrity-repair \
920+
tasks for this scan.
929921
930-
If the block above is not empty, the daemon's deterministic post-write \
922+
If that block is not empty, the daemon's deterministic post-write \
931923
pass found library invariants it could NOT fix on its own and is handing \
932924
them to you. These are NOT discretionary — propose an action to repair \
933925
each one. They take priority over salience-driven proposals.
@@ -1213,11 +1205,10 @@ def _slugify(s: str) -> str:
12131205
APPLIES-WHEN TABLE (for interrupt candidates only)
12141206
═══════════════════════════════════════════════════════════════════
12151207
1216-
<applies_when_table>
1217-
{{APPLIES_WHEN_TABLE}}
1218-
</applies_when_table>
1208+
The ``<applies_when_table>`` block in the user message lists one \
1209+
``<lesson_id> | <scope> | <applies-when phrase>`` row per confirmed entry.
12191210
1220-
Scan the buffer against these phrases. If a buffer turn matches a phrase \
1211+
Scan the buffer against those phrases. If a buffer turn matches a phrase \
12211212
(intent, not literal substring), emit an interrupt candidate. Score 0.5+ \
12221213
only. Max 5 interrupts per run.
12231214
@@ -1254,35 +1245,91 @@ def format_repair_tasks(tasks: Sequence[repair_queue.RepairTask]) -> str:
12541245
return "\n".join(lines)
12551246

12561247

1257-
def assemble_prompt(
1248+
# ── System prompt (byte-stable static prefix — the cacheable region) ──
1249+
#
1250+
# Prompt caching: the SDK/Claude Code engine prompt-caches the prefix
1251+
# ``system_prompt + tool definitions`` automatically. To make that cache
1252+
# hit across scans, the system prompt must be BYTE-IDENTICAL on every
1253+
# call — so it carries ONLY the static instructions (plus the two
1254+
# schema-derived, deterministic blocks ACTION_TYPES / RESPONSE_SHAPE), and
1255+
# NONE of the per-scan dynamic data. All five dynamic blocks (project
1256+
# context, rolling buffer, library snapshot, applies-when table, repair
1257+
# tasks) move to the first user message via :func:`build_librarian_user_message`.
1258+
1259+
# Role preamble folded in at the top of the system prompt (was the short
1260+
# ``_SYSTEM_PROMPT`` in librarian.py). Its final line now points at the
1261+
# user-message blocks rather than "the detailed instructions".
1262+
_SYSTEM_PREAMBLE = (
1263+
"You are the Librarian, the recall tier of a two-tier memory curator. "
1264+
"Use the read-only research tools (read_entry, grep_library, bm25_search, "
1265+
"embedding_search) to inspect the existing library before proposing "
1266+
"actions. You write nothing to disk and you only PROPOSE — the Scholar "
1267+
"approves or vetoes. When ready, call submit_result EXACTLY ONCE with a "
1268+
"LibrarianProposal object. The conversation buffer, library snapshot, "
1269+
"project context, applies-when table, and any repair tasks are provided "
1270+
"as tagged blocks in the user message.\n\n"
1271+
)
1272+
1273+
1274+
def librarian_system_prompt() -> str:
1275+
"""The byte-stable static Librarian system prompt (the cacheable prefix).
1276+
1277+
It is the full instruction template with the FIVE dynamic placeholders
1278+
removed (their data now rides in the user message — see
1279+
:func:`build_librarian_user_message`) and the TWO static, schema-derived
1280+
placeholders substituted. ACTION_TYPES and RESPONSE_SHAPE are generated
1281+
from ``_schemas.py``; both are deterministic (they iterate a fixed
1282+
``_ACTION_CLASSES`` list and ``model_fields`` in declaration order, and
1283+
``model_json_schema()`` is stable), so the output is byte-identical on
1284+
every call and the engine's prefix cache hits across scans.
1285+
1286+
Contains NO timestamp, slug, buffer, snapshot, applies-when table, or
1287+
repair tasks — anything per-scan would defeat the cache.
1288+
"""
1289+
out = _SYSTEM_PREAMBLE + load_prompt_template()
1290+
for needle, value in (
1291+
("{{ACTION_TYPES}}", describe_action_types_markdown()),
1292+
("{{RESPONSE_SHAPE}}", describe_librarian_response_shape()),
1293+
):
1294+
out = out.replace(needle, value)
1295+
return out
1296+
1297+
1298+
def build_librarian_user_message(
12581299
*,
12591300
project_slug: str,
12601301
rolling_buffer: str,
12611302
library_snapshot: str,
12621303
applies_when_table: str,
12631304
repair_tasks: str = _NO_REPAIR_TASKS,
12641305
) -> str:
1265-
"""Substitute placeholders into the prompt template.
1266-
1267-
ACTION_TYPES and RESPONSE_SHAPE are generated from ``_schemas.py``
1268-
at call time so the prompt instructions can never drift from the
1269-
Pydantic models the parser actually validates against. ``repair_tasks``
1270-
is the pre-rendered ``<repair_tasks>`` body (see
1271-
:func:`format_repair_tasks`); it defaults to the empty sentinel so
1272-
callers that don't escalate anything need not pass it.
1306+
"""Emit ONLY the five per-scan dynamic blocks, each in a clearly-labelled
1307+
tag, in a STABLE order.
1308+
1309+
This is the first user message; the static instructions (which reference
1310+
these blocks by tag) live in :func:`librarian_system_prompt`. The
1311+
empty-value fallbacks match the old ``assemble_prompt`` behaviour so
1312+
nothing the model sees changes apart from where the data lives.
1313+
``repair_tasks`` is the pre-rendered ``<repair_tasks>`` body (see
1314+
:func:`format_repair_tasks`); it defaults to the empty sentinel.
12731315
"""
1274-
out = load_prompt_template()
1275-
for needle, value in (
1276-
("{{PROJECT_SLUG}}", project_slug or "unknown"),
1277-
("{{ROLLING_BUFFER}}", rolling_buffer or "(empty)"),
1278-
("{{LIBRARY_SNAPSHOT}}", library_snapshot or "(empty)"),
1279-
("{{REPAIR_TASKS}}", repair_tasks or _NO_REPAIR_TASKS),
1280-
("{{APPLIES_WHEN_TABLE}}", applies_when_table or "(empty)"),
1281-
("{{ACTION_TYPES}}", describe_action_types_markdown()),
1282-
("{{RESPONSE_SHAPE}}", describe_librarian_response_shape()),
1283-
):
1284-
out = out.replace(needle, value)
1285-
return out
1316+
return (
1317+
"<project_context>\n"
1318+
f"slug: {project_slug or 'unknown'}\n"
1319+
"</project_context>\n"
1320+
"<rolling_buffer>\n"
1321+
f"{rolling_buffer or '(empty)'}\n"
1322+
"</rolling_buffer>\n"
1323+
"<library_snapshot>\n"
1324+
f"{library_snapshot or '(empty)'}\n"
1325+
"</library_snapshot>\n"
1326+
"<applies_when_table>\n"
1327+
f"{applies_when_table or '(empty)'}\n"
1328+
"</applies_when_table>\n"
1329+
"<repair_tasks>\n"
1330+
f"{repair_tasks or _NO_REPAIR_TASKS}\n"
1331+
"</repair_tasks>\n"
1332+
)
12861333

12871334

12881335
# ── Convenience: flatten + format in one shot ─────────────────────────

daemon/agent_mem_daemon/scholar.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,13 @@
7676
# Role-specific (higher than the Librarian's — precision over recall).
7777
OUTPUT_RETRIES = 4
7878

79-
# Concise framing for the model; the heavy role instructions live in the
80-
# user prompt built by ``scholar_prompt.build_prompt`` (single source of
81-
# truth). The shim wires ``submit_result`` whose schema is ScholarDecisions.
82-
_SYSTEM_PROMPT = (
83-
"You are the Scholar, the precision gatekeeper of a two-tier memory curator. "
84-
"Use the read-only research tools (read_entry, grep_library, bm25_search, "
85-
"embedding_search) to verify the Librarian's proposals against the real "
86-
"library — never trust its summary. You write nothing to disk: when you have "
87-
"decided which proposals to approve, call submit_result EXACTLY ONCE with a "
88-
"ScholarDecisions object holding the approved actions. A deterministic "
89-
"executor applies them. Follow the detailed instructions in the user message."
90-
)
79+
# The big static instruction prefix that the SDK/engine prompt-caches across
80+
# batches. Built once and reused — it is byte-identical every call (the
81+
# per-batch dynamic data lives in the user message, not here), so the
82+
# engine's prefix cache (system_prompt + tool defs) hits. The short role
83+
# preamble that used to live here is now folded into the TOP of this prompt
84+
# by ``scholar_prompt.scholar_system_prompt``.
85+
_SYSTEM_PROMPT = scholar_prompt.scholar_system_prompt()
9186

9287

9388
_HETEROGENEOUS_SESSION_ID = "batch"
@@ -726,7 +721,11 @@ def _review_inner(
726721
session_id = _batch_session_id(packets)
727722
ensure_home() # create ~/.agent-mem/ if missing (side effect only)
728723
knowledge_root = knowledge_dir()
729-
prompt = scholar_prompt.build_prompt(packets)
724+
# The big static instructions live in the SDK-cached system prompt
725+
# (_SYSTEM_PROMPT, byte-stable). Only the per-batch dynamic blocks
726+
# (timestamp / snapshot / proposals) go in the user message, so the
727+
# engine's prefix cache hits across batches.
728+
prompt = scholar_prompt.build_scholar_user_message(packets)
730729

731730
record = runs.InvocationRecord(
732731
role="scholar",
@@ -745,11 +744,14 @@ def _review_inner(
745744
_bump_fired_helpful_counters(packets, record)
746745

747746
log.info(
748-
"scholar.review: invoking agent (session=%s packets=%d proposals=%d interrupts=%d)",
747+
"scholar.review: invoking agent (session=%s packets=%d proposals=%d "
748+
"interrupts=%d prompt_chars=%d system_prompt_chars=%d)",
749749
session_id,
750750
n_packets,
751751
n_props,
752752
n_ints,
753+
len(prompt),
754+
len(_SYSTEM_PROMPT),
753755
)
754756

755757
try:

0 commit comments

Comments
 (0)