Files
mission-control-v2/backend/services/agent_aktivitaet.py
T
HitonabiandClaude Opus 5 a494d320d7 feat(agent): P6 — Werkzeug-Verlauf, und der Blocker war nur eine halbe Wand
Der Blueprint sah eine "Live Agent Matrix" mit Denkstrom vor (§4.4). In P4 hatte ich
sie als blockiert gemeldet: Lucys Denkschritte entstehen im Hermes-Prozess, und
AGENTS.md verbietet es, dessen Quellcode zu patchen.

Beim Nachsehen zeigte sich, dass die HAELFTE davon offen daliegt.
`~/.hermes/logs/agent.log` protokolliert JEDEN Werkzeug-Ruf:

  INFO [cron_195e…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
  WARNING [cron_195e…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}

Eine Log-Datei zu LESEN ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug,
wie lange, mit welchem Ergebnis, in welchem Lauf — ist fuer "was tut sie gerade und wo
haengt es" oft nuetzlicher als der Fliesstext ihrer Gedanken.

## Es hat sich beim ersten Blick bezahlt gemacht

Der Parser lief gegen den echten Box-Log und meldete sofort:

  web_extract      2 Rufe   2 Fehler   <-- IMMER ROT
      DuckDuckGo (ddgs) is a search-only backend and cannot extract URL content.
  web_search      20 Rufe   0 Fehler   Schnitt 1,77 s

Nachgesehen: `web_extract` scheitert seit MINDESTENS dem 25.08. jeden Morgen um 07:00
mit derselben Meldung — im Daily-News-Cron, vier Tage lang, ohne dass es irgendwo
aufgefallen waere. Genau dafuer steht die Bilanz OBEN und die Zeitleiste darunter:
Ein Dauerfehler verschwindet in einer Ereignisliste, in der Zeile
"web_extract · 2 Rufe · 2 Fehler" nicht.

## Was die Ansicht NICHT verspricht

Denkstrom und Werkzeug-Argumente stehen nicht im Log und tauchen darum auch nicht auf.
Das steht so in der Ansicht selbst, nicht nur im Code — eine Oberflaeche, die mehr
andeutet als sie hat, ist schlimmer als eine, die ihre Grenze nennt.

## Umsetzung

  services/agent_aktivitaet.py   liest nur die letzten 512 kB (die Datei waechst auf
                                 MB und wird rotiert), vier gemessene Zeilenformen,
                                 Bilanz je Werkzeug + Gruppierung je Lauf
  GET /api/agent/aktivitaet      limit gedeckelt auf 300
  features/agent/Werkzeugverlauf.tsx   Bilanz -> Laeufe -> aufklappbare Zeitleiste

Fehlt das Log (Entwicklungsrechner ohne Hermes), verschwindet der Abschnitt still,
statt eine leere Karte zu zeigen — wie der Rest der Seite es haelt.

## Verifiziert

  Parser gegen den echten Box-Log: 26 Rufe, 4 Werkzeuge, 2 Laeufe erkannt,
    Rauschen (mem_trim, aiohttp) ignoriert
  Im Browser gegen dieselben Daten: "IMMER ROT"-Markierung sitzt, Grund im Klartext,
    Laufgruppen mit Zeitspanne, 26 Einzelrufe in der Zeitleiste
  7 neue Tests (44 gesamt) — sie pruefen gezielt die BILANZ-Karte, nicht irgendein
    Vorkommen des Werkzeugnamens; der steht auch in der Zeitleiste

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:51:14 +02:00

167 lines
6.6 KiB
Python

"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
zu patchen.
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
nützlicher als der Fließtext ihrer Gedanken.
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
halten kann.
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
"""
import logging
import os
import re
from datetime import datetime
from pathlib import Path
log = logging.getLogger(__name__)
LOG_PFAD = Path(os.path.expanduser(
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
LESE_BYTES = 512 * 1024
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
#
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
_FERTIG = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
_FEHLER = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
def _schwanz(pfad: Path, n: int) -> list[str]:
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
try:
groesse = pfad.stat().st_size
with pfad.open("rb") as f:
f.seek(max(0, groesse - n))
roh = f.read()
zeilen = roh.decode("utf-8", errors="replace").splitlines()
return zeilen[1:] if groesse > n and zeilen else zeilen
except OSError:
return []
def _kurz(detail: str | None) -> str | None:
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
JSON ab; interessant ist daran nur das `error`-Feld."""
if not detail:
return None
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
text = treffer.group(1) if treffer else detail.strip()
return (text[:297] + "…") if len(text) > 300 else text
def rufe(limit: int = 60) -> list[dict]:
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
ergebnis: list[dict] = []
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
m = _FERTIG.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": int(m.group("zeichen")),
"ok": True,
"fehler": None,
})
continue
m = _FEHLER.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": None,
"ok": False,
"fehler": _kurz(m.group("detail")),
})
return ergebnis[-limit:]
def _iso(s: str) -> str:
"""`2026-08-28 07:03:18` → ISO. Die Box läuft in Europe/Berlin; die Zeit bleibt naiv,
weil der Klient sie ohnehin nur anzeigt und nicht rechnet."""
try:
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").isoformat()
except ValueError:
return s
def uebersicht(limit: int = 60) -> dict:
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
wiederkehrenden Fehler zusammengefasst.
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
liste = rufe(limit)
if not LOG_PFAD.exists():
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
"werkzeuge": [], "laeufe": []}
bilanz: dict[str, dict] = {}
for r in liste:
b = bilanz.setdefault(r["werkzeug"], {
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
"dauer_summe": 0.0, "letzter_fehler": None,
})
b["rufe"] += 1
b["dauer_summe"] += r["dauer_s"]
if not r["ok"]:
b["fehler"] += 1
b["letzter_fehler"] = r["fehler"]
werkzeuge = sorted(
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
for b in bilanz.values()),
key=lambda b: (-b["fehler"], -b["rufe"]),
)
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
laeufe: list[dict] = []
gesehen: dict[str, dict] = {}
for r in liste:
schluessel = r["lauf"] or "(interaktiv)"
if schluessel not in gesehen:
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
"rufe": 0, "fehler": 0}
laeufe.append(gesehen[schluessel])
e = gesehen[schluessel]
e["bis"] = r["zeit"]
e["rufe"] += 1
if not r["ok"]:
e["fehler"] += 1
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
"werkzeuge": werkzeuge, "laeufe": laeufe}