Caldera docs
calderaapp.io

Adapter-Modus und sein Vertrag

Alles, was die Autorin oder der Autor eines Agents braucht, um einen Adapter zu bauen, und nichts sonst: was ein Adapter ist, wie die Runtime ihn aufruft, die Verben mit Eingabe, Ausgabe und Exit-Codes, Umgebung, Arbeitsverzeichnis, Timeouts und Größengrenzen, unter denen er läuft, die Grenzenregel und ein vollständiges minimales Beispiel. Diese Seite ist die Schnittstelle. Welche Dateien der Adapter liest und schreibt, ist Sache des Agents und nicht Teil davon.

Die maschinenlesbare Hälfte ist als JSON-Schemas veröffentlicht: adapter-status.schema.json, adapter-settings-get.schema.json, adapter-log-list.schema.json und adapter-answer.schema.json. Wo diese Seite und ein Schema sich widersprechen, ist das ein Fehler in einem von beiden.

Was ein Adapter ist

Ein Agent, der eigene Prozesse behält (einen Timer, einen Bot, einen Wächter, eine Pause-Datei, die auch ein Chat-Befehl setzt), kann nicht von der Runtime ausgeführt werden: Eine Runtime, die einen Prozess stoppte, würde eine zweite Pause hinzufügen, von der sonst niemand weiß. Deshalb sagt ein Manifest runtime.type: adapter, und die Runtime

  • startet nichts und stoppt nichts und liest keine Datei des Agents;
  • meldet im Agent-Protokoll an Caldera, mit dem Body, den sie aus adapter status bekommt;
  • gibt Calderas Befehle weiter, indem sie den Adapter aufruft, einen Befehl, den der Agent bereitstellt, mit einem Verb.

Der Adapter ist der Code des Agents. Er ist ein Programm (in beliebiger Sprache), das ein Verb als Argumente nimmt, die eigenen Dateien des Agents liest und schreibt, JSON ausgibt und endet. Nichts daran läuft dauerhaft: Die Runtime startet ihn für jeden Aufruf.

# caldera.agent.yaml
name: task-agent
version: 1.0.0
description: The task agent of the ops team.
runtime:
  type: adapter
  command: [.venv/bin/python, caldera/adapter.py]   # argv: nie eine Shell-Zeichenkette
  workdir: /home/agent/task-agent                    # der eigene Checkout des Agents
  timeout_seconds: 25                                # optional, 1 bis 120
settings:
  schema:
    - { key: max_7d, label: Weekly limit, type: percent, min: 0.1, max: 0.95 }
  defaults: { max_7d: 0.75 }
controls: [pause, resume, run_now]
account: false                                       # optional, siehe den Hinweis auf das Konto

caldera init installiert für einen solchen Blueprint das Manifest und keinen Agent-Prozess: Das Bundle trägt nur das Manifest (und den Adapter, wenn der Agent ihn mitliefern will; dann ist workdir ein Pfad im Bundle). Die systemd-Unit führt nur die Runtime aus. Ein Update von Caldera ändert das Manifest und seine (verengenden) Grenzen, nicht den Code des Agents.

Ein Manifest mit runtime.type: adapter darf status, logs, state, secrets, bindings, instructions oder mcp nicht nennen: Das sind Dateien und Zugangsdaten, die die Runtime lesen oder übergeben würde, und in diesem Modus tut sie keins von beidem. Es darf settings, controls, health, account und runtime.timeout_seconds nennen.

Blueprint-Updates und das 30-Minuten-Fenster funktionieren wie bei jedem Agent: Das Manifest ändert sich, und das Fenster liest den Level von adapter status. Die Gesundheitsprüfung läuft im Arbeitsverzeichnis des Adapters mit der Umgebung des Adapters.

Wie er aufgerufen wird

<command...> <verb> [arguments]

command ist das argv des Manifests, das Verb und seine Argumente werden angehängt, nie durch eine Shell geschickt. Jeder Aufruf ist ein neuer Prozess.

Benutzer Der eigene Benutzer der Runtime. Es gibt keinen Rechtewechsel.
Arbeitsverzeichnis runtime.workdir: ein absoluter Pfad (der Checkout des Agents, außerhalb des Bundles) oder ein relativer Pfad im Bundle. Standard: das Versionsverzeichnis des Bundles. Ein Verzeichnis, das nicht existiert, ist ein Fehlschlag des Aufrufs.
Programm command[0] mit einem Schrägstrich wird gegen das Arbeitsverzeichnis aufgelöst; ohne einen wird es in PATH gesucht.
stdin Geschlossen (/dev/null).
stdout Die Antwort: JSON, wie jedes Verb unten es sagt. Gelesen bis zur Obergrenze des Verbs.
stderr Keine Antwort. Die ersten 2 KB werden behalten, und die erste Zeile eines fehlschlagenden Aufrufs kann in der Fehlermeldung erscheinen. Diagnose hierhin, nie Geheimnisse.
Session Eine eigene Prozessgruppe. Ein Aufruf, der ein Timeout erreicht, wird mit allem beendet, was er gestartet hat.
Umgebung Minimal, unten.

Die Umgebung

Der Adapter bekommt nur das hier, und kein CALDERA_TOKEN, nichts aus agent.env oder secrets.env der Runtime und nichts sonst aus der Umgebung der Runtime:

Variable Ist
PATH, HOME, USER, LOGNAME, LANG, LC_ALL, LC_CTYPE, LC_MESSAGES, TZ, TMPDIR von der Runtime kopiert, wenn gesetzt
XDG_RUNTIME_DIR, XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, DBUS_SESSION_BUS_ADDRESS kopiert, wenn gesetzt (ein Adapter darf mit systemctl --user sprechen)
CALDERA_ADAPTER immer 1: "ich wurde von der Runtime aufgerufen"
CALDERA_ADAPTER_VERB das Verb: status, pause, resume, run-now, settings, log
CALDERA_AGENT_NAME der Name des Agents in Caldera

Der Adapter liest, was er braucht (die eigenen Zugangsdaten, die Konfiguration), aus seinen eigenen Dateien und Umgebungsquellen. Das Token, das für die Runtime spricht, bleibt durch die Umgebung außerhalb seiner Reichweite; die Dateirechte eines einzelnen Benutzers können ein Programm nicht daran hindern, eine Datei zu lesen, und deshalb ist der Adapter der eigene Code des Agents.

Exit-Codes

Code Bedeutung Die Runtime
0 Erledigt. stdout ist die Antwort des Verbs. Verwendet sie.
1 Abgelehnt. Der Agent hat in einem Satz nein gesagt (stdout {"message": ...} oder die erste Textzeile, sonst stderr). Eine Ablehnung ist eine normale Antwort. Zeigt der Person die Nachricht als Antwort auf ihren Befehl. Nichts hat sich geändert.
2 Aufruf: Der Adapter kennt dieses Verb oder seine Argumente nicht. Ein Fehlschlag: "the adapter does not know ...".
jeder andere, oder durch ein Signal beendet Fehlschlag. Ein Fehlschlag mit dem Code und der ersten Zeile von stderr.

Ein Timeout und eine Ausgabe über der Obergrenze sind ebenfalls Fehlschläge (der Prozess wird beendet). Bei status kostet ein Fehlschlag diese eine Meldung ihren Body: Caldera wird "Adapter not answering" gesagt (Zustand bad, ein Gesundheitseintrag Adapter mit dem Grund), und die nächste Meldung funktioniert, sobald der Adapter es tut; Acks früherer Befehle gehen weiterhin hinaus. Bei einem Befehlsverb ist ein Fehlschlag eine Nicht-ok-Antwort auf den Befehl der Person (The agent's adapter failed: ...). Sonst ändert sich am Agent nichts, und die Runtime hält nie die Arbeit des Agents an: Ist der Adapter kaputt, arbeitet der Agent von selbst weiter.

Timeouts und Obergrenzen

Verb Timeout Obergrenze stdout
status 20 s 200 KB
settings get 20 s 100 KB
log --list 20 s 64 KB
log <name> 20 s 4 MB gelesen; die letzten 512 KB werden benutzt
pause, resume, run-now, settings set 30 s 64 KB

runtime.timeout_seconds im Manifest ersetzt jeden Timeout oben (1 bis 120). Über einer Obergrenze wird der Prozess beendet und der Aufruf schlägt fehl. Zeichenketten und Listen in einer Antwort, die länger sind als die Obergrenzen des Protokolls, werden von Caldera gekürzt, nicht abgelehnt.

Die Verben

status

adapter status

Gibt ein JSON-Objekt aus: den Body einer Meldung, in der Form, die ein Prozess-Agent in seine Status-Datei schreibt. Alle Schlüssel sind optional. Die Runtime gibt nur diese weiter, Schlüssel für Schlüssel:

Schlüssel Ist
state `{level: ok
health [{name, level, detail?}]
active was gerade läuft (kind, model, started, steps, detail, subjects, log)
gauges [{name, used (0 bis 1), mark?, detail?}]
runs die letzten Läufe (id, started, ended, kind, status, cost_usd, output_tokens, models, subjects, log); dieselbe id überschreibt
panels list-, table-, kv- oder text-Panels
agent {version}: die eigene Version des Agents (ein Commit), die in der Meldung die Blueprint-Version ersetzt

Jeder andere Schlüssel wird verworfen. settings und controls kommen nicht von hier: Das Schema und die Knöpfe sind die des Manifests, die Werte kommen aus settings get. Die Log-Namen in active[].log und runs[].log sind die Namen, nach denen adapter log <name> gefragt wird. Was jedes Feld bedeutet und seine Obergrenzen stehen im Protokoll.

Der Adapter darf aus einem Zwischenspeicher antworten, den er selbst führt (zum Beispiel der Zustand des Boards und sein Alter, im Hintergrund aufgefrischt), ein Status-Aufruf muss also nie einen anderen Dienst erreichen. Tut er es, sollte er in einem detail sagen, wie alt die Daten sind. Ein Aufruf, der länger als das Timeout läuft, wird beendet: Der Zwischenspeicher ist der Weg, innerhalb davon zu bleiben.

Schema: adapter-status.schema.json.

pause

adapter pause

Setzt die eigene manuelle Pause des Agents: die, die seine anderen Kanäle setzen und löschen (ein Chat-Befehl, ein Wächter), Caldera fügt also keine zweite Pause hinzu. Exit 0 mit optional {"message": "Paused."}.

resume

adapter resume

Hebt nur die manuelle Pause auf. Andere Sperren bleiben (eine Person arbeitet in einer Sitzung, ein Kontingent-Stopp), und die Antwort nennt sie:

{ "message": "Pause lifted. Alex is working in a session, so no run starts.", "held": ["Alex is working in a session"] }

message ist der Satz, den eine Person liest. held ist die Liste der Sperren, die noch bestehen; die Runtime hängt jede Sperre an, die die Nachricht nicht schon enthält ("Held: ..."). Exit 0.

run-now

adapter run-now

Fordert einen Lauf an: eine Bitte, kein Befehl. Die eigenen Sperren des Agents (Pause, Kontingent, eine Sitzung) gelten weiter, und die Antwort sagt, wenn der Lauf gesperrt ist:

{ "message": "Requested, but held.", "held": ["paused"] }

Exit 0, wenn die Bitte festgehalten wurde (auch wenn sie gesperrt ist); Exit 1 mit einer Nachricht, wenn das nicht möglich war, zum Beispiel weil es nichts auszuführen gibt.

settings get

adapter settings get

Gibt die aktuellen Werte der eigenen Einstellungsquelle des Agents aus und optional die eigenen harten Grenzen des Agents:

{
  "values": { "max_7d": 0.75, "note": null },
  "bounds": { "max_7d": { "min": 0.1, "max": 0.95 } }
}
  • values: ein Schlüssel je Einstellung, die das Manifest deklariert; null für ungesetzt. Schlüssel, die das Manifest nicht deklariert, werden ignoriert.
  • bounds (optional): je Schlüssel min und/oder max. Caldera bietet das Engere aus den Grenzen des Manifests und diesen an (siehe unten).

Schema: adapter-settings-get.schema.json.

settings set

adapter settings set '{"max_7d": 0.5, "note": "hello"}'

Das eine Argument ist ein JSON-Objekt aus Schlüssel und neuem Wert (null löscht), höchstens 8 KB.

  • Ganz oder gar nicht. Wird ein Wert abgelehnt, wird nichts geschrieben. Die Einstellungsquelle atomar schreiben (eine temporäre Datei, dann umbenennen), damit ein Leser die alte oder die neue Datei sieht.
  • Exit 0, wenn angewendet, mit optionaler Nachricht (Saved; the next run uses it.). Eine Änderung wirkt mit dem nächsten Lauf des Agents.
  • Exit 1, wenn abgelehnt, mit dem Grund in einer Nachricht. Die Runtime zeigt sie als Nothing changed: <message>.

log --list und log <name>

adapter log --list
adapter log run-20261003-124101.log

--list gibt die Namen aus, die der Agent herausgibt:

{ "logs": [{ "name": "run-20261003-124101.log" }, { "name": "chat-ops.log" }] }

Höchstens 50 Namen mit höchstens 190 Zeichen werden benutzt. log <name> gibt das Ende eines Logs als reinen Text auf stdout aus (die Runtime behält die letzten 512 KB und sendet sie an Caldera). Der Adapter entscheidet, welche Namen existieren: Sie dürfen Mustern folgen (run-YYYYMMDD-HHMMSS.log); das Manifest nennt keine Liste. Bei einem unbekannten Namen Exit 1 (die Runtime antwortet "There is no such log."). Die Runtime lehnt Namen ohne Aufruf ab, die leer sind, länger als 190 Zeichen sind, ein NUL enthalten oder mit - beginnen. Den Namen auch selbst validieren: Er kommt von Caldera, und ein Pfad darin ist abzulehnen (../../etc/passwd).

Schema der Antworten von pause, resume, run-now und settings set: adapter-answer.schema.json; von log --list: adapter-log-list.schema.json.

Grenzen verengen nur

Die eigenen harten Grenzen des Adapters haben das letzte Wort. Ein Manifest darf sie verengen und nie erweitern: Kein Manifest-Update kann zum Beispiel eine Wochenobergrenze auf 100 % öffnen, wenn die eigene Grenze des Agents 95 % ist. So funktioniert es:

  1. Das settings.schema des Manifests hat Grenzen (min, max, step, Optionen), und die Runtime prüft jedes set zuerst dagegen. Ein Wert außerhalb wird von der Runtime abgelehnt, und der Adapter wird nicht aufgerufen.
  2. Meldet settings get bounds, verengt die Runtime min und max des Manifests damit (das größere min, das kleinere max). Dieses verengte Schema ist das, was die Meldung anbietet und was die Prüfung benutzt, Caldera zeigt also keinen Wert, den der Adapter ablehnen würde.
  3. Die Runtime gibt weiter, was übrig blieb, und der Adapter prüft erneut gegen seine eigenen Regeln und darf trotzdem ablehnen (Exit 1). Seine Antwort ist die Antwort.

Aufrufende können den Adapter nicht schwächen, nur ihn fragen. Den Adapter so schreiben, dass er sicher wäre, wenn das Manifest alles erlaubte.

Pausieren und Fortsetzen ohne Caldera

caldera pause und caldera resume auf dem Rechner rufen denselben Adapter auf (pause, resume) und brauchen keine Verbindung, und der eigene Kanal des Agents (zum Beispiel ein Chat-Befehl) funktioniert wie bisher. caldera log [name] listet oder gibt Logs genauso aus. Alles, was Caldera kann, ist ohne Caldera erreichbar und umkehrbar.

Ein vollständiger minimaler Adapter

#!/usr/bin/env python3
"""Adapter for an agent whose state is a few files in its working directory."""
import json, os, sys

def say(message, held=None, code=0):
    print(json.dumps({"message": message, **({"held": held} if held else {})}))
    sys.exit(code)

def main(argv):
    verb = argv[0] if argv else ""
    if verb == "status":
        paused = os.path.exists("paused")
        print(json.dumps({
            "state": {"level": "warn", "label": "Paused"} if paused else {"level": "ok", "label": "Ready"},
            "gauges": [{"name": "Week", "used": 0.5}],
            "agent": {"version": "abc1234"},
        }))
    elif verb == "pause":
        open("paused", "w").close()
        say("Paused.")
    elif verb == "resume":
        if os.path.exists("paused"):
            os.remove("paused")
        say("Pause lifted.")
    elif verb == "run-now":
        open("run-now", "w").close()
        say("Requested.")
    elif verb == "settings" and argv[1:2] == ["get"]:
        print(json.dumps({"values": {"max_7d": 0.75}, "bounds": {"max_7d": {"min": 0.1, "max": 0.95}}}))
    elif verb == "settings" and argv[1:2] == ["set"]:
        wanted = json.loads(argv[2])
        for key, value in wanted.items():
            if key != "max_7d" or not 0.1 <= value <= 0.95:
                say(f"{key}: not allowed.", code=1)       # ganz oder gar nicht: nichts wurde geschrieben
        with open("settings.json.tmp", "w") as f:
            json.dump(wanted, f)
        os.replace("settings.json.tmp", "settings.json")  # atomar
        say("Saved.")
    elif verb == "log" and argv[1:2] == ["--list"]:
        print(json.dumps({"logs": []}))
    else:
        sys.exit(2)                                       # ein Verb, das er nicht kennt

main(sys.argv[1:])

Checkliste für die Autorin oder den Autor eines Adapters

  • Gibt bei status, settings get, log --list genau ein JSON-Objekt auf stdout aus und sonst nichts. Diagnose geht nach stderr.
  • Beantwortet status aus dem eigenen Zwischenspeicher, wenn die echte Quelle langsam ist; nimmt sich nie absichtlich die vollen 20 s.
  • pause und resume berühren nur die manuelle Pause. resume nennt die Sperren, die bleiben.
  • run-now hält eine Bitte fest und sagt, wenn sie gesperrt ist.
  • settings set ist atomar und ganz oder gar nicht, prüft gegen die eigenen Grenzen des Agents und endet mit 1 und einem Grund, statt stillschweigend zu begrenzen.
  • Validiert Log-Namen; gibt nur das Ende aus.
  • Endet mit 2 bei einem Verb, das er nicht kennt, mit 1 zum Ablehnen, mit 0, wenn erledigt, und nie mit 0 nach einem Fehlschlag.
  • Funktioniert gleich, wenn Caldera nicht erreichbar ist: Die Runtime ruft ihn für caldera pause lokal auf.