Skip to content

Commit 29a8039

Browse files
authored
Merge pull request #11 from nickroci/fix/librarian-recency-cap-and-wikilink-repair
fix(daemon): cap Librarian input + auto-repair broken wikilinks
2 parents 0d6b3f1 + 3a996bc commit 29a8039

12 files changed

Lines changed: 1797 additions & 20 deletions

daemon/agent_mem_daemon/librarian.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
import logging
2626
from typing import Any, Dict, List, TypedDict
2727

28+
from typing_extensions import NotRequired
29+
2830
from . import librarian_prompt as lp
29-
from . import llm, runs
31+
from . import llm, repair_queue, runs
3032
from .paths import ensure_home, knowledge_dir
3133

3234
log = logging.getLogger("agent_mem_daemon.librarian")
@@ -42,13 +44,23 @@ class EvidencePacket(TypedDict):
4244
schema (see ``_schemas.py``); items inside ``interrupts`` match
4345
``LibrarianInterrupt``.
4446
45-
All three keys are always set by ``_empty_packet`` — there is no
46-
valid packet missing any of them.
47+
``session_id`` / ``proposals`` / ``interrupts`` are always set by
48+
``_empty_packet`` — there is no valid packet missing any of them.
49+
50+
``repair_fingerprints`` is OPTIONAL: present only when this packet was
51+
produced by a Librarian run that drained integrity-repair tasks from
52+
``repair_queue``. It carries the ``(kind, file, target)`` fingerprints
53+
of the issues this run took ownership of, so the Scholar can RELEASE
54+
their in-flight markers once it concludes the review. It is attached to
55+
EVERY packet the run emits — including the error/empty fallback — so a
56+
drained task can never leak its marker (a Librarian that drained but
57+
then failed must still hand the fingerprints to the Scholar to clear).
4758
"""
4859

4960
session_id: str
5061
proposals: List[Dict[str, Any]]
5162
interrupts: List[Dict[str, Any]]
63+
repair_fingerprints: NotRequired[List[repair_queue.Fingerprint]]
5264

5365

5466
def _empty_packet(session_id: str) -> EvidencePacket:
@@ -64,9 +76,42 @@ def scan(buffer_snapshot: Dict[str, Any]) -> EvidencePacket:
6476
6577
Returns an EvidencePacket. Never raises — exceptions are logged and
6678
turned into an empty packet. The scheduler relies on this guarantee.
79+
80+
Integrity-repair escalation: before doing anything else, this drains
81+
any pending repair tasks from ``repair_queue`` and renders them into
82+
the prompt so the Librarian researches each broken target and proposes
83+
the right EXISTING fix. The drained fingerprints are attached to
84+
WHATEVER packet this run emits — success, empty, or error — so the
85+
Scholar can always release their in-flight markers; a drained task that
86+
fell on the floor would otherwise stay in-flight forever and block
87+
re-escalation.
6788
"""
6889
session_id = str(buffer_snapshot.get("session_id") or "?")
6990

91+
# Drain BEFORE the work so the fingerprints ride out on every return
92+
# path. Attaching them to the final packet is centralised at the bottom.
93+
repair_tasks = repair_queue.get_queue().drain_pending()
94+
fingerprints = repair_queue.fingerprints_of(repair_tasks)
95+
if repair_tasks:
96+
log.info(
97+
"librarian.scan: session=%s carrying %d integrity-repair task(s)",
98+
session_id,
99+
len(repair_tasks),
100+
)
101+
102+
packet = _scan_for_packet(buffer_snapshot, session_id, repair_tasks)
103+
if fingerprints:
104+
packet["repair_fingerprints"] = fingerprints
105+
return packet
106+
107+
108+
def _scan_for_packet(
109+
buffer_snapshot: Dict[str, Any],
110+
session_id: str,
111+
repair_tasks: List[repair_queue.RepairTask],
112+
) -> EvidencePacket:
113+
"""Run one Librarian pass and return the raw packet (no fingerprint
114+
attachment — :func:`scan` owns that so it happens on every path)."""
70115
record = runs.InvocationRecord(role="librarian", session_id=session_id)
71116
packet: EvidencePacket = _empty_packet(session_id)
72117

@@ -75,8 +120,10 @@ def scan(buffer_snapshot: Dict[str, Any]) -> EvidencePacket:
75120
formatted_buffer, flat = lp.buffer_to_prompt_text(buffer_snapshot)
76121
record.input_buffer_turns = len(buffer_snapshot.get("turns") or [])
77122

78-
# If the buffer has no quotable text at all, short-circuit.
79-
if not flat:
123+
# If the buffer has no quotable text AND there are no repair tasks,
124+
# short-circuit. When repair tasks are present we still invoke the
125+
# LLM — the escalated issue is itself the reason to run.
126+
if not flat and not repair_tasks:
80127
log.debug(
81128
"librarian.scan: session=%s has no quotable turns; skipping LLM call",
82129
session_id,
@@ -109,14 +156,16 @@ def scan(buffer_snapshot: Dict[str, Any]) -> EvidencePacket:
109156
rolling_buffer=formatted_buffer,
110157
library_snapshot=library_snapshot,
111158
applies_when_table=applies_when_table,
159+
repair_tasks=lp.format_repair_tasks(repair_tasks),
112160
)
113161
record.input_prompt = prompt
114162

115163
log.debug(
116-
"librarian.scan: session=%s bucket=%s turns=%d prompt_chars=%d",
164+
"librarian.scan: session=%s bucket=%s turns=%d repair_tasks=%d prompt_chars=%d",
117165
session_id,
118166
bucket,
119167
len(flat),
168+
len(repair_tasks),
120169
len(prompt),
121170
)
122171

daemon/agent_mem_daemon/librarian_prompt.py

Lines changed: 167 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
import yaml
4848
from aliases import session_bucket # type: ignore[import-not-found]
4949

50-
from . import _response_parser
50+
from . import _response_parser, repair_queue
5151
from ._schemas import (
5252
LibrarianProposal,
5353
describe_action_types_markdown,
@@ -64,6 +64,23 @@
6464
LIBRARY_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+
171246
def 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
═══════════════════════════════════════════════════════════════════
753876
HIERARCHY 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+
9101054
def 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

9901139
def 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

Comments
 (0)