4747import yaml
4848from aliases import session_bucket # type: ignore[import-not-found]
4949
50- from . import _response_parser
50+ from . import _response_parser , repair_queue
5151from ._schemas import (
5252 LibrarianProposal ,
5353 describe_action_types_markdown ,
6464LIBRARY_SNAPSHOT_MAX_CHARS = 3 * 1024
6565
6666
67+ # Hard ceiling on the rolling-buffer block — the single largest, most
68+ # variable part of the Librarian prompt. Feeding the WHOLE session every
69+ # pass blew prompts up to 259K chars (median 71K), driving cost (~$100 /
70+ # 2 days) and OOM-killing the spawned SDK subprocess ("Fatal error in
71+ # message reader: exit code -N"). Re-scanning the whole session is also
72+ # pointless: anything already learned is persisted in the library, so the
73+ # Librarian only needs the most RECENT activity to catch NEW lessons.
74+ #
75+ # Budget is expressed in tokens and converted to a char proxy at ~4
76+ # chars/token. We keep the most-recent turns and drop the OLDEST when
77+ # over budget, always retaining at least the single most recent turn.
78+ # Tune ROLLING_BUFFER_BUDGET_TOKENS to trade recall depth against cost.
79+ ROLLING_BUFFER_BUDGET_TOKENS = 30_000
80+ _CHARS_PER_TOKEN = 4
81+ ROLLING_BUFFER_MAX_CHARS = ROLLING_BUFFER_BUDGET_TOKENS * _CHARS_PER_TOKEN
82+
83+
6784# ── Shape definitions ─────────────────────────────────────────────────
6885#
6986# Buffer snapshots come from ``buffer.BufferStore.snapshot()`` as plain
@@ -168,6 +185,64 @@ def flatten_buffer(snapshot: Mapping[str, Any]) -> List[Tuple[int, str, str, boo
168185 return out
169186
170187
188+ def _render_turn_line (tid : int , role : str , text : str , user_asserted : bool ) -> str :
189+ """Render one (turn_id, role, text, user_asserted) tuple as the single
190+ ``[id] [role] [USER-ASSERTED?] <squashed text>`` line the Librarian
191+ sees. Shared by the formatter and the recency-cap char accounting so
192+ the cap is measured against the EXACT bytes that land in the prompt."""
193+ squashed = " " .join (text .split ())
194+ prefix = "[USER-ASSERTED] " if user_asserted else ""
195+ return f"[{ tid } ] [{ role } ] { prefix } { squashed } "
196+
197+
198+ def cap_buffer_to_recent (
199+ flat : Sequence [Tuple [int , str , str , bool ]],
200+ * ,
201+ max_chars : int = ROLLING_BUFFER_MAX_CHARS ,
202+ ) -> List [Tuple [int , str , str , bool ]]:
203+ """Trim ``flat`` to the most-recent turns that fit within ``max_chars``.
204+
205+ The rendered ``<rolling_buffer>`` block is the dominant, unbounded
206+ part of the Librarian prompt; feeding an entire session here is what
207+ pushed prompts to 259K chars and OOM-killed the SDK subprocess. We
208+ walk the turns NEWEST-first, accumulating their rendered line length
209+ (the exact bytes ``format_rolling_buffer`` will emit, newline
210+ included), and stop once the next-oldest turn would blow the budget.
211+ Always keeps at least the single most-recent turn even if that one
212+ line alone exceeds ``max_chars`` — dropping everything would defeat
213+ the Librarian entirely.
214+
215+ Returns the kept turns in their original (oldest-first) order so the
216+ caller can format them unchanged.
217+ """
218+ if not flat :
219+ return []
220+ kept_rev : List [Tuple [int , str , str , bool ]] = []
221+ used = 0
222+ for tid , role , text , user_asserted in reversed (flat ):
223+ line_len = len (_render_turn_line (tid , role , text , user_asserted ))
224+ # +1 for the newline join cost between lines (the very first kept
225+ # line has no separator, but over-counting by one is harmless and
226+ # keeps the accounting a strict upper bound on the joined output).
227+ cost = line_len + 1
228+ if kept_rev and used + cost > max_chars :
229+ break
230+ kept_rev .append ((tid , role , text , user_asserted ))
231+ used += cost
232+ kept_rev .reverse ()
233+ dropped = len (flat ) - len (kept_rev )
234+ if dropped :
235+ log .info (
236+ "librarian buffer truncated to recency budget: kept %d of %d turns "
237+ "(dropped %d oldest, budget=%d chars)" ,
238+ len (kept_rev ),
239+ len (flat ),
240+ dropped ,
241+ max_chars ,
242+ )
243+ return kept_rev
244+
245+
171246def format_rolling_buffer (flat : Sequence [Tuple [int , str , str , bool ]]) -> str :
172247 """Render the (turn_id, role, text, user_asserted) list as the
173248 ``<rolling_buffer>`` body.
@@ -178,11 +253,9 @@ def format_rolling_buffer(flat: Sequence[Tuple[int, str, str, bool]]) -> str:
178253 """
179254 if not flat :
180255 return "(empty — no turns with quotable text)"
181- lines : List [str ] = []
182- for tid , role , text , user_asserted in flat :
183- squashed = " " .join (text .split ())
184- prefix = "[USER-ASSERTED] " if user_asserted else ""
185- lines .append (f"[{ tid } ] [{ role } ] { prefix } { squashed } " )
256+ lines = [
257+ _render_turn_line (tid , role , text , user_asserted ) for tid , role , text , user_asserted in flat
258+ ]
186259 return "\n " .join (lines )
187260
188261
@@ -749,6 +822,56 @@ def _slugify(s: str) -> str:
749822 above. Use Glob (e.g. `Glob("**/*.md")`) to find anything you suspect \
750823 exists but don't see in the snapshot.
751824
825+ ═══════════════════════════════════════════════════════════════════
826+ INTEGRITY-REPAIR TASKS (HIGHEST PRIORITY — fix these first)
827+ ═══════════════════════════════════════════════════════════════════
828+
829+ <repair_tasks>
830+ {{REPAIR_TASKS}}
831+ </repair_tasks>
832+
833+ If the block above is not empty, the daemon's deterministic post-write \
834+ pass found library invariants it could NOT fix on its own and is handing \
835+ them to you. These are NOT discretionary — propose an action to repair \
836+ each one. They take priority over salience-driven proposals.
837+
838+ Each task names a ``file`` (relative to ``knowledge/``), a broken ``target`` \
839+ (the wikilink that does not resolve), and a ``context`` snippet showing \
840+ where it appears. For EACH broken-wikilink task, do this:
841+
842+ 1. **Research the intended target.** The broken target usually got the \
843+ PATH wrong, not the concept. Run ``mcp__agent_mem_library__bm25_search`` \
844+ AND ``mcp__agent_mem_library__embedding_search`` IN PARALLEL on the \
845+ target's leaf name and the surrounding context, and ``Glob("**/<leaf>.md")`` \
846+ for the filename. Read the top hits to confirm which existing entry the \
847+ link was meant to point at.
848+
849+ 2. **Then propose exactly ONE of these EXISTING actions** (no new action \
850+ type — the Scholar executes it as a normal proposal):
851+ - **Link points at the wrong path but the right entry EXISTS** → \
852+ ``update_entry`` on ``file`` whose ``new_body`` is the file's full body \
853+ with the broken ``[[target]]`` rewritten to the correct \
854+ ``[[full/path/from/knowledge/root]]`` (no ``.md``; trailing ``/`` for a \
855+ folder link). Read ``file`` first so you reproduce its body faithfully and \
856+ change only the link.
857+ - **The intended target genuinely does NOT exist yet but SHOULD** \
858+ (the link describes a real lesson worth having) → ``write_entry`` creating \
859+ the missing entry at the path the link points to, with proper frontmatter \
860+ and body. The link then resolves.
861+ - **The link is bogus / the concept isn't worth an entry** → \
862+ ``update_entry`` on ``file`` that removes the broken ``[[target]]`` (drop \
863+ the link or replace it with plain descriptive text), preserving the rest \
864+ of the prose. Explain in ``reasoning`` why no target should exist.
865+
866+ 3. In ``reasoning``, quote the task's ``file`` and ``target`` and state \
867+ which of the three fixes you chose and why (cite the entry you found, or \
868+ state that no entry exists). Set ``salience_signal: null`` for repair \
869+ proposals — they are integrity fixes, not salience judgments.
870+
871+ Do the link research with the SAME parallel-search discipline as for \
872+ dedup. A repair proposal that guesses the path without searching will \
873+ likely be vetoed.
874+
752875═══════════════════════════════════════════════════════════════════
753876HIERARCHY INVARIANTS (the Scholar will veto violations)
754877═══════════════════════════════════════════════════════════════════
@@ -907,24 +1030,50 @@ def load_prompt_template() -> str:
9071030 return _PROMPT_TEMPLATE
9081031
9091032
1033+ _NO_REPAIR_TASKS = "(none — no integrity-repair tasks this run)"
1034+
1035+
1036+ def format_repair_tasks (tasks : Sequence [repair_queue .RepairTask ]) -> str :
1037+ """Render drained integrity-repair tasks as the ``<repair_tasks>`` body.
1038+
1039+ One numbered block per task, listing kind/file/target/context so the
1040+ Librarian can research and repair each one. Returns a sentinel when
1041+ there are no tasks so the prompt block is never blank."""
1042+ if not tasks :
1043+ return _NO_REPAIR_TASKS
1044+ lines : List [str ] = []
1045+ for i , t in enumerate (tasks , start = 1 ):
1046+ lines .append (f"{ i } . kind: { t .kind } " )
1047+ lines .append (f" file: { t .file } " )
1048+ lines .append (f" target: { t .target } " )
1049+ if t .context :
1050+ lines .append (f" context: { t .context } " )
1051+ return "\n " .join (lines )
1052+
1053+
9101054def assemble_prompt (
9111055 * ,
9121056 project_slug : str ,
9131057 rolling_buffer : str ,
9141058 library_snapshot : str ,
9151059 applies_when_table : str ,
1060+ repair_tasks : str = _NO_REPAIR_TASKS ,
9161061) -> str :
9171062 """Substitute placeholders into the prompt template.
9181063
9191064 ACTION_TYPES and RESPONSE_SHAPE are generated from ``_schemas.py``
9201065 at call time so the prompt instructions can never drift from the
921- Pydantic models the parser actually validates against.
1066+ Pydantic models the parser actually validates against. ``repair_tasks``
1067+ is the pre-rendered ``<repair_tasks>`` body (see
1068+ :func:`format_repair_tasks`); it defaults to the empty sentinel so
1069+ callers that don't escalate anything need not pass it.
9221070 """
9231071 out = load_prompt_template ()
9241072 for needle , value in (
9251073 ("{{PROJECT_SLUG}}" , project_slug or "unknown" ),
9261074 ("{{ROLLING_BUFFER}}" , rolling_buffer or "(empty)" ),
9271075 ("{{LIBRARY_SNAPSHOT}}" , library_snapshot or "(empty)" ),
1076+ ("{{REPAIR_TASKS}}" , repair_tasks or _NO_REPAIR_TASKS ),
9281077 ("{{APPLIES_WHEN_TABLE}}" , applies_when_table or "(empty)" ),
9291078 ("{{ACTION_TYPES}}" , describe_action_types_markdown ()),
9301079 ("{{RESPONSE_SHAPE}}" , describe_librarian_response_shape ()),
@@ -989,10 +1138,19 @@ def _clean(items: Any) -> List[Dict[str, Any]]:
9891138
9901139def buffer_to_prompt_text (
9911140 snapshot : Mapping [str , Any ],
1141+ * ,
1142+ max_chars : int = ROLLING_BUFFER_MAX_CHARS ,
9921143) -> Tuple [str , List [Tuple [int , str , str , bool ]]]:
9931144 """Flatten a snapshot and return both the formatted block and the
994- raw 4-tuples (turn_id, role, text, user_asserted)."""
995- flat = flatten_buffer (snapshot )
1145+ raw 4-tuples (turn_id, role, text, user_asserted).
1146+
1147+ The flattened turns are capped to the most-recent ones that fit
1148+ within ``max_chars`` (see :func:`cap_buffer_to_recent`) before
1149+ formatting, so the rolling-buffer block — and therefore the
1150+ Librarian prompt — stays bounded. The returned tuple list is the
1151+ SAME capped window, so downstream consumers (seed text, turn count)
1152+ agree with what the model actually saw."""
1153+ flat = cap_buffer_to_recent (flatten_buffer (snapshot ), max_chars = max_chars )
9961154 return format_rolling_buffer (flat ), flat
9971155
9981156
0 commit comments