DEV Community

Cover image for Warum gibt osFoundry Codex ein Tool, um einen Handoff vorzuschlagen, und ein anderes, um einen Run zu starten?
BBT for BBT Deutsch

Posted on

Warum gibt osFoundry Codex ein Tool, um einen Handoff vorzuschlagen, und ein anderes, um einen Run zu starten?

Das bekommt Codex CLI zurück, wenn es auf osFoundry zum ersten Mal ein Session-Tool aufruft, ohne angemeldet zu sein:

POST /api/v1/external-mcp  {"method":"tools/call","params":{"name":"propose_session_handoff","arguments":{}}}

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp",
                  error="invalid_token", error_description="Connect osFoundry to continue"

{"jsonrpc":"2.0","id":3,"result":{"isError":true,
 "content":[{"type":"text","text":"Authentication required"}], …}}
Enter fullscreen mode Exit fullscreen mode

initialize und tools/list antworten ohne Token, ein Client sieht also alle 59 Tools, bevor sich überhaupt jemand anmeldet. Zwei davon scheinen dasselbe zu tun:

{"name": "propose_session_handoff",
 "description": "Send a scoped task and context summary to the owner of an invited session. The owner must accept before a run is queued.",
 "required": ["session_type","session_id","goal","context_summary","requested_output","idempotency_key"],
 "annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}

{"name": "request_session_run",
 "description": "Queue a bounded goal for an agent, team, or loop session where this assistant is an invited runner. The osFoundry runtime executes the task when available.",
 "required": ["session_type","session_id","goal","idempotency_key"],
 "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]}
Enter fullscreen mode Exit fullscreen mode

Bei beiden arbeitet am Ende eine Session. Im Rest des Beitrags geht es darum, warum die beiden getrennt sind, welche Grenzen jeweils gelten, und um einen Python-Nachbau der Client-Seite, der diese Grenzen gegen die Live-Schemas prüft.

Was unterscheidet die beiden Aufrufe?

Wer Ja sagt, und wann.

Ein Handoff fragt einen Menschen, und zwar jetzt. Der Owner bekommt eine Benachrichtigung ("Codex CLI requests a handoff"). Der Handoffs-Tab der Session listet die Anfrage unter "Outside assistant requests" (Anfragen externer Assistenten), mit dem Ziel, einem aufklappbaren Bereich "Context and expected output" und der Zeile "Up to N rounds on your agent runtime". Solange niemand auf Accept and run klickt, wird nichts eingereiht. Der Web-Client schickt dann {decision: "accept"} an /agent-sessions/{id}/external-handoffs/{handoff_id}/decision, und der Toast meldet "Handoff accepted and run queued". Die Handoff-Zeile wechselt von pending zu accepted, declined oder cancelled.

Bei einem Run kam das Ja schon vorher. Der Owner hat der Verbindung im Connected-Tab einen Runner-Platz gegeben, und das ist die Zustimmung. Der Hilfetext dort: "Invite it to a session as Contributor to post notes and propose handoffs, or Runner to also request runs. A handoff still needs the session owner's approval." Auf der Anmeldeseite heißt der Run-Scope "Run sessions where you grant a runner role".

Würde man beides in ein Tool mit einem needs_approval-Flag packen, entschiede der Aufrufer, ob ein Mensch gefragt wird. Mit zwei Tools liegt diese Entscheidung beim Owner, und zwar an zwei Stellen, an die der Aufrufer nicht herankommt: beim OAuth-Scope (osfoundry:write gegenüber osfoundry:run) und beim Platz (Contributor gegenüber Runner). Eine Codex-Konfiguration, die osfoundry:run nicht in scopes aufführt, kann selbst auf einem Runner-Platz keinen Run starten.

Die Annotations folgen derselben Trennung. propose_session_handoff hat openWorldHint: true, weil das Ergebnis einen Menschen außerhalb des Aufrufs erreicht. request_session_run hat openWorldHint: false, weil es nur die Queue einer Session berührt, die vorab zugestimmt hat. Beide sind destructiveHint: true, ein Client mit Codex' default_tools_approval_mode = "writes" fragt also vor jedem der beiden nach.

Warum verlangt der Handoff eine Kontextzusammenfassung?

Weil ein Mensch sie liest, bevor irgendetwas läuft. context_summary und requested_output sind beim Handoff Pflicht und fehlen beim Run ganz. Der Owner entscheidet, ob er die Laufzeit seiner Session dafür hergibt, und "fix the export" reicht für diese Entscheidung nicht. Ein Runner hat dieses Vertrauen schon, deshalb braucht der Run nur ein goal.

Auch die Obergrenzen unterscheiden sich. Ein Handoff-goal endet bei 5,000 Zeichen, dazu 5,000 für den Kontext und 1,000 für den erwarteten Output. Ein Run-goal darf bis 10,000 gehen. In der Praxis trägt das goal des Runs den Kontext mit, weil es kein anderes Feld dafür gibt.

Was darf Codex zurücklesen?

Weniger, als die Session enthält:

  • read_session_task_result: "the status and attributed runtime result for one task started by this connection". Mit der Task-ID einer anderen Verbindung kommt man nicht weiter.
  • read_session_mail: "notes addressed to this connection and notes it posted", 1 bis 20 pro Seite, ältere über before_id. "Internal agent analysis is excluded."
  • read_session_state: aktueller Status und letzte Tasks einer Session, der die Verbindung beigetreten ist.
  • post_session_note: eine zugeordnete Notiz, auf die Agents über "the returned external sender slug" antworten können. Sie "does not trigger an automatic run", mit einer Notiz lässt sich der Klick des Owners also nicht umgehen.

In einer Team-Session erscheinen diese Notizen in Team mail mit dem Anzeigenamen der Verbindung und dem Label "outside assistant".

Der Nachbau

Auf diesem Mac ist kein codex-Binary installiert, deshalb spielt handoff_sim.py (etwa 220 Zeilen) den Client. Das Skript macht drei Dinge:

  1. Es ruft den Live-Server ohne Token auf: initialize, tools/list und ein tools/call, jeweils 15 Mal, und speichert die Tool-Liste.
  2. Es validiert zwölf Argumentfälle mit jsonschema (Draft 2020-12) gegen die Live-inputSchemas, sodass bei den Grenzen nichts geraten ist.
  3. Es lässt ein kleines In-Memory-Modell aus Plätzen, Scopes, Owner-Entscheidungen und Idempotenz laufen. Der Servercode von osFoundry ist nicht öffentlich, dieser Teil ist also aus den Tool-Beschreibungen und dem Web-Client, der Handoffs rendert, rekonstruiert. Seine Antworten stammen vom Modell, nicht vom Server.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py
Enter fullscreen mode Exit fullscreen mode

Die Leitung

initialize             HTTP 200  median  566.3  p90  799.0
tools/list             HTTP 200  median  559.7  p90  937.7
tools/call (no token)  HTTP 401  median  482.6  p90  677.0
Enter fullscreen mode Exit fullscreen mode

Millisekunden, gemessen von diesem Mac in Japan über die KIX-Edge von CloudFront. Die Zahlen sagen also mehr über die Route als über den Server. Entscheidend ist das Muster. Discovery kostet nichts und ist schnell genug, um bei jedem Codex-Start zu laufen. Der erste echte Aufruf kostet einen 401-Roundtrip, und dieser 401 enthält resource_metadata. So findet Codex den OAuth-Server, ohne dass man ihn angeben muss.

Die sieben Session-Tools machen 1,152 der 10,197 Tokens der vollen Liste aus (11.3%, cl100k_base, für Codex' eigenen Tokenizer also nur ein Näherungswert). Wer nur diese in enabled_tools einträgt, hält den Prompt klein.

Die Grenzen

Tool Scope Pflichtfelder Obergrenzen
propose_session_handoff write 6 goal 5,000, context 5,000, output 1,000, max_rounds 1–10
cancel_session_handoff write 1 nur handoff_id
request_session_run run 4 goal 10,000, max_rounds 1–10
read_session_state read 2 keine
read_session_task_result read 3 keine
read_session_mail read 2 limit 1–20
post_session_note write 5 subject 200, body 10,000

Zwölf Fälle gegen die Live-Schemas:

propose_session_handoff   6,000-char goal        REJECT maxLength: goal
request_session_run       6,000-char goal        ok
propose_session_handoff   no context_summary     REJECT required
propose_session_handoff   max_rounds omitted     ok
request_session_run       max_rounds 0           REJECT minimum: max_rounds
request_session_run       max_rounds 11          REJECT maximum: max_rounds
request_session_run       no idempotency_key     REJECT required
request_session_run       session_type 'room'    REJECT enum: session_type
request_session_run       extra field 'model'    REJECT additionalProperties
read_session_mail         limit 21               REJECT maximum: limit
post_session_note         201-char subject       REJECT maxLength: subject
cancel_session_handoff    only handoff_id        ok
Enter fullscreen mode Exit fullscreen mode

Zwei davon lohnen einen zweiten Blick. Dasselbe goal mit 6,000 Zeichen geht als Run durch und ist als Handoff zu lang. Ein Client, der einen langen Prompt baut und ihn an das Tool schickt, das gerade verfügbar ist, läuft genau hier auf. Und additionalProperties: false weist ein model-Feld ab: Der Aufrufer kann das Modell der Session nicht wählen. Die Session läuft mit ihren eigenen Einstellungen.

max_rounds ist bei beiden optional. Die Schemas veröffentlichen keinen Default, wenn einem die Zahl also wichtig ist, sollte man sie mitschicken.

Plätze, Abbrechen und Annehmen (Modell)

tool                      observer contributor runner
propose_session_handoff   -        yes         yes
cancel_session_handoff    -        yes         yes
request_session_run       -        -           yes
read_session_state        yes      yes         yes
read_session_task_result  yes      yes         yes
read_session_mail         yes      yes         yes
post_session_note         -        yes         yes
runner seat, token without osfoundry:run -> {'error': 'token lacks osfoundry:run'}

cancel while pending -> {'status': 'cancelled'}
owner clicks Accept after cancel -> cancelled
owner accepts -> accepted | cancel after -> {'error': 'already accepted'}
another connection reads that task -> {'error': 'not a task started by this connection'}
Enter fullscreen mode Exit fullscreen mode

Die Matrix ist der Hilfetext aus dem Connected-Tab, als Tabelle. Die interessante Regel ist das Zeitfenster fürs Abbrechen. cancel_session_handoff funktioniert laut Beschreibung "before its owner accepts it", es gewinnt also, was zuerst passiert. Sobald ein Run eingereiht ist, kann der Aufrufer ihn nicht mehr zurückziehen. Deshalb nimmt cancel auch nur eine handoff_id: Es gibt keinen Zustand zu übergeben, nur eine offene Zeile zu schließen.

Verlorene Antworten

Jeder Schreibaufruf verlangt einen idempotency_key. Um zu sehen, was das bringt, schickt das Modell 1,000 request_session_run-Aufrufe, verwirft 20% der Antworten (Seed 7) und lässt den Client bis zu dreimal mit demselben Key wiederholen:

server honours idempotency_key=True  calls sent 1276  tasks queued 1000  duplicates 0
server honours idempotency_key=False calls sent 1276  tasks queued 1276  duplicates 276
Enter fullscreen mode Exit fullscreen mode

276 zusätzliche Runs auf der Laufzeit von jemand anderem, genau dafür gibt es den Pflicht-Key. Jeder Client, der nach einem Timeout neu sendet, gerät in diesen Fall, und einen Run, der schon gestartet ist, will man nicht doppelt haben. Weil der Key Pflicht und nicht optional ist, kann ihn kein Client vergessen.

Das ganze Skript brauchte 26.4 s Wall-Clock-Zeit auf einem Apple M1. Fast alles davon entfällt auf 45 Netzwerkaufrufe, die User-CPU-Zeit lag bei 0.37 s.

Wenn du das selbst verdrahtest

  • Trenne "einen Menschen fragen" und "mit stehender Erlaubnis handeln" in zwei Tools mit zwei Scopes, damit die Entscheidung des Owners im Token und im Platz steckt und nicht in einem Argument, das der Aufrufer setzt.
  • Mach die Felder, die ein Mensch für seine Entscheidung braucht (context_summary, requested_output), nur beim Vorschlag zur Pflicht und begrenze sie enger als das goal des Runs.
  • Lass den Aufrufer nur abbrechen, solange eine Anfrage offen ist. Nach dem Annehmen ist das Stoppen Sache des Owners.
  • Beschränke jedes Zurücklesen auf das, was diese Verbindung gestartet hat oder bekommen hat, und lass die interne Analyse der Session draußen.
  • Mach idempotency_key bei jedem Schreibaufruf zur Pflicht und gib bei einer Wiederholung das erste Ergebnis zurück.
  • Schließ das Schema mit additionalProperties: false, damit ein Aufrufer kein Modell und keine Tool-Liste einschmuggeln kann, die die Session nicht gewählt hat.

Wie die eigenen Agents von osFoundry Arbeit zwischen Schritten weitergeben, steht auf der Handoffs-Seite. Das Protokoll darunter erklärt der Glossareintrag zu MCP.

Top comments (0)