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 statusbekommt; - 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;nullfür ungesetzt. Schlüssel, die das Manifest nicht deklariert, werden ignoriert.bounds(optional): je Schlüsselminund/odermax. 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 alsNothing 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:
- Das
settings.schemades Manifests hat Grenzen (min,max,step, Optionen), und die Runtime prüft jedessetzuerst dagegen. Ein Wert außerhalb wird von der Runtime abgelehnt, und der Adapter wird nicht aufgerufen. - Meldet
settings getbounds, verengt die Runtimeminundmaxdes Manifests damit (das größeremin, das kleineremax). 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. - 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 --listgenau ein JSON-Objekt auf stdout aus und sonst nichts. Diagnose geht nach stderr. - Beantwortet
statusaus dem eigenen Zwischenspeicher, wenn die echte Quelle langsam ist; nimmt sich nie absichtlich die vollen 20 s. pauseundresumeberühren nur die manuelle Pause.resumenennt die Sperren, die bleiben.run-nowhält eine Bitte fest und sagt, wenn sie gesperrt ist.settings setist atomar und ganz oder gar nicht, prüft gegen die eigenen Grenzen des Agents und endet mit1und einem Grund, statt stillschweigend zu begrenzen.- Validiert Log-Namen; gibt nur das Ende aus.
- Endet mit
2bei einem Verb, das er nicht kennt, mit1zum Ablehnen, mit0, wenn erledigt, und nie mit0nach einem Fehlschlag. - Funktioniert gleich, wenn Caldera nicht erreichbar ist: Die Runtime ruft ihn für
caldera pauselokal auf.