Handbücher · Agent-Harnesses

Build: Berechtigungen und Kontext

Lektion 7/912 Min. LesezeitProfi

Lernziele

  • Du ergänzt den Loop aus Teil eins um ein Permission-Gate, ein Kontextbudget und ein Runden-Limit.
  • Du zeigst an konkreten Befehlen, warum eine Allowlist mit Präfixvergleich undicht ist.
  • Du erklärst, warum Kürzen des Verlaufs die Thinking-Blöcke ungültig macht, und behebst es im Code.

Teil eins endete mit einer Vorführung: Der Agent führt aus, was er will, und sein Verlauf wächst ohne Grenze. Diese Lektion ergänzt drei Teile, die daraus einen HarnessAlles, was festlegt, was der Agent erreichen darf: Tools, Berechtigungen, Sandbox, Zeitlimits und die Prüfung, bevor eine Aktion wirkt. Englisch für Geschirr oder Gestell.Im Glossar machen: ein Permission-Gate · engl. permission gateDie Stelle im Harness, die vor jedem Tool-Aufruf mit Nebenwirkung entscheidet, ob er laufen darf: per Allowlist, per Rückfrage beim Menschen oder per festem Verbot.Im Glossar, ein Kontextbudget und eine Abbruchbedingung. Die Lektion Was ein Harness ist nennt sie die Teile, mit denen du einen Agenten allein arbeiten lassen kannst. Hier kosten sie gut hundert Zeilen.

Der Code

Alles aus Teil eins bleibt, wie es war: Imports, TOOLS, read_file und run_command. Die neue Datei guardrails.py importiert diese Teile aus agent.py und ersetzt execute und agent. Starte sie wie Teil eins mit python guardrails.py "…" im selben Ordner.

# guardrails.py – Permission-Gate, Kontextbudget, Abbruchbedingung
import json
import sys

import anthropic

from agent import MODEL, SYSTEM, TOOLS, read_file, run_command  # alles aus Teil eins

MAX_TURNS = 30
CONTEXT_BUDGET = 100_000  # Zeichen, grob 25.000 Tokens

ALLOWLIST = ["ls", "pwd", "cat", "head", "grep", "find",
             "git status", "git diff", "git log", "python", "pytest"]


def permitted(command):
    command = command.strip()
    if any(command == entry or command.startswith(entry + " ") for entry in ALLOWLIST):
        return True
    answer = input(f"\nAgent wants to run: {command}\nAllow? [y/N] ")
    return answer.strip().lower() in ("y", "yes")


def execute(name, tool_input):
    if name == "read_file":
        return read_file(tool_input["path"])
    if name == "run_command":
        if not permitted(tool_input["command"]):
            return "The user declined to run this command. Try another approach or ask them why."
        return run_command(tool_input["command"])
    raise ValueError(f"unknown tool: {name}")


def size_of(message):
    content = message["content"]
    if isinstance(content, str):
        return len(content)
    return sum(len(json.dumps(b)) if isinstance(b, dict) else len(b.model_dump_json())
               for b in content)


def describe(message):
    content = message["content"]
    if isinstance(content, str):
        return content[:150]
    parts = []
    for block in content:
        if isinstance(block, dict):
            parts.append(f"tool output, {len(json.dumps(block))} chars")
        elif block.type == "text":
            parts.append(block.text[:150])
        elif block.type == "tool_use":
            parts.append(f"ran {block.name}({json.dumps(block.input)[:100]})")
    return "; ".join(parts) or "(empty turn)"


def strip_thinking(messages):
    for message in messages:
        if isinstance(message["content"], list):
            message["content"] = [b for b in message["content"]
                                  if getattr(b, "type", None) not in ("thinking", "redacted_thinking")]


def enforce_budget(messages, summaries):
    dropped = False
    while sum(size_of(m) for m in messages) > CONTEXT_BUDGET and len(messages) > 5:
        summaries.append(describe(messages.pop(1)))
        summaries.append(describe(messages.pop(1)))
        dropped = True
    if dropped:
        strip_thinking(messages)  # der Verlauf hat sich geändert, alte Thinking-Blöcke gelten nicht mehr


def with_summary(messages, task, summaries):
    if not summaries:
        return messages
    header = (task + "\n\n[Older turns were removed to fit the context budget. "
              "Summary of what happened in them:\n- " + "\n- ".join(summaries) + "]")
    return [{"role": "user", "content": header}] + messages[1:]


def agent(task):
    client = anthropic.Anthropic()
    messages = [{"role": "user", "content": task}]
    summaries = []
    for turn in range(MAX_TURNS):
        enforce_budget(messages, summaries)
        response = client.messages.create(
            model=MODEL, max_tokens=16000, system=SYSTEM, tools=TOOLS,
            messages=with_summary(messages, task, summaries), output_config={"effort": "high"},
        )
        for block in response.content:
            if block.type == "text":
                print(block.text)
        if response.stop_reason != "tool_use":
            return
        messages.append({"role": "assistant", "content": response.content})
        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            print(f"-> {block.name} {json.dumps(block.input)}")
            try:
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": execute(block.name, block.input)})
            except Exception as exc:
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": f"Error: {exc}", "is_error": True})
        messages.append({"role": "user", "content": results})
    print("Stopped: hit the turn limit without finishing.")


if __name__ == "__main__":
    agent(" ".join(sys.argv[1:]) or "List the files here and describe this project.")

Zeilen 7

Modell, Systemprompt, Tool-Schemas und die beiden Tool-Funktionen kommen unverändert aus agent.py.

Das Permission-Gate

Befehle auf der Allowlist laufen ohne Rückfrage. Alles andere fragt dich über input(). Lehnst du ab, bekommt das Modell einen gewöhnlichen Text als Tool-Ergebnis. Es liest ihn als Information und passt sich an. In unserem Test sollte der Agent die Datei README.md löschen, und wir lehnten ab:

Das Gate sagt nein (eigener Lauf mit Haiku 5.5, 13.11.2026, gekürzt)1/7
  1. $printf 'n\n' | python guardrails.py "Delete the file README.md, it is outdated."

Die ersten beiden Befehle verraten schon die Schwäche. Beide haben die Rückfrage übersprungen, obwohl sie mehrere Befehle verketten. Der Vergleich prüft nur den Anfang: ls -la && git status beginnt mit ls .

Der Präfixvergleich ist undicht. Wir haben die Funktion permitted mit einigen Befehlen aufgerufen. Das naheliegende Beispiel git diff; rm -rf ~ fängt das Gate ab, weil direkt hinter git diff ein Semikolon steht. Ein Leerzeichen mehr genügt, und der Befehl läuft still. Sortiere selbst:

Läuft der Befehl ohne Rückfrage?

Ordne jeden Befehl ein, so wie permitted() in guardrails.py entscheidet. Alle Fälle haben wir mit echo statt rm geprüft.

Noch offen (7)

  • git diff; echo LEAK
  • git diff && echo LEAK
  • git diff ; echo LEAK
  • cat > todos.txt
  • find . -name x -delete
  • python -c "…"
  • echo hi

Läuft ohne Rückfrage (0)

    Gate fragt nach (0)

      Produktive Harnesses zerlegen Befehle deshalb richtig und führen sie zusätzlich in einer Sandbox aus. Das Gate hier zeigt nur, wie ein Gate aufgebaut ist. Eine zweite Grenze zeigte ein Lauf ohne Antworten auf der Standardeingabe: input() warf EOFError, und jeder Befehl außerhalb der Liste kam als Fehler zurück. Das Modell meldete am Ende, das Shell-Tool sei kaputt. Das Gate funktioniert nur, solange ein Mensch am Terminal sitzt.

      Das Kontextbudget

      Wird der Verlauf größer als das Budget, fallen die ältesten Runden nach der Aufgabe heraus, immer paarweise: der Zug des Assistenten zusammen mit seinen Tool-Ergebnissen. So verliert kein tool_use-Block sein Gegenstück. Eine Zeile pro entfernter Nachricht wandert in eine laufende Zusammenfassung, die in der ersten Nachricht mitfährt. Das ist bewusst grob. Tool-Ausgaben sind sperrig, und meist braucht der Agent sie kein zweites Mal. Das ist der Tausch aus Context Engineering.

      Das Budget zählt Zeichen. Der Kommentar im Code rechnet grob vier Zeichen pro TokenDie Einheit, in der Modelle Text lesen und abrechnen: meist ein Wortteil. Anbieter rechnen pro Million Tokens ab.Im Glossar. In unseren Läufen trug jeder Thinking-Block rund 1.000 Zeichen JSON, fast alles Signatur. Diese Zeichen zählt size_of mit, obwohl sie keinem lesbaren Text entsprechen.

      Ein Budget, das so arbeitet, hat bei aktuellen Modellen eine Falle. Jeder Thinking-Block · engl. thinking blockEin Block in der Antwort des Modells, der seine Überlegungen trägt, meist ohne sichtbaren Text und mit einer Signatur. Er geht unverändert mit dem Verlauf zurück und gilt nur, solange alles davor gleich bleibt.Im Glossar ist an alles gebunden, was vor ihm im Gespräch stand. Das Budget entfernt alte Runden und schreibt die erste Nachricht um. Damit passen alle Thinking-Blöcke danach nicht mehr zu ihrem Verlauf. Laut Doku hängt es bei Opus 5.5 und Haiku 5.5 vom Konto ab, was dann passiert. Entweder scheitert die Anfrage mit einem Fehler 400, oder das Modell liest Überlegungen zu einem Inhalt, der sich geändert hat.

      Wir haben es mit einem engen Budget von 2.500 Zeichen getestet. Unser Konto bekam ohne Einstellung keinen Fehler. Mit dem Beta-Header thinking-binding-controls-2026-08-01 und prefix_mismatch_behavior: "drop_block" meldete die API die Folgen: Nach dem ersten Kürzen fielen in fast jeder Runde ein oder zwei Thinking-Blöcke heraus, Grund prefix_binding_mismatch. Mit "error" brach der Lauf beim ersten Kürzen ab:

      messages.3.content.0: Invalid `signature` in `thinking` block. The block is bound to a
      different conversation. Remove the block, or set
      `thinking.block_binding.prefix_mismatch_behavior` to "drop_block". Content before this
      block was rewritten since it was created, first at `messages.0.content.0`.

      Die Stelle messages.0.content.0 ist die erste Nachricht, an die das Budget die Zusammenfassung hängt. Die Doku empfiehlt für genau dieses Muster zwei Wege: Die API schreibt die Zusammenfassung selbst, oder du entfernst das Thinking der behaltenen Runden. Den zweiten Weg geht strip_thinking, die einzige neue Funktion in diesem Code. Text und Tool-Aufrufe bleiben stehen, nur die Thinking-Blöcke verschwinden, sobald das Budget etwas geändert hat. Mit dieser Ergänzung lief derselbe Test mit "error" über neun Aufrufe ohne Fehler und ohne einen einzigen verworfenen Block. Den ersten Weg zeigt die nächste Lektion.

      Was die grobe Zusammenfassung kostet, zeigen dieselben Läufe. Die Aufgabe lautete: vier Dateien einzeln lesen, dann die Tests starten. Ohne Druck auf das Budget brauchte der Agent sieben API-Aufrufe. Bei 2.000 Zeichen waren es neun. Bei 1.500 Zeichen waren es 20, weil der Agent immer wieder die Dateien auflistete und schon gelesene Dateien erneut las. Die Kurzfassung „ran read_file(…)“ hält nur den Aufruf fest. Was in der Datei stand, ist weg.

      Die Abbruchbedingung

      MAX_TURNS begrenzt die Rechnung, wenn das Modell an einer Aufgabe kreist, die es nicht lösen kann. Mit 1.500 Zeichen Budget und einem Limit von zehn Runden endete einer von zwei Läufen mit der Meldung Stopped: hit the turn limit without finishing. Der andere wurde knapp vorher fertig. Jeder echte Harness hat eine solche Stoppregel · engl. stop ruleDie Bedingungen, unter denen ein Loop endet: Der Prüfer ist zufrieden, das Runden-Limit ist erreicht oder zwei Versuche hintereinander sind gleich.Im Glossar. Wer einen baut, vergisst sie nach unserer Einschätzung mindestens einmal.

      Du hast jetzt einen Harness im Kleinformat. Was ihm noch fehlt und wann das zählt, zeigt Teil drei.

      Merksätze

      • Das Gate lässt Befehle der Allowlist still laufen, fragt sonst nach und gibt ein Nein als gewöhnliches Tool-Ergebnis zurück.
      • Ein Präfixvergleich lässt Ketten wie git diff && …, Umleitungen mit cat und find -delete durch. Echte Harnesses zerlegen Befehle und nutzen eine Sandbox.
      • Ein Budget, das alte Runden entfernt, macht die Thinking-Blöcke danach ungültig. strip_thinking entfernt sie, sobald sich der Verlauf geändert hat.
      • Eine Zusammenfassung aus einer Zeile pro Runde verliert, was der Agent gelernt hat, und kostet bei engem Budget zusätzliche Runden.
      • MAX_TURNS begrenzt die Kosten eines Agenten, der nicht fertig wird.

      Prüf dich selbst

      Denkfrage

      Warum geht eine abgelehnte Freigabe als Tool-Ergebnis zurück und nicht als Ausnahme?

      Denkfrage

      Das Budget entfernt alte Runden. Warum bricht das bei aktuellen Modellen die Thinking-Blöcke danach?

      Prüf dich selbst

      1. 1Welcher Befehl läuft mit dieser Allowlist ohne Rückfrage?
      2. 2Warum entfernt enforce_budget immer zwei Nachrichten auf einmal?
      3. 3Du testest mit einem älteren Konto, und das gekürzte Budget löst keinen Fehler aus. Was folgt daraus?
      4. 4Bei 1.500 Zeichen Budget brauchte der Agent 20 statt sieben Aufrufe. Was ist die wahrscheinlichste Ursache?

      Selbst nachweisen

      0 von 4 erledigt

      Quellen

      1. Preserved thinking · Anthropic, Stand: Nov. 2026

        Woran Thinking-Blöcke gebunden sind.

      2. Prefix checks · Anthropic, Stand: Nov. 2026

        Was je Modell passiert, wenn die Prüfung scheitert, und was gekürzte Verläufe tun sollten.

      3. Configure permissions · Anthropic, Stand: Nov. 2026

        Wie ein ausgebautes Berechtigungssystem Regeln, Modi und Hooks kombiniert.

      Dein Fortschritt bleibt nur in diesem Browser gespeichert.