Cosa costruiremo
L’implementazione di riferimento è un piccolo package Python con un compito per modulo:
Una singola domanda lo attraversa così:
- Chiedi a Venice il modello corrente con function calling, a meno che tu non ne abbia fissato uno.
- Connettiti al server MCP di Apify ed elenca i suoi tool.
- Riscrivi quei tool MCP come definizioni di funzione compatibili con OpenAI.
- Invia la domanda con la lista dei tool allegata.
- Se il modello restituisce
tool_calls, eseguili su Apify e aggiungi i risultati come messaggitool. - Ripeti finché il modello risponde con testo invece che con una chiamata a un tool.
Questo agente può spendere compute di Apify sul tuo account. Parti senza
APIFY_TOKEN se vuoi solo i tool di ricerca e documentazione, e lascia --yes disattivato finché non intendi davvero eseguire degli Actor.Configurare il progetto
Il progetto di riferimento usa Python 3.12+ e uv. Crea un nuovo progetto:httpx2, la linea 2.x di httpx, da cui sia openai sia mcp già dipendono. Installarlo direttamente evita di ritrovarsi con due client HTTP nello stesso ambiente.
Poi crea un file .env:
VENICE_API_KEY proviene dalle impostazioni API di Venice. APIFY_TOKEN proviene dalla Apify Console ed è opzionale — vedremo tra un attimo cosa ottieni senza di esso.
Caricare la configurazione
Le impostazioni vengono per prime perché ogni altro modulo le riceve come argomento. Useremopydantic-settings così le variabili d’ambiente, il file .env e i flag della CLI finiscono tutti in un unico oggetto validato.
In src/venice_terminal_agent/config.py, una classe Settings(BaseSettings) contiene i campi che contano:
venice_model è None invece di un ID di modello, e ci torneremo nella prossima sezione. E max_rounds con max_tool_result_chars sono i limiti che fermano un agente fuori controllo: il primo pone un tetto a quanti round di tool può richiedere una domanda, il secondo a quanta parte di una pagina sottoposta a scraping viene reimmessa nel contesto.
La funzione interessante di questo modulo è il costruttore dell’URL:
tools che decide quali tool pubblicizza. Senza un APIFY_TOKEN chiediamo i quattro tool anonimi che funzionano senza autenticazione: ricerca di Actor, dettagli degli Actor, ricerca nella documentazione e recupero della documentazione. Ciò significa che qualcuno può clonare il progetto, aggiungere solo una chiave Venice, e avere comunque un agente funzionante in grado di fare ricerche sugli Actor di Apify. Semplicemente non può eseguirne uno.
Parlare con Venice
Venice è compatibile con OpenAI, quindi possiamo usare l’SDK di OpenAI per le chat completion e un semplicehttpx per la chiamata di discovery dei modelli.
Crea src/venice_terminal_agent/venice.py:
AsyncOpenAI ci regala l’helper per lo streaming e i tool_calls tipizzati. Il client httpx grezzo serve per gli endpoint di Venice che l’SDK di OpenAI non conosce, il che in questo progetto significa /models/traits.
Il timeout della chat è di proposito molto più lungo di quello di discovery. Una domanda che innesca un crawl del web può legittimamente richiedere un paio di minuti.
Scoprire un modello a runtime
Gli ID dei modelli Venice ruotano, e cablarne uno nel codice è il modo più rapido per distribuire un agente che si rompe nel giro di un mese.GET /models/traits mappa nomi di trait stabili su qualunque modello ricopra attualmente quel ruolo, quindi chiediamo function_calling_default invece di nominare un modello:
--model esplicito vince, poi VENICE_MODEL dall’ambiente, infine la ricerca del trait. Quindi il percorso predefinito non richiede alcuna configurazione, ma puoi comunque fissare un modello quando stai confrontando il comportamento di due di essi.
Completions in streaming
Ora aggiungi la chiamata di completion:stream() dell’SDK gestisce entrambe le cose: gli eventi content.delta guidano l’output del terminale, e get_final_completion() restituisce un messaggio completo con i tool_calls già ricuciti insieme.
La richiesta stessa è costruita da una funzione separata così rimane facile da testare:
extra_body è il modo in cui l’SDK di OpenAI fa passare campi che non modella, ed è lì che vanno i venice_parameters. Impostare include_venice_system_prompt a false tiene il prompt assistente predefinito di Venice fuori dalla conversazione, così il nostro system prompt è l’unica istruzione che il modello riceve. Per un agente con regole rigide sui tool, è esattamente ciò che vuoi.
Allega tools e tool_choice solo quando c’è almeno un tool. Inviare un array tools vuoto è un modo inutile di confondere un modello.
Il modulo ha anche un helper format_http_error() che trasforma un APIStatusError o un httpx2.HTTPStatusError in una stringa di una riga con il codice di stato e il corpo della risposta. Gli agenti falliscono al confine con l’API più spesso che altrove, e un messaggio leggibile lì risparmia un sacco di congetture.
Convertire i tool MCP in tool Venice
I tool MCP e i function tool in stile OpenAI descrivono la stessa cosa in forme diverse. Entrambi hanno un nome, una descrizione e uno JSON Schema per gli argomenti. La traduzione è per lo più meccanica, con un’insidia: i nomi dei tool di Apify includono caratteri che i nomi di funzione non ammettono. Un tool Actor può chiamarsiapify/rag-web-browser, e quello slash non è valido.
Quindi sanifichiamo i nomi in uscita e teniamo una mappa per poterli ripristinare al ritorno.
In src/venice_terminal_agent/tools.py, un ToolCatalog fa la traduzione e conserva la mappa:
sanitize_tool_name() sostituisce i caratteri illegali con trattini, aggiunge un prefisso ai nomi che iniziano con una cifra e tronca a 64 caratteri. unique_name() poi aggiunge un suffisso numerico se quel troncamento ha fatto collidere due Actor — il che ti risparmia un bug genuinamente confondente in cui il modello chiama un Actor e ne viene eseguito un altro. tool_input_schema() gestisce i server MCP che restituiscono un dict, un modello Pydantic, o niente del tutto.
Formattare i risultati verso il contesto
I risultati dei tool vanno dritti nella conversazione, quindi devono essere una stringa, e serve loro un limite di dimensione. Fare scraping di un sito di documentazione può facilmente restituire più testo di quanto la context window contenga.format_tool_result() preferisce structured_content quando il server lo fornisce, altrimenti appiattisce i blocchi di contenuto in testo, gestendo i blocchi che non sono TextContent. Termina con le due righe che contano:
{"error": "..."} invece di essere sollevati. Una chiamata a un tool fallita è informazione su cui il modello può agire — può scegliere un Actor diverso o correggere i suoi argomenti — e può farlo solo se il fallimento gli arriva come un normale risultato di tool.
Contrassegnare i tool che costano denaro
I tool di Apify si dividono nettamente in due gruppi: quelli che leggono metadati e documentazione, e quelli che avviano compute. Vogliamo una conferma per il secondo gruppo, quindi mettiamo in allowlist il primo:Connettersi ad Apify via MCP
Apify offre due vie d’accesso. Il server ospitato suhttps://mcp.apify.com parla Streamable HTTP, e @apify/actors-mcp-server gira localmente su stdio tramite npx. Le supporteremo entrambe, dato che servono situazioni diverse: quello ospitato non richiede Node.js, e stdio mantiene la connessione sulla tua macchina.
In src/venice_terminal_agent/apify_mcp.py, una classe ApifyMcp avvolge la sessione connessa. Il suo call_tool() è dove il nome sanificato viene ritradotto — Venice invia apify-rag-web-browser, Apify riceve apify/rag-web-browser:
client.list_tools(), dato che un token con accesso a molti Actor produce una lista paginata.
Possedere il transport
Una connessione MCP è una risorsa async a lunga vita, e lo è anche il client HTTP che le sta sotto. Un context manager asyncApifyMcpSession tiene entrambi in un AsyncExitStack, sceglie un transport in base alle impostazioni e carica il catalogo. Il dettaglio da copiare è la pulizia:
except BaseException conta più di quanto sembri. Se l’elenco dei tool fallisce dopo che il transport è attivo, senza di esso perdi un sottoprocesso o un socket aperto ogni volta che l’agente non riesce ad avviarsi.
Ecco i due transport:
APIFY_TOKEN, non tutto il tuo ambiente shell — inclusa la tua chiave Venice.
Eseguire una chiamata a un tool
L’ultimo pezzo di questo modulo,execute_venice_tool_call(), trasforma una chiamata a un tool di Venice in un risultato stringa. Avvolge entrambe le classi di fallimento — argomenti non analizzabili e una chiamata Apify fallita — come {"error": "..."} invece di sollevare eccezioni:
{"error": "invalid arguments: ..."} ti procura una chiamata corretta al round successivo, mentre sollevare un’eccezione uccide la sessione e perde la conversazione.
Eseguire il ciclo dei tool
Ora l’agente vero e proprio, insrc/venice_terminal_agent/agent.py. Inizia con il system prompt:
search-actors e fetch-actor-details prima di chiamare un Actor sconosciuto” esiste perché un modello che tira a indovinare lo schema di input di un Actor spreca un’esecuzione a pagamento. La riga sui tool rifiutati esiste perché altrimenti il modello tratta un rifiuto come un errore transitorio e riprova immediatamente.
La classe Agent prende i due client, un modello, un limite di round e tre callback:
on_tool segnala una chiamata a un tool, on_text riceve i token in streaming, e approve_tool risponde alla domanda di conferma. Sostituiscile e lo stesso agente funziona dietro una web app o un chat bot.
Ecco il ciclo:
start e il del nel gestore dell’eccezione meritano uno sguardo più attento. Se una domanda fallisce a metà — errore di rete, Ctrl+C, limite di round — la conversazione resta con un turno assistente che richiede tool che non hanno mai prodotto risultati. Venice rifiuterà la richiesta successiva, perché un turno tool_calls deve essere seguito da messaggi tool corrispondenti. Fare rollback al punto in cui la domanda è iniziata significa che una domanda fallita non lascia traccia e la REPL resta usabile.
Riecheggiare il turno dell’assistente
Questa prossima funzione è piccola e facile da sbagliare:message.model_dump(exclude_none=True), e rompe il tool calling. Un turno di chiamata a tool ha content: null, e rimuovere quella chiave cambia la forma del messaggio che rimandi indietro. exclude_unset=True è la versione che vuoi: mantiene i valori null che il modello ha effettivamente impostato, e omette i campi che non ha mai inviato.
Preserva anche campi che lo schema OpenAI non conosce. I modelli di reasoning restituiscono reasoning_content e reasoning_details, e quei campi devono sopravvivere al viaggio di andata e ritorno così il modello mantiene la propria catena di pensiero attraverso i round di tool.
Eseguire e filtrare le chiamate
I modelli possono richiedere più tool in un solo turno, e non c’è motivo di eseguirli uno alla volta. Ma vogliamo chiedere l’approvazione in modo sequenziale, dato che prompt di conferma intercalati sarebbero illeggibili. Quindi prima pianifichiamo, poi eseguiamo in modo concorrente:tool. Ogni tool_call_id ha bisogno di una risposta, e saltarne uno lascia la conversazione malformata. La risposta semplicemente spiega che l’utente ha detto di no.
Il controllo di approvazione stesso consulta entrambi i nomi, dato che il modello lavora con i nomi sanificati e la nostra allowlist usa i nomi MCP:
Aggiungere la CLI
La CLI insrc/venice_terminal_agent/cli.py è Typer più una REPL, ed è il file meno interessante del progetto — ma tre dettagli al suo interno vale la pena copiarli.
Il primo è che le opzioni Typer sono tipizzate come opzionali e hanno None come predefinito, così il loader delle impostazioni può distinguere “non passato” da “passato un valore falsy”:
None sono ciò che rende sicuro il passaggio a load_settings(), dato che un flag che non hai usato non sovrascrive mai l’ambiente:
yes or None è la stessa idea applicata a un flag booleano: --yes lo imposta, e ometterlo passa None invece di False, così AUTO_APPROVE_TOOLS dall’ambiente sopravvive.
Il secondo è l’ordine di avvio. Risolvi il modello, poi apri la sessione MCP, poi costruisci l’agente — e chiudi il client Venice in un finally, dato che la sessione MCP e i client HTTP hanno entrambi bisogno di essere smontati che la domanda sia riuscita o no:
isatty() è la parte che la gente dimentica. Esegui l’agente da cron o dalla CI e non c’è nessuno a rispondere al prompt, quindi un’implementazione ingenua o resta appesa per sempre o approva silenziosamente. Qui rifiuta, spiega perché, e lascia che il modello prosegua con i tool di sola lettura. default=False significa che un Invio distratto non avvia un’esecuzione a pagamento, e interrompere il prompt conta come un no.
Il resto del modulo è ordinario lavoro da terminale, quindi vale la pena sapere cosa c’è più che leggerlo: un ciclo REPL con prompt_toolkit, un lookup _handle_command() per i comandi slash, un render.py di helper Rich, e un _settings_error() che trasforma un VENICE_API_KEY mancante in un messaggio leggibile invece di un traceback Pydantic. Tre di questi portano con sé una decisione:
I comandi slash sono
/help, /clear, /quit, e due che si guadagnano il posto: /tools stampa il catalogo caricato, il che di solito spiega perché l’agente ha scelto un tool strano, e /reload recupera gli Actor che hai aggiunto al tuo account Apify a sessione in corso.
Infine, collega l’entry point in pyproject.toml così uv run venice-agent funziona:
Eseguire l’agente
Avvia una sessione interattiva:APIFY_TOKEN non è stato caricato, ed è molto meglio accorgersene ora che dopo dieci minuti a chiedersi perché l’agente si rifiuta di eseguire un Actor.
Restringi il catalogo dei tool quando sai di cosa hai bisogno:
--tools è il modo più economico per restringere la scelta.
Esegui il server MCP localmente invece di usare quello ospitato:
@apify/actors-mcp-server tramite npx, e richiede un APIFY_TOKEN — non esiste una modalità anonima per il server locale.
E quando vuoi davvero esecuzioni di Actor non presidiate:
Testare i pezzi
Nessuna della logica interessante qui ha bisogno della rete. UnFakeVenice che estrae da una lista predefinita di risposte, più un FakeApify che costruisce un vero ToolCatalog da tool SimpleNamespace, basta per pilotare un round di tool completo:
agent.messages allo stesso modo. Che un’esecuzione fallita riporti la cronologia a solo ["system"], sia che sia fallita per un errore Venice sia per l’esaurimento di max_rounds. Che un tool di sola lettura venga comunque eseguito quando l’approvatore restituisce False. E che un tool a pagamento rifiutato lasci un messaggio tool contenente declined mentre apify.calls resta vuoto.
Esegui la suite con:
Note su privacy e costi
Per un agente che raggiunge due API vale la pena essere precisi:
La zero data retention di Venice copre il lato modello. Non copre Apify, e l’esecuzione di un Actor scrive risultati nel tuo account Apify. Se questo conta per un compito particolare, esegui senza
APIFY_TOKEN e attieniti ai tool di discovery anonimi.
Sul fronte costi, tre abitudini fanno molta strada:
- Lascia
--yesdisattivato durante lo sviluppo. Osservare quali Actor il modello vuole eseguire è informativo di per sé. - Usa
--toolsper restringere il catalogo agli Actor che hai effettivamente esaminato. - Mantieni
max_roundsmodesto. Dodici round bastano e avanzano per compiti di ricerca, e un tetto più basso limita i danni quando un modello si blocca in un loop.
Estendere questo esempio
Il ciclo è la fondazione. Una volta che funziona, tra le direzioni utili ci sono:- Aggiungere un secondo server MCP. Niente in
Agentè specifico di Apify, quindi unire i cataloghi di più server significa per lo più mettere in namespace i nomi dei tool. - Persistere le conversazioni su SQLite così puoi riprendere una sessione o verificare cosa ha restituito un Actor.
- Aggiungere budget per singolo tool che tracciano le esecuzioni degli Actor e si fermano a un tetto, invece di confermarle una a una.
- Mettere in cache i risultati dei tool per nome e argomenti, così ricerche ripetute nella documentazione non rifanno il crawl.
- Fissare un modello con
--modele confrontare la qualità della selezione dei tool confunction_calling_default. - Sostituire l’approvatore con una funzione di policy che approva automaticamente Actor specifici con argomenti specifici e chiede conferma per tutto il resto.