25 min

Die Capability Card

Ein Artefakt ist gleichzeitig Registrierung, Doku, Routing-Index und Testfall.

Meilenstein Drei handgeschriebene Cards laufen durch den Validator, eine absichtlich kaputte wird mit lesbarer Meldung abgewiesen.

Jetzt könnte man anfangen, einen Router zu bauen. Man würde ihn auf Beschreibungen von Agenten loslassen, die es noch nicht gibt, und hätte den schwierigsten Teil des Systems auf Vermutungen gebaut.

Deshalb kommt zuerst das Artefakt. Eine Capability Card ist eine YAML-Datei pro Fähigkeit, und sie erfüllt vier Aufgaben auf einmal:

  1. Registrierung - was es gibt
  2. Dokumentation - was es tut, und ausdrücklich, was es nicht tut
  3. Routing-Index - die Beispiele darin werden eingebettet
  4. Testfall - dieselben Beispiele sind die Eval-Fälle des Routers

Vier Aufgaben, eine Datei. Das ist der Grund, warum das Format so aussieht, wie es aussieht.

Das Ziel

Ein Artefakt ist gleichzeitig Registrierung, Doku, Routing-Index und Testfall. Wer die Beispiele ändert, ändert damit zwangsläufig auch die Testfälle - eine Card kann nicht heimlich veralten, ohne dass die Messung es merkt.

Das Format

Leg registry/web-reader.card.yaml an:

id: web-reader
version: 1.0.0
summary: >
  Ruft eine oder mehrere URLs ab, extrahiert den Textinhalt und beantwortet
  eine konkrete Frage dazu. Liefert Zusammenfassung plus Quellenbelege.

# --- Routing: Positiv-Index ---
examples:
  - "was steht auf der seite https://..."
  - "lies mir mal die seite durch und sag was drinsteht"
  - "hol dir die preise von der website von firma x"
  - "auf der landingpage steht irgendwo die telefonnummer, finde die"
  - "fass mir den blogartikel zusammen"
  - "welche produkte listet die seite auf"
  - "steht da was zu lieferzeiten drin"
  - "check mal ob die noch offene stellen haben"
  - "was kostet das bei denen laut website"
  - "gibt es auf der seite ein impressum mit adresse"

# --- Routing: Veto-Index, SEPARAT ---
counter_examples:
  - "suche im web nach informationen ueber"
  - "google mal wer der geschaeftsfuehrer ist"
  - "trag den kontakt ins crm ein"
  - "schick eine mail an"

does_not:
  - "Sucht nicht selbst im Web, braucht eine konkrete URL"
  - "Fuellt keine Formulare aus, klickt nichts"
  - "Umgeht keine Paywalls und keine Logins"

# --- Vertrag ---
inputs:
  required: [urls, question]

returns:
  required: [answer, sources, found]

# --- Betrieb ---
budget:
  max_output_tokens: 800
  max_tool_calls: 8
  max_wall_seconds: 60
tools: [http_get, html_to_text]
side_effects: none        # none | writes | irreversible

Vier Felder verdienen eine Begründung, weil man sie sonst falsch ausfüllt.

examples sind Nutzerformulierungen, nicht Beschreibungen. Das ist die wichtigste Regel des ganzen Formats. Der Grund steht in Kapitel 03 ausführlich; kurz: Der Router vergleicht die Anfrage des Nutzers mit diesen Zeilen. „Ich brauch die Rechnung von Meier nochmal“ ist semantisch weit weg von „Agent für Dokumentenabruf aus dem DMS mit Volltextsuche“ und ganz nah an „hol mir nochmal die rechnung von“. Schreib die Beispiele deshalb schlampig: klein geschrieben, umgangssprachlich, mit den Auslassungen, die echte Leute machen.

counter_examples liegen in einem eigenen Index. „Ich mache keine Web-Suche“ enthält das Wort Web-Suche und matcht damit prächtig auf genau die Anfrage, die es ausschließen soll. Im selben Index wäre das ein Eigentor. In Kapitel 04 wirken sie als Veto: binär, nicht als Punktabzug.

does_not wird nicht eingebettet. Es ist Prosa, sie geht in den Systemprompt des Agenten und in die Antwort auf die Frage „kannst du das?“. Menschen lesen sie, der Index nicht.

budget ist keine Empfehlung. In Kapitel 06 setzt der Runner es durch, notfalls mit der Schere. Eine Card ohne Budget ist eine Card ohne Kompressionsgrenze, und dann kannst du dir das ganze System sparen.

Zwei weitere Cards

Ein Router mit einem einzigen Agenten routet immer richtig. Für alles ab Kapitel 03 brauchst du mindestens drei, und sie sollten benachbart genug sein, dass Verwechslung möglich ist. Leg an:

registry/crm-writer.card.yaml - schreibt ins CRM (Kontakte, Notizen). Wichtig: side_effects: writes. Beispiele wie „trag den neuen kontakt ein“, „notier bei meier dass er zurueckruft“, „leg eine firma an fuer“. Counter-Examples: „wer ist der ansprechpartner bei“, „such mir die telefonnummer von“ - das ist Lesen, und Lesen macht ein anderer.

registry/docs-finder.card.yaml - durchsucht die interne Dokumentation. Beispiele: „wie war nochmal der ablauf fuer“, „wo steht was zur urlaubsregelung“, „gibt es eine anleitung fuer“. Counter-Examples: „was steht auf der seite https://...“Extern - Öffnet in neuem Tab, „google mal“ - das erste ist der web-reader, das zweite kann niemand.

Nimm dir dafür die zehn Minuten. Die Qualität deines Routers hängt ab Kapitel 03 an nichts anderem als an diesen Zeilen.

Der Validator

Eine Card, die still falsch ist, vergiftet den Index. Also prüft ein Validator sie beim Laden. Er ist kurz, weil er nur die Regeln durchsetzt, die man tatsächlich verletzt:

# tiny/cards.py
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

import yaml

SIDE_EFFECTS = {"none", "writes", "irreversible"}


class CardError(ValueError):
    """Eine Card verletzt das Format. Die Meldung nennt Datei und Feld."""


@dataclass
class Budget:
    max_output_tokens: int = 800
    max_tool_calls: int = 8
    max_wall_seconds: int = 60


@dataclass
class Card:
    id: str
    summary: str
    examples: list[str]
    counter_examples: list[str]
    does_not: list[str]
    returns_required: list[str]
    tools: list[str]
    budget: Budget
    side_effects: str = "none"
    version: str = "0.0.0"
    quelle: Path | None = field(default=None, repr=False)


def _liste(daten: dict, feld: str, datei: Path, mindestens: int = 0) -> list[str]:
    wert = daten.get(feld, [])
    if not isinstance(wert, list) or any(not isinstance(x, str) for x in wert):
        raise CardError(f"{datei.name}: '{feld}' muss eine Liste aus Strings sein.")
    if len(wert) < mindestens:
        raise CardError(
            f"{datei.name}: '{feld}' hat {len(wert)} Eintraege, "
            f"mindestens {mindestens} noetig."
        )
    return wert


def card_laden(datei: Path) -> Card:
    daten = yaml.safe_load(datei.read_text(encoding="utf-8")) or {}

    for pflicht in ("id", "summary", "returns", "tools"):
        if pflicht not in daten:
            raise CardError(f"{datei.name}: Pflichtfeld '{pflicht}' fehlt.")

    # Die Faustregel aus dem Konzept: unter 8 Positivbeispielen wird das
    # Routing bruechig, unter 3 Counter-Examples hat das Veto keine Wirkung.
    examples = _liste(daten, "examples", datei, mindestens=8)
    counter = _liste(daten, "counter_examples", datei, mindestens=3)

    ueberschneidung = set(examples) & set(counter)
    if ueberschneidung:
        raise CardError(
            f"{datei.name}: {sorted(ueberschneidung)} steht in beiden Indizes. "
            "Ein Beispiel kann nicht gleichzeitig Treffer und Veto sein."
        )

    seiteneffekt = daten.get("side_effects", "none")
    if seiteneffekt not in SIDE_EFFECTS:
        raise CardError(
            f"{datei.name}: side_effects '{seiteneffekt}' unbekannt, "
            f"erlaubt sind {sorted(SIDE_EFFECTS)}."
        )

    budget = Budget(**(daten.get("budget") or {}))
    if budget.max_output_tokens > 2000:
        raise CardError(
            f"{datei.name}: max_output_tokens={budget.max_output_tokens}. "
            "Ueber 2000 ist das keine Kompressionsgrenze mehr."
        )

    return Card(
        id=str(daten["id"]),
        summary=str(daten["summary"]).strip(),
        examples=examples,
        counter_examples=counter,
        does_not=_liste(daten, "does_not", datei),
        returns_required=list((daten.get("returns") or {}).get("required", [])),
        tools=[str(t) for t in daten["tools"]],
        budget=budget,
        side_effects=seiteneffekt,
        version=str(daten.get("version", "0.0.0")),
        quelle=datei,
    )


def registry_laden(ordner: str | Path = "registry") -> dict[str, Card]:
    cards: dict[str, Card] = {}
    for datei in sorted(Path(ordner).glob("*.card.yaml")):
        card = card_laden(datei)
        if card.id in cards:
            raise CardError(f"{datei.name}: id '{card.id}' gibt es schon.")
        cards[card.id] = card
    if not cards:
        raise CardError(f"In '{ordner}' liegt keine einzige Card.")
    return cards


if __name__ == "__main__":
    import sys

    ordner = sys.argv[1] if len(sys.argv) > 1 else "registry"
    try:
        cards = registry_laden(ordner)
    except CardError as fehler:
        print(f"FEHLER  {fehler}")
        raise SystemExit(1)
    for card in cards.values():
        print(
            f"OK  {card.id:<14} v{card.version:<8} "
            f"{len(card.examples):>2} Beispiele  "
            f"{len(card.counter_examples):>2} Counter  "
            f"{len(card.tools)} Werkzeuge  "
            f"side_effects={card.side_effects}"
        )

Zwei Prüfungen darin sind mehr als Formalie.

Die Überschneidungsprüfung fängt den Fehler ab, den jeder einmal macht: dieselbe Zeile aus Bequemlichkeit in beide Listen kopieren. Das Ergebnis wäre ein Agent, der sich selbst per Veto ausschließt, und die Fehlersuche dauert eine Stunde.

Die Budget-Obergrenze ist eine Meinung, und sie steht mit Absicht im Code: Wer 4.000 Ausgabe-Token erlaubt, hat keine Kompressionsgrenze, sondern eine Höflichkeitsfloskel. Wenn du anderer Meinung bist, ändere die Zahl - aber bewusst.

Der Meilenstein

python -m tiny.cards registry/
OK  crm-writer     v1.0.0    9 Beispiele   4 Counter  2 Werkzeuge  side_effects=writes
OK  docs-finder    v1.0.0   10 Beispiele   3 Counter  1 Werkzeuge  side_effects=none
OK  web-reader     v1.0.0   10 Beispiele   4 Counter  2 Werkzeuge  side_effects=none

Und jetzt die zweite Hälfte des Meilensteins: Mach eine Card kaputt. Lösch drei Beispiele aus docs-finder, oder setz max_output_tokens: 5000, oder kopier eine Zeile aus examples nach counter_examples. Lauf noch mal.

FEHLER  docs-finder.card.yaml: 'examples' hat 7 Eintraege, mindestens 8 noetig.

Wenn die Meldung Datei und Feld nennt, ist der Schritt fertig. Ein Validator, der nur „ungültig“ sagt, ist ein Validator, den man nach der dritten Card abschaltet.