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:
co-authored by
Claude Opus 5
parent
7957cda438
commit
a494d320d7
@@ -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:05–07: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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user