Skip to content

Commit 3a996bc

Browse files
authored
Merge pull request #12 from nickroci/feat/escalate-unrepairable-wikilinks
feat(curator): escalate unrepairable wikilinks into the Librarian pipeline
2 parents b6a6a93 + 4171394 commit 3a996bc

12 files changed

Lines changed: 1222 additions & 37 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: 78 additions & 2 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,
@@ -822,6 +822,56 @@ def _slugify(s: str) -> str:
822822
above. Use Glob (e.g. `Glob("**/*.md")`) to find anything you suspect \
823823
exists but don't see in the snapshot.
824824
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+
825875
═══════════════════════════════════════════════════════════════════
826876
HIERARCHY INVARIANTS (the Scholar will veto violations)
827877
═══════════════════════════════════════════════════════════════════
@@ -980,24 +1030,50 @@ def load_prompt_template() -> str:
9801030
return _PROMPT_TEMPLATE
9811031

9821032

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+
9831054
def assemble_prompt(
9841055
*,
9851056
project_slug: str,
9861057
rolling_buffer: str,
9871058
library_snapshot: str,
9881059
applies_when_table: str,
1060+
repair_tasks: str = _NO_REPAIR_TASKS,
9891061
) -> str:
9901062
"""Substitute placeholders into the prompt template.
9911063
9921064
ACTION_TYPES and RESPONSE_SHAPE are generated from ``_schemas.py``
9931065
at call time so the prompt instructions can never drift from the
994-
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.
9951070
"""
9961071
out = load_prompt_template()
9971072
for needle, value in (
9981073
("{{PROJECT_SLUG}}", project_slug or "unknown"),
9991074
("{{ROLLING_BUFFER}}", rolling_buffer or "(empty)"),
10001075
("{{LIBRARY_SNAPSHOT}}", library_snapshot or "(empty)"),
1076+
("{{REPAIR_TASKS}}", repair_tasks or _NO_REPAIR_TASKS),
10011077
("{{APPLIES_WHEN_TABLE}}", applies_when_table or "(empty)"),
10021078
("{{ACTION_TYPES}}", describe_action_types_markdown()),
10031079
("{{RESPONSE_SHAPE}}", describe_librarian_response_shape()),

0 commit comments

Comments
 (0)