Skip to main content
Un modello da solo non può dirti cosa c’è in prima pagina su Hacker News in questo momento. Per farlo gli servono dei tool, e qualcuno deve costruirli e mantenerli. Apify lo ha già fatto: ospita migliaia di Actor che fanno scraping di siti, esplorano documentazione ed estraggono dati strutturati, e li espone tramite il Model Context Protocol. Quella combinazione si sposa bene con Venice. Venice fornisce function calling compatibile con OpenAI senza ritenzione dei dati, Apify fornisce i tool, e MCP è il formato di trasporto tra i due. Non scrivi uno scraper per ogni sito: ti connetti una volta e lasci che sia il modello a scegliere l’Actor. In questo tutorial costruiremo un agente da terminale in Python che fa esattamente questo. Alla fine avrai una CLI che scopre a runtime un modello Venice con function calling, carica il catalogo dei tool di Apify via MCP, trasmette in streaming le risposte nel tuo terminale, e chiede conferma prima di spendere denaro per l’esecuzione di un Actor. Ti interessa l’implementazione completa del codice? Dai un’occhiata al repository GitHub. Prima di continuare, ti servirà una chiave API di Venice:

Cosa costruiremo

L’implementazione di riferimento è un piccolo package Python con un compito per modulo: Una singola domanda lo attraversa così:
  1. Chiedi a Venice il modello corrente con function calling, a meno che tu non ne abbia fissato uno.
  2. Connettiti al server MCP di Apify ed elenca i suoi tool.
  3. Riscrivi quei tool MCP come definizioni di funzione compatibili con OpenAI.
  4. Invia la domanda con la lista dei tool allegata.
  5. Se il modello restituisce tool_calls, eseguili su Apify e aggiungi i risultati come messaggi tool.
  6. Ripeti finché il modello risponde con testo invece che con una chiamata a un tool.
I passi da 4 a 6 sono l’intero agente. Tutto il resto esiste per rendere quei tre passi sicuri e piacevoli da usare.
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:
Installa le dipendenze:
Quello è 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. Useremo pydantic-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:
Due di questi campi portano decisioni più che valori predefiniti. 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:
Il server MCP ospitato di Apify accetta un parametro di query 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 semplice httpx per la chiamata di discovery dei modelli. Crea src/venice_terminal_agent/venice.py:
Due client per una sola API sembrano ridondanti, ma fanno lavori diversi. 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:
La precedenza qui conta: un flag --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.
Non tutti i modelli di testo supportano il function calling. Chiedere il trait function_calling_default ti garantisce di ottenerne uno che lo supporta, senza dover mantenere una lista per conto tuo. Consulta le Deprecazioni per sapere con quale frequenza cambiano gli ID sottostanti.

Completions in streaming

Ora aggiungi la chiamata di completion:
Facciamo streaming così l’utente vede il testo apparire mentre viene generato, ma vogliamo comunque il messaggio assemblato alla fine — le chiamate ai tool arrivano a frammenti distribuiti su molti chunk, e riassemblarle a mano è tedioso. Il context manager 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ò chiamarsi apify/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:
Tre piccoli helper fanno il lavoro poco glamour. 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:
L’avviso di troncamento è scritto per il modello, non per te. Dirgli che il contenuto è stato tagliato e suggerire filtri, limiti o offset è di solito sufficiente perché faccia una seconda chiamata più mirata invece di presumere di aver visto tutto. Gli errori vengono avvolti come {"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:
Una allowlist invece di una blocklist è la scelta importante. Apify continua ad aggiungere tool e Actor, e qualsiasi cosa l’agente non abbia mai visto prima chiede conferma per impostazione predefinita. Fallo al contrario e ogni nuovo Actor viene approvato automaticamente.

Connettersi ad Apify via MCP

Apify offre due vie d’accesso. Il server ospitato su https://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:
Costruire il catalogo richiede un ciclo con cursore su 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 async ApifyMcpSession tiene entrambi in un AsyncExitStack, sceglie un transport in base alle impostazioni e carica il catalogo. Il dettaglio da copiare è la pulizia:
Quell’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:
Nota il read timeout di 300 secondi sul transport HTTP. Le esecuzioni degli Actor sono lente, e il timeout predefinito di 30 secondi taglierà crawl perfettamente sani. Nota anche che il sottoprocesso stdio riceve nel suo ambiente solo 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:
Argomenti JSON malformati succedono. Quando capita, consegnare al modello {"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, in src/venice_terminal_agent/agent.py. Inizia con il system prompt:
Ogni regola lì mappa su un fallimento specifico che vogliamo evitare. “Preferisci 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:
Quelle callback sono ciò che mantiene l’agente indipendente dal terminale. 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:
Quello è l’intero agente: chiama il modello, e se ha chiesto dei tool, eseguili e chiama di nuovo. L’indice 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:
L’implementazione ovvia è 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:
Anche i tool rifiutati ricevono un messaggio 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 in src/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”:
Quei predefiniti None sono ciò che rende sicuro il passaggio a load_settings(), dato che un flag che non hai usato non sovrascrive mai l’ambiente:
Il 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:
Il terzo è l’approvatore, che è l’unico pezzo dell’agente che esiste puramente per proteggere la tua bolletta Apify:
Il controllo 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:
Oppure fai una domanda ed esci:
Vedrai il banner, poi le chiamate ai tool man mano che avvengono:
Leggi la riga Apify di quel banner prima di ogni altra cosa. Se dice “anonymous Apify tools only”, il tuo 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:
Un catalogo più piccolo non è solo una questione di costi. I modelli in genere scelgono meglio quando ci sono meno tool, più pertinenti, tra cui scegliere, e --tools è il modo più economico per restringere la scelta. Esegui il server MCP localmente invece di usare quello ospitato:
Questo richiede Node.js nel tuo PATH, dato che lancia @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. Un FakeVenice 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:
Fare assert sulla sequenza dei ruoli è una buona abitudine per il codice degli agenti. Cattura i bug di conversazione malformata che altrimenti restano invisibili finché Venice non restituisce un 400. Vale la pena scrivere altri tre test, e tutti fanno assert su 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 --yes disattivato durante lo sviluppo. Osservare quali Actor il modello vuole eseguire è informativo di per sé.
  • Usa --tools per restringere il catalogo agli Actor che hai effettivamente esaminato.
  • Mantieni max_rounds modesto. 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 --model e confrontare la qualità della selezione dei tool con function_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.
Per un punto di partenza più piccolo senza MCP, Costruire un agente che usa tool copre lo stesso ciclo con tre funzioni Python locali.

Per concludere

Grazie per aver letto! Speriamo che questo ti abbia aiutato a costruire un agente da terminale che pensa con Venice e agisce tramite Apify. Il pattern da portarsi a casa è quanto poco di questo codice riguardi l’intelligenza. Il modello restituisce chiamate a tool, e il tuo codice decide quali possono essere eseguite, come tornano i loro risultati, e cosa succede quando qualcosa fallisce. Una volta che quelle decisioni sono esplicite, aggiungere capacità è per lo più questione di puntare l’agente verso più tool.