Files
nanobot-runtime/skills/reflect/scripts/reflect_auto.py
2026-09-16 08:56:39 +02:00

908 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""reflect_auto.py — unattended analysis run for the /reflect skill.
Distils new sessions, asks the agent to diagnose recurring mistakes, and files the
findings. **It never applies anything** — there is no edit path in this script at all.
Applying a fix happens only in the interactive `/reflect` review, one finding at a time,
after the user approves it.
Two guards protect that boundary:
* the agent is told not to write, and a git fingerprint of the workspace is compared
before and after the turn — if the agent wrote anything, nothing is filed;
* a finding seen only once is filed as `watch` and stays silent. Telegram is notified
only once a pattern repeats, so a daily run does not mean a daily notification.
Same external-script pattern as skills/compact-memory/scripts/compact_memory_auto.py:
fresh never-reused session_key, delivery straight to the Telegram Bot API, and the
message composed here rather than by the model.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import subprocess
import sys
import time
import traceback
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from reflect_distill import DEFAULT_BUDGET_CHARS, SessionDigest, collect_sessions, iter_batches
if TYPE_CHECKING:
from nanobot import Nanobot # ty: ignore[unresolved-import]
# A digest prompt is tens of thousands of tokens, and prefilling it on glm-5.3:cloud overruns
# nanobot's 120s per-request default — whose retry then throws the finished prefill away and
# starts over. Set before nanobot is imported (`_open_bot` defers it); overridable from the shell.
os.environ.setdefault("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", "600")
os.environ.setdefault("NANOBOT_LLM_TIMEOUT_S", "900")
CONFIG = Path.home() / ".nanobot" / "config.json"
WORKSPACE_FALLBACK = Path.home() / ".nanobot" / "workspace"
STATE_REL = "reflect/state.json"
FINDINGS_REL = "reflect/findings.jsonl"
RESULTS_REL = "results"
FALLBACK_CHAT_ID = "8826147089"
MODEL_PRESET = "glm"
# The soft deadline is what actually bounds a run: no new batch starts past it, and everything
# already analysed is on disk. TIMEOUT_SECONDS only catches a single batch that hangs.
DEFAULT_DEADLINE_MINUTES = 20
# The cursor is the floor of a run, this is the ceiling: findings are meant to describe recent
# behaviour, and a months-deep backlog walked one batch a night never gets there (2026-09-02:
# the cursor sat on 05-29 while 3 runs of findings were presented as current).
DEFAULT_WINDOW_DAYS = 21
TIMEOUT_SECONDS = 45 * 60
MAX_ATTEMPTS = 3
MAX_LLM_ERROR_RETRIES = 2
SEVERITIES = ("low", "medium", "high")
STATUS_WATCH = "watch"
STATUS_OPEN = "open"
STATUS_APPLIED = "applied"
STATUS_REJECTED = "rejected"
PATCH_KEYS = frozenset({"file", "old_text", "new_text"})
FINDING_KEYS = frozenset(
{"pattern", "severity", "diagnosis", "evidence", "occurrences", "sessions_affected", "proposal", "patch"}
)
MAX_DIAGNOSIS_CHARS = 600
MAX_PROPOSAL_CHARS = 400
MAX_FINDINGS_PER_BATCH = 12
# Folded findings keep evidence from earlier slices too, or a cumulative count is unauditable.
MAX_EVIDENCE = 6
PATTERN_RE = re.compile(r"^[a-z][a-z0-9-]{2,48}$")
# `when` is a free string the model fills in; the shapes seen so far are `2026-08-31` and
# `2026-08-31 13:53`, plus the occasional non-date. Only the leading date is usable.
EVIDENCE_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
JSON_FENCE = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
GOAL = """\
You are auditing your own past behaviour. Below is a distilled log of {count} of your earlier
sessions: user and assistant messages in full, tool calls collapsed to `name(args) -> ok|ERROR`.
Find **recurring mistakes worth fixing** — for example a tool retried with identical arguments
after an error instead of being diagnosed, a runaway loop, a tool chosen where a better one
existed, a multi-step request answered without doing the work, or an instruction you clearly
missed. Judge severity by wasted turns and by the harm to the user's result.
Rules:
- Report a repeated pattern, with concrete evidence: which sessions, which turns. A single
occurrence is worth reporting only when it matches a pattern in "Known patterns" below — reuse
its exact id and give the honest count for this slice (1 is fine), because those counts add up
across slices. A novel slip you have seen only once here, skip.
- These {count} sessions are one slice of a longer history, not all of it. Count only what you can
see in this slice; the store sums the counts across slices for you.
- Do NOT report a pattern already listed in "Known patterns" below unless you see new occurrences;
reuse its exact `pattern` identifier when you do.
- `pattern` is a short stable kebab-case id (e.g. `retry-without-diagnosis`). Reuse existing ids
from the list rather than inventing a new name for the same thing.
- Include a `patch` only when you are confident: `old_text` must be copied character-for-character
from the current file and must occur exactly once in it. When unsure, give `proposal` alone.
- **Read at most 2 files**, and only to copy `old_text` for a patch. Every extra read costs another
full pass over this whole digest, so spend those two reads on a patch you are sure about.
- At most {max_findings} findings. Fewer, well-evidenced findings are better than a long list.
- **Use no quotation marks of any kind inside JSON string values.** Quote a log line by
writing it plainly, without wrapping it in quotes. A stray `"` breaks the whole answer.
**Write nothing.** Do not edit, create or delete any file, and do not run any command that
changes state. This is an analysis-only run; a human reviews and applies your suggestions later.
Answer with exactly one ```json code block and nothing else — no narration before or after:
```json
{{"findings": [{{"pattern": "retry-without-diagnosis", "severity": "medium",
"diagnosis": "After an HTTP error the same call is repeated with identical arguments…",
"evidence": [{{"session": "websocket_e5a6aa…", "when": "2026-07-11", "excerpt": "web_fetch → ERROR 403 ×4"}}],
"occurrences": 7, "sessions_affected": 4,
"proposal": "Add a hard STOP gate to the Fetching section",
"patch": {{"file": "skills/flight-search/SKILL.md", "old_text": "", "new_text": ""}}}}]}}
```
If you find nothing worth reporting, answer with an empty `findings` list.
## Known patterns
{known_patterns}
## Sessions
{digest}
"""
RETRY_PROMPT = """\
Your findings were rejected by the validator:
{errors}
Answer again with exactly one corrected ```json code block and nothing else.
"""
class ReflectError(Exception):
"""A failure worth reporting to the user in one line."""
class FindingsError(ReflectError):
"""The agent's answer is malformed."""
@dataclass(frozen=True)
class Finding:
"""One diagnosed pattern, as filed in reflect/findings.jsonl."""
id: str
status: str
created: str
last_seen: str
pattern: str
severity: str
diagnosis: str
evidence: tuple[dict[str, str], ...]
occurrences: int
sessions_affected: int
proposal: str
patch: dict[str, str] | None = None
regression_of: str | None = None
history: tuple[str, ...] = field(default_factory=tuple)
def to_json(self) -> dict[str, Any]:
record = {
"id": self.id,
"status": self.status,
"created": self.created,
"last_seen": self.last_seen,
"pattern": self.pattern,
"severity": self.severity,
"diagnosis": self.diagnosis,
"evidence": list(self.evidence),
"occurrences": self.occurrences,
"sessions_affected": self.sessions_affected,
"proposal": self.proposal,
}
if self.patch:
record["patch"] = self.patch
if self.regression_of:
record["regression_of"] = self.regression_of
if self.history:
record["history"] = list(self.history)
return record
def _config() -> dict[str, Any]:
return json.loads(CONFIG.read_text(encoding="utf-8"))
def _workspace(config: dict[str, Any]) -> Path:
configured = config.get("agents", {}).get("defaults", {}).get("workspace")
return Path(configured).expanduser() if configured else WORKSPACE_FALLBACK
def _telegram_config(config: dict[str, Any]) -> tuple[str, str]:
telegram = config["channels"]["telegram"]
allow_from = telegram.get("allowFrom") or []
chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID
return telegram["token"], chat_id
def _send_telegram(text: str, token: str, chat_id: str) -> None:
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
request = urllib.request.Request(url, data=payload, method="POST")
with urllib.request.urlopen(request, timeout=15) as response:
response.read()
def _git_fingerprint(workspace: Path) -> str:
"""HEAD plus the porcelain status — an otherwise unchanged workspace hashes identically.
This is the guard that the analysis turn stayed read-only. It covers the whole tree,
not just the files the agent was asked about.
"""
try:
head = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=workspace, capture_output=True, text=True, check=True
).stdout
status = subprocess.run(
["git", "status", "--porcelain"], cwd=workspace, capture_output=True, text=True, check=True
).stdout
except (subprocess.CalledProcessError, FileNotFoundError) as error:
raise ReflectError(f"workspace git is unavailable, refusing to run unguarded: {error}") from error
return head + status
def _load_state(workspace: Path) -> dict[str, Any]:
path = workspace / STATE_REL
if not path.exists():
return {"cursor": "", "runs": []}
return json.loads(path.read_text(encoding="utf-8"))
def _save_state(workspace: Path, state: dict[str, Any]) -> None:
path = workspace / STATE_REL
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _load_findings(workspace: Path) -> list[dict[str, Any]]:
path = workspace / FINDINGS_REL
if not path.exists():
return []
records = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
records.append(json.loads(line))
return records
def _write_findings(workspace: Path, records: list[dict[str, Any]]) -> None:
path = workspace / FINDINGS_REL
path.parent.mkdir(parents=True, exist_ok=True)
body = "\n".join(json.dumps(record, ensure_ascii=False) for record in records)
path.write_text(body + "\n" if body else "", encoding="utf-8")
def _known_patterns(records: list[dict[str, Any]]) -> str:
"""Render the pattern vocabulary handed to the model, so it reuses ids instead of renaming."""
if not records:
return "_(none yet)_"
seen: dict[str, dict[str, Any]] = {}
for record in records:
current = seen.get(record["pattern"])
if not current or record.get("occurrences", 0) > current.get("occurrences", 0):
seen[record["pattern"]] = record
rows = []
for pattern, record in sorted(seen.items()):
rows.append(f"- `{pattern}` [{record['status']}] — {record['diagnosis'][:120]}")
return "\n".join(rows)
def _new_id(existing: set[str]) -> str:
while True:
candidate = f"f{uuid.uuid4().hex[:4]}"
if candidate not in existing:
return candidate
def _text_field(raw: Any, name: str, index: int, limit: int) -> str:
"""Trimmed non-empty string, cut to `limit` — an overlong answer is shortened, not discarded."""
if not isinstance(raw, str) or not raw.strip():
raise FindingsError(f'- finding {index}: "{name}" must be a non-empty string')
text = raw.strip()
return text if len(text) <= limit else text[: limit - 1] + ""
def _positive_int(raw: Any, name: str, index: int) -> int:
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1:
raise FindingsError(f'- finding {index}: "{name}" must be an integer >= 1')
return raw
def _parse_evidence(raw: Any, index: int) -> tuple[dict[str, str], ...]:
if not isinstance(raw, list) or not raw:
raise FindingsError(f'- finding {index}: "evidence" must be a non-empty list')
items = []
for item in raw:
if not isinstance(item, dict) or not item.get("session"):
raise FindingsError(f'- finding {index}: every evidence item needs a "session"')
items.append({key: str(value) for key, value in item.items() if key in ("session", "when", "excerpt")})
return tuple(items)
def _parse_patch(raw: Any, index: int) -> dict[str, str] | None:
"""Validate a proposed patch. Shape only — whether it still applies is checked at review time."""
if raw is None:
return None
if not isinstance(raw, dict):
raise FindingsError(f'- finding {index}: "patch" must be an object or omitted')
unknown = sorted(str(key) for key in set(raw) - PATCH_KEYS)
if unknown:
raise FindingsError(f"- finding {index}: patch has unknown fields {unknown}")
missing = sorted(PATCH_KEYS - set(raw))
if missing:
raise FindingsError(f"- finding {index}: patch is missing {missing}")
if not all(isinstance(raw[key], str) for key in PATCH_KEYS):
raise FindingsError(f"- finding {index}: patch fields must be strings")
if not raw["old_text"].strip():
raise FindingsError(f'- finding {index}: patch "old_text" must not be empty')
if raw["old_text"] == raw["new_text"]:
raise FindingsError(f"- finding {index}: patch changes nothing")
if Path(raw["file"]).is_absolute() or ".." in Path(raw["file"]).parts:
raise FindingsError(f'- finding {index}: patch "file" must be a path inside the workspace')
return {key: raw[key] for key in ("file", "old_text", "new_text")}
def _decode_payload(content: str) -> tuple[Any, str]:
"""Return (payload, "") or (None, an error the model can act on).
Naming the offending line, column and surrounding text matters: the usual failure is
an unescaped `"` inside a Czech quotation (`„…"`) in a diagnosis, and a bare
"no parseable json" message gives the retry nothing to fix.
"""
# Fenced blocks first, last one first: that is where the answer is supposed to be, so
# both the successful parse and the reported error come from there rather than from
# the whole reply (whose column numbers would be meaningless to the model).
candidates = [*reversed(JSON_FENCE.findall(content)), content]
best_error = ""
for candidate in candidates:
text = candidate.strip()
if not text:
continue
try:
return json.loads(text), ""
except json.JSONDecodeError as error:
if not best_error:
excerpt = text[max(0, error.pos - 90) : error.pos + 30].replace("\n", " ")
best_error = (
f"- the JSON is invalid: {error.msg} at line {error.lineno} column {error.colno}\n"
f"- around: …{excerpt}\n"
'- most likely an unescaped double quote inside a string value; write \\" '
"or drop the quotes entirely"
)
return None, best_error or "- the answer contains no parseable ```json block"
def _parse_finding(raw: Any, index: int, session_count: int) -> tuple[dict[str, Any], list[str]]:
"""Validate one finding, returning it with any notes about what had to be salvaged.
Raises only when the record itself is unusable. A bad patch costs the patch, not the
finding — the diagnosis and the proposal are still worth putting in front of the user.
"""
if not isinstance(raw, dict):
raise FindingsError(f"- finding {index}: must be a JSON object")
pattern = raw.get("pattern")
if not isinstance(pattern, str) or not PATTERN_RE.match(pattern):
raise FindingsError(f'- finding {index}: "pattern" must be a kebab-case id like `retry-without-diagnosis`')
severity = raw.get("severity")
if severity not in SEVERITIES:
raise FindingsError(f'- finding {index}: "severity" must be one of {list(SEVERITIES)}')
notes = []
unknown = sorted(str(key) for key in set(raw) - FINDING_KEYS)
if unknown:
notes.append(f"- finding {index}: ignored unknown fields {unknown}")
try:
patch = _parse_patch(raw.get("patch"), index)
except FindingsError as error:
patch = None
notes.append(f"{error} — kept the finding without it")
# The counts are the model's own arithmetic and drive both the threshold and the ranking, so
# they get the one check that needs no second opinion: a pattern cannot touch more sessions
# than the times it occurred, nor more than the slice even held. Clamping beats re-asking —
# a whole turn is far too expensive to spend on one wrong integer.
occurrences = _positive_int(raw.get("occurrences"), "occurrences", index)
sessions = _positive_int(raw.get("sessions_affected"), "sessions_affected", index)
ceiling = min(occurrences, session_count)
if sessions > ceiling:
notes.append(f'- finding {index}: "sessions_affected" {sessions} exceeds {ceiling}, clamped')
sessions = ceiling
finding = {
"pattern": pattern,
"severity": severity,
"diagnosis": _text_field(raw.get("diagnosis"), "diagnosis", index, MAX_DIAGNOSIS_CHARS),
"evidence": _parse_evidence(raw.get("evidence"), index),
"occurrences": occurrences,
"sessions_affected": sessions,
"proposal": _text_field(raw.get("proposal"), "proposal", index, MAX_PROPOSAL_CHARS),
"patch": patch,
}
return finding, notes
def parse_findings(content: str, session_count: int) -> tuple[list[dict[str, Any]], list[str]]:
"""Parse the agent's answer into raw finding dicts, plus notes on anything dropped.
Only an unusable *answer* raises: a single malformed finding is discarded and the rest of
the batch survives. Re-asking costs a whole ~420k token turn, far too much to spend on one
bad record — the plan calls for discarding it (plans/reflect-skill.md).
"""
payload, decode_error = _decode_payload(content)
if payload is None:
raise FindingsError(decode_error)
if not isinstance(payload, dict) or not isinstance(payload.get("findings"), list):
raise FindingsError('- the JSON must be an object with a "findings" list')
raw_findings = payload["findings"]
problems: list[str] = []
if len(raw_findings) > MAX_FINDINGS_PER_BATCH:
problems.append(f"- kept the first {MAX_FINDINGS_PER_BATCH} of {len(raw_findings)} findings")
raw_findings = raw_findings[:MAX_FINDINGS_PER_BATCH]
parsed = []
for index, raw in enumerate(raw_findings, start=1):
try:
finding, notes = _parse_finding(raw, index, session_count)
except FindingsError as error:
problems.append(f"{error} — finding dropped")
continue
parsed.append(finding)
problems += notes
if raw_findings and not parsed:
raise FindingsError("\n".join(problems))
return parsed, problems
def _fold_evidence(fresh: tuple[dict[str, str], ...], previous: Any) -> tuple[dict[str, str], ...]:
"""Newest evidence first, older kept behind it, deduplicated and capped.
`occurrences` sums across runs while the evidence used to be replaced, so a folded finding
claimed 18 occurrences and showed the two examples from the last batch — a count the user
had no way to audit during review.
"""
seen: set[tuple[str, str]] = set()
kept: list[dict[str, str]] = []
for item in (*fresh, *(previous or ())):
key = (item.get("session", ""), item.get("excerpt", ""))
if key in seen:
continue
seen.add(key)
kept.append(item)
if len(kept) == MAX_EVIDENCE:
break
return tuple(kept)
def _last_seen(evidence: tuple[dict[str, str], ...], created: str) -> str:
"""The newest date the evidence actually carries — `created` only says when it was filed."""
dates = [item["when"][:10] for item in evidence if EVIDENCE_DATE_RE.match(item.get("when", ""))]
return max(dates, default=created)
def merge_findings(existing: list[dict[str, Any]], parsed: list[dict[str, Any]], today: str) -> list[Finding]:
"""Apply the notification threshold and fold repeats into the record they repeat.
A pattern seen once stays `watch` and silent; the second sighting promotes it to `open`.
Folding sums the counts, so it also carries the older evidence forward — a cumulative count
with only the newest batch's examples behind it cannot be checked by the person reviewing it.
A pattern that was already applied and comes back is a regression and opens immediately —
but only when the evidence is newer than the fix; evidence from before it describes
behaviour already dealt with, so the finding waits on `watch` until the window catches up.
A pattern the user rejected never opens again — rejection is a decision, not a deferral;
it keeps being counted and stays visible in the report and the pattern list. Rejection is
checked against *every* record of the pattern, not just the newest one: the `watch` record
filed after a rejection would otherwise supersede it and promote the pattern right back.
"""
by_pattern: dict[str, dict[str, Any]] = {}
for record in existing:
current = by_pattern.get(record["pattern"])
if not current or record["created"] >= current["created"]:
by_pattern[record["pattern"]] = record
used_ids = {record["id"] for record in existing}
rejected_patterns = {record["pattern"] for record in existing if record["status"] == STATUS_REJECTED}
results = []
for item in parsed:
previous = by_pattern.get(item["pattern"])
occurrences = item["occurrences"]
sessions = item["sessions_affected"]
evidence = item["evidence"]
regression_of = None
history: tuple[str, ...] = ()
stale_after_fix = False
if previous and previous["status"] in (STATUS_WATCH, STATUS_OPEN):
occurrences += previous.get("occurrences", 0)
sessions += previous.get("sessions_affected", 0)
evidence = _fold_evidence(item["evidence"], previous.get("evidence"))
history = (*previous.get("history", []), f"{previous['created']}:{previous['id']}")
# A pattern that already came back after a fix stays a regression until it is
# dealt with; folding the repeat in must not quietly drop the flag.
regression_of = previous.get("regression_of")
last_seen = _last_seen(evidence, today)
if previous and previous["status"] == STATUS_APPLIED:
# Only a sighting *after* the fix is a regression. The window still reaches back over
# sessions that predate it, and flagging those made the fix look undone — worse, it
# opened findings whose whole evidence is about behaviour already dealt with.
if last_seen > previous.get("applied", {}).get("at", "")[:10]:
regression_of = previous["id"]
else:
stale_after_fix = True
was_rejected = item["pattern"] in rejected_patterns
repeats = occurrences >= 2 and sessions >= 2
silenced = was_rejected or stale_after_fix
status = STATUS_OPEN if (repeats or regression_of) and not silenced else STATUS_WATCH
results.append(
Finding(
id=_new_id(used_ids),
status=status,
created=today,
last_seen=last_seen,
pattern=item["pattern"],
severity=item["severity"],
diagnosis=item["diagnosis"],
evidence=evidence,
occurrences=occurrences,
sessions_affected=sessions,
proposal=item["proposal"],
patch=item["patch"],
regression_of=regression_of,
history=history,
)
)
used_ids.add(results[-1].id)
return results
def supersede(existing: list[dict[str, Any]], merged: list[Finding]) -> list[dict[str, Any]]:
"""Drop the watch/open records that the new findings fold in, then append the new ones."""
folded = {record_id for finding in merged for entry in finding.history for record_id in [entry.split(":")[-1]]}
kept = [record for record in existing if record["id"] not in folded]
return kept + [finding.to_json() for finding in merged]
def _rate_line(rate: float, previous: float | None) -> str:
"""How often already-known patterns still recur — the one number that says whether this pays off.
Without a trend the report is only a restatement of findings.jsonl; with it, an applied fix
that did nothing becomes visible in the next run.
"""
trend = f" (previous run {previous:.1f})" if previous is not None else ""
return f"Known patterns: {rate:.1f} occurrences / 100 sessions{trend}."
def _window_line(stats: dict[str, Any]) -> str:
"""What the findings below actually cover — without it the report reads as if it saw everything."""
scope = f"from {stats['window_from'][:10]}" if stats.get("window_from") else "from the cursor, no window"
return f"Window: {scope}, batches {stats['batches']}/{stats.get('batches_total', stats['batches'])}."
def _seen_line(finding: Finding) -> str:
"""First and last sighting: `occurrences` is cumulative, so the count alone hides staleness.
"Last seen" is the newest date in the evidence, not `created` — a record refiled every night
reported today's date whatever the evidence behind it said.
"""
first = finding.history[0].split(":")[0] if finding.history else finding.created
return f"first seen {first}, last seen {finding.last_seen}"
def _is_stale(finding: Finding, stats: dict[str, Any]) -> bool:
"""Nothing in the run's window backs this finding any more, so no run will refresh it either."""
window_from = str(stats.get("window_from") or "")[:10]
return bool(window_from) and finding.last_seen < window_from
def _report_order(finding: Finding) -> tuple[bool, bool, int]:
"""Open first, then regressions, then severity — see render_report for why regressions win."""
return finding.status != STATUS_OPEN, finding.regression_of is None, SEVERITIES.index(finding.severity) * -1
def render_report(merged: list[Finding], stats: dict[str, Any], previous_rate: float | None, when: datetime) -> str:
"""The dated results/ report — the human-readable record behind the Telegram one-liner.
Regressions come before severity: the model re-guesses `severity` every run and it drifts on
the same pattern, while `regression_of` is a fact from the audit — a fix that already failed
once belongs at the top whatever today's guess says.
"""
lines = [
f"# Self-reflection {when:%Y-%m-%d}",
"",
f"Analysed {stats['sessions']} sessions in {stats['batches']} batches. Findings: {len(merged)} "
f"({sum(1 for f in merged if f.status == STATUS_OPEN)} to review, "
f"{sum(1 for f in merged if f.status == STATUS_WATCH)} watched).",
"",
_window_line(stats),
_rate_line(stats["repeat_per_100"], previous_rate),
"",
]
if not merged:
lines.append("Nothing to report.")
return "\n".join(lines) + "\n"
for finding in sorted(merged, key=_report_order):
flag = " — REGRESSION" if finding.regression_of else ""
if _is_stale(finding, stats):
flag += " — STALE"
lines += [
f"## {finding.id} · `{finding.pattern}` [{finding.status}/{finding.severity}]{flag}",
"",
finding.diagnosis,
"",
f"**Occurrences:** {finding.occurrences}× in {finding.sessions_affected} sessions · {_seen_line(finding)}",
"",
"**Evidence:**",
]
lines += [
f"- `{item.get('session', '?')}` {item.get('when', '')}{item.get('excerpt', '')}".rstrip("")
for item in finding.evidence
]
lines += ["", f"**Proposal:** {finding.proposal}", ""]
if finding.patch:
lines += [
f"**Patch:** `{finding.patch['file']}`",
"",
"```diff",
*[f"- {line}" for line in finding.patch["old_text"].splitlines()],
*[f"+ {line}" for line in finding.patch["new_text"].splitlines()],
"```",
"",
]
return "\n".join(lines) + "\n"
async def _resolve_findings(
bot: Nanobot, session_key: str, prompt: str, workspace: Path, session_count: int
) -> list[dict[str, Any]]:
"""Ask the agent for findings, retrying with validator feedback in the same session.
A provider failure is counted separately from a malformed answer. The two look alike from
here — nanobot hands back `Error calling LLM: …` as the reply — but blaming the model for
text it never wrote burns a validator attempt and sends it a nonsensical correction.
"""
fingerprint = _git_fingerprint(workspace)
message = prompt
last_error: FindingsError = FindingsError("- no attempt was made")
attempt = 0
llm_errors = 0
while attempt < MAX_ATTEMPTS:
result = await bot.run(message, session_key=session_key)
if result.stop_reason == "error" or result.error:
llm_errors += 1
detail = (result.error or result.content or "unknown").splitlines()[0]
print(f"[llm-error {llm_errors}/{MAX_LLM_ERROR_RETRIES}] {detail}", file=sys.stderr)
if llm_errors > MAX_LLM_ERROR_RETRIES:
raise ReflectError(f"model unavailable: {detail}")
# The failed turn left an error message and the whole digest in that session; a fresh
# key re-asks the original question instead of paying for the poisoned history.
session_key = f"{session_key}r{llm_errors}"
message = prompt
continue
attempt += 1
content = result.content or ""
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] {len(content)} chars", file=sys.stderr)
if _git_fingerprint(workspace) != fingerprint:
raise ReflectError("the agent modified the workspace during an analysis run — nothing filed")
try:
parsed, problems = parse_findings(content, session_count)
except FindingsError as error:
last_error = error
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] rejected:\n{error}", file=sys.stderr)
message = RETRY_PROMPT.format(errors=error)
continue
for problem in problems:
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] {problem}", file=sys.stderr)
return parsed
raise ReflectError(f"after {MAX_ATTEMPTS} attempts: {str(last_error).splitlines()[0].lstrip('- ')}")
def build_prompt(digest: str, known_patterns: str, session_count: int) -> str:
return GOAL.format(
count=session_count,
max_findings=MAX_FINDINGS_PER_BATCH,
known_patterns=known_patterns,
digest=digest,
)
def _open_bot(preset: str):
"""Deferred import so a dry run works on a machine without nanobot-ai installed."""
from nanobot import Nanobot # ty: ignore[unresolved-import]
return Nanobot.from_config(model_preset=preset)
async def _run(workspace: Path, args: argparse.Namespace, now: datetime) -> tuple[str, int]:
state = _load_state(workspace)
# Cursor is the floor, window the ceiling: nothing is analysed twice and nothing older than
# the window is analysed at all, so a run always describes recent behaviour and the backlog
# cannot starve it. Sessions the window skips are skipped for good — the cursor moves past them.
window_from = f"{now - timedelta(days=args.window_days):%Y-%m-%dT%H:%M:%S}" if args.window_days else ""
since = "" if args.all else max(str(state.get("cursor") or ""), window_from)
paths = collect_sessions(workspace / "sessions", since=since)
batches = list(iter_batches(paths, args.budget_chars))
if args.max_batches:
batches = batches[: args.max_batches]
if not batches:
return "", 0
existing = _load_findings(workspace)
known_before = {record["pattern"] for record in existing}
bot = None if args.dry_run else _open_bot(MODEL_PRESET)
runs = state.setdefault("runs", [])
previous_rate = runs[-1].get("repeat_per_100") if runs else None
stats: dict[str, Any] = {
"at": f"{now:%Y-%m-%d %H:%M}",
"window_from": window_from,
"sessions": 0,
"batches": 0,
"batches_total": len(batches),
"open": 0,
"watch": 0,
"repeat_per_100": 0.0,
}
# Keyed by pattern: a pattern found in five batches is one finding, and only the last fold
# of it is the complete one. Appending every batch's copy inflated the report headings, the
# open/watch counts and the Telegram number to several times what the store actually holds.
merged: dict[str, Finding] = {}
session_count = 0
repeat_occurrences = 0
deadline_seconds = args.deadline_minutes * 60
started_at = time.monotonic()
def commit(batch: list[SessionDigest]) -> None:
"""Persist everything analysed so far, after every batch.
A run that gets cut short — by the deadline, the hard timeout or a dead provider — must
keep the batches it did finish and leave the cursor past them. Doing this once at the end
meant a timeout threw away finished work and left the cursor still pointing at it.
"""
_write_findings(workspace, existing)
stats.update(
sessions=session_count,
batches=stats["batches"] + 1,
open=sum(1 for f in merged.values() if f.status == STATUS_OPEN),
watch=sum(1 for f in merged.values() if f.status == STATUS_WATCH),
repeat_per_100=round(repeat_occurrences / session_count * 100, 1) if session_count else 0.0,
)
report = workspace / RESULTS_REL / f"{now:%Y-%m-%d}_reflect.md"
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text(render_report(list(merged.values()), stats, previous_rate, now), encoding="utf-8")
# Batches run oldest-first, so the newest start inside one is a safe cursor. Never move it
# backwards: an `--all` run walks the whole backlog and must not undo the nightly progress
# if it dies halfway.
newest = max((item.started for item in batch), default="")
state["cursor"] = max(newest, str(state.get("cursor") or ""))
if stats["batches"] == 1:
runs.append(stats)
_save_state(workspace, state)
for index, batch in enumerate(batches):
if index and time.monotonic() - started_at > deadline_seconds:
print(f"deadline: stopping after {index}/{len(batches)} batches", file=sys.stderr)
break
digest = "\n\n".join(item.text for item in batch)
session_count += len(batch)
prompt = build_prompt(digest, _known_patterns(existing), len(batch))
if args.dry_run:
target = workspace / "tmp" / f"reflect-batch.{index:03d}.md"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(prompt, encoding="utf-8")
print(f"dry-run: {target} ({len(prompt) / 1000:.0f} kB)", file=sys.stderr)
continue
session_key = f"reflect:{now:%Y%m%d-%H%M%S}-{index}"
parsed = await _resolve_findings(bot, session_key, prompt, workspace, len(batch))
# Newly observed occurrences only — the cumulative counts on `merged` carry earlier
# runs with them and would inflate the rate every time a pattern is folded in.
repeat_occurrences += sum(item["occurrences"] for item in parsed if item["pattern"] in known_before)
batch_findings = merge_findings(existing, parsed, f"{now:%Y-%m-%d}")
existing = supersede(existing, batch_findings)
merged.update({finding.pattern: finding for finding in batch_findings})
commit(batch)
if args.dry_run:
return "", session_count
pending = len(batches) - stats["batches"]
opened = [f for f in merged.values() if f.status == STATUS_OPEN]
if not opened:
# Silence is how the starved cursor went unnoticed for three nights: a run that cannot
# keep up finds nothing new, and used to say nothing about the backlog it left behind.
if pending:
scope = window_from[:10] or "the cursor"
return f"🔍 reflect: 0 findings, {pending} batches left (window from {scope}).", session_count
return "", session_count
patchable = sum(1 for f in opened if f.patch)
regressions = sum(1 for f in opened if f.regression_of)
text = f"🔍 reflect: {len(opened)} findings to review ({patchable} with a patch)"
if regressions:
text += f", {regressions} regressions"
done = stats["batches"]
progress = "" if done == len(batches) else f" Analysed {done}/{len(batches)} batches."
return f"{text}.{progress} Type /reflect.", session_count
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--dry-run", action="store_true", help="write prompts to tmp/, call no model")
parser.add_argument("--all", action="store_true", help="ignore the cursor and re-read everything (dry runs only)")
parser.add_argument(
"--window-days",
type=int,
default=DEFAULT_WINDOW_DAYS,
help=f"analyse only sessions from the last N days (default {DEFAULT_WINDOW_DAYS}, 0 = from the cursor)",
)
parser.add_argument("--budget-chars", type=int, default=DEFAULT_BUDGET_CHARS)
parser.add_argument("--max-batches", type=int, default=0, help="stop after N batches (0 = no limit)")
parser.add_argument(
"--deadline-minutes",
type=int,
default=DEFAULT_DEADLINE_MINUTES,
help="start no new batch past this many minutes (0 = one batch per run)",
)
parser.add_argument("--workspace", type=Path, help="override the configured workspace (for dry runs)")
args = parser.parse_args(argv)
if args.all and not args.dry_run:
# merge_findings folds by pattern and sums the counts, so re-reading analysed sessions
# inflates every occurrence count. It is a debugging view, not a way to recount.
parser.error("--all re-reads sessions already counted and would inflate the counts; use it with --dry-run")
now = datetime.now()
# A dry run must work without the server config, so it can be checked from a dev machine.
config = {} if (args.dry_run and args.workspace) else _config()
workspace = args.workspace or _workspace(config)
try:
message, sessions = asyncio.run(asyncio.wait_for(_run(workspace, args, now), timeout=TIMEOUT_SECONDS))
failed = False
except TimeoutError:
message = f"🔍 reflect: ERROR — timed out after {TIMEOUT_SECONDS // 60} min, finished batches are saved."
sessions, failed = 0, True
except ReflectError as error:
message, sessions = f"🔍 reflect: ERROR — {error}.", 0
failed = True
except Exception as error: # noqa: BLE001 — an unattended job must report, not just die
traceback.print_exc()
message, sessions = f"🔍 reflect: ERROR — {type(error).__name__}: {error}.", 0
failed = True
if not message:
print(f"reflect_auto: {sessions} sessions, nothing to report", file=sys.stderr)
return 0
if failed:
print(f"reflect_auto: {message}", file=sys.stderr)
if args.dry_run:
return 1 if failed else 0
token, chat_id = _telegram_config(config)
try:
_send_telegram(message, token, chat_id)
except Exception as error: # noqa: BLE001 — delivery failure must not hide the original outcome
print(f"reflect_auto: telegram delivery failed: {error}", file=sys.stderr)
return 1
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())