Skip to content

Commit 5ed0923

Browse files
authored
Merge pull request #14 from nickroci/feat/scholar-pydantic-ai
feat(scholar): migrate curator to Pydantic AI with typed structured output
2 parents 238e7a0 + d7f28ec commit 5ed0923

35 files changed

Lines changed: 4243 additions & 3122 deletions
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""Shared Pydantic-AI building blocks for the two curator agents.
2+
3+
The Librarian and the Scholar are both Pydantic AI ``Agent``s with the
4+
SAME read-only research surface — read an entry, grep the library, BM25 +
5+
embedding search — and the same run/cost plumbing. That code lives here,
6+
ONCE, so the two agent modules stay focused on what differs (their
7+
``output_type`` and their boundary ``output_validator``) and the
8+
duplicate-code gate has nothing to flag.
9+
10+
What this module owns:
11+
12+
- ``ResearchDeps`` — the dependency object every research tool reads
13+
(just the pinned ``knowledge_dir``). Both agents' deps types are this
14+
type, so the registered tools are deps-compatible across agents.
15+
- ``register_research_tools(agent)`` — wires the four read-only
16+
``@agent.tool``s onto an agent built with ``deps_type=ResearchDeps``.
17+
- the pure tool helpers (``inside`` / ``grep_library`` / ``search_text``)
18+
so they can be unit-tested directly without a model in the loop.
19+
- ``estimate_cost`` (best-effort USD from token usage) and
20+
``run_agent_to_output`` (the ``asyncio.run`` + wall-clock-timeout
21+
wrapper that both ``run_*_agent`` functions delegate to).
22+
23+
It imports only ``pydantic_ai`` + the daemon's ``library_tools`` (and,
24+
lazily, ``genai_prices``) so either agent module can import it without a
25+
cycle.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import asyncio
31+
import logging
32+
from dataclasses import dataclass
33+
from pathlib import Path
34+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Tuple, TypeVar
35+
36+
from pydantic_ai import RunContext
37+
38+
from . import library_tools
39+
from .llm import LLMTimeout
40+
41+
if TYPE_CHECKING:
42+
from pydantic_ai import Agent
43+
44+
log = logging.getLogger("agent_mem_daemon._agent_common")
45+
46+
_OutputT = TypeVar("_OutputT")
47+
48+
49+
@dataclass
50+
class ResearchDeps:
51+
"""Dependencies handed to the shared read-only research tools.
52+
53+
``knowledge_dir`` is the pinned knowledge store root; the tools resolve
54+
every model-supplied path against it (no path injection). Each agent's
55+
own ``deps_type`` is this class (or a trivial subclass), so the tools
56+
registered by :func:`register_research_tools` work for both.
57+
"""
58+
59+
knowledge_dir: Path
60+
61+
62+
# ── Pure tool helpers (module-level, easy to unit-test) ──────────────────
63+
64+
65+
def inside(root: Path, candidate: Path) -> bool:
66+
"""True iff ``candidate`` resolves inside ``root`` (no path escape)."""
67+
try:
68+
candidate.relative_to(root)
69+
except ValueError:
70+
return False
71+
return True
72+
73+
74+
def grep_library(knowledge_dir: Path, pattern: str, path: str) -> str:
75+
"""Case-insensitive literal-substring search over the library, optionally
76+
scoped to a subdirectory. Returns up to 40 ``<rel>:<line-no>: <line>``
77+
matches, a truncation note past that, or a sentinel. Read-only,
78+
``_archive`` excluded."""
79+
root = knowledge_dir.resolve()
80+
if not pattern.strip():
81+
return "(grep_library: empty pattern)"
82+
scope = (root / path).resolve() if path else root
83+
if not inside(root, scope) or not scope.exists():
84+
return f"(grep_library: {path!r} not found under the knowledge store)"
85+
needle = pattern.lower()
86+
search_root = scope if scope.is_dir() else scope.parent
87+
out: List[str] = []
88+
for md in sorted(search_root.rglob("*.md")):
89+
if "_archive" in md.parts:
90+
continue
91+
try:
92+
text = md.read_text(encoding="utf-8")
93+
except OSError:
94+
continue
95+
for i, line in enumerate(text.splitlines(), start=1):
96+
if needle in line.lower():
97+
rel = md.relative_to(root).as_posix()
98+
out.append(f"{rel}:{i}: {line.strip()}")
99+
if len(out) >= 40:
100+
return "\n".join(out) + "\n(truncated at 40 matches)"
101+
return "\n".join(out) if out else f"(no matches for {pattern!r})"
102+
103+
104+
def search_text(
105+
runner: Callable[[Dict[str, Any], Path], Dict[str, Any]],
106+
knowledge_dir: Path,
107+
query: str,
108+
k: int,
109+
) -> str:
110+
"""Call a library search runner and unwrap its MCP-shaped response into
111+
the plain text the agent reads."""
112+
response = runner({"query": query, "k": k}, knowledge_dir.resolve())
113+
text = library_tools.unwrap_text_response(response)
114+
return text or "(search returned no content)"
115+
116+
117+
# ── Read-only research tools (registered onto each agent) ────────────────
118+
119+
120+
def register_research_tools(agent: "Agent[ResearchDeps, Any]") -> None:
121+
"""Register the four read-only research ``@agent.tool``s onto ``agent``.
122+
123+
``agent`` must have been built with ``deps_type=ResearchDeps`` (or a
124+
subclass). The tools are READ-ONLY: there is no file-writing tool — the
125+
daemon's deterministic executor is the only writer. Called once at
126+
module import time by each agent module."""
127+
128+
@agent.tool(name="read_entry")
129+
def read_entry(ctx: RunContext[ResearchDeps], path: str) -> str:
130+
"""Read a knowledge file by its path relative to the knowledge root
131+
(e.g. ``index.md`` or ``global/python/use-uv.md``). Returns the file
132+
contents, or a ``(not found ...)`` sentinel. Read-only."""
133+
root = ctx.deps.knowledge_dir.resolve()
134+
target = (root / path).resolve()
135+
if not inside(root, target):
136+
return f"(path {path!r} resolves outside the knowledge store — refused)"
137+
try:
138+
return target.read_text(encoding="utf-8")
139+
except FileNotFoundError:
140+
return f"(not found: {path})"
141+
except OSError as e:
142+
return f"(could not read {path}: {e})"
143+
144+
@agent.tool(name="grep_library")
145+
def grep_library_tool(ctx: RunContext[ResearchDeps], pattern: str, path: str = "") -> str:
146+
"""Search the knowledge library for a literal substring (case-
147+
insensitive), optionally scoped to a subdirectory ``path``. Returns
148+
up to 40 ``<rel-path>:<line-no>: <line>`` matches. Read-only."""
149+
return grep_library(ctx.deps.knowledge_dir, pattern, path)
150+
151+
@agent.tool(name="bm25_search")
152+
def bm25_search(ctx: RunContext[ResearchDeps], query: str, k: int = 6) -> str:
153+
"""Lexical (BM25) search over the library. Returns the top-K entries
154+
as ``<path> score=<float> <snippet>`` lines. Complements
155+
``embedding_search``. Read-only."""
156+
return search_text(library_tools.run_bm25_search, ctx.deps.knowledge_dir, query, k)
157+
158+
@agent.tool(name="embedding_search")
159+
def embedding_search(ctx: RunContext[ResearchDeps], query: str, k: int = 6) -> str:
160+
"""Semantic (embedding) search over the library. Returns the top-K
161+
entries as ``<path> score=<float> <snippet>`` lines. Complements
162+
``bm25_search`` — run both for any concept query. Read-only."""
163+
return search_text(library_tools.run_embedding_search, ctx.deps.knowledge_dir, query, k)
164+
165+
# Reference the closures so linters don't flag them as unused — the
166+
# decorator already registered each on the agent.
167+
_ = (read_entry, grep_library_tool, bm25_search, embedding_search)
168+
169+
170+
# ── Cost + run wrapper ───────────────────────────────────────────────────
171+
172+
173+
def estimate_cost(model_ref: str, usage: object) -> float:
174+
"""Best-effort USD cost for a run from token usage via ``genai_prices``.
175+
Returns 0.0 if pricing is unavailable — cost is telemetry, never on the
176+
critical path."""
177+
try:
178+
import genai_prices # noqa: PLC0415 — optional, best-effort
179+
180+
bare = model_ref.split(":", 1)[1] if ":" in model_ref else model_ref
181+
calc = genai_prices.calc_price(usage, bare, provider_id="anthropic") # type: ignore[arg-type]
182+
return float(calc.total_price)
183+
except Exception: # noqa: BLE001 — pricing must never break the pipeline
184+
log.debug("agent cost estimation unavailable", exc_info=True)
185+
return 0.0
186+
187+
188+
def run_agent_to_output(
189+
agent: "Agent[ResearchDeps, _OutputT]",
190+
prompt: str,
191+
deps: ResearchDeps,
192+
*,
193+
model_ref: str,
194+
timeout_s: float,
195+
role: str,
196+
) -> Tuple[_OutputT, float]:
197+
"""Run ``agent`` on ``prompt`` and return ``(validated_output, cost_usd)``.
198+
199+
Wraps the agent run in ``asyncio.run`` with a wall-clock budget; raises
200+
:class:`LLMTimeout` (which the daemon already handles) when exceeded, and
201+
propagates any other agent/model error to the caller. ``role`` only
202+
flavours the timeout message. Pydantic AI has already run the per-output
203+
validators and the agent's ``output_validator`` (re-prompting on any
204+
``ModelRetry`` up to the agent's output-retry budget) before this
205+
returns."""
206+
207+
async def _run() -> Tuple[_OutputT, float]:
208+
try:
209+
result = await asyncio.wait_for(
210+
agent.run(prompt, deps=deps, model_settings={"timeout": timeout_s}),
211+
timeout=timeout_s,
212+
)
213+
except asyncio.TimeoutError as e:
214+
raise LLMTimeout(f"{role} agent exceeded {timeout_s}s") from e
215+
return result.output, estimate_cost(model_ref, result.usage)
216+
217+
return asyncio.run(_run())

0 commit comments

Comments
 (0)