Source code for factor_agent.web._round

"""The round in progress, replayed from the events since the run was last opened, and one LLM call read in full.

A round a resume dropped left its events before the resume, so it is not
among them; a run without events has no round in progress to show. An LLM
call is read by the number of its call file, from the file itself: tokens,
truncation and the model's reasoning are the provider's reply's; what it
concerned (round, slots) only the events file says.
"""

import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path

from factor_agent import events
from factor_agent.llm._raw import raw_facts
from factor_agent.llm.base import Reply
from factor_agent.web._files import Replayed, loads, utc
from factor_agent.web._health import Tokens
from factor_agent.web._plans import picked_slots


[docs] @dataclass(frozen=True) class Check: """One check of a pending trial's candidate: the parser and the Gate on one version of it, or the reviewer. ``by`` is ``"gate"`` or ``"reviewer"``. For the Gate, ``step`` is the version's: ``"proposed"`` (the first), ``"fixed"`` (sent back by the parser or the Gate) or ``"rewritten"`` (sent back by the reviewer), and ``stage`` is ``"parse"`` or ``"gate"`` when it refused the version, ``None`` when it passed it. ``passed`` is true when the Gate passed the version or the reviewer found it consistent. ``reasons`` are the Gate's reasons for a refusal, or the reviewer's reason for its verdict either way; ``hints`` are the Gate's. """ by: str step: str | None passed: bool stage: str | None reasons: tuple[str, ...] hints: tuple[str, ...] time: datetime
[docs] @dataclass(frozen=True) class PendingTrial: """A pending trial: what is known so far of one slot of the round in progress. ``stage`` is the furthest it went: ``"planned"`` (its research direction is chosen), ``"proposed"``, ``"fixed"`` (after the Gate refused it), ``"rewritten"`` (after the reviewer found it inconsistent), and ``"consistent"`` or ``"inconsistent"`` as the reviewer last found it. ``direction`` is its research direction as the plan gives it. ``latest`` is its latest version as the proposer wrote it, ``None`` before the first. ``checks`` are the Gate's outcomes and the reviewer's verdicts so far, the earliest first. ``parent_picked`` is true when the code picked its parent (a Picked parent). It becomes a trial when its round is written. """ slot: int stage: str direction: dict latest: object checks: tuple[Check, ...] parent_picked: bool = False
[docs] @dataclass(frozen=True) class RoundCall: """An LLM call of the round in progress, as the call timeline shows it. ``number`` is its call file's number, by which ``read_call`` reads it; ``None`` while it has not come back, or when it failed (``error`` says how). ``group`` is its proposal group and ``slots`` the slots it concerns; both are empty for the planner, whose call concerns the round. ``sent`` and ``returned`` are when it was sent and came back (UTC). """ number: int | None role: str model: str group: int | None slots: tuple[int, ...] sent: datetime returned: datetime | None error: str | None
[docs] @dataclass(frozen=True) class RunningRound: """The round in progress, as its events tell since the run was last opened. ``started`` is when the round before it was written, or the run was opened when it is the first since. ``calls`` are its LLM calls in the order they were sent, and ``evaluation`` when the Evaluator started on it (``None`` before). ``pending`` holds a pending trial per slot of its plan, in slot order; it is empty until the plan is made. """ round: int started: datetime calls: tuple[RoundCall, ...] evaluation: datetime | None pending: tuple[PendingTrial, ...]
[docs] @dataclass(frozen=True) class LLMCall: """One LLM call of a run, in full, as its call file holds it. ``number`` is the number of its call file. ``round``, ``group`` and ``slots`` are the round, proposal group and slots it concerned, as the events file says (``None`` and empty without one). ``time`` is when it came back (UTC) and ``seconds`` how long it took (``None`` without an events file). ``tokens``, ``truncated`` (cut off at the output limit, ``None`` when the provider's reply does not say) and ``empty`` are read from the provider's reply. ``reasoning`` is the model's reasoning when the provider returns it, ``None`` otherwise. """ number: int role: str model: str round: int | None group: int | None slots: tuple[int, ...] time: datetime seconds: float | None tokens: Tokens truncated: bool | None empty: bool prompt: str reply: str reasoning: str | None
def running(number: int, replayed: Replayed) -> RunningRound | None: """Round ``number``, in progress, from the events since the run was last opened; ``None`` without events.""" if replayed.opened is None: return None started = replayed.opened.time calls: dict[int, RoundCall] = {} evaluation = None directions: list[dict] = [] picked: set[int] = set() stages: dict[int, str] = {} latest: dict[int, object] = {} checks: dict[int, list[Check]] = {} for event in replayed.current: if isinstance(event, events.RoundEnd): started = event.time if getattr(event, "round", None) != number: continue if isinstance(event, events.CallEnd): sent = calls[event.call].sent if event.call in calls else event.time - timedelta(seconds=event.seconds) calls[event.call] = RoundCall( number=_call_number(event.file), role=event.role, model=event.model, group=event.group, slots=tuple(event.slots), sent=sent, returned=event.time, error=event.error, ) elif isinstance(event, events.CallStart): calls[event.call] = RoundCall(number=None, role=event.role, model=event.model, group=event.group, slots=tuple(event.slots), sent=event.time, returned=None, error=None) elif isinstance(event, events.EvaluationStart): evaluation = event.time elif isinstance(event, events.Plan): directions, picked = event.directions, picked_slots(event) stages = dict.fromkeys(range(len(directions)), "planned") elif isinstance(event, events.GateOutcome): stages[event.slot], latest[event.slot] = event.step, event.item checks.setdefault(event.slot, []).append(Check( by="gate", step=event.step, passed=event.passed, stage=event.stage, reasons=tuple(event.reasons), hints=tuple(event.hints), time=event.time, )) elif isinstance(event, events.ReviewVerdict): stages[event.slot] = "consistent" if event.consistent else "inconsistent" checks.setdefault(event.slot, []).append(Check( by="reviewer", step=None, passed=event.consistent, stage=None, reasons=(event.reason,), hints=(), time=event.time, )) pending = tuple( PendingTrial(slot=slot, stage=stages[slot], direction=direction, latest=latest.get(slot), checks=tuple(checks.get(slot, ())), parent_picked=slot in picked) for slot, direction in enumerate(directions) ) return RunningRound(round=number, started=started, calls=tuple(calls.values()), evaluation=evaluation, pending=pending) def _call_number(file: str | None) -> int | None: """The number of the call file named ``file``, as ``<number>-<role>.json``.""" return int(file.partition("-")[0]) if file else None
[docs] def read_call(run_dir: str | Path, number: int) -> LLMCall | None: """The LLM call of the run in ``run_dir`` whose call file has ``number``, ``None`` when it has none. A call of a round a resume dropped is not read: its file is no longer in the run's sequence of calls. """ run_dir = Path(run_dir) files = list((run_dir / "llm_log").glob(f"{number:06d}-*.json")) if number > 0 else [] if not files: return None path = files[0] try: call = loads(path.read_text()) except (FileNotFoundError, json.JSONDecodeError): return None request, reply = call.get("request") or {}, call.get("reply") or {} text, raw = reply.get("text") or "", reply.get("raw") or "" facts = events.reply_facts(Reply(text=text, usage=reply.get("usage") or {}, model_id=reply.get("model_id") or "", raw=raw)) end = next((e for e in reversed(events.read(run_dir / events.FILE)) if isinstance(e, events.CallEnd) and e.file == path.name), None) return LLMCall( number=number, role=request.get("role") or path.stem.partition("-")[2], model=request.get("model") or "", round=end.round if end else None, group=end.group if end else None, slots=tuple(end.slots) if end else (), time=end.time if end else utc(path.stat().st_mtime), seconds=end.seconds if end else None, tokens=Tokens(facts["input_tokens"], facts["output_tokens"], facts["cache_hits"], facts["reasoning"]), truncated=facts["truncated"], empty=facts["empty"], prompt=request.get("prompt") or "", reply=text, reasoning=raw_facts(raw).reasoning, )