Qué vamos a construir
La implementación de referencia es un paquete pequeño de Python con una tarea por módulo:
Una sola pregunta fluye a través de él así:
- Pide a Venice el modelo actual con function calling, salvo que hayas fijado uno.
- Conéctate al servidor MCP de Apify y lista sus herramientas.
- Reescribe esas herramientas MCP como definiciones de función compatibles con OpenAI.
- Envía la pregunta con la lista de herramientas adjunta.
- Si el modelo devuelve
tool_calls, ejecútalos contra Apify y añade los resultados como mensajestool. - Repite hasta que el modelo responda con texto en lugar de una llamada a herramienta.
Este agente puede gastar cómputo de Apify en tu cuenta. Empieza sin
APIFY_TOKEN si solo quieres herramientas de búsqueda y documentación, y deja --yes desactivado hasta que realmente tengas la intención de ejecutar Actors.Configurar el proyecto
El proyecto de referencia usa Python 3.12+ y uv. Crea un proyecto nuevo:httpx2, la línea 2.x de httpx, de la que tanto openai como mcp ya dependen. Instalarlo directamente evita acabar con dos clientes HTTP en el mismo entorno.
Después crea un archivo .env:
VENICE_API_KEY viene de la configuración de la API de Venice. APIFY_TOKEN viene de la Consola de Apify y es opcional — enseguida veremos qué obtienes sin él.
Cargar la configuración
La configuración va primero porque todos los demás módulos la reciben como argumento. Usaremospydantic-settings para que las variables de entorno, .env y las banderas del CLI acaben en un único objeto validado.
En src/venice_terminal_agent/config.py, una clase Settings(BaseSettings) lleva los campos que importan:
venice_model es None en lugar de un ID de modelo, algo a lo que volveremos en la siguiente sección. Y max_rounds junto con max_tool_result_chars son los límites que impiden que un agente se descontrole: el primero limita cuántas rondas de herramientas puede consumir una pregunta, el segundo limita cuánto de una página raspada vuelve a entrar en el contexto.
La función interesante de este módulo es el constructor de URL:
tools que decide qué herramientas anuncia. Sin un APIFY_TOKEN pedimos las cuatro herramientas anónimas que funcionan sin autenticación: búsqueda de Actors, detalles de Actors, búsqueda de documentación y descarga de documentación. Eso significa que alguien puede clonar el proyecto, añadir solo una clave de Venice y aun así tener un agente funcional capaz de investigar Actors de Apify. Simplemente no puede ejecutar ninguno.
Hablar con Venice
Venice es compatible con OpenAI, así que podemos usar el SDK de OpenAI para chat completions yhttpx a secas para la llamada de descubrimiento de modelos.
Crea src/venice_terminal_agent/venice.py:
AsyncOpenAI nos da gratis el helper de streaming y los tool_calls tipados. El cliente httpx en crudo está ahí para los endpoints de Venice que el SDK de OpenAI no conoce, que en este proyecto significa /models/traits.
El timeout del chat es mucho más largo que el timeout de descubrimiento a propósito. Una pregunta que desencadena un rastreo web puede tardar legítimamente un par de minutos.
Descubrir un modelo en tiempo de ejecución
Los IDs de modelo de Venice rotan, y hardcodear uno es la forma más rápida de publicar un agente que se rompe en un mes.GET /models/traits mapea nombres de traits estables al modelo que actualmente cumple ese rol, así que pedimos function_calling_default en lugar de nombrar un modelo:
--model explícita gana, luego VENICE_MODEL del entorno, y después la consulta del trait. Así que la ruta por defecto no necesita configuración alguna, pero aún puedes fijar un modelo cuando estés comparando el comportamiento entre dos de ellos.
Completions en streaming
Ahora añade la llamada de completion:stream() del SDK se encarga de ambas cosas: los eventos content.delta alimentan la salida de la terminal, y get_final_completion() devuelve un mensaje completo con los tool_calls ya cosidos.
La petición en sí la construye una función aparte para que siga siendo fácil de probar:
extra_body es la forma en que el SDK de OpenAI pasa campos que no modela, y ahí es donde va venice_parameters. Poner include_venice_system_prompt a false mantiene el prompt de asistente por defecto de Venice fuera de la conversación, de modo que nuestro propio system prompt es la única instrucción que recibe el modelo. Para un agente con reglas estrictas sobre herramientas, eso es lo que quieres.
Adjunta tools y tool_choice solo cuando haya al menos una herramienta. Enviar un array tools vacío es una forma innecesaria de confundir a un modelo.
El módulo también tiene un helper format_http_error() que convierte un APIStatusError o un httpx2.HTTPStatusError en una cadena de una línea con el código de estado y el cuerpo de la respuesta. Los agentes fallan en la frontera de la API más que en cualquier otro sitio, y un mensaje legible ahí ahorra mucho adivinar.
Convertir herramientas MCP en herramientas de Venice
Las herramientas MCP y las herramientas de función al estilo OpenAI describen lo mismo con formas distintas. Ambas tienen un nombre, una descripción y un JSON Schema para los argumentos. La traducción es mayormente mecánica, con una pega: los nombres de las herramientas de Apify incluyen caracteres que los nombres de función no permiten. Una herramienta de Actor puede llamarseapify/rag-web-browser, y esa barra no es válida.
Así que saneamos los nombres a la salida y guardamos un mapa para poder restaurarlos a la vuelta.
En src/venice_terminal_agent/tools.py, un ToolCatalog hace la traducción y guarda el mapa:
sanitize_tool_name() sustituye los caracteres ilegales por guiones, añade un prefijo a los nombres que empiezan por dígito y trunca a 64 caracteres. unique_name() añade después un sufijo numérico si ese truncamiento hizo colisionar dos Actors — lo que te ahorra un bug genuinamente confuso en el que el modelo llama a un Actor y se ejecuta otro distinto. tool_input_schema() se las arregla con servidores MCP que devuelven un dict, un modelo de Pydantic o nada en absoluto.
Formatear los resultados de vuelta al contexto
Los resultados de las herramientas van directos a la conversación, así que tienen que ser una cadena, y necesitan un límite de tamaño. Raspar un sitio de documentación puede devolver fácilmente más texto del que cabe en la ventana de contexto.format_tool_result() prefiere structured_content cuando el servidor lo proporciona, y en caso contrario aplana los bloques de contenido a texto, lidiando con bloques que no son TextContent. Termina con las dos líneas que importan:
{"error": "..."} en lugar de lanzarse. Una llamada a herramienta fallida es información sobre la que el modelo puede actuar — puede elegir otro Actor o corregir sus argumentos — y solo puede hacerlo si el fallo le llega como un resultado de herramienta normal.
Marcar las herramientas que cuestan dinero
Las herramientas de Apify se dividen limpiamente en dos grupos: las que leen metadatos y documentación, y las que arrancan cómputo. Queremos confirmación para el segundo grupo, así que ponemos el primero en una lista de permitidos:Conectarse a Apify por MCP
Apify ofrece dos vías de entrada. El servidor alojado enhttps://mcp.apify.com habla Streamable HTTP, y @apify/actors-mcp-server corre localmente sobre stdio vía npx. Admitiremos ambas, porque encajan en situaciones distintas: la alojada no necesita Node.js, y stdio mantiene la conexión en tu propia máquina.
En src/venice_terminal_agent/apify_mcp.py, una clase ApifyMcp envuelve la sesión conectada. Su call_tool() es donde el nombre saneado se traduce de vuelta — Venice envía apify-rag-web-browser, Apify recibe apify/rag-web-browser:
client.list_tools(), ya que un token con acceso a muchos Actors produce una lista paginada.
Ser dueño del transporte
Una conexión MCP es un recurso async de larga vida, y también lo es el cliente HTTP que hay debajo. Un async context managerApifyMcpSession guarda ambos en un AsyncExitStack, elige un transporte según la configuración y carga el catálogo. El detalle que vale la pena copiar es la limpieza:
except BaseException importa más de lo que parece. Si listar las herramientas falla después de que el transporte esté levantado, sin él filtras un subproceso o un socket abierto cada vez que el agente falla al arrancar.
Aquí están los dos transportes:
APIFY_TOKEN en su entorno, no todo tu entorno de shell — incluida tu clave de Venice.
Ejecutar una llamada a herramienta
La última pieza de este módulo,execute_venice_tool_call(), convierte una llamada a herramienta de Venice en un resultado de cadena. Envuelve las dos clases de fallo — argumentos imposibles de parsear y una llamada fallida a Apify — como {"error": "..."} en lugar de lanzar una excepción:
{"error": "invalid arguments: ..."} te consigue una llamada corregida en la siguiente ronda, mientras que lanzar una excepción mata la sesión y pierde la conversación.
Ejecutar el bucle de herramientas
Ahora el agente en sí, ensrc/venice_terminal_agent/agent.py. Empieza con el system prompt:
search-actors and fetch-actor-details before calling an unfamiliar Actor” existe porque un modelo que adivina el esquema de entrada de un Actor desperdicia una ejecución de pago. La línea sobre herramientas rechazadas existe porque, de lo contrario, el modelo trata un rechazo como un error transitorio y lo intenta de nuevo inmediatamente.
La clase Agent recibe los dos clientes, un modelo, un límite de rondas y tres callbacks:
on_tool informa de una llamada a herramienta, on_text recibe los tokens en streaming, y approve_tool responde la pregunta de confirmación. Cámbialos y el mismo agente funciona detrás de una aplicación web o un bot de chat.
Aquí está el bucle:
start y el del en el manejador de excepciones merecen una mirada más de cerca. Si una pregunta falla a medio camino — error de red, Ctrl+C, límite de rondas — la conversación queda con un turno del asistente pidiendo herramientas que nunca produjeron resultados. Venice rechazará la siguiente petición, porque un turno con tool_calls debe ir seguido de los mensajes tool correspondientes. Retroceder hasta donde empezó la pregunta significa que una pregunta fallida no deja rastro y el REPL sigue siendo usable.
Devolver el turno del asistente
Esta función es pequeña y fácil de hacer mal:message.model_dump(exclude_none=True), y rompe el tool calling. Un turno de llamada a herramienta tiene content: null, y eliminar esa clave cambia la forma del mensaje que devuelves. exclude_unset=True es la versión que quieres: conserva los valores null que el modelo realmente estableció, y omite los campos que nunca envió.
También preserva campos que el esquema de OpenAI no conoce. Los modelos de razonamiento devuelven reasoning_content y reasoning_details, y esos necesitan sobrevivir el viaje de ida y vuelta para que el modelo mantenga su propia cadena de pensamiento a través de las rondas de herramientas.
Ejecutar y controlar las llamadas
Los modelos pueden pedir varias herramientas en un turno, y no hay razón para ejecutarlas de una en una. Pero sí queremos pedir aprobación de forma secuencial, ya que unos avisos de confirmación entrelazados serían ilegibles. Así que primero planificamos y luego ejecutamos en concurrencia:tool. Cada tool_call_id necesita una respuesta, y saltarse una deja la conversación malformada. La respuesta simplemente explica que el usuario dijo que no.
La comprobación de aprobación en sí consulta ambos nombres, ya que el modelo trabaja con nombres saneados y nuestra lista de permitidos usa nombres MCP:
Añadir el CLI
El CLI desrc/venice_terminal_agent/cli.py es Typer más un REPL, y es el archivo menos interesante del proyecto — pero tres detalles suyos merecen copiarse.
El primero es que las opciones de Typer están tipadas como opcionales y su valor por defecto es None, para que el cargador de configuración pueda distinguir “no se pasó” de “se pasó un valor falsy”:
None son lo que hace seguro el traspaso a load_settings(), porque una bandera que no usaste nunca sobrescribe el entorno:
yes or None es la misma idea aplicada a una bandera booleana: --yes la activa, y omitirla pasa None en lugar de False, de modo que AUTO_APPROVE_TOOLS del entorno sobrevive.
El segundo es el orden de arranque. Resuelve el modelo, luego abre la sesión MCP, luego construye el agente — y cierra el cliente de Venice en un finally, ya que tanto la sesión MCP como los clientes HTTP necesitan desmontarse tanto si la pregunta tuvo éxito como si no:
isatty() es la parte que la gente olvida. Ejecuta el agente desde cron o CI y no hay nadie para responder al aviso, así que una implementación ingenua o se cuelga para siempre o aprueba en silencio. Aquí declina, dice por qué y deja que el modelo siga adelante con las herramientas de solo lectura. default=False significa que un Enter perdido no arranca una ejecución de pago, e interrumpir el aviso cuenta como un no.
El resto del módulo es trabajo de terminal corriente, así que vale la pena saber qué hay ahí en lugar de leerlo: un bucle REPL con prompt_toolkit, una búsqueda _handle_command() para los comandos slash, un render.py con helpers de Rich, y un _settings_error() que convierte un VENICE_API_KEY ausente en un mensaje legible en lugar de un traceback de Pydantic. Tres de esas piezas llevan una decisión:
Los comandos slash son
/help, /clear, /quit, y dos que se ganan el sueldo: /tools imprime el catálogo cargado, lo que suele explicar por qué el agente eligió una herramienta rara, y /reload recoge los Actors que añadiste a tu cuenta de Apify a mitad de sesión.
Por último, conecta el punto de entrada en pyproject.toml para que uv run venice-agent funcione:
Ejecutar el agente
Inicia una sesión interactiva:APIFY_TOKEN no se cargó, y es mucho mejor notarlo ahora que después de diez minutos preguntándote por qué el agente se niega a ejecutar un Actor.
Restringe el catálogo de herramientas cuando sepas lo que necesitas:
--tools es la forma más barata de acotar la elección.
Ejecuta el servidor MCP localmente en lugar de usar el alojado:
@apify/actors-mcp-server a través de npx, y necesita un APIFY_TOKEN — no hay modo anónimo para el servidor local.
Y cuando realmente quieras ejecuciones de Actors sin supervisión:
Probar las piezas
Nada de la lógica interesante de aquí necesita red. UnFakeVenice que va sacando respuestas de una lista guionizada, más un FakeApify que construye un ToolCatalog real a partir de herramientas SimpleNamespace, es suficiente para hacer una ronda completa de herramientas:
agent.messages de la misma manera. Que una ejecución fallida retrocede el historial hasta solo ["system"], tanto si falló por un error de Venice como por agotar max_rounds. Que una herramienta de solo lectura se ejecuta igualmente cuando el aprobador devuelve False. Y que una herramienta de pago rechazada deja un mensaje tool que contiene declined mientras apify.calls permanece vacío.
Ejecuta la suite con:
Notas de privacidad y coste
Un agente que llega a dos APIs merece precisión:
La retención cero de datos de Venice cubre el lado del modelo. No cubre Apify, y una ejecución de un Actor escribe resultados en tu cuenta de Apify. Si eso importa para una tarea concreta, ejecuta sin
APIFY_TOKEN y quédate con las herramientas anónimas de descubrimiento.
Sobre el coste, tres hábitos dan mucho de sí:
- Deja
--yesdesactivado durante el desarrollo. Ver qué Actors quiere ejecutar el modelo es informativo por sí mismo. - Usa
--toolspara acotar el catálogo a Actors que realmente hayas revisado. - Mantén
max_roundsmodesto. Doce rondas son de sobra para tareas de investigación, y un techo más bajo limita el daño cuando un modelo se queda atascado en un bucle.
Extender este ejemplo
El bucle es la base. Una vez que funciona, algunas direcciones útiles incluyen:- Añade un segundo servidor MCP. Nada en
Agentes específico de Apify, así que fusionar catálogos de varios servidores consiste sobre todo en aplicar espacios de nombres a las herramientas. - Persiste las conversaciones en SQLite para poder retomar una sesión o auditar lo que devolvió un Actor.
- Añade presupuestos por herramienta que rastreen las ejecuciones de Actors y se detengan en un techo, en lugar de confirmar cada una.
- Cachea los resultados de herramientas por nombre y argumentos, para que las consultas repetidas de documentación no vuelvan a rastrear.
- Fija un modelo con
--modely compara la calidad de selección de herramientas frente afunction_calling_default. - Sustituye el aprobador por una función de política que auto-apruebe Actors concretos con argumentos concretos y pregunte por todo lo demás.