zettelkasten.llm_adapter¶
zettelkasten.llm_adapter ¶
Shared LLM-adapter plumbing for the zettelkasten literature-review loop.
The "run a one-shot in-process dashboard agent and parse its streamed SSE reply" pattern was copy-pasted across the claim-producer, outline, auto-note, tables, and remine passes. This module holds the single source of truth for it:
- :func:
parse_sse— parse one Server-Sent-Event chunk into(event, data). - :func:
run_async— drive an awaitable to completion, tolerating a loop that is already running (re-raising worker-thread failures rather than swallowing them). - :func:
make_write_free_agent— construct the in-process zettelkasten dashboard agent with graph writes (and deletes) disabled. - :func:
run_agent_once— run a pre-built agent once and collect its final text. - :func:
run_write_free_agent— the convenience combination the callers use: build a write-free agent, run it once, return its text.
The dashboard/LLM stack is imported lazily inside :func:make_write_free_agent, so
importing this module never pulls it in — the review loop stays optional.
parse_sse ¶
Parse one Server-Sent-Event string into (event, data) (best-effort).
The whole per-line parse is wrapped in a broad guard that resets
(event, data) to ("", {}) on any failure. This mirrors the outer
try/except Exception the original propose/classify/summarize/extract
copies wrapped their parse in: a malformed or unexpected chunk (e.g. a
non-str chunk whose .splitlines() raises) is skipped rather than
propagated, so the enclosing async for keeps streaming instead of the
whole call failing. The inner json.loads guard is kept for a malformed
data: payload.
Source code in zettelkasten/llm_adapter.py
run_async ¶
Run an awaitable to completion, tolerating an already-running loop.
The review passes are synchronous but the dashboard agent is async. When no
loop is running we just :func:asyncio.run; when one IS (e.g. called from
inside a dashboard endpoint) we run the coroutine on a dedicated thread with
its own loop so we never re-enter the caller's loop.
The worker thread captures BOTH success and failure: a bare
box["value"] = asyncio.run(coro) silently drops any exception (the thread
dies, the box stays empty, and the caller reads None → an empty result
with no error). We re-raise it on the calling thread so a failure surfaces as
a real error instead of a phantom "it just produced nothing".
timeout is a BACKSTOP for the worker-thread path only: if the coroutine
does not finish within it we abandon the (daemon) worker and raise
:class:TimeoutError so the caller is never wedged forever. The real,
clean-teardown timeout lives inside the coroutine (see
:func:run_agent_once, which wraps its stream in asyncio.wait_for); this
is only the safety net for a coroutine that ignores cancellation. Pass a
value slightly LARGER than the inner wait_for so the inner one fires
first and the bridge is disposed cleanly.
Source code in zettelkasten/llm_adapter.py
make_write_free_agent ¶
make_write_free_agent(name: str, system: str, *, disable_write: bool = True, graphs_dir: 'str | Path | None' = None, model: str = '')
Construct the in-process zettelkasten dashboard agent, writes disabled.
Lazy/optional by construction — the dashboard/LLM stack is imported INSIDE this function, so importing this module never requires it.
graphs_dir is the store root exported to the agent's zettelkasten MCP as
ZETTELKASTEN_PATH. Each caller passes ITS OWN module's GRAPHS_DIR
binding so a per-module monkeypatch.setattr(<caller>, "GRAPHS_DIR", tmp)
is honored (the original inline agent-launch blocks read the caller module's
binding, not this adapter's). It defaults to
:data:zettelkasten.graph.GRAPHS_DIR only as a fallback when a caller omits
it.
The agent's zettelkasten MCP is launched with ZK_DISABLE_DELETE=1 always,
and additionally ZK_DISABLE_WRITE=1 when disable_write (the default).
ZK_DISABLE_WRITE drops every graph-mutating tool (it implies
ZK_DISABLE_DELETE); the delete flag is kept explicit for clarity and for
older server builds that predate the write flag. A pass that hands the agent
all its material in-prompt (it only returns JSON/prose) wants the write-free
default; the DRAFT path keeps deletes disabled but leaves writes available.
A non-empty model is forwarded as the requested Cursor model id.
Empty keeps :class:DashboardAgent's configured default.
Source code in zettelkasten/llm_adapter.py
make_toolless_agent ¶
Construct the in-process dashboard agent with NO MCP servers attached.
A HARD grounding guarantee for pure synthesis passes: with an empty
mcp_servers the agent has no tools whatsoever, so it CANNOT read the graph
(or anything else) and can only work from the material handed to it in the
prompt. Contrast :func:make_write_free_agent, which still attaches a
read-only zettelkasten MCP (writes disabled, but reads available). Use this
when the prompt already carries all the evidence and any extra retrieval would
be a grounding leak. A non-empty model is forwarded as the requested
Cursor model id; empty keeps :class:DashboardAgent's configured default.
Lazy/optional by construction — the dashboard/LLM stack is imported INSIDE
this function, so importing this module never requires it.
Source code in zettelkasten/llm_adapter.py
run_agent_once ¶
run_agent_once(agent, prompt: str, *, error_message: str = 'agent error', timeout: float | None = None) -> str
Run a pre-built dashboard agent once and return its final text.
Streams the agent's SSE reply, preferring the final result text and
falling back to the assembled assistant deltas. An error event is
turned into a :class:RuntimeError (carrying error_message when the event
omits its own message) so the caller/route can surface it. The agent is always
disposed. Blocks via :func:run_async.
timeout (seconds) bounds the whole stream: when set, the streaming is
wrapped in :func:asyncio.wait_for, so a bridge that launches but never
returns a completion (the failure mode when the cursor-sdk agent runtime does
not come up — e.g. inside a stdio MCP server) raises :class:TimeoutError
instead of hanging indefinitely. On timeout the agent is still disposed (the
finally), tearing the bridge down so a caller degrades gracefully rather
than wedging. None (the default) preserves the historical unbounded
behavior for callers that block on their own loop (e.g. the dashboard).
Source code in zettelkasten/llm_adapter.py
run_write_free_agent ¶
run_write_free_agent(name: str, system: str, prompt: str, *, disable_write: bool = True, error_message: str = 'agent error', graphs_dir: 'str | Path | None' = None, timeout: float | None = None, model: str = '') -> str
Build a write-free in-process agent, run it once, and return its text.
The convenience combination of :func:make_write_free_agent and
:func:run_agent_once that the review passes call. disable_write,
error_message, graphs_dir and timeout are forwarded so each call
site keeps its exact launch discipline, error string, per-module
GRAPHS_DIR monkeypatch surface, requested model, and (optional)
per-call timeout.
Source code in zettelkasten/llm_adapter.py
run_toolless_agent ¶
run_toolless_agent(name: str, system: str, prompt: str, *, error_message: str = 'agent error', timeout: float | None = None, model: str = '') -> str
Build a TOOL-FREE in-process agent, run it once, and return its text.
The hard-grounding companion to :func:run_write_free_agent: the agent has no
MCP servers, so it works only from prompt. For synthesis passes that must
not touch the graph at all. model is the requested Cursor model id and is
forwarded to :class:DashboardAgent; timeout (seconds) bounds the call
so a bridge that never returns a completion degrades to a raised
:class:TimeoutError instead of hanging.