llm.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. """llama.cpp (OpenAI-compatible) client and the chat session.
  2. Reasoning models return a separate `reasoning_content` field, which is ignored
  3. by field name — never by scraping it out of `content`.
  4. """
  5. import json
  6. import logging
  7. import threading
  8. import urllib.error
  9. import urllib.request
  10. from dataclasses import dataclass, field
  11. from typing import Any, Callable
  12. logger = logging.getLogger(__name__)
  13. class LlmError(RuntimeError):
  14. """Raised when the LLM endpoint cannot be reached or returns garbage."""
  15. @dataclass
  16. class ToolCall:
  17. """One tool call requested by the model."""
  18. id: str
  19. name: str
  20. arguments: str
  21. def message(self) -> dict:
  22. return {
  23. "id": self.id,
  24. "type": "function",
  25. "function": {"name": self.name, "arguments": self.arguments},
  26. }
  27. @dataclass
  28. class LlmResponse:
  29. """Parsed completion."""
  30. content: str
  31. tool_calls: list[ToolCall] = field(default_factory=list)
  32. finish_reason: str = "stop"
  33. def _default_opener(payload: dict, timeout: float) -> dict:
  34. request = urllib.request.Request(
  35. payload["__url__"],
  36. data=json.dumps(payload["body"]).encode("utf-8"),
  37. headers={"Content-Type": "application/json"},
  38. )
  39. with urllib.request.urlopen(request, timeout=timeout) as response:
  40. return json.loads(response.read().decode("utf-8"))
  41. def _safe_io(cb, event) -> None:
  42. try:
  43. cb(event)
  44. except Exception as e: # noqa: BLE001 - a dump hook must not break chat
  45. logger.warning("on_io callback failed: %s", e)
  46. class LlmClient:
  47. """Minimal OpenAI-compatible chat client (stdlib only)."""
  48. def __init__(self, cfg, opener: Callable[[dict, float], dict] | None = None):
  49. """Create the client.
  50. Args:
  51. cfg: An `LlmConfig`.
  52. opener: Transport override for tests; receives
  53. `{"__url__":..., "body":...}` and a timeout, returns the parsed JSON.
  54. """
  55. self.cfg = cfg
  56. self._opener = opener or _default_opener
  57. def chat(
  58. self,
  59. messages: list[dict],
  60. tools: list[dict] | None = None,
  61. max_tokens: int | None = None,
  62. timeout_s: float | None = None,
  63. on_io: Callable[[dict], None] | None = None,
  64. *,
  65. slot_id: int | None = None,
  66. ) -> LlmResponse:
  67. """Send one completion request and parse the reply.
  68. Args:
  69. messages: The conversation messages to send.
  70. tools: Optional tool schemas.
  71. max_tokens: Per-request output cap; uses cfg.max_tokens when None.
  72. timeout_s: Per-request timeout; uses cfg.timeout_s when None.
  73. on_io: Optional hook receiving `{"phase": "request", ...}` before
  74. the call and `{"phase": "response", ...}` after it.
  75. slot_id: Optional llama.cpp slot id (`id_slot`); omitted when None.
  76. """
  77. effective_max_tokens = self.cfg.max_tokens if max_tokens is None else max_tokens
  78. effective_timeout = self.cfg.timeout_s if timeout_s is None else timeout_s
  79. body: dict[str, Any] = {
  80. "messages": messages,
  81. "temperature": self.cfg.temperature,
  82. "max_tokens": effective_max_tokens,
  83. "cache_prompt": self.cfg.cache_prompt,
  84. "stream": False,
  85. }
  86. if slot_id is not None:
  87. body["id_slot"] = slot_id
  88. if tools:
  89. body["tools"] = tools
  90. body["tool_choice"] = "auto"
  91. if on_io is not None:
  92. _safe_io(
  93. on_io, {"phase": "request", "url": self.cfg.endpoint, "body": body}
  94. )
  95. try:
  96. payload = self._opener(
  97. {"__url__": self.cfg.endpoint, "body": body}, effective_timeout
  98. )
  99. except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
  100. if on_io is not None:
  101. _safe_io(on_io, {"phase": "response", "payload": None, "error": str(e)})
  102. raise LlmError(str(e)) from e
  103. if on_io is not None:
  104. _safe_io(on_io, {"phase": "response", "payload": payload, "error": None})
  105. choices = payload.get("choices") or []
  106. if not choices:
  107. raise LlmError("response contained no choices")
  108. choice = choices[0]
  109. message = choice.get("message") or {}
  110. # `reasoning_content` is deliberately never read.
  111. content = (message.get("content") or "").strip()
  112. tool_calls = []
  113. for call in message.get("tool_calls") or []:
  114. function = call.get("function") or {}
  115. tool_calls.append(
  116. ToolCall(
  117. id=call.get("id") or "",
  118. name=function.get("name") or "",
  119. arguments=function.get("arguments") or "",
  120. )
  121. )
  122. return LlmResponse(
  123. content=content,
  124. tool_calls=tool_calls,
  125. finish_reason=choice.get("finish_reason") or "stop",
  126. )
  127. class ConversationEngine:
  128. """Conversation history with rolling-summary compaction and block-trim fallback.
  129. State:
  130. summary — compressed text of evicted turns (empty string when none).
  131. history — verbatim recent window (append-only between compactions).
  132. Prompt assembly:
  133. [system: system_prompt]
  134. [system: "Earlier conversation summary:\\n{summary}"] # only when summary is set
  135. *history
  136. Lock discipline: self._lock (RLock) guards history/summary mutations and reads.
  137. It is NEVER held across an HTTP call or a finalize callback.
  138. """
  139. def __init__(
  140. self,
  141. cfg,
  142. client,
  143. system_prompt: str,
  144. compaction_system_prompt: str | None = None,
  145. max_tokens: int | None = None,
  146. timeout_s: float | None = None,
  147. notify: Callable[..., None] | None = None,
  148. slot_id: int | None = None,
  149. ):
  150. self.cfg = cfg
  151. self.client = client
  152. self.system = system_prompt
  153. self._compaction_system_prompt = compaction_system_prompt
  154. self._max_tokens = max_tokens
  155. self._timeout_s = timeout_s
  156. self.notify = notify
  157. self._slot_id = slot_id
  158. self.history: list[dict] = []
  159. self.summary: str = ""
  160. self._generation = 0
  161. self._lock = threading.RLock()
  162. def reset(self) -> None:
  163. """Forget the conversation (volatile by design)."""
  164. with self._lock:
  165. self.history = []
  166. self.summary = ""
  167. self._generation += 1
  168. def last_reply(self) -> str:
  169. """Return the most recent assistant turn, or "" when there is none."""
  170. with self._lock:
  171. for msg in reversed(self.history):
  172. if msg["role"] == "assistant":
  173. return msg["content"]
  174. return ""
  175. def send(
  176. self,
  177. user_text: str,
  178. on_io: Callable[[dict], None] | None = None,
  179. finalize: Callable[[str, str], str | None] | None = None,
  180. ) -> str:
  181. """Append a user turn, ask the model, append and return the reply.
  182. Args:
  183. user_text: The user turn to append.
  184. on_io: Optional per-call IO hook, forwarded to the client only when set.
  185. finalize: Optional gate called as `finalize(reply, finish_reason)` after
  186. the staleness check. Returning None rejects the turn: history and
  187. summary are restored from the pre-call snapshot and "" is returned.
  188. Returning a string appends THAT string as the assistant turn.
  189. """
  190. with self._lock:
  191. generation = self._generation
  192. snapshot_history = list(self.history)
  193. snapshot_summary = self.summary
  194. self.history.append({"role": "user", "content": user_text})
  195. self._maybe_compact()
  196. with self._lock:
  197. messages = [{"role": "system", "content": self.system}]
  198. if self.summary:
  199. messages.append(
  200. {"role": "system", "content": f"Earlier conversation summary:\n{self.summary}"}
  201. )
  202. messages += self.history
  203. # HTTP call — no lock held. Optional kwargs are included only when set so
  204. # that old fakes (which accept neither) keep working.
  205. chat_kwargs: dict[str, Any] = {}
  206. if on_io is not None:
  207. chat_kwargs["on_io"] = on_io
  208. if self._slot_id is not None:
  209. chat_kwargs["slot_id"] = self._slot_id
  210. response = self.client.chat(
  211. messages,
  212. max_tokens=self._max_tokens,
  213. timeout_s=self._timeout_s,
  214. **chat_kwargs,
  215. )
  216. reply = response.content
  217. finish_reason = response.finish_reason
  218. truncated = finish_reason == "length"
  219. with self._lock:
  220. if generation != self._generation:
  221. # The session was reset while the model was thinking: the reply belongs
  222. # to a conversation that no longer exists, so drop it.
  223. return ""
  224. # Finalize callback — no lock held, so a reset() inside it is not undone.
  225. if finalize is not None:
  226. final = finalize(reply, finish_reason)
  227. with self._lock:
  228. if generation != self._generation:
  229. # A reset() intervened (possibly inside finalize itself); the session
  230. # is already clean — restoring would undo the reset.
  231. return ""
  232. if final is None:
  233. self.history = snapshot_history
  234. self.summary = snapshot_summary
  235. return ""
  236. if final is not None:
  237. self.history.append({"role": "assistant", "content": final})
  238. return final
  239. with self._lock:
  240. if generation != self._generation:
  241. return ""
  242. if reply:
  243. self.history.append({"role": "assistant", "content": reply})
  244. if truncated and self.notify is not None:
  245. self.notify("truncated", message="response truncated")
  246. return reply
  247. def _estimate_tokens(self) -> int:
  248. # Approximate: 4 chars ≈ 1 token (good enough for budget gating).
  249. characters = (
  250. len(self.system)
  251. + len(self.summary)
  252. + sum(len(m["content"]) for m in self.history)
  253. )
  254. return characters // 4
  255. def _trim(self) -> None:
  256. block = max(1, self.cfg.trim_block_turns) * 2
  257. while self._estimate_tokens() > self.cfg.prompt_budget and self.history:
  258. del self.history[: min(block, len(self.history))]
  259. def _maybe_compact(self) -> None:
  260. """Compact history when the prompt approaches the budget.
  261. Compaction path (compaction_enabled=True):
  262. 1. Return early if still under the trigger threshold.
  263. 2. If nothing to evict (window alone is over budget), fall back to _trim().
  264. 3. Snapshot evicted/recent under the lock, release, call the summarizer.
  265. 4. Re-acquire, re-check generation; apply or fall back to _trim().
  266. Legacy path (compaction_enabled=False):
  267. Trim under the lock (history stays bounded).
  268. """
  269. cfg = self.cfg
  270. if not cfg.compaction_enabled:
  271. with self._lock:
  272. self._trim()
  273. return
  274. with self._lock:
  275. if self._estimate_tokens() <= cfg.compaction_trigger_ratio * cfg.prompt_budget:
  276. return
  277. keep = max(0, cfg.compaction_keep_turns) * 2
  278. if len(self.history) <= keep:
  279. # The recent window alone is over budget; no turns to evict.
  280. # Fall back to block trimming (Oracle R5).
  281. self._trim()
  282. return
  283. evicted = list(self.history[:-keep])
  284. recent = list(self.history[-keep:])
  285. generation = self._generation
  286. previous = self.summary
  287. # Summarizer HTTP call — no lock held.
  288. new_summary = self._summarize(previous, evicted)
  289. with self._lock:
  290. if generation != self._generation:
  291. # A reset() intervened; the session is already clean.
  292. return
  293. if new_summary is None:
  294. # Summarizer failed; fall back to block trimming (Oracle R7/R8).
  295. self._trim()
  296. return
  297. self.summary = new_summary
  298. self.history = recent
  299. # Notify outside the lock to avoid re-entry issues.
  300. if self.notify is not None:
  301. self.notify("compacted", turns=len(evicted))
  302. def _summarize(self, previous: str, evicted: list[dict]) -> str | None:
  303. """Summarize evicted turns, optionally folding over a previous summary.
  304. Bounded input: if the combined text exceeds compaction_input_tokens * 4
  305. characters, split evicted into chronological chunks and fold sequentially.
  306. Returns the summary string, or None on any failure.
  307. """
  308. cfg = self.cfg
  309. char_limit = cfg.compaction_input_tokens * 4
  310. # Build chunks that each fit within the character limit.
  311. chunks: list[list[dict]] = []
  312. current_chunk: list[dict] = []
  313. current_chars = 0
  314. for msg in evicted:
  315. msg_chars = len(msg["content"])
  316. if current_chunk and current_chars + msg_chars > char_limit:
  317. chunks.append(current_chunk)
  318. current_chunk = [msg]
  319. current_chars = msg_chars
  320. else:
  321. current_chunk.append(msg)
  322. current_chars += msg_chars
  323. if current_chunk:
  324. chunks.append(current_chunk)
  325. # Fold sequentially: s = summarize_once(s, chunk) for each chunk.
  326. running = previous
  327. for chunk in chunks:
  328. result = self._summarize_once(running, chunk)
  329. if result is None:
  330. return None
  331. running = result
  332. return running if running != previous else None
  333. def _summarize_once(self, previous: str, chunk: list[dict]) -> str | None:
  334. """Call the LLM to summarize one chunk of evicted turns.
  335. Returns the summary string, or None on failure (LlmError, empty content,
  336. or finish_reason == "length" — Oracle R8).
  337. """
  338. cfg = self.cfg
  339. lines = []
  340. if previous:
  341. lines.append(f"Previous summary:\n{previous}\n")
  342. lines.append("Conversation to summarize:")
  343. for msg in chunk:
  344. lines.append(f"{msg['role']}: {msg['content']}")
  345. user_text = "\n".join(lines)
  346. compaction_prompt = self._compaction_system_prompt or cfg.compaction_system_prompt
  347. messages = [
  348. {"role": "system", "content": compaction_prompt},
  349. {"role": "user", "content": user_text},
  350. ]
  351. try:
  352. # Never pin the summarizer to a slot: compaction must not evict the
  353. # engine's own conversation slot.
  354. response = self.client.chat(
  355. messages,
  356. max_tokens=cfg.compaction_max_tokens,
  357. timeout_s=cfg.compaction_timeout_s,
  358. )
  359. except LlmError:
  360. logger.warning("Summarizer LLM call failed; falling back to block trim")
  361. return None
  362. if not response.content or response.finish_reason == "length":
  363. logger.warning(
  364. "Summarizer returned unusable reply (empty=%s, finish_reason=%r); "
  365. "falling back to block trim",
  366. not response.content,
  367. response.finish_reason,
  368. )
  369. return None
  370. return response.content
  371. class ChatSession(ConversationEngine):
  372. """Back-compat chat session: a ConversationEngine on the chat system prompt."""
  373. def __init__(self, cfg, client, notify: Callable[..., None] | None = None):
  374. super().__init__(cfg, client, system_prompt=cfg.system_prompt_chat, notify=notify)
  375. class BriefEngine:
  376. """Stateless-per-call brief engine that pins the last accepted brief.
  377. Unlike ``ConversationEngine`` this never accumulates history: each call
  378. sends the same static system prompt plus ONE user turn carrying the current
  379. brief inline (built by the analyzer). The previous brief is therefore the
  380. single source of truth, the prompt stays flat (~2-3k tokens) instead of
  381. growing linearly, the model never competes with stale copies of its own
  382. output, and there is nothing for compaction to evict or mangle.
  383. Lock discipline: ``self._lock`` guards the pinned brief and generation and
  384. is never held across the HTTP call or the finalize callback.
  385. """
  386. def __init__(
  387. self,
  388. cfg,
  389. client,
  390. system_prompt: str,
  391. max_tokens: int | None = None,
  392. timeout_s: float | None = None,
  393. slot_id: int | None = None,
  394. notify: Callable[..., None] | None = None,
  395. ):
  396. self.cfg = cfg
  397. self.client = client
  398. self.system = system_prompt
  399. self._max_tokens = max_tokens
  400. self._timeout_s = timeout_s
  401. self._slot_id = slot_id
  402. self.notify = notify
  403. self._brief = ""
  404. self._generation = 0
  405. self._lock = threading.RLock()
  406. def reset(self) -> None:
  407. """Invalidate any in-flight reply (the service owns the actual brief state)."""
  408. with self._lock:
  409. self._generation += 1
  410. def send(
  411. self,
  412. user_text: str,
  413. on_io: Callable[[dict], None] | None = None,
  414. finalize: Callable[[str, str], str | None] | None = None,
  415. ) -> str:
  416. """Ask the model to update the brief from one user turn.
  417. The engine is stateless with respect to brief content: the caller owns
  418. both the volatile narrative and the code-owned ledger, and passes them in
  419. via ``user_text``. ``finalize`` behaves as on ``ConversationEngine.send``:
  420. returning None rejects the turn; returning a string returns it as the
  421. accepted reply. A truncated (``finish_reason == "length"``) reply is still
  422. returned so the caller's parser can salvage it, but the caller must not
  423. treat a missing section as an empty fact list for a truncated reply.
  424. """
  425. with self._lock:
  426. generation = self._generation
  427. messages = [
  428. {"role": "system", "content": self.system},
  429. {"role": "user", "content": user_text},
  430. ]
  431. chat_kwargs: dict[str, Any] = {}
  432. if on_io is not None:
  433. chat_kwargs["on_io"] = on_io
  434. if self._slot_id is not None:
  435. chat_kwargs["slot_id"] = self._slot_id
  436. response = self.client.chat(
  437. messages,
  438. max_tokens=self._max_tokens,
  439. timeout_s=self._timeout_s,
  440. **chat_kwargs,
  441. )
  442. reply = response.content
  443. finish_reason = response.finish_reason
  444. with self._lock:
  445. if generation != self._generation:
  446. return ""
  447. if finalize is not None:
  448. final = finalize(reply, finish_reason)
  449. with self._lock:
  450. if generation != self._generation:
  451. return ""
  452. return final or ""
  453. if finish_reason == "length" and self.notify is not None:
  454. self.notify("truncated", message="response truncated")
  455. return reply