Was wir bauen
Die Referenzimplementierung ist ein kleines Python-Paket mit einer Aufgabe pro Modul:
Eine einzelne Frage fließt so hindurch:
- Frag Venice nach dem aktuellen Function-Calling-Modell, es sei denn, du hast eines gepinnt.
- Verbinde dich mit dem Apify-MCP-Server und liste seine Werkzeuge auf.
- Schreibe diese MCP-Werkzeuge als OpenAI-kompatible Funktionsdefinitionen um.
- Sende die Frage mit angehängter Werkzeugliste.
- Wenn das Modell
tool_callszurückgibt, führe sie gegen Apify aus und hänge die Ergebnisse alstool-Nachrichten an. - Wiederhole, bis das Modell mit Text statt mit einem Werkzeugaufruf antwortet.
Dieser Agent kann Apify-Compute auf deinem Konto verbrauchen. Starte ohne
APIFY_TOKEN, wenn du nur Such- und Dokumentationswerkzeuge willst, und lass --yes weg, bis du tatsächlich Actors ausführen willst.Das Projekt aufsetzen
Das Referenzprojekt nutzt Python 3.12+ und uv. Erstelle ein neues Projekt:httpx2, die 2.x-Linie von httpx, von der sowohl openai als auch mcp bereits abhängen. Sie direkt zu installieren vermeidet, dass am Ende zwei HTTP-Clients in derselben Umgebung landen.
Erstelle dann eine .env-Datei:
VENICE_API_KEY kommt aus den Venice-API-Einstellungen. APIFY_TOKEN kommt aus der Apify Console und ist optional — was du ohne ihn bekommst, klären wir gleich.
Konfiguration laden
Die Einstellungen kommen zuerst, weil jedes andere Modul sie als Argument entgegennimmt. Wir verwendenpydantic-settings, damit Umgebungsvariablen, .env und CLI-Flags alle in einem validierten Objekt landen.
In src/venice_terminal_agent/config.py trägt eine Settings(BaseSettings)-Klasse die Felder, auf die es ankommt:
venice_model ist None statt einer Modell-ID, worauf wir im nächsten Abschnitt zurückkommen. Und max_rounds mit max_tool_result_chars sind die Grenzen, die einen davonlaufenden Agenten stoppen: Die erste deckelt, wie viele Werkzeugrunden eine Frage in Anspruch nehmen darf, die zweite deckelt, wie viel einer gescrapten Seite zurück in den Kontext gespeist wird.
Die interessante Funktion in diesem Modul ist der URL-Builder:
tools-Query-Parameter entgegen, der entscheidet, welche Werkzeuge er anbietet. Ohne APIFY_TOKEN fragen wir nach den vier anonymen Werkzeugen, die ohne Authentifizierung funktionieren — Actor-Suche, Actor-Details, Dokumentationssuche und Dokumentationsabruf. Das bedeutet, jemand kann das Projekt klonen, nur einen Venice-Schlüssel hinzufügen und bekommt trotzdem einen funktionierenden Agenten, der Apify Actors recherchieren kann. Er kann nur keinen ausführen.
Mit Venice sprechen
Venice ist OpenAI-kompatibel, also können wir das OpenAI-SDK für Chat Completions verwenden und schlichteshttpx für den Modell-Discovery-Aufruf.
Erstelle src/venice_terminal_agent/venice.py:
AsyncOpenAI gibt uns den Streaming-Helper und typisierte tool_calls gratis dazu. Der rohe httpx-Client ist für Venice-Endpunkte da, die das OpenAI-SDK nicht kennt, was in diesem Projekt /models/traits bedeutet.
Das Chat-Timeout ist absichtlich viel länger als das Discovery-Timeout. Eine Frage, die einen Web-Crawl auslöst, kann legitimerweise ein paar Minuten dauern.
Ein Modell zur Laufzeit entdecken
Venice-Modell-IDs rotieren, und eine hartzucodieren ist der schnellste Weg, einen Agenten auszuliefern, der in einem Monat kaputtgeht.GET /models/traits bildet stabile Trait-Namen auf das Modell ab, das diese Rolle gerade ausfüllt, also fragen wir nach function_calling_default, statt ein Modell zu benennen:
--model-Flag gewinnt, dann VENICE_MODEL aus der Umgebung, dann die Trait-Abfrage. Der Standardpfad braucht also gar keine Konfiguration, aber du kannst trotzdem ein Modell pinnen, wenn du das Verhalten zweier Modelle vergleichst.
Completions streamen
Füge nun den Completion-Aufruf hinzu:stream()-Context-Manager des SDK erledigt beides: content.delta-Events treiben die Terminalausgabe an, und get_final_completion() liefert eine vollständige Nachricht mit bereits zusammengefügten tool_calls.
Die Anfrage selbst wird von einer separaten Funktion gebaut, damit sie leicht testbar bleibt:
extra_body ist der Weg, über den das OpenAI-SDK Felder durchreicht, die es nicht modelliert, und dort landet venice_parameters. include_venice_system_prompt auf false zu setzen hält den Standard-Assistenten-Prompt von Venice aus der Konversation heraus, sodass unser eigener System-Prompt die einzige Anweisung ist, die das Modell bekommt. Für einen Agenten mit strengen Werkzeugregeln ist das genau das, was du willst.
Hänge tools und tool_choice nur an, wenn es mindestens ein Werkzeug gibt. Ein leeres tools-Array zu senden ist eine unnötige Art, ein Modell zu verwirren.
Das Modul hat außerdem einen format_http_error()-Helfer, der einen APIStatusError oder einen httpx2.HTTPStatusError in einen Einzeiler mit Statuscode und Response-Body verwandelt. Agenten scheitern an der API-Grenze öfter als irgendwo sonst, und eine lesbare Meldung dort erspart viel Rätselraten.
MCP-Werkzeuge in Venice-Werkzeuge umwandeln
MCP-Werkzeuge und Function-Tools im OpenAI-Stil beschreiben dasselbe in unterschiedlichen Formen. Beide haben einen Namen, eine Beschreibung und ein JSON-Schema für Argumente. Die Übersetzung ist größtenteils mechanisch, mit einem Haken: Apify-Werkzeugnamen enthalten Zeichen, die Funktionsnamen nicht erlauben. Ein Actor-Werkzeug kannapify/rag-web-browser heißen, und dieser Schrägstrich ist nicht gültig.
Also bereinigen wir die Namen auf dem Hinweg und behalten eine Zuordnung, damit wir sie auf dem Rückweg wiederherstellen können.
In src/venice_terminal_agent/tools.py erledigt ein ToolCatalog die Übersetzung und hält die Zuordnung:
sanitize_tool_name() ersetzt unzulässige Zeichen durch Bindestriche, stellt Namen, die mit einer Ziffer beginnen, ein Präfix voran und kürzt auf 64 Zeichen. unique_name() hängt dann ein numerisches Suffix an, falls diese Kürzung zwei Actors kollidieren lässt — das erspart dir einen wirklich verwirrenden Bug, bei dem das Modell einen Actor aufruft und ein anderer läuft. tool_input_schema() kommt damit zurecht, dass MCP-Server ein dict, ein Pydantic-Modell oder gar nichts zurückgeben.
Ergebnisse zurück in den Kontext formatieren
Werkzeugergebnisse gehen direkt in die Konversation, also müssen sie ein String sein, und sie brauchen ein Größenlimit. Das Scrapen einer Dokumentationsseite kann leicht mehr Text zurückgeben, als das Kontextfenster fasst.format_tool_result() bevorzugt structured_content, wenn der Server es liefert, und flacht andernfalls die Content-Blöcke zu Text ab, wobei es auch mit Blöcken zurechtkommt, die kein TextContent sind. Es endet mit den zwei Zeilen, auf die es ankommt:
{"error": "..."} verpackt statt geworfen. Ein fehlgeschlagener Werkzeugaufruf ist Information, auf die das Modell reagieren kann — es kann einen anderen Actor wählen oder seine Argumente korrigieren — und das kann es nur, wenn der Fehlschlag es als normales Werkzeugergebnis erreicht.
Die Werkzeuge markieren, die Geld kosten
Apify-Werkzeuge teilen sich sauber in zwei Gruppen: solche, die Metadaten und Dokumentation lesen, und solche, die Compute starten. Für die zweite Gruppe wollen wir eine Bestätigung, also setzen wir die erste auf eine Allowlist:Über MCP mit Apify verbinden
Apify bietet zwei Zugänge. Der gehostete Server unterhttps://mcp.apify.com spricht Streamable HTTP, und @apify/actors-mcp-server läuft lokal über stdio via npx. Wir unterstützen beide, da sie zu unterschiedlichen Situationen passen: Gehostet braucht kein Node.js, und stdio hält die Verbindung auf deiner eigenen Maschine.
In src/venice_terminal_agent/apify_mcp.py umschließt eine ApifyMcp-Klasse die verbundene Session. Ihr call_tool() ist die Stelle, an der der bereinigte Name zurückübersetzt wird — Venice sendet apify-rag-web-browser, Apify empfängt apify/rag-web-browser:
client.list_tools(), da ein Token mit Zugriff auf viele Actors eine paginierte Liste erzeugt.
Den Transport besitzen
Eine MCP-Verbindung ist eine langlebige asynchrone Ressource, und der HTTP-Client darunter ebenso. EinApifyMcpSession-Async-Context-Manager hält beide in einem AsyncExitStack, wählt anhand der Einstellungen einen Transport und lädt den Katalog. Das Detail, das es zu kopieren lohnt, ist das Aufräumen:
except BaseException ist wichtiger, als es aussieht. Wenn das Auflisten der Werkzeuge fehlschlägt, nachdem der Transport steht, leckst du ohne es bei jedem Fehlstart des Agenten einen Subprozess oder einen offenen Socket.
Hier sind die beiden Transporte:
APIFY_TOKEN in seiner Umgebung bekommt, nicht deine gesamte Shell-Umgebung — einschließlich deines Venice-Schlüssels.
Einen Werkzeugaufruf ausführen
Das letzte Stück dieses Moduls,execute_venice_tool_call(), verwandelt einen Venice-Werkzeugaufruf in ein String-Ergebnis. Es verpackt beide Fehlerklassen — nicht parsbare Argumente und einen fehlgeschlagenen Apify-Aufruf — als {"error": "..."}, statt sie zu werfen:
{"error": "invalid arguments: ..."} an das Modell einen korrigierten Aufruf in der nächsten Runde, während ein geworfener Fehler die Session beendet und die Konversation verliert.
Die Werkzeugschleife ausführen
Nun zum Agenten selbst, insrc/venice_terminal_agent/agent.py. Beginne mit dem System-Prompt:
search-actors and fetch-actor-details before calling an unfamiliar Actor” existiert, weil ein Modell, das das Eingabeschema eines Actors errät, einen kostenpflichtigen Lauf verschwendet. Die Zeile über abgelehnte Werkzeuge existiert, weil das Modell eine Ablehnung sonst als vorübergehenden Fehler behandelt und es sofort erneut versucht.
Die Agent-Klasse nimmt die beiden Clients, ein Modell, ein Rundenlimit und drei Callbacks entgegen:
on_tool meldet einen Werkzeugaufruf, on_text empfängt gestreamte Tokens, und approve_tool beantwortet die Bestätigungsfrage. Tausche sie aus, und derselbe Agent funktioniert hinter einer Web-App oder einem Chatbot.
Hier ist die Schleife:
start-Index und das del im Exception-Handler verdienen einen genaueren Blick. Wenn eine Frage auf halbem Weg scheitert — Netzwerkfehler, Ctrl+C, Rundenlimit — bleibt in der Konversation ein Assistant-Turn zurück, der Werkzeuge anfordert, die nie Ergebnisse geliefert haben. Venice wird die nächste Anfrage ablehnen, denn auf einen tool_calls-Turn müssen passende tool-Nachrichten folgen. Zum Anfang der Frage zurückzurollen bedeutet, dass eine gescheiterte Frage keine Spur hinterlässt und die REPL nutzbar bleibt.
Den Assistant-Turn zurückspiegeln
Die nächste Funktion ist klein und leicht falsch zu machen:message.model_dump(exclude_none=True), und sie zerstört das Tool Calling. Ein Tool-Call-Turn hat content: null, und diesen Schlüssel wegzulassen verändert die Form der Nachricht, die du zurücksendest. exclude_unset=True ist die Variante, die du willst: Sie behält null-Werte, die das Modell tatsächlich gesetzt hat, und lässt Felder weg, die es nie gesendet hat.
Sie bewahrt außerdem Felder, die das OpenAI-Schema nicht kennt. Reasoning-Modelle geben reasoning_content und reasoning_details zurück, und diese müssen den Rundtrip überleben, damit das Modell seine eigene Gedankenkette über Werkzeugrunden hinweg behält.
Aufrufe ausführen und absichern
Modelle können mehrere Werkzeuge in einem Turn anfordern, und es gibt keinen Grund, sie nacheinander auszuführen. Aber die Genehmigungen wollen wir sequenziell abfragen, da verschachtelte Bestätigungsabfragen unlesbar wären. Also planen wir erst und führen dann nebenläufig aus:tool-Nachricht. Jede tool_call_id braucht eine Antwort, und eine auszulassen hinterlässt eine fehlerhafte Konversation. Die Antwort erklärt eben nur, dass die Nutzerin oder der Nutzer Nein gesagt hat.
Die Genehmigungsprüfung selbst konsultiert beide Namen, da das Modell mit bereinigten Namen arbeitet und unsere Allowlist MCP-Namen verwendet:
Die CLI hinzufügen
Die CLI insrc/venice_terminal_agent/cli.py ist Typer plus eine REPL, und sie ist die uninteressanteste Datei im Projekt — aber drei Details darin lohnen das Kopieren.
Das erste ist, dass die Typer-Optionen als optional typisiert sind und standardmäßig None sind, damit der Settings-Loader „nicht übergeben” von „ein falsy Wert übergeben” unterscheiden kann:
None-Defaults machen die Übergabe an load_settings() sicher, da ein Flag, das du nicht genutzt hast, niemals die Umgebung überschreibt:
yes or None ist dieselbe Idee, angewandt auf ein boolesches Flag: --yes setzt es, und es wegzulassen übergibt None statt False, sodass AUTO_APPROVE_TOOLS aus der Umgebung erhalten bleibt.
Das zweite ist die Startreihenfolge. Löse das Modell auf, öffne dann die MCP-Session, baue dann den Agenten — und schließe den Venice-Client in einem finally, da die MCP-Session und die HTTP-Clients beide abgewickelt werden müssen, ob die Frage nun erfolgreich war oder nicht:
isatty()-Prüfung ist der Teil, den viele vergessen. Führe den Agenten aus cron oder CI aus, und es ist niemand da, der die Abfrage beantwortet — eine naive Implementierung hängt also entweder für immer oder genehmigt stillschweigend. Hier lehnt sie ab, sagt warum, und lässt das Modell mit den Nur-Lese-Werkzeugen weitermachen. default=False bedeutet, dass ein versehentliches Enter keinen kostenpflichtigen Lauf startet, und das Unterbrechen der Abfrage zählt als Nein.
Der Rest des Moduls ist gewöhnliche Terminalarbeit, es lohnt sich also eher zu wissen, was drin ist, als es zu lesen: eine prompt_toolkit-REPL-Schleife, ein _handle_command()-Lookup für die Slash-Befehle, eine render.py mit Rich-Helfern und ein _settings_error(), das einen fehlenden VENICE_API_KEY in eine lesbare Meldung statt eines Pydantic-Tracebacks verwandelt. Drei davon tragen eine Entscheidung:
Die Slash-Befehle sind
/help, /clear, /quit und zwei, die sich bezahlt machen: /tools gibt den geladenen Katalog aus, was meist erklärt, warum der Agent ein merkwürdiges Werkzeug gewählt hat, und /reload nimmt Actors auf, die du mitten in der Session zu deinem Apify-Konto hinzugefügt hast.
Verdrahte zum Schluss den Einstiegspunkt in pyproject.toml, damit uv run venice-agent funktioniert:
Den Agenten ausführen
Starte eine interaktive Session:APIFY_TOKEN nicht geladen, und das jetzt zu bemerken ist deutlich besser als nach zehn Minuten Rätselns, warum der Agent sich weigert, einen Actor auszuführen.
Beschränke den Werkzeugkatalog, wenn du weißt, was du brauchst:
--tools ist der günstigste Weg, die Auswahl einzugrenzen.
Führe den MCP-Server lokal aus, statt den gehosteten zu nutzen:
@apify/actors-mcp-server über npx startet, und sie braucht ein APIFY_TOKEN — für den lokalen Server gibt es keinen anonymen Modus.
Und wenn du wirklich unbeaufsichtigte Actor-Läufe willst:
Die Bausteine testen
Nichts von der interessanten Logik hier braucht ein Netzwerk. EinFakeVenice, das aus einer geskripteten Liste von Antworten poppt, plus ein FakeApify, das einen echten ToolCatalog aus SimpleNamespace-Werkzeugen baut, reicht aus, um eine volle Werkzeugrunde zu treiben:
agent.messages. Dass ein fehlgeschlagener Lauf den Verlauf auf nur ["system"] zurückrollt, egal ob er an einem Venice-Fehler oder am Erschöpfen von max_rounds gescheitert ist. Dass ein Nur-Lese-Werkzeug trotzdem läuft, wenn der Approver False zurückgibt. Und dass ein abgelehntes kostenpflichtiges Werkzeug eine tool-Nachricht mit declined hinterlässt, während apify.calls leer bleibt.
Führe die Suite aus mit:
Hinweise zu Privatsphäre und Kosten
Bei einem Agenten, der zwei APIs erreicht, lohnt sich Präzision:
Venices Zero Data Retention deckt die Modellseite ab. Sie deckt nicht Apify ab, und ein Actor-Lauf schreibt Ergebnisse in dein Apify-Konto. Wenn das für eine bestimmte Aufgabe wichtig ist, lauf ohne
APIFY_TOKEN und bleib bei den anonymen Discovery-Werkzeugen.
Bei den Kosten bringen drei Gewohnheiten viel:
- Lass
--yeswährend der Entwicklung weg. Zu beobachten, welche Actors das Modell ausführen will, ist an sich schon aufschlussreich. - Nutze
--tools, um den Katalog auf Actors einzugrenzen, die du tatsächlich geprüft hast. - Halte
max_roundsmoderat. Zwölf Runden sind für Recherche-Aufgaben reichlich, und eine niedrigere Obergrenze begrenzt den Schaden, wenn ein Modell in einer Schleife feststeckt.
Dieses Beispiel erweitern
Die Schleife ist das Fundament. Sobald sie funktioniert, sind nützliche Richtungen unter anderem:- Füge einen zweiten MCP-Server hinzu. Nichts in
Agentist Apify-spezifisch, das Zusammenführen von Katalogen mehrerer Server bedeutet also hauptsächlich Namespacing der Werkzeugnamen. - Persistiere Konversationen in SQLite, damit du eine Session fortsetzen oder auditieren kannst, was ein Actor zurückgegeben hat.
- Füge Budgets pro Werkzeug hinzu, die Actor-Läufe verfolgen und an einer Obergrenze stoppen, statt jeden einzelnen zu bestätigen.
- Cache Werkzeugergebnisse nach Name und Argumenten, damit wiederholte Dokumentationsabfragen nicht erneut crawlen.
- Pinne ein Modell mit
--modelund vergleiche die Qualität der Werkzeugauswahl mitfunction_calling_default. - Tausche den Approver gegen eine Policy-Funktion, die bestimmte Actors mit bestimmten Argumenten automatisch genehmigt und bei allem anderen nachfragt.