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>
This commit is contained in:
Hitonabi
2026-08-28 09:51:14 +02:00
co-authored by Claude Opus 5
parent 7957cda438
commit a494d320d7
44 changed files with 592 additions and 110 deletions
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { AktivitaetResp } from "@/lib/api"
// Die Query wird ersetzt, nicht das Netz: Der Test prüft die ANSICHT, nicht den Abruf.
const useWerkzeugverlauf = vi.hoisted(() => vi.fn())
vi.mock("@/lib/queries", () => ({ useWerkzeugverlauf }))
const { Werkzeugverlauf } = await import("./Werkzeugverlauf")
function zeigen(data: AktivitaetResp | undefined, isLoading = false) {
useWerkzeugverlauf.mockReturnValue({ data, isLoading })
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<Werkzeugverlauf />
</QueryClientProvider>,
)
}
const ANTWORT: AktivitaetResp = {
verfuegbar: true,
pfad: "~/.hermes/logs/agent.log",
rufe: [
{ zeit: "2026-08-28T07:01:20", lauf: "cron_a", werkzeug: "web_extract", dauer_s: 0.17, zeichen: null, ok: false, fehler: "DuckDuckGo (ddgs) is a search-only backend." },
{ zeit: "2026-08-28T07:02:36", lauf: null, werkzeug: "web_search", dauer_s: 2.61, zeichen: 2944, ok: true, fehler: null },
{ zeit: "2026-08-28T07:03:18", lauf: "cron_a", werkzeug: "terminal", dauer_s: 1.4, zeichen: 53, ok: true, fehler: null },
],
werkzeuge: [
{ werkzeug: "web_extract", rufe: 2, fehler: 2, dauer_schnitt_s: 0.17, letzter_fehler: "DuckDuckGo (ddgs) is a search-only backend." },
{ werkzeug: "web_search", rufe: 20, fehler: 0, dauer_schnitt_s: 1.77, letzter_fehler: null },
],
laeufe: [{ lauf: "cron_a", von: "2026-08-28T07:01:05", bis: "2026-08-28T07:03:18", rufe: 3, fehler: 1 }],
}
/** Die Bilanz-Karte eines Werkzeugs — der Name steht auch in der Zeitleiste, deshalb
* wird hier gezielt die Karte gesucht und nicht irgendein Vorkommen des Namens. */
function bilanzKarte(werkzeug: string): HTMLElement {
const treffer = screen.getAllByText(werkzeug)
.map((el) => el.closest("div.mc-card-sm"))
.filter((el): el is HTMLElement => el instanceof HTMLElement && el.textContent!.includes("Rufe"))
expect(treffer.length, `genau eine Bilanz-Karte fuer ${werkzeug}`).toBe(1)
return treffer[0]
}
describe("Werkzeugverlauf", () => {
// Der eigentliche Zweck der Ansicht: Ein Werkzeug, das IMMER scheitert, verschwindet
// in einer Ereignisliste. Genau so lief `web_extract` vier Tage lang jeden Morgen
// ins Leere, ohne dass es jemandem auffiel.
it("markiert ein durchgehend scheiterndes Werkzeug als „immer rot“", () => {
zeigen(ANTWORT)
const karte = bilanzKarte("web_extract")
expect(karte.textContent).toMatch(/immer rot/i)
expect(karte.textContent).toMatch(/2 Fehler/)
})
it("markiert ein fehlerfreies Werkzeug NICHT", () => {
zeigen(ANTWORT)
const karte = bilanzKarte("web_search")
expect(karte.textContent).not.toMatch(/immer rot/i)
expect(karte.textContent).toMatch(/0 Fehler/)
// Und die Markierung gibt es im ganzen Abschnitt genau einmal.
expect(screen.getAllByText(/immer rot/i)).toHaveLength(1)
})
it("zeigt den echten Grund des Fehlers, nicht nur „Fehler“", () => {
zeigen(ANTWORT)
expect(bilanzKarte("web_extract").textContent).toMatch(/search-only backend/)
})
it("nennt Rufe und Fehler in der Abschnitts-Überschrift", () => {
const { container } = zeigen(ANTWORT)
// Die Überschrift setzt sich aus mehreren Textknoten zusammen (der Fehler-Teil
// erscheint nur bedingt), deshalb gegen den zusammengesetzten Text prüfen.
const kopf = container.querySelector("section")!.textContent!
expect(kopf).toMatch(/3 Rufe/) // 3 Einträge in ANTWORT.rufe
expect(kopf).toMatch(/2 Fehler/) // Summe aus der Werkzeug-Bilanz
})
it("gruppiert nach Lauf und zeigt dessen Zeitspanne", () => {
zeigen(ANTWORT)
const zeile = screen.getByText("cron_a").closest("div")!
expect(zeile.textContent).toMatch(/07:01:0507:03:18/)
expect(zeile.textContent).toMatch(/1 Fehler/)
})
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
// weglassen — eine leere Karte waere eine Zusage ohne Inhalt.
it("verschwindet ganz, wenn es kein Hermes-Log gibt", () => {
const { container } = zeigen({ verfuegbar: false, pfad: "", rufe: [], werkzeuge: [], laeufe: [] })
expect(container).toBeEmptyDOMElement()
})
it("sagt beim Laden, was es tut", async () => {
zeigen(undefined, true)
await waitFor(() => expect(screen.getByText(/Lese Hermes/)).toBeInTheDocument())
})
})