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())
})
})
@@ -0,0 +1,138 @@
import { AlertTriangle, Activity, CheckCircle2, Clock, Wrench } from "lucide-react"
import { useWerkzeugverlauf } from "@/lib/queries"
import { SectionLabel } from "@/components/ui/SectionLabel"
import { cn } from "@/lib/utils"
// Werkzeug-Verlauf des Agenten (v3-Umbau P6).
//
// Der Blueprint sah hier eine „Live Agent Matrix" mit Denkstrom vor (§4.4). Die ist so
// nicht baubar: Lucys Denkschritte entstehen im Hermes-Prozess, und AGENTS.md verbietet
// es, dessen Quellcode zu patchen. Was aber offen dalag: Hermes protokolliert JEDEN
// Werkzeug-Ruf in sein Log. Zuschauen ist kein Patchen.
//
// Diese Ansicht zeigt deshalb nur, was wirklich da ist — welches Werkzeug, wie lange,
// mit welchem Ergebnis, in welchem Lauf. Kein erfundener Denkstrom, keine leere Leitung.
//
// Die BILANZ steht oben, nicht die Zeitleiste. Grund: Ein Werkzeug, das immer scheitert,
// verschwindet in einer Liste von Ereignissen — in der Zeile „web_extract · 2 Rufe ·
// 2 Fehler" nicht. Genau so ist beim Bauen aufgefallen, dass `web_extract` seit
// mindestens vier Tagen jeden Morgen um 07:00 scheitert.
const uhr = (iso: string) => iso.slice(11, 19)
/** Dauer lesbar: unter einer Sekunde in ms, sonst in Sekunden. */
function dauer(s: number): string {
return s < 1 ? `${Math.round(s * 1000)} ms` : `${s.toFixed(1)} s`
}
export function Werkzeugverlauf() {
const { data, isLoading } = useWerkzeugverlauf()
if (isLoading) {
return (
<section className="space-y-3">
<SectionLabel icon={Activity}>Werkzeug-Verlauf</SectionLabel>
<div className="mc-card-sm p-4 text-xs text-muted-foreground">Lese Hermes' Log …</div>
</section>
)
}
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
// weglassen statt eine leere Karte zu zeigen — wie der Rest der Seite es hält.
if (!data?.verfuegbar) return null
const { rufe, werkzeuge, laeufe } = data
const fehlerhaft = werkzeuge.filter((w) => w.fehler > 0)
return (
<section className="space-y-3">
<SectionLabel icon={Activity}>
Werkzeug-Verlauf · {rufe.length} Rufe
{fehlerhaft.length > 0 && ` · ${fehlerhaft.reduce((n, w) => n + w.fehler, 0)} Fehler`}
</SectionLabel>
<p className="max-w-2xl text-[11px] leading-relaxed text-muted-foreground">
Gelesen aus Hermes' eigenem Log Mission Control schaut zu, greift aber nicht ein.
Was dort <em>nicht</em> steht, steht auch hier nicht: Lucys Gedankengang und die
Argumente eines Werkzeug-Rufs bleiben unsichtbar.
</p>
{/* Bilanz zuerst — hier faellt ein Dauerfehler auf, den eine Zeitleiste verschluckt. */}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{werkzeuge.map((w) => {
const immerRot = w.fehler > 0 && w.fehler === w.rufe
return (
<div
key={w.werkzeug}
className={cn("mc-card-sm space-y-1.5 p-3",
immerRot && "border-red-500/40 bg-red-500/5")}
>
<div className="flex items-center gap-2">
<Wrench className={cn("h-3.5 w-3.5 shrink-0",
immerRot ? "text-red-400" : w.fehler ? "text-amber-400" : "text-primary")} aria-hidden="true" />
<span className="truncate font-mono text-xs font-semibold text-foreground">{w.werkzeug}</span>
{immerRot && (
<span className="ml-auto shrink-0 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-red-300">
immer rot
</span>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-[10px] text-muted-foreground">
<span className="tabular-nums">{w.rufe} Rufe</span>
<span className={cn("tabular-nums", w.fehler > 0 && "font-semibold text-red-400")}>
{w.fehler} Fehler
</span>
<span className="tabular-nums">Ø {dauer(w.dauer_schnitt_s)}</span>
</div>
{w.letzter_fehler && (
<p className="border-t border-border/30 pt-1.5 text-[10px] leading-relaxed text-red-300/90">
{w.letzter_fehler}
</p>
)}
</div>
)
})}
</div>
{/* Laeufe: welcher Cron/welche Sitzung hat wann was getan. */}
{laeufe.length > 0 && (
<div className="mc-card-sm divide-y divide-border/30">
{laeufe.map((l) => (
<div key={l.lauf} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 font-mono text-[10px]">
<Clock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate font-semibold text-foreground">{l.lauf}</span>
<span className="tabular-nums text-muted-foreground">{uhr(l.von)}{uhr(l.bis)}</span>
<span className="ml-auto tabular-nums text-muted-foreground">{l.rufe} Rufe</span>
{l.fehler > 0 && (
<span className="tabular-nums font-semibold text-red-400">{l.fehler} Fehler</span>
)}
</div>
))}
</div>
)}
{/* Zeitleiste, juengster Ruf oben. */}
<details className="mc-card-sm">
<summary className="cursor-pointer px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground">
Einzelne Rufe ({rufe.length})
</summary>
<ul className="max-h-72 divide-y divide-border/20 overflow-y-auto scrollbar-thin border-t border-border/30">
{[...rufe].reverse().map((r, i) => (
<li key={`${r.zeit}-${r.werkzeug}-${i}`} className="flex items-start gap-2 px-3 py-1.5 font-mono text-[10px]">
{r.ok
? <CheckCircle2 className="mt-px h-3 w-3 shrink-0 text-emerald-500" aria-hidden="true" />
: <AlertTriangle className="mt-px h-3 w-3 shrink-0 text-red-400" aria-hidden="true" />}
<span className="shrink-0 tabular-nums text-muted-foreground">{uhr(r.zeit)}</span>
<span className="shrink-0 font-semibold text-foreground">{r.werkzeug}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{dauer(r.dauer_s)}</span>
{r.zeichen != null && (
<span className="shrink-0 tabular-nums text-muted-foreground/60">{r.zeichen} Z.</span>
)}
{r.fehler && <span className="min-w-0 flex-1 truncate text-red-300/90" title={r.fehler}>{r.fehler}</span>}
</li>
))}
</ul>
</details>
</section>
)
}
+34
View File
@@ -397,6 +397,40 @@ export interface Job {
eta_s?: number
}
// Werkzeug-Verlauf des Agenten (GET /api/agent/aktivitaet, v3-Umbau P6).
// Gelesen aus Hermes' Log — Denkstrom und Werkzeug-Argumente stehen dort NICHT und
// tauchen darum auch hier nicht auf.
export interface WerkzeugRuf {
zeit: string
lauf: string | null
werkzeug: string
dauer_s: number
zeichen: number | null
ok: boolean
fehler: string | null
}
export interface WerkzeugBilanz {
werkzeug: string
rufe: number
fehler: number
dauer_schnitt_s: number
letzter_fehler: string | null
}
export interface AgentLauf {
lauf: string
von: string
bis: string
rufe: number
fehler: number
}
export interface AktivitaetResp {
verfuegbar: boolean
pfad: string
rufe: WerkzeugRuf[]
werkzeuge: WerkzeugBilanz[]
laeufe: AgentLauf[]
}
export interface Health {
status: string
version: string
+11
View File
@@ -7,6 +7,7 @@ import { sseVerbunden } from "./events"
import {
api,
type AgentStatus,
type AktivitaetResp,
type AuftragsbuchResp,
type ChronikResp,
type ConnectResp,
@@ -46,6 +47,7 @@ export const qk = {
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const,
agentAktivitaet: ["agent-aktivitaet"] as const,
hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const,
discover: ["discover"] as const,
@@ -221,6 +223,15 @@ export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Werkzeug-Verlauf des Agenten. Liest das Ende von Hermes' Log — billig, aber es
// aendert sich nur, wenn Lucy wirklich arbeitet; der gemuetliche Takt reicht.
export const useWerkzeugverlauf = (refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.agentAktivitaet,
queryFn: () => api<AktivitaetResp>("/api/agent/aktivitaet?limit=80"),
refetchInterval: relax(refetchInterval),
})
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
+4
View File
@@ -1,6 +1,7 @@
import { useState } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react"
import { useNavigate } from "@tanstack/react-router"
import { Werkzeugverlauf } from "@/features/agent/Werkzeugverlauf"
import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
@@ -340,6 +341,9 @@ export function AgentView() {
</div>
)}
{/* Werkzeug-Verlauf (v3-Umbau P6): was Lucy tatsaechlich getan hat, aus ihrem Log. */}
<Werkzeugverlauf />
{dialogElement}
</div>
)