Ce que nous construisons
L’implémentation de référence est un petit paquet Python avec une responsabilité par module :
Une seule question le traverse ainsi :
- Demander à Venice le modèle à appel de fonctions courant, sauf si vous en avez épinglé un.
- Se connecter au serveur MCP d’Apify et lister ses outils.
- Réécrire ces outils MCP en définitions de fonctions compatibles OpenAI.
- Envoyer la question avec la liste d’outils attachée.
- Si le modèle renvoie des
tool_calls, les exécuter contre Apify et ajouter les résultats comme messagestool. - Répéter jusqu’à ce que le modèle réponde par du texte plutôt que par un appel d’outil.
Cet agent peut dépenser du calcul Apify sur votre compte. Commencez sans
APIFY_TOKEN si vous ne voulez que les outils de recherche et de documentation, et n’activez pas --yes tant que vous n’avez pas réellement l’intention d’exécuter des Actors.Mettre en place le projet
Le projet de référence utilise Python 3.12+ et uv. Créez un nouveau projet :httpx2, la ligne 2.x de httpx, dont openai et mcp dépendent déjà tous les deux. L’installer directement évite de se retrouver avec deux clients HTTP dans le même environnement.
Créez ensuite un fichier .env :
VENICE_API_KEY provient des paramètres d’API Venice. APIFY_TOKEN provient de la Console Apify et est facultatif — nous verrons dans un instant ce que vous obtenez sans lui.
Charger la configuration
Les paramètres viennent en premier parce que tous les autres modules les prennent en argument. Nous utiliseronspydantic-settings pour que les variables d’environnement, .env et les options de la CLI atterrissent tous dans un seul objet validé.
Dans src/venice_terminal_agent/config.py, une classe Settings(BaseSettings) porte les champs qui comptent :
venice_model vaut None plutôt qu’un identifiant de modèle, et nous y reviendrons dans la section suivante. Et max_rounds avec max_tool_result_chars sont les limites qui empêchent un agent de s’emballer : le premier plafonne le nombre de tours d’outils qu’une question peut prendre, le second plafonne la quantité de page scrapée réinjectée dans le contexte.
La fonction intéressante de ce module est le constructeur d’URL :
tools qui décide quels outils il annonce. Sans APIFY_TOKEN, nous demandons les quatre outils anonymes qui fonctionnent sans authentification — recherche d’Actors, détails d’un Actor, recherche dans la documentation et récupération de documentation. Quelqu’un peut donc cloner le projet, n’ajouter qu’une clé Venice, et obtenir malgré tout un agent fonctionnel capable de faire des recherches sur les Actors Apify. Il ne peut simplement pas en exécuter un.
Parler à Venice
Venice est compatible OpenAI, nous pouvons donc utiliser le SDK OpenAI pour les chat completions et du simplehttpx pour l’appel de découverte de modèle.
Créez src/venice_terminal_agent/venice.py :
AsyncOpenAI nous donne gratuitement l’assistant de streaming et des tool_calls typés. Le client httpx brut est là pour les points de terminaison Venice que le SDK OpenAI ne connaît pas, ce qui dans ce projet signifie /models/traits.
Le délai d’attente du chat est volontairement beaucoup plus long que celui de la découverte. Une question qui déclenche un crawl web peut légitimement prendre quelques minutes.
Découvrir un modèle au moment de l’exécution
Les identifiants de modèles Venice tournent, et en coder un en dur est le moyen le plus rapide de livrer un agent qui casse au bout d’un mois.GET /models/traits associe des noms de traits stables au modèle qui remplit actuellement ce rôle, nous demandons donc function_calling_default au lieu de nommer un modèle :
--model gagne, puis VENICE_MODEL depuis l’environnement, puis la recherche par trait. Le chemin par défaut n’exige donc aucune configuration, mais vous pouvez toujours épingler un modèle quand vous comparez le comportement de deux d’entre eux.
Diffuser les complétions en continu
Ajoutez maintenant l’appel de complétion :stream() du SDK gère les deux : les événements content.delta pilotent la sortie du terminal, et get_final_completion() restitue un message complet avec les tool_calls déjà recousus.
La requête elle-même est construite par une fonction séparée pour rester facile à tester :
extra_body est le moyen par lequel le SDK OpenAI transmet les champs qu’il ne modélise pas, et c’est là que va venice_parameters. Mettre include_venice_system_prompt à false garde le prompt d’assistant par défaut de Venice hors de la conversation, de sorte que notre propre prompt système est la seule instruction que le modèle reçoit. Pour un agent avec des règles d’outils strictes, c’est ce que vous voulez.
N’attachez tools et tool_choice que lorsqu’il y a au moins un outil. Envoyer un tableau tools vide est une façon inutile de dérouter un modèle.
Le module dispose aussi d’un assistant format_http_error() qui transforme une APIStatusError ou une httpx2.HTTPStatusError en une chaîne d’une ligne avec le code de statut et le corps de la réponse. Les agents échouent à la frontière de l’API plus souvent que partout ailleurs, et un message lisible à cet endroit épargne beaucoup de tâtonnements.
Convertir les outils MCP en outils Venice
Les outils MCP et les outils de fonction de style OpenAI décrivent la même chose sous des formes différentes. Les deux ont un nom, une description et un JSON Schema pour les arguments. La traduction est essentiellement mécanique, avec un piège : les noms d’outils Apify contiennent des caractères que les noms de fonctions n’autorisent pas. Un outil d’Actor peut s’appelerapify/rag-web-browser, et cette barre oblique n’est pas valide.
Nous assainissons donc les noms à la sortie et gardons une table de correspondance pour pouvoir les restaurer au retour.
Dans src/venice_terminal_agent/tools.py, un ToolCatalog fait la traduction et détient la table :
sanitize_tool_name() remplace les caractères illégaux par des traits d’union, préfixe les noms qui commencent par un chiffre, et tronque à 64 caractères. unique_name() ajoute ensuite un suffixe numérique si cette troncature a fait entrer deux Actors en collision — ce qui vous épargne un bogue vraiment déroutant où le modèle appelle un Actor et un autre s’exécute. tool_input_schema() s’accommode des serveurs MCP qui renvoient un dict, un modèle Pydantic, ou rien du tout.
Mettre en forme les résultats pour le contexte
Les résultats d’outils vont directement dans la conversation, ils doivent donc être une chaîne, et il leur faut une limite de taille. Scraper un site de documentation peut facilement renvoyer plus de texte que la fenêtre de contexte n’en contient.format_tool_result() privilégie structured_content quand le serveur le fournit, et sinon aplatit les blocs de contenu en texte, en s’accommodant des blocs qui ne sont pas des TextContent. Il se termine par les deux lignes qui comptent :
{"error": "..."} plutôt que levées. Un appel d’outil échoué est une information sur laquelle le modèle peut agir — il peut choisir un autre Actor ou corriger ses arguments — et il ne peut le faire que si l’échec lui parvient comme un résultat d’outil normal.
Marquer les outils qui coûtent de l’argent
Les outils Apify se scindent proprement en deux groupes : ceux qui lisent des métadonnées et de la documentation, et ceux qui lancent du calcul. Nous voulons une confirmation pour le second groupe, nous mettons donc le premier sur liste d’autorisation :Se connecter à Apify via MCP
Apify offre deux voies d’accès. Le serveur hébergé surhttps://mcp.apify.com parle Streamable HTTP, et @apify/actors-mcp-server s’exécute localement sur stdio via npx. Nous prendrons en charge les deux, car ils conviennent à des situations différentes : la version hébergée ne nécessite pas de Node.js, et stdio garde la connexion sur votre propre machine.
Dans src/venice_terminal_agent/apify_mcp.py, une classe ApifyMcp enveloppe la session connectée. Son call_tool() est l’endroit où le nom assaini est retraduit — Venice envoie apify-rag-web-browser, Apify reçoit apify/rag-web-browser :
client.list_tools(), car un jeton ayant accès à de nombreux Actors produit une liste paginée.
Posséder le transport
Une connexion MCP est une ressource asynchrone à longue durée de vie, tout comme le client HTTP en dessous. Un gestionnaire de contexte asynchroneApifyMcpSession détient les deux dans un AsyncExitStack, choisit un transport selon les paramètres, et charge le catalogue. Le détail qui vaut la peine d’être copié est le nettoyage :
except BaseException compte plus qu’il n’y paraît. Si le listage des outils échoue après que le transport est établi, sans lui vous laissez fuir un sous-processus ou une socket ouverte à chaque fois que l’agent échoue au démarrage.
Voici les deux transports :
APIFY_TOKEN dans son environnement, pas tout votre environnement shell — y compris votre clé Venice.
Exécuter un appel d’outil
La dernière pièce de ce module,execute_venice_tool_call(), transforme un appel d’outil Venice en résultat sous forme de chaîne. Elle enveloppe les deux classes d’échec — des arguments impossibles à analyser et un appel Apify échoué — en {"error": "..."} plutôt que de lever une exception :
{"error": "invalid arguments: ..."} vous vaut un appel corrigé au tour suivant, alors que lever une exception tue la session et perd la conversation.
Exécuter la boucle d’outils
Passons à l’agent lui-même, danssrc/venice_terminal_agent/agent.py. Commencez par le prompt système :
search-actors et fetch-actor-details avant d’appeler un Actor inconnu » existe parce qu’un modèle qui devine le schéma d’entrée d’un Actor gaspille une exécution payante. La ligne sur les outils refusés existe parce que, sinon, le modèle traite un refus comme une erreur transitoire et réessaie immédiatement.
La classe Agent prend les deux clients, un modèle, une limite de tours, et trois rappels :
on_tool signale un appel d’outil, on_text reçoit les jetons diffusés en continu, et approve_tool répond à la question de confirmation. Remplacez-les et le même agent fonctionne derrière une application web ou un bot de discussion.
Voici la boucle :
start et le del dans le gestionnaire d’exception méritent un examen plus attentif. Si une question échoue à mi-parcours — erreur réseau, Ctrl+C, limite de tours —, la conversation reste avec un tour d’assistant qui demande des outils n’ayant jamais produit de résultats. Venice rejettera la requête suivante, car un tour de tool_calls doit être suivi des messages tool correspondants. Revenir en arrière jusqu’au point où la question a commencé signifie qu’une question échouée ne laisse aucune trace et que le REPL reste utilisable.
Renvoyer le tour de l’assistant
Cette fonction est petite et facile à rater :message.model_dump(exclude_none=True), et elle casse l’appel d’outils. Un tour d’appel d’outil a content: null, et supprimer cette clé change la forme du message que vous renvoyez. exclude_unset=True est la version que vous voulez : elle conserve les valeurs null que le modèle a réellement définies, et omet les champs qu’il n’a jamais envoyés.
Elle préserve aussi les champs que le schéma OpenAI ne connaît pas. Les modèles de raisonnement renvoient reasoning_content et reasoning_details, et ceux-ci doivent survivre à l’aller-retour pour que le modèle conserve sa propre chaîne de pensée d’un tour d’outils à l’autre.
Exécuter et filtrer les appels
Les modèles peuvent demander plusieurs outils dans un même tour, et il n’y a aucune raison de les exécuter un par un. Mais nous voulons demander l’approbation séquentiellement, car des invites de confirmation entrelacées seraient illisibles. Nous planifions donc d’abord, puis exécutons en parallèle :tool. Chaque tool_call_id a besoin d’une réponse, et en sauter une laisse la conversation mal formée. La réponse se trouve simplement expliquer que l’utilisateur a dit non.
La vérification d’approbation elle-même consulte les deux noms, puisque le modèle travaille avec les noms assainis et que notre liste d’autorisation utilise les noms MCP :
Ajouter la CLI
La CLI danssrc/venice_terminal_agent/cli.py est du Typer plus un REPL, et c’est le fichier le moins intéressant du projet — mais trois détails y valent la peine d’être copiés.
Le premier est que les options Typer sont typées comme optionnelles et valent None par défaut, pour que le chargeur de paramètres puisse distinguer « non passé » de « passé avec une valeur fausse » :
None sont ce qui rend le passage à load_settings() sûr, puisqu’une option que vous n’avez pas utilisée n’écrase jamais l’environnement :
yes or None est la même idée appliquée à une option booléenne : --yes la définit, et l’omettre passe None plutôt que False, si bien que AUTO_APPROVE_TOOLS provenant de l’environnement survit.
Le deuxième est l’ordre de démarrage. Résoudre le modèle, puis ouvrir la session MCP, puis construire l’agent — et fermer le client Venice dans un finally, car la session MCP et les clients HTTP doivent tous deux être démontés, que la question ait réussi ou non :
isatty() est la partie que les gens oublient. Lancez l’agent depuis cron ou la CI et il n’y a personne pour répondre à l’invite, donc une implémentation naïve soit se bloque pour toujours, soit approuve silencieusement. Ici, elle refuse, dit pourquoi, et laisse le modèle continuer avec les outils en lecture seule. default=False signifie qu’un appui malencontreux sur Entrée ne lance pas une exécution payante, et interrompre l’invite compte comme un non.
Le reste du module est du travail de terminal ordinaire, il vaut donc mieux savoir ce qui s’y trouve plutôt que de le lire : une boucle REPL prompt_toolkit, une table _handle_command() pour les commandes slash, un render.py d’assistants Rich, et une _settings_error() qui transforme un VENICE_API_KEY manquant en un message lisible au lieu d’une trace Pydantic. Trois de ces éléments portent une décision :
Les commandes slash sont
/help, /clear, /quit, et deux qui méritent leur place : /tools imprime le catalogue chargé, ce qui explique généralement pourquoi l’agent a choisi un outil étrange, et /reload récupère les Actors que vous avez ajoutés à votre compte Apify en cours de session.
Enfin, câblez le point d’entrée dans pyproject.toml pour que uv run venice-agent fonctionne :
Exécuter l’agent
Démarrez une session interactive :APIFY_TOKEN ne s’est pas chargé, et il vaut bien mieux s’en apercevoir maintenant qu’après dix minutes à se demander pourquoi l’agent refuse d’exécuter un Actor.
Restreignez le catalogue d’outils quand vous savez ce dont vous avez besoin :
--tools est le moyen le moins coûteux de restreindre le choix.
Exécutez le serveur MCP localement au lieu d’utiliser la version hébergée :
@apify/actors-mcp-server via npx, et il lui faut un APIFY_TOKEN — il n’y a pas de mode anonyme pour le serveur local.
Et quand vous voulez véritablement des exécutions d’Actors sans surveillance :
Tester les pièces
Aucune des logiques intéressantes ici n’a besoin du réseau. UnFakeVenice qui dépile une liste scriptée de réponses, plus un FakeApify qui construit un vrai ToolCatalog à partir d’outils SimpleNamespace, suffisent à piloter un tour d’outils complet :
agent.messages de la même manière. Qu’une exécution échouée ramène l’historique à juste ["system"], qu’elle ait échoué sur une erreur Venice ou en épuisant max_rounds. Qu’un outil en lecture seule s’exécute quand même lorsque l’approbateur renvoie False. Et qu’un outil payant refusé laisse un message tool contenant declined tandis que apify.calls reste vide.
Lancez la suite avec :
Notes sur la confidentialité et les coûts
Un agent qui atteint deux API mérite qu’on soit précis :
La rétention de données nulle de Venice couvre le côté modèle. Elle ne couvre pas Apify, et une exécution d’Actor écrit des résultats dans votre compte Apify. Si cela compte pour une tâche particulière, exécutez sans
APIFY_TOKEN et tenez-vous-en aux outils de découverte anonymes.
Côté coûts, trois habitudes font beaucoup :
- Laissez
--yesdésactivé pendant le développement. Observer quels Actors le modèle veut exécuter est instructif en soi. - Utilisez
--toolspour restreindre le catalogue aux Actors que vous avez réellement examinés. - Gardez
max_roundsmodeste. Douze tours suffisent largement pour des tâches de recherche, et un plafond plus bas limite les dégâts quand un modèle se retrouve coincé dans une boucle.
Prolonger cet exemple
La boucle est la fondation. Une fois qu’elle fonctionne, les directions utiles incluent :- Ajouter un second serveur MCP. Rien dans
Agentn’est spécifique à Apify, donc fusionner les catalogues de plusieurs serveurs revient surtout à préfixer les noms d’outils. - Persister les conversations dans SQLite pour pouvoir reprendre une session ou auditer ce qu’un Actor a renvoyé.
- Ajouter des budgets par outil qui suivent les exécutions d’Actors et s’arrêtent à un plafond, plutôt que de confirmer chacune.
- Mettre en cache les résultats d’outils par nom et arguments, pour que les consultations de documentation répétées ne relancent pas un crawl.
- Épingler un modèle avec
--modelet comparer la qualité de sélection d’outils à celle defunction_calling_default. - Remplacer l’approbateur par une fonction de politique qui auto-approuve certains Actors avec certains arguments et demande confirmation pour tout le reste.