Source code for factor_agent.llm.log

"""The LLM log: every call of a run, in full, one JSON file per call under ``llm_log/``.

A file ``<number>-<role>.json`` holds the request (role, model, prompt and
the parameters sent) and the reply (text, usage, the model that
answered and the provider's raw reply); the numbers count the calls of the
run from 1, across every role, in the order they finish (calls sent side by
side are written one at a time); the calls of a round a resume dropped are
kept in ``dropped/``, out of the sequence. No key is ever part of a request
or a reply, so none is written.
"""

import json
import threading
from dataclasses import asdict
from pathlib import Path

from factor_agent.llm.base import LLMBackend, Reply, Request


[docs] class LLMLog: """The call files of one run directory's ``llm_log``.""" def __init__(self, path: str | Path): self.path = Path(path) self._writing = threading.Lock()
[docs] def files(self) -> list[Path]: """The call files, in call order.""" return sorted(self.path.glob("*.json"))
[docs] def drop_after(self, count: int) -> None: """Move every call after the first ``count`` to ``dropped/``: calls of a round a crash cut off.""" for file in self.files()[count:]: (self.path / "dropped").mkdir(exist_ok=True) file.replace(self.path / "dropped" / file.name)
[docs] def calls(self) -> list[dict]: """Every recorded call, in call order, as ``{"request": ..., "reply": ...}``.""" return [json.loads(file.read_text()) for file in self.files()]
[docs] def write(self, request: Request, reply: Reply) -> Path: """Write the next call file and return its path; the file appears whole or not at all. Safe to call from several threads: one call is written at a time, each under the next number. """ call = { "request": {**asdict(request), "params": {**request.params, **reply.params}}, "reply": {k: v for k, v in asdict(reply).items() if k != "params"}, } with self._writing: self.path.mkdir(parents=True, exist_ok=True) number = len(self.files()) + 1 path = self.path / f"{number:06d}-{request.role}.json" tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(call, indent=2, ensure_ascii=False) + "\n") tmp.replace(path) return path
[docs] class RecordingBackend: """A backend that writes every call it passes on to ``log``.""" def __init__(self, backend: LLMBackend, log: LLMLog): self.backend = backend self.log = log
[docs] def complete(self, request: Request) -> Reply: """Pass ``request`` on and write the call once its reply is back.""" return self.record(request)[0]
[docs] def record(self, request: Request) -> tuple[Reply, Path]: """Pass ``request`` on, write the call once its reply is back, and return the reply and the call file.""" reply = self.backend.complete(request) return reply, self.log.write(request, reply)